../

Software architecture

How to shape a TypeScript codebase above the level of single classes: quality attributes, architecture styles, boundaries, DDD, modular monoliths, and how to document and enforce decisions. Code targets TypeScript 5.9+ on Bun. Object-level patterns live in Design patterns, packaging in Modules, packages & libraries, and distributed concerns in System design.

Architectural characteristics

The "-ilities" a design must meet. Pick the top 3 that drive decisions; the rest are constraints to not break.

CharacteristicQuestionMeasured by
Performancehow fast under normal load?p50/p95/p99 latency, throughput
Scalabilitydoes it hold as load grows?throughput vs resources curve
Elasticitycan it absorb sudden spikes?time to scale, errors during bursts
Availabilityis it up when needed?uptime %, SLO
Reliability / fault tolerancedoes it keep working when parts fail?error rate, MTTR, blast radius
Securitywho can do what, and is data protected?threat model, audit findings
Maintainability / modifiabilityhow cheap is a change?lead time, change failure rate, churn hotspots
Testabilitycan behavior be checked in isolation?test speed, coverage of logic without I/O
Deployabilityhow often and safely can we ship?deploy frequency, rollback time
Observabilitycan we explain behavior from outside?traces, metrics, logs coverage
Interoperabilitydoes it talk to other systems cleanly?contract tests, API versioning
Evolvabilitycan the architecture itself change?fitness functions passing
Simplicity / costcan the team run it?moving parts, cloud bill, on-call load

Characteristics trade against each other: microservices buy deployability and scalability with simplicity and performance.

Architecture styles

StyleShapeUse whenAvoid when
Layered (n-tier)UI → service → data layerssmall CRUD apps, one teamdomain logic grows; changes cut across every layer
Modular monolithone deployable, modules by business capability with enforced boundariesdefault for most products and teamsteams need independent deploy/scale today
Hexagonal (ports & adapters)core defines ports; adapters implement themlogic worth testing without I/O, swappable infrathin CRUD over a DB
Clean / onionconcentric: entities, use cases, adapters, frameworkslong-lived domains, strict dependency rulesmall apps (ceremony)
Microservicesservices own data, deploy independentlymany teams, different scaling or tech needsone team, unclear domain boundaries, no platform/ops maturity
Event-drivencomponents react to events via a brokerdecoupled workflows, fan-out, audit trailsyou need simple request/response and easy debugging
Serverlessfunctions + managed servicesspiky or low traffic, glue code, small teamslong-running, latency-critical, heavy local state
CQRSseparate write model and read modelsreads and writes differ a lot in shape or scalesimple CRUD
Event sourcingstate = fold over stored eventsaudit, temporal queries, complex domainsmost apps: schema evolution and replay are costly
Microkernel (plugins)core + plug-in moduleseditors, tools, rules enginesfeatures aren't naturally pluggable

Start with a modular monolith; extract a service when a module has a measured reason (scale, team ownership, isolation). Distributed monoliths (services that must deploy together) are the worst of both.

Dependency rule & boundaries

Source dependencies point inward, toward policy. The domain knows nothing about HTTP, SQL, queues or frameworks.

  frameworks & drivers  (Bun.serve, Hono, Postgres, Stripe)
        │ implements / calls
  adapters              (http routes, pg repository, clients)
        │ depends on
  application           (use cases, ports = interfaces)
        │ depends on
  domain                (entities, value objects, events)
TermMeaning
Portinterface owned by the core (OrderRepository, Clock, Mailer)
Driving (primary) adaptercalls into the core: HTTP route, CLI, queue consumer, test
Driven (secondary) adapterimplements a port: Postgres repo, SMTP mailer, fake
Dependency inversionthe core defines the interface; infra implements it
Composition rootthe one place that builds adapters and injects them
Module public APIthe index.ts other modules may import; everything else is private
Allowed importdomainapplicationadaptersmain
domainyesyesyesyes
applicationnoyesyesyes
adaptersnonoyesyes
main (composition root)nononoyes

Read the table as "row can be imported by column".

