Skip to main content
Every Ooneex application is a monorepo: a single repository, a single dependency tree, and a single set of tooling that hosts many self-contained modules. app:create scaffolds the whole structure, and app:init lays down the workspace root that holds it together. This page explains how that monorepo is wired and how to work inside it.

Why a monorepo

An Ooneex app grows by adding modulesapp, shared, then one per business domain (movie, billing, user). A monorepo keeps all of them in one place while letting each evolve independently:
  • One install, one lockfilebun install resolves every module’s dependencies into a single node_modules at the root, so versions stay consistent across the whole app.
  • Shared tooling — formatter, linter, TypeScript config, commit rules, and git hooks are defined once at the root and apply to every module.
  • Cross-module imports without publishing — modules reference each other through path aliases and Bun workspaces, with no build-and-publish step in between.
  • Cached, incremental tasksNx runs lint, test, and build across every module, only re-running what changed.
  • Independent slices — microservices and SPAs live as modules in the same repo yet build and deploy on their own.

Anatomy of the workspace

The monorepo is a Bun workspace. The root package.json declares the workspace, the scripts, and the shared lint-staged config; everything under modules/* is a workspace member.
movie-app/
├── modules/                    # Workspace members — one folder per module
│   ├── app/                    # API module — the application entrypoint
│   ├── shared/                 # Shared module — cross-cutting code, env, roles
│   └── movie/                  # A business-domain module you add
├── .commitlintrc.ts            # Conventional commit rules
├── .gitignore
├── .husky/                     # Git hooks (pre-commit, commit-msg)
├── .zed/settings.json          # Editor settings
├── biome.jsonc                 # Formatter + linter config
├── bunfig.toml                 # Bun config (test coverage on)
├── nx.json                     # Nx task orchestration + caching
├── package.json                # Workspace root: scripts, workspaces, lint-staged
├── README.md
└── tsconfig.json               # Root TS config with module path aliases
The root package.json is the heart of the workspace:
package.json
{
  "name": "movie-app",
  "scripts": {
    "fmt": "bunx biome check --write",
    "lint": "bunx nx run-many -t lint --output-style=stream --verbose",
    "test": "bunx nx run-many -t test --output-style=stream --verbose",
    "check": "bun install && bun run build && bun run lint && bun run test",
    "commit": "bunx commit"
  },
  "workspaces": ["modules/*"],
  "lint-staged": {
    "*.{js,ts,cjs,mjs,d.cts,d.mts,jsx,tsx,json,jsonc}": [
      "biome check --files-ignore-unknown=true"
    ]
  }
}
The workspaces: ["modules/*"] glob is what makes this a monorepo: Bun treats every folder under modules/ as a package, hoists their dependencies into the root node_modules, and links them together.

Modules as workspace packages

Each module under modules/<name>/ is its own package with its own package.json and tsconfig.json, but it shares the root’s install, tooling, and TypeScript config. A module owns a complete vertical slice of your domain — controllers, services, repositories, entities, migrations, and seeds — so all the code for one feature lives in one folder rather than being scattered across global controllers/ and services/ directories. Modules come in a few flavors, all of them members of the same workspace:
TypeRuns asRegistered into AppModule / SharedModuleCreated with
apiPart of the main app processYesmodule:create (the app module)
modulePart of the main app processYesmodule:create
microserviceStandalone HTTP service (own entrypoint + Dockerfile)Yesmicroservice:create
spaStandalone Vite front-end (own dev server)Nospa:create
microservice and spa modules live in the same monorepo and reuse the same install, aliases, and tooling, but they build and deploy independently. See Microservices and Single Page App.

Cross-module imports with path aliases

The root tsconfig.json maps each module to a @module/<name> alias, so modules import each other by name instead of brittle relative paths:
tsconfig.json
{
  "compilerOptions": {
    "paths": {
      "@module/app/*": ["./modules/app/src/*"],
      "@module/shared/*": ["./modules/shared/src/*"]
    }
  }
}
// From any module, import shared code by alias
import { SharedDatabase } from "@module/shared/databases/SharedDatabase";
When you run module:create or microservice:create, the generator adds the new alias to tsconfig.json automatically (and registers the module in AppModule, its entities in SharedModule, and its scope in the commitlint config). SPAs are deliberately left out of the path aliases and module registries so they never pull server-side wiring into the front-end.

Task orchestration with Nx

Tooling tasks run across the whole monorepo through Nx. The lint and test scripts use nx run-many to execute the matching target in every module at once, while nx.json configures dependency order and caching:
nx.json
{
  "targetDefaults": {
    "build": { "dependsOn": ["^build"], "cache": true },
    "fmt": { "cache": true },
    "lint": { "dependsOn": ["^build"], "cache": true },
    "test": { "dependsOn": ["^build"], "cache": true }
  },
  "packageManager": "bun"
}
  • dependsOn: ["^build"] ensures a module’s dependencies are built before it is linted, tested, or built.
  • cache: true lets Nx skip any task whose inputs haven’t changed, so repeated runs only touch what you actually modified.
Run them from the root:
bun run fmt     # format the whole repo with Biome
bun run lint    # lint every module via Nx
bun run test    # test every module via Nx
bun run check   # install + build + lint + test — the full gate

Shared root tooling

Because it’s a monorepo, configuration is defined once at the root and inherited by every module:
  • biome.jsonc — a single formatter and linter for all modules. bun run fmt applies it everywhere.
  • tsconfig.json — the strict, shared TypeScript baseline plus the module path aliases. Each module’s tsconfig.json extends from this root.
  • .commitlintrc.ts — conventional-commit rules. Module names become valid commit scopes, kept in sync as you add and remove modules.
  • .husky/ — git hooks installed at the root: pre-commit runs lint-staged (Biome on staged files) and commit-msg runs commitlint.
  • bunfig.toml — Bun config; enables test coverage across the workspace.
This is what app:init writes — see below.

How the monorepo is generated

Two commands build the monorepo, one layered on the other.
1

app:init lays down the workspace root

app:init writes the root tooling — package.json (with the modules/* workspace), tsconfig.json, biome.jsonc, nx.json, bunfig.toml, .commitlintrc.ts, .gitignore, .zed/settings.json, and an .env.yml — installs the shared dev dependencies (Biome, commitlint, husky, lint-staged, nx, TypeScript), initializes the git repository, and configures the husky hooks. It can optionally add the Claude and Codex skills.Run it on its own to turn an existing folder into an Ooneex workspace:
ooneex app:init --name=MovieApp --destination=movie-app
2

app:create builds the full app on top of it

app:create creates the app and shared modules, the entrypoint, the shared database, roles.yml, and Docker files, then calls app:init internally for the workspace root, installs the runtime dependencies, and offers CI/CD files. This is the one command you normally run:
ooneex app:create --name=MovieApp --destination=movie-app
3

Add modules as the app grows

Each new domain becomes another workspace member, wired into the monorepo automatically:
ooneex module:create --name=movie --destination=app
The generator scaffolds modules/movie/, registers it in AppModule and SharedModule, adds its @module/movie path alias to tsconfig.json, and adds its commit scope.
app:init is the workspace root; app:create is app:init plus the app and shared modules and dependencies. Use app:init directly only when you want the monorepo scaffold without the starter modules.

Working in the monorepo day to day

bun install                      # resolve every module's deps into one node_modules
ooneex app:start                 # bring up Docker, then run every api/microservice/spa
ooneex module:create --name user # add a module — aliases and registries updated for you
bun run lint                     # Nx lints every module, skipping unchanged ones
bun run test                     # Nx tests every module
bun run check                    # the full install + build + lint + test gate
bunx commit                      # commitlint-guided conventional commit
A single bun install at the root installs dependencies for every module. You almost never run bun install inside a module folder — the workspace hoists and links everything from the root.

Use with Claude and Codex

Initialize the AI skills, then ask your agent to work across the monorepo in natural language — it uses the project’s real module structure, aliases, and tooling.
ooneex claude:init
Prompt
Add a `billing` module to my Ooneex monorepo, register it in AppModule
and SharedModule, wire its @module/billing path alias, then run the
formatter, linter, and tests across the workspace.

External resources

Bun Workspaces

How Bun resolves and links the modules/* members.

Nx

Task orchestration and caching for lint, test, and build.

TypeScript Paths

How the @module/* aliases map to module source.

Conventional Commits

The commit format enforced across the monorepo.

Next steps

Create your app

Scaffold the monorepo and build your first module.

Configuration

The .env.yml and roles.yml files in the shared module.

Module overview

What a module is and how it slices your domain.

app:create

The command that generates the whole monorepo.