Skip to content

Project structure

The fastest way to a sound layout is to scaffold one: pnpm create nwire my-app --template service gives you a single bounded context (app/ + config/ + batteries), and --template enterprise gives you a composed monorepo of contexts under apps/. The shapes below are what those produce — and what works once a project outgrows a single file.

There's no enforced layout — Nwire is happy with any TypeScript project; these are conventions, not rules.

Tiny: one folder

For a service with one bounded context, no actors, plain handlers:

my-app/
  app/
    main.ts          # endpoint().use(httpKoa()).mount(app).run()
    api.ts           # wires array — { binding, handler } pairs
    store.ts         # in-memory or DB-backed store
    middleware.ts    # auth, logging (Koa.Middleware)
    errors.ts        # defineError() — typed throwables
  package.json
  tsconfig.json

Example: examples/todo-app.

Medium: bounded contexts as folders

When the app has multiple bounded contexts (orders, billing, notifications) each one is self-contained under a folder. There's no special "module" concept — each domain folder is a collection of files that share imports. buildApp() walks the domains and pulls everything into one createApp:

my-app/
  app/
    main.ts                              # bootstrap — endpoint + adapters
    app.ts                               # buildApp(): createApp + forgePlugins
    api.ts                               # wires array (re-exports from domains)
  domains/
    orders/
      events.ts                          # defineEvent — past-tense facts
      actor.ts                           # defineActor — state + invariants
      actions/place-order.ts             # defineAction + handler
      projections/orders-by-customer.ts  # defineProjection + defineQuery
      workflows/auto-charge.ts           # defineWorkflow
      routes/                            # HTTP route bindings + handlers
    billing/
      events.ts
      ...
  package.json
  tsconfig.json

Each domain folder is relocatable — copy orders/ to a sibling repo, fix imports, and you have a working module. That means no domain imports from app/main.ts, no domain imports between siblings via relative paths (use published events for cross-domain reactions).

buildApp() looks like:

ts
// app/app.ts
import { createApp, defineRegistry } from "@nwire/app";
import { forgePlugins } from "@nwire/forge";

import { placeOrder } from "../domains/orders/actions/place-order";
import { autoCharge } from "../domains/orders/workflows/auto-charge";
import { ordersByCustomer, getOrdersByCustomer } from "../domains/orders/projections/orders-by-customer";

export function buildApp() {
  return createApp({
    appName: "my-app",
    registry: defineRegistry({
      handlers: [placeOrder, getOrdersByCustomer],
      projections: [ordersByCustomer],
      workflows: [autoCharge],
    }),
    plugins: [...forgePlugins()],
  });
}

Example: examples/moderation-queue.

Large: workspace packages + multi-app composition

Once a bounded context ships its own tests, configs, optional adapters, lift it into a workspace package. Each domain is its own App; the monolith composes them via appCompose:

my-repo/
  packages/                              # one pnpm workspace per domain
    orders/                              # @my-app/orders
      package.json
      src/
        index.ts                         # exports buildOrdersApp()
        events.ts
        actor.ts
        actions/
        projections/
        workflows/
        routes/
      __tests__/
    billing/                             # @my-app/billing
      package.json
      src/
        index.ts                         # exports buildBillingApp()
        ...
  apps/
    monolith/                            # deploy as one process
      package.json
      app/
        main.ts                          # appCompose(orders, billing) + endpoint
    orders-only/                         # deploy domain split
      package.json
      app/
        main.ts                          # buildOrdersApp + endpoint
  package.json                           # pnpm workspace root

apps/monolith/app/main.ts:

ts
import { appCompose } from "@nwire/app";
import { endpoint } from "@nwire/endpoint";
import { httpKoa } from "@nwire/koa";
import { buildOrdersApp } from "@my-app/orders";
import { buildBillingApp } from "@my-app/billing";

const orders = buildOrdersApp();
const billing = buildBillingApp();
const monolith = appCompose(orders, billing);

await orders.start();
await billing.start();
await endpoint("monolith", { port: 3000 })
  .use(httpKoa({ prefix: "/api" }))
  .mount(monolith)
  .run();

Each app keeps its own container. Adapters resolve handler context from the source app's container per dispatch — no cross-app coupling at the container layer.

Example: examples/station-management.

Naming

The conventions the framework's examples follow (not enforced, but they make codebases readable):

ElementConventionExample
Domain folderplural nounenrollments/
Actor fileactor.ts (or <concept>.actor.ts)order.actor.ts
Action file<verb>-<noun>.tsplace-order.ts
Action name<plural>.<verb-noun>orders.place-order
Workflow file<verb>-<noun>.tsauto-charge.ts
Projection file<aggregate>.tsorders-by-customer.ts
Query file<verb>-<noun>.ts or co-located with projectionget-orders-by-customer.ts
Events fileevents.tsevents.ts
Event symbol<Subject>Was<VerbPast>OrderWasPlaced
Event name<plural>.<thing-was-verbed>orders.order-was-placed
Route file<verb>-<noun>.tsplace-order.route.ts

When to split a domain into its own workspace package

When two or more deployables (apps, services) need it, or when it ships its own test setup / adapters. Until then, the folder-under-domains/ layout is simpler and tests are easier.

Where to next

  • HTTP routes — wiring routes onto an app.
  • Modules + appscreateApp + appCompose
    • the forge plugin in detail.
  • Multi-transport — running the same wires as HTTP + queue worker + MCP under one endpoint.

MIT licensed.