# Structuring a Bun Monorepo with Shared Packages

A monorepo does not make code sharing good by itself. It only makes sharing easier. That is useful when the shared code has a real boundary, and dangerous when the shared code is just whatever happened to be copied twice.

Monorepos work best when the applications are related and the shared packages have clear responsibilities. A package should reduce the amount of context needed to understand an app. If every change requires touching the package and all of its consumers, the boundary is probably wrong.

## Start with Apps

The most important folder in a monorepo is still `apps/`. That is where the deployable things live.

```txt
monorepo/
  apps/
    dashboard/
    marketing/
    api/
  packages/
    result/
    validation/
    logger/
  package.json
```

The exact package names do not matter much. I usually care more about the rule behind them: apps can depend on packages, but packages should not know which apps exist.

That rule keeps shared code honest. A validation package can validate data. It should not import a route module from the dashboard because one app needed a shortcut.

## Wire the Workspace

Bun workspaces make each app and package installable from the same repository.

```json {% path="package.json" %}
{
	"name": "company-codebase",
	"private": true,
	"type": "module",
	"workspaces": ["apps/*", "packages/*"]
}
```

Each workspace gets its own `package.json`.

```json {% path="packages/result/package.json" %}
{
	"name": "@company/result",
	"private": true,
	"type": "module",
	"exports": {
		".": "./src/index.ts"
	}
}
```

The package name is not decoration. It is the public contract for the rest of the repository. If an app imports `@company/result`, it should not need to know where that package lives on disk.

```ts
import { failure, success } from "@company/result";
```

That import is better than `../../packages/result/src/index.ts` because it preserves the boundary. The filesystem is an implementation detail. The package API is the dependency.

## Use Workspace Dependencies

When one workspace depends on another, make that dependency explicit.

```json {% path="packages/validation/package.json" %}
{
	"name": "@company/validation",
	"private": true,
	"type": "module",
	"exports": {
		".": "./src/index.ts"
	},
	"dependencies": {
		"@company/result": "workspace:*"
	}
}
```

`workspace:*` tells Bun to resolve the package from the local workspace. It also documents the dependency graph. That matters more than people think.

Hidden dependencies are how shared packages turn into mud. If a validation package relies on result objects, say that in `dependencies`. If an app relies on validation, say that in the app package.

```json {% path="apps/dashboard/package.json" %}
{
	"name": "@company/dashboard",
	"private": true,
	"dependencies": {
		"@company/result": "workspace:*",
		"@company/validation": "workspace:*",
		"react-router": "^8.0.0"
	}
}
```

The app should import the package, not its source file. If a symbol is not exported from the package, the app should not use it.

## Keep the Dependency Graph Boring

The dependency graph should flow in one direction.

```txt
apps -> feature packages -> foundational packages
```

Foundational packages should have very few dependencies. Result helpers, logging primitives, date helpers, and small type utilities fit here.

Feature packages can build on top of those. A validation package can return a result object. A mail package can use a logger. A billing package can use both.

Apps sit at the top. They compose everything and contain the product-specific behavior.

The mistake is letting the arrows point both ways. Once a low-level package imports a high-level package, you no longer have layers. You have a knot.

## Extract Less Than You Want

The temptation with a monorepo is to extract too early. Two apps use similar code, so it becomes a package. Then the third app needs a slightly different behavior, so the package gets options. Then every caller passes a different set of options, and the shared package becomes harder to use than the duplicated code was.

I prefer to extract only after the shape has settled. Copying code once is not a disaster. Extracting the wrong abstraction into a shared package is harder to undo.

A good shared package usually has one of these shapes:

- It wraps a third-party dependency so apps do not repeat integration details.
- It encodes a convention the whole codebase wants to share.
- It provides a small primitive that does not know about product behavior.
- It contains UI or infrastructure that would otherwise drift across apps.

If the package needs to know which app is calling it, it is probably not shared code yet.

## Versioning Is Not the Point

Inside one repository, I do not optimize for publishing packages. I optimize for changing related code together.

That is the main benefit of this setup. If a validation return type changes, you can update the package and every app in the same commit. There is no waiting for a publish, no temporary compatibility layer, and no stale consumer sitting on an old version.

The trade-off is that breaking changes are easier to make. That sounds good until a small package change breaks five apps. Monorepos reward good boundaries and punish vague ones.

## Conclusion

A Bun monorepo with shared packages is useful when the packages describe real boundaries. Apps should compose packages. Packages should expose stable entry points. Dependencies should be explicit and boring.

Start with fewer packages than you think you need. Extract the code that has a clear contract, and leave product-specific behavior in the app until the pattern proves itself.