Enforces Onion Architecture (dependencies point inward) on a TypeScript backend (a Fastify + Drizzle + Zod server/ package and a pure domain core, reviewer-core in the reference layout). Use this skill whenever you add or change a backend route, service, repository, adapter, DB query, contract, or DI wiring — and whenever deciding WHERE a piece of logic, an external-tool call (LLM/OpenAI/Anthropic/OpenRouter, GitHub/octokit, git, ripgrep/ast-grep, Postgres), a type, or a validation belongs across layers. Also use when reviewing layering, an import that crosses package/layer boundaries, a leaked Drizzle query, a directly-instantiated SDK client, or business logic creeping into a route. Trigger terms: onion architecture, layer, dependency direction/rule, ports and adapters, repository, service, adapter, composition root, container, dependency inversion, where should this live, layering violation.
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
Keeps the backend's dependencies pointing **inward**: the pure domain core
Keeps the backend's dependencies pointing inward: the pure domain core
(reviewer-core) knows nothing about the database, GitHub, git, or any SDK;
those are replaceable outer details reached only through interfaces. This skill is
about layers, dependency direction, and where each kind of code lives — not
how to write a Fastify route, a Drizzle query, or a Zod schema (those have their
own skills).
For a good/bad code example per rule see examples.md; for sources and the canonical definition see references.md.
Sibling skills (do not duplicate — defer to them for mechanics):
engineering-paved-path:fastify-best-practices → route/plugin/hook mechanics, JSON-schema validation, error handling.safeParse, z.infer, refinements) → dedicated ORM/validation skills, when the host project provides them.engineering-paved-path:react-frontend-architecture → the same "where does it live" question for the frontend.Dependencies may point only inward (toward the core). Nothing inner may import anything outer.
| Layer | Where | Tool |
|---|---|---|
| Presentation / edge | server/src/modules/<f>/routes.ts | Fastify 5 |
| Application services | server/src/modules/<f>/service.ts (+ run-executor.ts, helpers.ts) | — |
| Ports (interfaces) | server/src/vendor/shared/adapters.ts + Zod contracts in vendor/shared/contracts/ | Zod 3 |
| Infrastructure adapters | server/src/adapters/** (llm, github, git, astgrep, codeindex, embedder, depgraph, tokenizer, secrets, auth, skill-import) | vendor SDKs |
| Data access (repositories) | server/src/modules/<f>/repository.ts (+ repository/*.repo.ts) | Drizzle 0.38 |
| Composition root (DI) | server/src/platform/container.ts | hand-rolled |
| Domain / application core (pure) | reviewer-core/src/** | pure TS |
The table above is a reference layout — map the names onto the host project's packages. This skill's job is to keep new code inside those boundaries, because they quietly erode exactly when a change "feels routine".
The database, the LLM, GitHub, and git are external details, not the center. Inner layers define interfaces; outer layers implement them. When you don't know where something goes, ask: "what does this depend on?" — and place it so its dependencies point inward, never outward.
A review loses credibility on false positives. These look like violations but are how this codebase is designed to work — do not report them:
container.db (e.g.
new SkillsRepository(container.db) in the service constructor) — the repository is
module-private and the test seam stays the injected db/Container (rule 3's sanctioned
exception). Constructing another module's repository is still a violation (rule 7).reviewer-core importing @app/shared — that IS the allowed inward direction
(rule 8); only server-internal imports are the forbidden back-edge.reviewer-core — purity is the bar, not emptiness; pure logic
belongs in the core, and moving it out is not a fix (rule 1).reviewer-core/src/llm/ — the core is allowed to ship the
sanctioned LLMProvider implementation (e.g. OpenRouterProvider); everything else in the
core depends on the interface, never the SDK (rule 1).platform/container.ts — the hand-rolled DI
intentionally passes the Container back into services it constructs; it is excluded from
no-circular by design (TD-001).reviewer-core stays pure (CRITICAL)reviewer-core is the innermost layer. Its only side effect is the injected
LLMProvider. No db, octokit, fs, simple-git, fetch, or process.env.
The diff is an input, never something it fetches. If you reach for I/O here, the
logic belongs in the server (a service or adapter), not in the core.
Why: purity is what lets the core run identically in every host (locally, in tooling,
in the CI runner), and be tested with a fake LLMProvider and no infrastructure. One real import of
fs/db ends that.
Every outside system is reached through an interface declared in
server/src/vendor/shared/adapters.ts (LLMProvider, GitHubClient, GitClient,
CodeIndex, Embedder, SecretsProvider, AuthProvider). Services and the core
depend on the interface, never on a concrete adapter class.
Never import { OpenAIProvider } (or Octokit, simple-git, the postgres
client…) into a service or the core. That inverts the dependency arrow and forces
real network/credentials into tests.
Concrete adapters and other modules' (shared) repositories are constructed only in
server/src/platform/container.ts (lazily, resolving secrets). Everything else
receives them via the Container. Tests inject fakes through ContainerOverrides
— that is the whole point of the indirection.
One sanctioned exception (this is how the codebase already works): a module's service
MAY construct its own repository from container.db — e.g. SkillsService does
new SkillsRepository(container.db) in its constructor. The repository is
module-private and the test seam remains the injected db/Container. Constructing
another module's repository this way is still a violation — reach shared entities
through the container facade (rule 7).
A new SomeAdapter(...) outside the container is a smell: it can't be overridden in
tests and it hard-wires a choice the composition root should own.
Drizzle (db, t.* tables, eq/and/desc) appears only in
modules/<f>/repository.ts / repository/*.repo.ts. Services and routes call
repository methods; they never build queries. Repositories return domain
types / rows, not a Drizzle query builder or a half-built query — that would leak
the ORM upward and let callers depend on its shape.
Why: the repository is the seam that keeps "what we store" swappable and lets the service be tested without a database. A leaked query builder re-couples every caller to Drizzle.
Validate once, at the edge: parse untrusted input at the route and parse/serialize
output via fastify-type-provider-zod, using the shared schemas in
@app/shared (vendor/shared/contracts/). Inward of that boundary, code works
with the already-parsed types (z.infer) — parse, don't validate; don't
re-validate the same data deeper in. Don't redefine a contract per layer; extend
@app/shared with a new file (never edit the barrel).
The tell-tale antipattern is a req.body as {...} cast (untyped trust at the edge)
followed by scattered typeof x === 'string' / if (!x) re-checks in the handler
and service. These are two symptoms of the same missing edge-parse, so fix them as
one change: replace the cast with a single Schema.parse(req.body) at the route, and
then delete the downstream manual re-checks — once the payload is parsed at the
boundary, every inward typeof/presence guard on those fields is dead code, because the
type is already guaranteed. Flag the cast and its now-redundant re-checks together as one
finding, not as two unrelated ones.
Why: one gate means one place to trust. Re-validation inward is dead weight and drifts out of sync; redefining shapes per layer breaks the single source of truth.
A route does three things: read context/auth, parse the request with a contract, call
one service method, return its result. No business logic, no Drizzle, no adapter
calls, no new-ing services-with-logic in the handler body.
Why: keeping orchestration in the service (not the handler) is what lets the same logic be reused (CI runner, jobs) and tested without an HTTP layer.
Cross-cutting subsystems are reached only through their published facade —
repo-intel only via container.repoIntel.*, never by importing files from
modules/repo-intel/'s internal pipeline. Shared entities (agents, reviews) are
reached via container.agentsRepo / container.reviewRepo, not by deep-importing
another module's repository.
Why: the facade is the contract; reaching past it couples you to internals that are free to change behind it.
reviewer-core must never import from server. @app/shared must import
nothing runtime (only Zod + its own contracts) — it sits at the center so every
package can depend on it. The arrows: server → reviewer-core → shared and
server → shared; never the reverse.
A reviewer-core → server import is CRITICAL, not merely HIGH: it both breaks
the inward-only dependency rule and leaks the outer package into the core, so the
core stops being pure and shareable — that is a rule 1 purity break, not a mild
coupling. Rank a core→server back-edge at the top tier, alongside the rule 1/2
findings — even when the imported symbol is only a type or an error class. Other
direction issues (e.g. @app/shared importing something runtime, which
couples the center outward) stay HIGH.
Why: a back-edge (core importing server) makes the "pure, shareable" core un-shareable and creates import cycles.
Conventions erode; a check in CI doesn't. This is already wired — do not propose
adding it: the forbidden rules live in server/.dependency-cruiser.cjs (the source of
truth), run via pnpm arch:check (from server/), and gate CI in
.github/workflows/server-unit.yml. Run pnpm arch:check before/after a backend
change — a new violation is a failing build, not a warning. What it forbids today:
reviewer-core → server internals, except the shared contracts (no-core-to-server, rule 8)fs, os, child_process, http…) anywhere in reviewer-core, infra
SDKs (simple-git, octokit, postgres, drizzle-orm) in the core, and the LLM vendor SDK
outside the sanctioned provider impl in reviewer-core/src/llm/ (rule 1)@app/shared (vendored at src/vendor/shared) → anything but zod + its own contracts (shared-stays-pure, rule 8)modules/**/{routes,*service}.ts → drizzle-orm and → db/schema (rule 4: DB only in repositories)modules/**/*service.ts & reviewer-core/** → concrete adapters/** impls (rule 2)repo-intel's pipeline/service/repository, and any cross-module
repository import (rule 7)no-circular, now error per TD-001; the intentional
composition-root cycle through platform/container.ts is excluded by path).When you add a boundary the config doesn't yet cover, extend that file — never invent a
parallel one. Not everything is mechanizable (e.g. "business logic in a route", rule 6;
fetch as a global in the core, rule 1) — those stay a review-time judgment. See
examples.md for the live forbidden block. eslint-plugin-boundaries is an
optional second, in-editor line of defense (faster feedback than CI).
When adding backend code, ask in order:
@app/shared.npx claudepluginhub syukpublic/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.