VSA Modular Monolith — Architecture Decision Framework
This skill captures a coherent way to structure a Node.js/TypeScript backend so it is modern, scalable, and AI-agent-friendly, and to lay out the monorepo around it. It is a decision framework, not a template to paste. The value is in applying the right rule at the right moment and — equally — in refusing complexity the project has not earned yet.
The two principles everything derives from
-
Organize by axis of change, not by technical role. Layered architecture (
controllers/,services/,repositories/) scatters code that changes together and couples code that has nothing in common. VSA inverts the axis: group by business capability / use-case. Code that changes together lives together. Things that change together stay together. The same inversion rejects the mirror-image trap — entity/table modules (product/,order/as CRUD silos), which are layered rotated 90°. Carve by what the business does, not what it has (see Decision 0). -
Sharing exists for consistency, not for DRY. The shared core (a module's
domain/) is shared to enforce one consistent model and its invariants — not to eliminate duplication. A little duplication across slices is fine and expected. Only push code down into a shared place when it is a model concern (an invariant, a value object, a domain rule), never just because it repeats.
These two ideas resolve almost every concrete question below.
Canonical structure
apps/
backend/
src/
modules/ # = bounded contexts (large-scale boundary)
orders/
domain/ # [SHARED in module] model — persistence-ignorant
order.entity.ts # aggregate root + invariants
order.value-objects.ts # Money, OrderStatus, branded OrderId
order.policy.ts # domain service (logic across VOs in 1 aggregate)
order.events.ts # domain event definitions
order.repository.ts # PORT (interface), in domain language
infra/ # [SHARED in module] adapters to the outside
order.prisma-repo.ts # ADAPTER implementing the port
order.mapper.ts # entity <-> prisma row, entity <-> response DTO
order.read-model.ts # projection for the query side
features/ # NOT shared — vertical slices, one per use-case
create-order/ # COMMAND -> goes through domain
create-order.route.ts # thin HTTP wiring
create-order.handler.ts# orchestration; receives port via DI
create-order.schema.ts # Zod for THIS slice only
cancel-order/ # COMMAND -> order.cancel() enforces invariant
list-orders/ # QUERY -> hits Prisma directly, bypasses domain
public/ # [SHARED outward] published language / contract
orders.contract.ts
orders.module.ts # [SHARED] composition root: bind port->adapter, routes
checkout/ # a cross-cutting PROCESS promoted to its own context
saga/place-order.saga.ts
ports/ # contracts it DEPENDS on (other modules' public/)
features/place-order/
shared/ # real cross-cutting infra: db, http, errors, events, config
app.ts # global composition root
packages/ # monorepo: shared across FE + BE
shared/ # value objects, enums, branded types, JSON shapes (Zod). PURE.
contracts/ # DTOs (create/update/response), composed from shared
Scale interpretation: VSA at the small scale (inside features/), bounded contexts
at the large scale (modules/), a domain core per module (never global). Dependency
points inward: features -> domain <- infra; domain imports nothing from infra or Prisma.
This layout is a modular monolith: each module is independently extractable to a service
later because it is already self-contained and talks via contracts/events.
Decision 0 — is the boundary in the right place? (settle this before the five below)
The five decisions assume your modules/ are already carved along the right seams. The
most consequential — and most common — mistake happens before them: drawing modules by
entity/table (product/, review/, store/, order/) instead of by
capability/behavior/language. Entity-modules are CRUD-per-table disguised as
architecture — the same error as layered, rotated 90°: instead of horizontal layers you
get vertical silos, one per database table.
This is the cause of the pain in decision 4. Real business behavior ("checkout", "return", "fulfill") naturally spans several entities, so entity-modules maximize cross-module traffic. If most features cross modules, that is not the nature of the domain — it is evidence the boundaries are drawn wrong. Cross-module features should be the exception (a genuine process like checkout), not the rule.
The test for a module name: a noun may name a module only when it names a capability
with its own model and rules — not when it is just a table you are about to CRUD.
checkout, pricing, fulfillment pass. A bare product/ that only reads/writes the
product row fails: the same word "Product" is a different model in Catalog (marketing
copy), Inventory (a SKU + stock), Pricing (price + promo rules), Ordering (a price
snapshot at purchase time). Forcing all five into one Product module creates a
god-model every other module must import — the single strongest sign of a wrong
boundary, and the reason entity-modules end up anemic (the real invariants live in the
interactions between entities, which now have no home).
Two red flags to scan for:
- A module everyone imports (a
Product/User/Customergod-context) → boundaries drawn by data, not by language. - Most features touch 3+ modules → boundaries carved by entity; the real capability has no home and lives in the gaps.
To score a proposed carving against the ranked boundary criteria, and to discover
boundaries from scratch (event storming on verbs/events, plus the Product/Review/Store/
Order worked example): → references/boundary-discovery.md. Sizing ties back to "don't
extract prematurely" in decision 3 — when the domain is unclear, stay coarse and split
when a fault line repeats; too-fine drowns you in orchestration, too-coarse is a
monolith-in-a-monolith.
The five recurring decisions
Each decision has a rule below. For the reasoning, code, and edge cases, read the referenced file — load it only when the current task touches that decision.
1. How much abstraction does THIS slice need? (CQRS asymmetry)
Do not apply the same layering everywhere. Split by command vs query:
- Commands (writes) go through the domain. Load the aggregate via the repo port,
call a domain method that enforces the invariant, save the whole aggregate in one
transaction. This is where a
domain/layer earns its keep. - Queries (reads) bypass the domain entirely. Hit Prisma directly and project to a read DTO. Forcing reads through the aggregate is pure ceremony.
Add a domain/ + infra/ layer to a module only if it has real invariants / state
transitions and multiple writes touching one aggregate. A module that is mostly
CRUD/read should keep slices calling Prisma directly — paying the abstraction tax on
an anemic domain model (entities that are just getters/setters mirroring DB columns)
gives you all the cost and none of the benefit.
→ Details, entity/port/handler code: references/module-design.md
2. What is actually shared within a module?
Features never call each other. What they share is six named things, each with a
reason — and a hard rule that there is no _shared/, common/, or utils/ junk
drawer inside a module. If you cannot name why something is shared, it is
duplication that should stay local.
The six: domain/ (the model), infra/ (adapters), public/ (published contract),
the composition root (*.module.ts), shared vocabulary (value-object / enum / branded
schemas — NOT per-use-case request/response shapes), and module-specific error types.
Asymmetry: command slices share domain + infra; query slices share mostly infra +
read-model. Features don't call each other precisely because would-be shared behavior
is pushed down into domain — the existence of feature-to-feature need is the signal
something belongs in the domain.
→ Full taxonomy and the share/don't-share test: references/module-design.md
3. Does this feature need its own module, or should I extend an existing one?
It is a spectrum, not a binary:
- A new slice in an existing module — when it is just another operation on the same aggregate.
- A thin orchestration coordinator (no domain of its own) — when the feature is pure coordination across modules with no state/invariants of its own.
- A full new bounded context (domain + infra) — when the process itself has its own model, lifecycle, and invariants.
Strongest extraction signals, in order: (1) the business has a distinct capability name for it — a verb/process (checkout, onboarding, settlement) or a capability-noun with its own model and rules, not a bare table name (Decision 0); (2) it has its own lifecycle/invariants not owned by any existing aggregate; (3) implementing it would force one module to import another's internals. Weaker but real: different rate of change, host domain bloating with foreign concepts, different consistency needs, painful test setup.
Discipline: do not extract prematurely. A wrong boundary extracted early costs more than a slightly-too-big module. Extract when you have seen the fault line repeat, not when you are guessing it. Module extraction is the in-monolith version of the microservice-decomposition question — same heuristics.
→ The full signal list and counter-signals: references/cross-module-features.md
4. How do I handle a feature that spans many modules?
A cross-cutting feature forces three tiers of concern (not just "validate + act"):
- Modeling tier (highest): are my boundaries even right? Promote the process to its own context, or re-draw boundaries — don't just bolt on an orchestrator.
- Mechanism tier: validation (fail-fast for UX vs correctness enforced at the point of action — beware TOCTOU), action (orchestration saga vs choreography), the consistency & failure model (strong vs eventual; saga + compensation + idempotency), read-side composition (assembling a cross-module view for the response), and concurrency control.
- Operational ring: authorization spanning modules (often dominant in multi-tenant systems), observability/traceability of the flow, and versioning of the contracts it depends on.
Key invariants to remember: the aggregate is the transactional consistency boundary — you cannot (should not) wrap one DB transaction across modules, so cross-module consistency is eventual via saga + compensation. A rule spanning two aggregates cannot be an invariant, only a policy enforced with compensation. Steps whose failure should block the operation are synchronous in the saga; pure downstream side-effects go async via events.
→ Saga skeleton, compensation, idempotency, the tier map, and the Module Public API
pattern (public/ barrel + publicApi accessor — the concrete "exports" mechanism for
crossing a boundary, with the rules that keep it from leaking infra): references/cross-module-features.md
5. How do I share types between frontend and backend?
Two monorepo packages, with one rule that prevents the whole thing from rotting:
packages/shared— value objects, enums, branded types, and complex JSON property shapes (UI config stored as JSON), defined as Zod schemas withz.infertypes. Must be pure (zod + ts only, zero framework deps) so both domain and FE can depend on it. This is the published vocabulary, the subset of value objects that cross the wire.packages/contracts— request/response DTOs, composed fromshared.
The rule that prevents the entity leak: the entity never crosses a package boundary;
value objects do; DTOs are deliberate projections, never Pick<Entity> or auto-derived
from the entity. Enforce dependency direction with dependency-cruiser / Nx boundaries,
not code review: domain -> shared OK, contracts -> shared OK, FE -> contracts+shared;
but shared/contracts never depend on domain, domain never depends on contracts,
and FE never depends on the backend package at all.
Versioning gotcha: JSON property shapes evolve and break old rows — version them
(version: z.literal(1)) and make the shared Zod schema the validation gate at the DB
boundary (parse on read and write).
→ Package layout, code, dependency-cruiser rules, tRPC/ts-rest alternative:
references/monorepo-type-sharing.md
Anti-pattern checklist (scan before finalizing a design)
- A
services/orrepositories/folder at the top level → you are doing layered, not VSA. - A module named after a DB table that only CRUDs that one table (
product/,store/) → entity-silo, not a capability boundary; carve by capability (Decision 0). - One module every other module imports (a
Product/Usergod-model) → boundaries drawn by data, not by language. - Most features touch 3+ modules as the norm → boundaries carved by entity; the real capability has no home.
- A shared
order.schema.tsused by both create and update → use-case schemas must be per-slice. - An entity with only getters/setters and no behavior → anemic domain; either give it invariants or drop the domain layer for that module.
- A
_shared//utils/junk drawer inside a module → everything shared needs a named home. - One module importing another module's
domain/orinfra/→ cross viapublic/contract or events. - A response DTO typed as
Pick<Entity, ...>→ DTOs must be independent projections. - A
prisma.$transactionspanning two modules → cross-module consistency must be a saga. - Extracting a module from a guess rather than an observed repeated fault line → wait.
- Reaching for a saga/CQRS/full DDD when the module is plain CRUD at low scale → over-engineering.
Default stack assumptions
Node.js + TypeScript, Fastify (or Hono) for HTTP, Prisma for persistence, Zod for validation + single-source-of-truth typing, pnpm workspace monorepo (turborepo optional), Vitest + Testcontainers for tests. The principles are stack-agnostic; the code samples in the references use this stack. Provide Fedora/RPM-based shell commands by default.
Migrating an existing codebase to VSA
When the task is refactoring a layered/legacy backend into this layout (rather than greenfield design), work one module per session and follow the repeatable procedure:
→ Per-module migration steps (Phases A–H — incl. Phase G: test migration — Definition of Done, gotchas): references/migration-playbook.md
→ Dependency-ordered sequence + live progress tracker (which module is next): references/migration-roadmap.md
These two files are project-specific to apps/api (they name its modules and wiring); the playbook's
structure generalizes to any layered→VSA migration.
How to apply this skill
- If the question is about where to draw module boundaries or smells like entity-modules, start at Decision 0 — a wrong carving invalidates every answer below it.
- Identify which of the five decisions the user's question maps to (often more than one).
- State the relevant rule, grounded in the two principles, and apply it to their concrete case.
- Load the matching reference file only when you need the code or the edge-case reasoning.
- Actively flag over-engineering: if the user's scale doesn't justify the machinery, say so and recommend the simpler path. Challenging the premise only adds value when it changes the outcome.