Communitygithub.com

lenguyenhoangkhang2/agent-skills

name: vsa-modular-monolith

What is agent-skills?

agent-skills is a Claude Code agent skill that name: vsa-modular-monolith.

Works with~Claude Code~Codex CLI~Cursor
npx skills add lenguyenhoangkhang2/agent-skills

Ask in your favorite AI

Open a new chat with this agent skill pre-loaded.

Documentation

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

  1. 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).

  2. 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/Customer god-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 with z.infer types. 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 from shared.

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/ or repositories/ 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/User god-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.ts used 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/ or infra/ → cross via public/ contract or events.
  • A response DTO typed as Pick<Entity, ...> → DTOs must be independent projections.
  • A prisma.$transaction spanning 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

  1. 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.
  2. Identify which of the five decisions the user's question maps to (often more than one).
  3. State the relevant rule, grounded in the two principles, and apply it to their concrete case.
  4. Load the matching reference file only when you need the code or the edge-case reasoning.
  5. 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.

Related Skills