Domain-driven design essentials

ConceptWhat it isIn TypeScript
Ubiquitous languageshared vocabulary of experts and codetype and function names match the words used by the business
Bounded contextboundary where a model and its language are consistenta module or package; "Order" in sales is not "Order" in shipping
Context maphow contexts relatecustomer/supplier, shared kernel, anti-corruption layer
Entityidentity that persists through changesobject with a branded id
Value objectdefined by its values, immutablereadonly type + smart constructor, compare by value
Aggregatecluster changed as one unit with invariantsone root type; functions return a new aggregate
Aggregate rootthe only entry point to the aggregaterepository per root, not per table
Domain eventsomething that happened, past tense{ type: "order.placed", ... } discriminated union
Domain servicelogic that fits no single entitypure function taking several aggregates
Repositorycollection-like access to aggregatesport interface in application, adapter in infra
Application service / use caseorchestrates one user intentasync function with injected ports
Anti-corruption layertranslator at the edge of a foreign modelmapper from a vendor DTO to your types

Aggregate rules: keep them small, reference other aggregates by ID, change one aggregate per transaction, and reach other aggregates through events (eventual consistency).

src/modules/orders/domain/order.ts
import { err, ok, type Result }
  from "../../../shared/result";
 
declare const brand: unique symbol;
type Brand<T, B extends string> = T & { [brand]: B };
 
export type OrderId = Brand<string, "OrderId">;
export type Cents = Brand<number, "Cents">;
 
export function cents(
  n: number,
): Result<Cents, "bad-amount"> {
  return Number.isInteger(n) && n >= 0
    ? ok(n as Cents)
    : err("bad-amount");
}
 
export type OrderLine = Readonly<{
  sku: string;
  qty: number;
  price: Cents;
}>;
 
export type Order = Readonly<{
  id: OrderId;
  status: "draft" | "placed";
  lines: readonly OrderLine[];
}>;
 
export type OrderPlaced = Readonly<{
  type: "order.placed";
  orderId: OrderId;
  total: Cents;
  at: Date;
}>;
 
export type PlaceError = "empty-order" | "already-placed";
 
export function place(
  order: Order,
  at: Date,
): Result<{ order: Order; event: OrderPlaced }, PlaceError> {
  if (order.status === "placed") {
    return err("already-placed");
  }
  if (order.lines.length === 0) return err("empty-order");
  const total = order.lines.reduce(
    (sum, l) => sum + l.qty * l.price,
    0,
  ) as Cents;
  return ok({
    order: { ...order, status: "placed" },
    event: {
      type: "order.placed",
      orderId: order.id,
      total,
      at,
    },
  });
}

The aggregate is plain data plus pure functions: no I/O, no framework, trivially testable.

Modular monolith layouts

Layer folders group by technical role; every feature change touches every folder.

layer folders (scales badly)
src/controllers/orders.tsbilling.tsservices/orders.tsbilling.tsrepositories/orders.tsmodels/order.ts

Feature (module) folders group by business capability; each module has a public API.

feature modules
src/modules/orders/domain/       # entities, value objects, eventsapplication/  # use cases + portsinfra/        # pg repo, in-memory repohttp/         # routes (driving adapter)index.ts      # the module's public APIbilling/...catalog/...shared/               # Result, ids, event bus: no domainconfig.tsmain.ts               # composition roottests/architecture.test.ts  # fitness functions
hexagonal naming inside one module
orders/core/domain/order.tsports/order-repository.ts  # driven portplace-order.ts       # driving port (use case)adapters/driving/http-routes.tsqueue-consumer.tsdriven/pg-order-repository.tsstripe-payments.tsin-memory-orders.ts  # for testsindex.ts
workspace packages per bounded context
repo/apps/api/              # composition root, Bun.serveworker/           # queue consumerspackages/orders/package.json  # exports: ./src/index.tsbilling/shared-kernel/    # Result, Money, idspackage.json          # workspaces: apps/*, packages/*
src/modules/orders/index.ts
// The only file other modules may import.
export { makePlaceOrder } from "./application/place-order";
export type { PlaceOrder } from "./application/place-order";
export type {
  Order,
  OrderId,
  OrderPlaced,
} from "./domain/order";

