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 inapps/api/src/{auth,business,employee}/.- The
libs/name doesn't say what kind of thing lives there.dto,ui,schemaat 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:
apps/— composition roots only. NestJS bootstrap + Next.js apps. No business logic. Each app named for its audience (*-webfor frontends).services/— independently deployable systems (today: Go ledger, integration-gateway, notifications worker). Not composition roots; not frontends.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:
infra/— how it runs (terraform, helm, argocd, docker for local dev).tooling/— how the repo is governed (custom eslint rules, prisma-lint, generators, scripts).docs/— internal knowledge (adr, runbooks, architecture, onboarding, security).
Alternatives considered
- Keep
libs/and re-tag. Rejected. The name doesn't carry meaning; thelibs/{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 — thedomain/andapplication/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
*-webfrontend rename. - ADR-011 codifies cross-domain event-only communication.
tooling/generators/shipsdomain-packageandshared-packagegenerators so the structure is reproducible without hand-rolling.- A separate PR introduces
pnpm prisma-lintto flag the existing money-column violations (Decimal(15,2), stored balance columns) without blocking; once the migration lands, the lint moves to error.