Backend architecture & code organization for a Fastify + DI/ports backend (onion / ports-and-adapters style). Use when deciding WHERE backend code lives and which way dependencies flow — routes vs service vs repository, where business logic / DB access / external calls belong, ports & adapters, module boundaries. Trigger phrases: "where should this go", "onion architecture", "clean architecture", "service vs repository", "layering", "dependency direction", "ports and adapters", "business logic location", "module structure".
How this skill is triggered — by the user, by Claude, or both
Slash command
/engineering-paved-path:onion-architectureThe summary Claude sees in its skill listing — used to decide when to auto-load this skill
How to organize a Fastify backend: **where backend code lives, which layer owns it, and
How to organize a Fastify backend: where backend code lives, which layer owns it, and which direction dependencies flow. This skill is about placement, layering, and dependency direction only — it assumes a repository/ORM of your choosing (Drizzle, Prisma, knex, raw SQL, …) and doesn't prescribe query-authoring style, request/response schema design, or type-level decisions; those belong to their own skills if your project has them.
For Fastify route mechanics (hooks, schema providers, error handling), see
fastify-best-practices in this same plugin.
Detailed annotated layout: structure.md. Sources & rationale: README.md.
Coupling is toward the center. An inner layer never imports an outer one.
outer ─────────────────────────────────────▶ inner (imports allowed →)
routes.ts → service.ts → domain → ports → (DI wires infra in)
presentation application (types/ (interfaces
helpers) in your shared-contracts package)
Concretely, per feature module <backend>/src/modules/<name>/:
| Layer | File(s) | May import | Must NOT import |
|---|---|---|---|
| Presentation | routes.ts | service, _shared, platform/errors, shared contracts | the ORM, db/*, another module |
| Application | service.ts | repository, domain, the DI Container (ports) | the ORM/db/* directly, routes |
| Domain (core) | types.ts, constants.ts, helpers.ts | shared contracts, pure libs | Fastify, the ORM, adapters/*, db/* |
| Infrastructure | repository.ts, repository/*.repo.ts | the ORM, db/schema, db/client | routes, service (anything outward) |
| Ports & adapters | adapters/*, platform/container.ts | concrete SDKs (implementations) | — (composition root knows all) |
The port interfaces (e.g. an LLMProvider, a GitClient, a SecretsProvider, …) live in
a shared-contracts package your backend and frontend both consume; adapters/* are the
implementations; platform/container.ts is the composition root — the one place allowed
to wire concrete adapters to modules. Services depend on the interface via the container,
never on a concrete adapter class.
container.llm, container.github, …), map rows to DTOs (helpers). Thin.
No ORM calls, no Fastify types, no raw SQL.Db, returns rows. No business decisions, no HTTP.adapters/mocks.ts).<backend>/src/
├── server.ts # entrypoint
├── app.ts # Fastify assembly (plugins → modules → error handler)
├── modules/ # FEATURES — one onion per module
│ ├── index.ts # static module registry (one import + entry each)
│ ├── _shared/ # cross-module helpers (context, schemas) — promote here
│ └── <name>/
│ ├── routes.ts # presentation
│ ├── service.ts # application / use cases
│ ├── repository.ts # infrastructure (the ORM) [or repository/*.repo.ts]
│ ├── types.ts # domain types
│ ├── constants.ts # domain constants
│ └── helpers.ts # domain pure functions (DTO mappers, predicates)
├── adapters/ # PORTS' implementations: llm, github, git, secrets, …
│ └── mocks.ts # mock adapters for tests
├── platform/ # composition root + cross-cutting
│ ├── container.ts # DI container (wires adapters → services)
│ └── errors.ts, jobs.ts, sse.ts, …
└── db/ # schema, client, migrations, rows (never hand-edit migrations)
Eluding the layers is the mistake. Pick your own cleanest module as the reference for new
work — one with a thin routes.ts, an orchestration-only service.ts, and a repository.ts
that is the sole place the ORM appears.
| You are adding… | Layer | File | Rule |
|---|---|---|---|
| An HTTP endpoint | presentation | routes.ts | declare a schema; call one service method |
| Request validation | presentation | routes.ts (schema) | never hand-roll parsing inside the handler — use the schema provider |
| A use case / orchestration | application | service.ts | calls repository + ports; returns DTOs |
| A business rule / derived value | domain or application | helpers.ts (pure) or service.ts | pure → helpers; needs I/O → service |
| A DB read/write | infrastructure | repository.ts | the only place the ORM/db/* appears |
| An external call (LLM, GitHub, git) | port via DI | service.ts → container.<port> | behind a shared-contracts interface |
| A new external integration | adapter | adapters/<area>/ | implement the port interface; wire in container.ts |
| A type/contract | domain | types.ts or the shared-contracts package | reuse shared contracts; don't duplicate |
| A constant | domain | constants.ts | promote to _shared only on 2nd module |
| Something two modules need | shared | modules/_shared/ or platform/ | never import a sibling module |
adapters/, hidden behind an interface so it can be mocked.If a "service" imports the ORM, it isn't a service — push the query into the repository. If a "util/helper" performs I/O, it isn't domain — it's an adapter or repository method.
modules/index.ts.modules/a → modules/b). If two modules need the
same thing, promote it to modules/_shared, platform, or an adapter behind a port.platform/container.ts) may reach into modules to wire them.db/* imported in routes.ts. Route should call a service
instead. → route-no-direct-db (warn).service.ts. → app-no-direct-db (error).fastify-type-provider-zod). Declare the schema; let the provider reject
invalid input.helpers/constants/types importing Fastify, the ORM, or adapters.
→ domain-stays-pure (error).routes/service/repository. → no-cross-module-coupling (error).service.ts (or anywhere outside platform/container.ts)
directly instantiating a concrete adapter/repository class (new PgCheckoutRepository(),
new OpenAIAdapter(), …) instead of receiving it via the DI container. Importing
repository.ts from service.ts is fine (that's the allowed layer edge); constructing the
concrete class outside the composition root is not — it defeats mockability and hides the real
dependency graph. → di-discipline (error). Not caught by import-graph tools like
dependency-cruiser — that class of tool flags imports, not call-site (new X()) patterns;
this one is caught only by an agent's read of the actual code (see architecture-reviewer
in the architecture-review plugin).Codify these rules with an import-graph linter (e.g. dependency-cruiser) and run it as its own script, e.g.:
cd <backend> && <package-manager> run lint:arch
Treat inward-only violations, inline-DB-in-service, impure domain, and cross-module coupling as error-tier (breaks the build); treat DB-in-route and a stray cross-module constants import as warn-tier (documented debt to pay down later). Add the architecture lint to CI alongside your typecheck step.
routes.ts declares a schema and calls one service method — no ORM, no business rules.service.ts has no ORM/db/* import and no Fastify types; external calls go through container.<port>.repository.ts / *.repo.ts.types/constants/helpers are pure (no Fastify, ORM, adapters, I/O)._shared/platform/an adapter.npx claudepluginhub kolodgeeva/dev-digest-ai-marketplace --plugin engineering-paved-pathGuides completion of development work by verifying tests, detecting environment, and presenting structured options for merge, PR, or cleanup.
Enforces test-driven development: write failing test first, then minimal code to pass. Use when implementing features or bugfixes.
Guides creation and editing of skills using test-driven development with pressure scenarios and subagents to verify agent compliance.