Enforcing boundaries

ToolEnforcesNotes
package.json exportsonly listed entry points resolveper workspace package; deep imports fail to resolve
TS project referencespackage build order, no cyclescomposite: true, tsc -b
ESLint no-restricted-importsforbidden paths per foldercore rule, flat config files globs
eslint-plugin-boundarieselement types and allowed dependenciesricher rules by folder pattern
dependency-cruiserany rule over the import graph, cycles, orphansCI check; also draws graphs
Knipunused files, exports and dependencieskeeps public APIs honest
Architecture testscustom checks in the test suitebun test, see fitness functions
eslint.config.js
const viaIndex = {
  group: ["@/modules/*/*/**"],
  message: "import another module via its index",
};
const pure = {
  group: ["**/infra/**", "**/http/**",
    "hono", "bun", "drizzle-orm*"],
  message: "domain must stay pure",
};
 
export default [
  {
    files: ["src/modules/**/*.ts"],
    rules: {
      "no-restricted-imports": ["error",
        { patterns: [viaIndex] }],
    },
  },
  {
    // a later match replaces the options: repeat viaIndex
    files: ["src/modules/*/domain/**/*.ts"],
    rules: {
      "no-restricted-imports": ["error",
        { patterns: [viaIndex, pure] }],
    },
  },
];

Patterns match the import string, so the second rule catches alias imports (@/modules/billing/domain/x) but not relative ones (../../billing/domain/x); dependency-cruiser resolves real paths and catches both. Spread these after typescript-eslint's configs so .ts files parse.

Coupling & cohesion

IdeaAim
Cohesionthings that change together live together (by feature, not by layer)
Couplingfewer, narrower, more stable dependencies between modules
Afferent coupling Caincoming dependents: high means changes are risky
Efferent coupling Ceoutgoing dependencies: high means fragile
Instability I = Ce / (Ca + Ce)0 stable, 1 unstable; depend toward stability
Stable abstractionsstable modules should be abstract (interfaces, types)
Acyclic dependenciesno cycles between modules

Connascence: two parts are connascent if changing one forces a change in the other. Prefer weaker forms, and keep strong forms inside a module (locality).

KindForm (weak → strong)Example
Staticnamecalling placeOrder
Statictypeboth sides agree on OrderId
Staticmeaning (convention)status === 3 means shipped: use a union instead
Staticpositionf(a, b, c) argument order: use an options object
Staticalgorithmboth sides hash the same way
Dynamicexecution orderinit() before use()
Dynamictimingrace between two writers
Dynamicvaluetwo values must change together
Dynamicidentityboth must reference the same instance

Communication between modules

StyleCouplingUse for
Direct call to another module's public APItemporal + typequeries, commands needing an answer now
In-process domain eventsloose, same transaction boundary optionalside effects in other modules (send email)
Outbox + message brokerloose, async, durablecross-service workflows, retries
HTTP/gRPC between servicestemporal, networkrequest/response across deployables
Shared database tablestight (hidden)avoid: each module owns its tables

Contracts at a module or service edge:

PracticeWhy
Explicit DTOs, never domain objects on the wireinternal changes don't break callers
Validate input with a schema (Zod) at the edgeinside the core, trust the types
Version events and APIs; additive changes onlyconsumers upgrade at their pace
Consumer-driven contract tests (Pact)catch breaking changes before deploy
Idempotency keys on commandssafe retries
Tolerant readerignore unknown fields, don't require optional ones

Errors as values at boundaries

Expected business failures are values in the type (Result); unexpected faults (DB down, bugs) stay exceptions and are caught once at the edge.

