Skip to main content

ADR-002: packages/ over libs/; domain-first layout

  • Status: Accepted
  • Date: 2026-05-23
  • Supersedes: the implicit libs/api/*, libs/shared/* layout in ADR-001
  • Deciders: CTO / Founding Engineering

Context

The first scaffold of the monorepo used Nx-default libs/ with three groupings: libs/api/<bounded-context>/, libs/shared/<infra>/, libs/contracts/. After a month of working with it we found:

  • Engineers asked "where do I put this?" too often.
  • libs/api/<context>/ was a placeholder; domain code actually lived in apps/api/src/{auth,business,employee}/.
  • The libs/ name doesn't say what kind of thing lives there. dto, ui, schema at the top level didn't follow any rule.
  • Go services lived under apps/ next to frontends; the two have different lifecycles, languages, and ownership.
  • Frontends were named after the team that asked for them (business, client) rather than the audience they serve (employer, employee).

Decision

Three top-level code roots, each with one clear job:

  1. apps/ — composition roots only. NestJS bootstrap + Next.js apps. No business logic. Each app named for its audience (*-web for frontends).
  2. services/ — independently deployable systems (today: Go ledger, integration-gateway, notifications worker). Not composition roots; not frontends.
  3. packages/ — every reusable code unit. Split into:
    • packages/<domain>/ for bounded contexts (payroll, wallet, ewa, lending, savings, workforce, identity, tenancy, kyc, risk).
    • packages/shared/<name>/ for cross-cutting infra (money, idempotency, audit, events, tenant-context, auth, database, logging, validation, ui).
    • packages/contracts/{grpc,openapi}/ for API source of truth.

And three supporting roots:

  1. infra/ — how it runs (terraform, helm, argocd, docker for local dev).
  2. tooling/ — how the repo is governed (custom eslint rules, prisma-lint, generators, scripts).
  3. docs/ — internal knowledge (adr, runbooks, architecture, onboarding, security).

Alternatives considered

  • Keep libs/ and re-tag. Rejected. The name doesn't carry meaning; the libs/{api,shared,contracts}/ triple-layer didn't reduce cognitive load.
  • Flat packages/ with everything next to each other. Rejected. With 10 domains + 10 shared infra packages + contracts, a flat namespace is unscannable.
  • apps/ for everything including services. Rejected. A Go ledger has a different lifecycle, language, and blast radius than a Next.js app; conflating them makes ownership ambiguous.

Consequences

Positive

  • "Where do I put this?" answers itself in most cases: domain code → packages/<domain>/, infra → packages/shared/<name>/, deploy unit → services/<name>/, composition → apps/<name>/.
  • Strict Nx tag rules (see ADR-011) can be expressed simply: scope:<domain> per domain, scope:shared, scope:contracts.
  • Going from "in the monolith" to "carved-out service" is a folder move (packages/wallet/services/wallet/) without a rewrite — the domain/ and application/ layers are framework-free.
  • Frontends names finally describe what they are.

Negative

  • All existing imports must be rewritten in one large PR per phase.
  • The packages/<domain>/backend/{domain,application,infrastructure,presentation} shape adds a layer compared to "everything in src/". Justified by Section 4 of the restructure plan; reinforced by ADR-003.
  • Path aliases multiply (@demoz-pay/<domain>, @demoz-pay/shared-<name>). Managed via generators (pnpm gen:domain, pnpm gen:shared) so engineers don't write them by hand.

Follow-ups

  • ADR-003 codifies the domain package shape.
  • ADR-004 codifies the *-web frontend rename.
  • ADR-011 codifies cross-domain event-only communication.
  • tooling/generators/ ships domain-package and shared-package generators so the structure is reproducible without hand-rolling.
  • A separate PR introduces pnpm prisma-lint to flag the existing money-column violations (Decimal(15,2), stored balance columns) without blocking; once the migration lands, the lint moves to error.