KindExamplesRepresent asHandled
Domain errorempty order, insufficient fundsResult with a string-literal unionmapped to 4xx at the adapter
Validation errorbad JSON, missing fieldZod safeParse result400 at the adapter
Infrastructure faulttimeout, connection refusedthrown Error with causeretry or 5xx in middleware
Bugundefined accessthrown500, alert
src/shared/result.ts
export type Ok<T> = { readonly ok: true; readonly value: T };
export type Err<E> = {
  readonly ok: false;
  readonly error: E;
};
export type Result<T, E> = Ok<T> | Err<E>;
 
export const ok = <T>(value: T): Ok<T> => ({
  ok: true,
  value,
});
export const err = <E>(error: E): Err<E> => ({
  ok: false,
  error,
});

Libraries such as neverthrow and Effect offer richer versions; a 10-line type is enough for boundaries.

Configuration & composition root

RuleWhy
Read env once, validate, freezefail at startup, not on first request
Pass config and ports as parametersno hidden globals, easy tests
Build the object graph in main.ts onlyone place to see and swap wiring
No DI container neededfunctions and constructors are enough in TS
Tests build their own small graphin-memory adapters, fixed clock
src/config.ts
import { z } from "zod";
 
const Config = z.object({
  DATABASE_URL: z.url(),
  PORT: z.coerce.number().int().default(3000),
  LOG_LEVEL: z
    .enum(["debug", "info", "warn", "error"])
    .default("info"),
});
 
export type Config = Readonly<z.infer<typeof Config>>;
 
export function loadConfig(
  env: Record<string, string | undefined>,
): Config {
  const parsed = Config.safeParse(env);
  if (!parsed.success) {
    throw new Error("Invalid configuration", {
      cause: z.prettifyError(parsed.error),
    });
  }
  return Object.freeze(parsed.data);
}

Documenting: ADRs & C4

An Architecture Decision Record is a short markdown file per significant decision, numbered, stored in the repo (docs/adr/0007-use-postgres.md) and never edited after acceptance: a later ADR supersedes it. Template in Recipes.

ADR statusMeaning
Proposedunder discussion (PR open)
Acceptedin force
Deprecatedno longer applies, nothing replaces it
Superseded by ADR-Nreplaced

The C4 model describes a system at four zoom levels, each for a different audience.

LevelShowsAudience
1. System contextyour system, its users, and external systemseveryone, non-technical included
2. Containerdeployable/runnable units (apps, DBs, queues) and their protocolsdevelopers, ops
3. Componentmajor components inside one container and their responsibilitiesdevelopers of that container
4. Codeclasses/types for one component (usually generated or skipped)rarely needed
Supplementarysystem landscape, dynamic (sequence), deployment diagramsas needed

Tools: Structurizr DSL (diagrams as code), Mermaid C4 diagrams, IcePanel, Likec4. Keep levels 1 and 2 current; generate or skip level 4.

Fitness functions & migration

A fitness function is an automated check that an architectural characteristic still holds: run it in CI like a test.

CharacteristicFitness function
Layering / boundariesdependency-cruiser, ESLint rules, architecture tests
No cyclesdepcruise --validate with no-circular
Performancebenchmark or load test budget (p95 under N ms)
Bundle sizesize-limit budget in CI
API compatibilitycontract tests, OpenAPI diff
Securitydependency audit, SAST, secret scanning
Resiliencechaos experiments in staging
tests/architecture.test.ts
import { expect, test } from "bun:test";
import { Glob } from "bun";
 
const FORBIDDEN = /\/infra\/|\/http\/|^hono|^bun$/;
const IMPORT = /from\s+["']([^"']+)["']/g;
 
test("domain code imports no infrastructure", async () => {
  const bad: string[] = [];
  const glob = new Glob("src/modules/*/domain/**/*.ts");
  for await (const file of glob.scan(".")) {
    const src = await Bun.file(file).text();
    for (const m of src.matchAll(IMPORT)) {
      const spec = m[1] ?? "";
      if (FORBIDDEN.test(spec)) bad.push(`${file}: ${spec}`);
    }
  }
  expect(bad).toEqual([]);
});
Migration strategyHow
Strangler figroute traffic for one capability at a time to the new system behind a facade/proxy until the old one is empty
Branch by abstractionput an interface in front of the old code, add the new implementation behind it, switch, delete old
Parallel runrun old and new, compare results, serve old until they agree
Expand / contractschema: add new column, dual-write, backfill, switch reads, drop old
Feature flagsship dark, enable per cohort, remove the flag after
Anti-corruption layertranslate between the legacy model and the new one at the seam

Recipes

Port and adapters with an in-memory fake

Define the port in the application layer; ship a real adapter and an in-memory one for tests.

src/modules/orders/application/ports.ts
import type { Order, OrderId, OrderPlaced }
  from "../domain/order";
 
export interface OrderRepository {
  findById(id: OrderId): Promise<Order | null>;
  save(order: Order): Promise<void>;
}
export interface EventPublisher {
  publish(events: readonly OrderPlaced[]): Promise<void>;
}
export interface Clock {
  now(): Date;
}
src/modules/orders/infra/in-memory-orders.ts
import type { Order, OrderId } from "../domain/order";
import type { OrderRepository } from "../application/ports";
 
export class InMemoryOrders implements OrderRepository {
  readonly #rows = new Map<OrderId, Order>();
 
  constructor(seed: readonly Order[] = []) {
    for (const o of seed) this.#rows.set(o.id, o);
  }
  async findById(id: OrderId): Promise<Order | null> {
    return this.#rows.get(id) ?? null;
  }
  async save(order: Order): Promise<void> {
    this.#rows.set(order.id, order);
  }
}

Use case (application service)

One async function per user intent: load, call the domain, persist, publish.

src/modules/orders/application/place-order.ts
import { err, ok, type Result }
  from "../../../shared/result";
import { place, type Order, type OrderId, type PlaceError }
  from "../domain/order";
import type { Clock, EventPublisher, OrderRepository }
  from "./ports";
 
export type PlaceOrderError = PlaceError | "not-found";
export type PlaceOrder = (
  id: OrderId,
) => Promise<Result<Order, PlaceOrderError>>;
 
export const makePlaceOrder = (deps: {
  orders: OrderRepository;
  events: EventPublisher;
  clock: Clock;
}): PlaceOrder => async (id) => {
  const order = await deps.orders.findById(id);
  if (!order) return err("not-found");
  const placed = place(order, deps.clock.now());
  if (!placed.ok) return placed;
  await deps.orders.save(placed.value.order);
  await deps.events.publish([placed.value.event]);
  return ok(placed.value.order);
};
src/modules/orders/application/place-order.test.ts
import { expect, test } from "bun:test";
import type { Cents, Order, OrderId, OrderPlaced }
  from "../domain/order";
import { InMemoryOrders } from "../infra/in-memory-orders";
import { makePlaceOrder } from "./place-order";
 
const draft: Order = {
  id: "o1" as OrderId,
  status: "draft",
  lines: [{ sku: "tea", qty: 2, price: 450 as Cents }],
};
 
test("placing a draft publishes order.placed", async () => {
  const sent: OrderPlaced[] = [];
  const placeOrder = makePlaceOrder({
    orders: new InMemoryOrders([draft]),
    events: { publish: async (e) => void sent.push(...e) },
    clock: { now: () => new Date(0) },
  });
  const r = await placeOrder(draft.id);
  expect(r.ok).toBe(true);
  expect(sent[0]?.total).toBe(900 as Cents);
});

Result type at an HTTP boundary

Map every domain error to a status once; Record over the union makes it exhaustive.

src/modules/orders/http/routes.ts
import type { OrderId } from "../domain/order";
import type { PlaceOrder, PlaceOrderError }
  from "../application/place-order";
 
const STATUS: Record<PlaceOrderError, number> = {
  "not-found": 404,
  "empty-order": 422,
  "already-placed": 409,
};
 
export const placeOrderRoute =
  (placeOrder: PlaceOrder) =>
  async (id: string): Promise<Response> => {
    const r = await placeOrder(id as OrderId);
    return r.ok
      ? Response.json(r.value)
      : Response.json(
          { error: r.error },
          { status: STATUS[r.error] },
        );
  };

Adding a new error to PlaceError fails compilation here until it has a status.

Typed domain event dispatch

An in-process bus keyed by the event's type; handlers get the narrowed event.

src/shared/event-bus.ts
type Handler<E> = (event: E) => Promise<void>;
type Of<E, T> = Extract<E, { type: T }>;
 
export function createEventBus<E extends { type: string }>(
  onError: (error: unknown, event: E) => void,
) {
  const handlers = new Map<E["type"], Handler<E>[]>();
  return {
    on<T extends E["type"]>(type: T, fn: Handler<Of<E, T>>) {
      const h: Handler<E> = (e) => fn(e as Of<E, T>);
      handlers.set(type, [...(handlers.get(type) ?? []), h]);
    },
    async publish(events: readonly E[]): Promise<void> {
      for (const e of events) {
        const hs = handlers.get(e.type) ?? [];
        const rs = await Promise.allSettled(
          hs.map((h) => h(e)),
        );
        for (const r of rs)
          if (r.status === "rejected") onError(r.reason, e);
      }
    },
  };
}

For events that must not be lost, write them to an outbox in the same transaction (see System design).

Composition root

Build every adapter once in main.ts and inject; nothing else reads process.env.

src/main.ts
import { loadConfig } from "./config";
import { createEventBus } from "./shared/event-bus";
import { makePlaceOrder, type OrderPlaced }
  from "./modules/orders";
import { InMemoryOrders }
  from "./modules/orders/infra/in-memory-orders";
import { placeOrderRoute }
  from "./modules/orders/http/routes";
 
const config = loadConfig(process.env);
const bus = createEventBus<OrderPlaced>((error, e) =>
  console.error("handler failed", e.type, error));
bus.on("order.placed", async (e) => console.info(e.orderId));
 
const place = placeOrderRoute(makePlaceOrder({
  orders: new InMemoryOrders(), // swap for a pg adapter
  events: bus,
  clock: { now: () => new Date() },
}));
Bun.serve({
  port: config.PORT,
  routes: {
    "/orders/:id/place": { POST: (r) => place(r.params.id) },
  },
});

ADR template

One file per decision in docs/adr/, numbered, reviewed in a pull request.

docs/adr/0007-modular-monolith.md
# 7. Start as a modular monolith
 
- Status: Accepted
- Date: 2026-09-25
- Deciders: platform team
 
## Context
Three developers, one product, domain still changing.
We expect orders, billing and catalog to evolve apart.
 
## Decision
One Bun service with modules under `src/modules/*`,
each with a public `index.ts`. Boundaries enforced by
dependency-cruiser in CI. Each module owns its tables.
 
## Consequences
+ One deploy, simple local dev, refactors are cheap.
+ A module can later become a service along its API.
- Needs discipline: CI rules must stay green.
- Shared DB instance: one module can still hog it.
 
## Alternatives considered
Microservices per module: rejected, no ops capacity.

dependency-cruiser rules

Fail CI when domain code imports infrastructure or a module reaches into another's internals.

.dependency-cruiser.mjs
/** @type {import("dependency-cruiser").IConfiguration} */
export default {
  forbidden: [
    { name: "domain-not-to-infra", severity: "error",
      from: { path: "^src/modules/[^/]+/domain/" },
      to: { path: "(/infra/|/http/|^node_modules/)" },
    },
    { name: "only-via-public-api", severity: "error",
      from: { path: "^src/modules/([^/]+)/" },
      to: {
        path: "^src/modules/[^/]+/.+",
        pathNot: ["^src/modules/$1/", "index\\.ts$"],
      },
    },
    { name: "no-circular", severity: "error",
      from: {}, to: { circular: true } },
  ],
  options: {
    doNotFollow: { path: "node_modules" },
    tsConfig: { fileName: "tsconfig.json" },
  },
};
bunx depcruise src --config .dependency-cruiser.mjs
bunx depcruise src --output-type dot | dot -T svg > deps.svg

dependency-cruiser 18 supports TypeScript up to 6; on TS 7, check its release notes first.

References