Files
agentic-dev-template/docs/architecture/overview.md
Danijel Martinek b455ae8018 docs(architecture): correct package lists and feature-to-feature boundary
Align the architecture docs with the current repo:

- Boundary matrix: feature may depend on core, feature, tooling — a
  feature may import another feature's public exports. overview.md,
  dependency-flow.md, and vertical-feature-spec.md all said the stale
  `feature -> core, tooling`.
- Optional-core lists completed with core-analytics, core-consent,
  core-dsr; tooling list completed with core-testing.
- Package count corrected to the accurate 19-package breakdown.
- BindContext table gained analytics, consentFactory, rateLimit.

Deeper drift (the HTML explainers, vertical-feature-spec §5/§9.5/§11)
is tracked in the local .tmp/ working note, not yet addressed.
2026-05-22 09:53:36 +02:00

9.0 KiB

Architecture Overview

A vertical-feature monorepo. Business capabilities are top-level packages; non-business foundations are core-*.

Package map

packages/
  # Must-have foundation (no business logic)
  core-shared/    Generic primitives — Payload field/block helpers, tRPC init/context,
                  instrumentation interfaces, audit protocol + truncate-ip + AuditEntry shape,
                  BindContext + protocol types for optional cross-cutting cores
  core-cms/       Composition only: assembles feature CMS exports into one Payload config
  core-api/       Composition only: aggregates feature tRPC routers into one appRouter

  # Optional cross-cutting cores (scaffold on demand via `pnpm turbo gen core-package <name>`)
  core-trpc/      Frontend tRPC client + per-framework providers (Next.js, TanStack)
  core-ui/        Design-system primitives (atoms, molecules, generic organisms, templates)
  core-realtime/  Socket.IO server + broadcaster + handler registry (ADR-016)
  core-events/    In-memory + Payload-backed event bus + job queue (ADR-015)
  core-audit/     DPA-compliant audit logging — sinks, hook factories, eraseSubject (ADR-018)
  core-analytics/ Product analytics capture channel (ADR-024)
  core-consent/   Consent records + cookie-consent banner (ADR-025)
  core-dsr/       Data-subject-rights — export, delete, rectify, restrict (ADR-025)

  # Business capabilities
  auth/             Users + sign-in/sign-up/sign-out + session/cookie domain
  blog/             Articles collection + publishing flow
  media/            Media upload collection (skeleton; expand with optimization, CDN, etc.)
  marketing-pages/  Pages collection + SiteSettings global
  navigation/       Header global + menu items

  # Tooling
  core-eslint/     Shared ESLint flat config + conformance rules + boundary rules
  core-typescript/ Shared tsconfig + vitest base
  core-testing/    Factories, contract suites, recording test doubles

See docs/architecture/template-tiers.md for the must-have/optional split and the scaffold commands.

Data flow

React component
  ↓ useQuery(trpc.blog.articleBySlug.queryOptions({slug}))      ← @repo/<feature>/ui (queries)
HTTP /api/trpc
  ↓
tRPC procedure (xProcedure.input(xInputSchema))               ← integrations/api/router.ts
  ↓ xProcedure has defineErrorMiddleware applied                ← integrations/api/procedures.ts
  ↓ container.get<IXController>(SYMBOL)
Controller factory (xInputSchema.safeParse)                   ← interface-adapters/controllers/<verb-noun>.controller.ts
  ↓ (useCase) => async (input: unknown) => Promise<view>
Use case factory                                              ← application/use-cases/<verb-noun>.use-case.ts
  ↓ (deps) => async (input: XInput) => XOutput
  ↓ ends with xOutputSchema.parse(result)
Repository implementation                                     ← infrastructure/repositories/<noun>.repository.ts
  ↓ getPayload({ config })
Payload Local API → Postgres
                                                              ↓ on throw:
                                            domain error → defineErrorMiddleware → TRPCError(code, cause)
                                                              ↓ on success:
                                            controller's `function presenter(value: XOutput)` shapes the view
                                                              ↓
                                                          tRPC response

Use cases and controllers are factory functions — they take their dependencies as arguments and return the callable. The container wires them via .toDynamicValue((ctx) => factoryFn(ctx.container.get(...))). Each exports export type I*UseCase = ReturnType<typeof xUseCase> (and the analogous I*Controller) so consumers can depend on the type without importing the impl. Controllers are one per use case — no multi-method controller files.

Schemas live in the use-case file: every use case exports xInputSchema (a z.ZodObject with .strict(); z.object({}).strict() for void inputs) and, for non-void use cases, xOutputSchema. Controllers and tRPC procedures import the schema — never redefine it. The use case body validates its output via xOutputSchema.parse(...) before returning, so a misbehaving repository fails loudly at the layer that owns the contract.

Controllers reshape via a co-located presenter: every non-void controller defines a top-level function presenter(value: XOutput) and returns Promise<ReturnType<typeof presenter>>. Identity is fine — return value; — but the function form is always present so adding a transform later is a one-line edit. Void controllers (e.g. signOutController, deleteMediaController) return Promise<void> and skip the presenter.

Domain errors map to TRPCError per feature: each feature owns integrations/api/procedures.ts exporting xProcedure = t.procedure.use( defineErrorMiddleware([[Ctor, "TRPC_CODE"], ...])). Routers use xProcedure instead of bare publicProcedure. core-shared provides the defineErrorMiddleware factory but never enumerates a feature's errors — each feature passes its own constructors in.

Three enforcement layers

  1. package.json deps — only declare allowed deps
  2. exports map — each feature exposes a small public surface (., ./ui, ./cms, ./api, ./di/bind-production, ./di/bind-dev-seed)
  3. Two parallel automated checks:
    • ESLint eslint-plugin-boundaries (lint-time) — enforces boundary rules at linting
    • Turborepo boundaries (build-graph time) — validates entire workspace dependency graph, including transitive reaches

Both use the same five-tag model; see "Five tags" section below.

Five tags

The workspace is organized into five mutually exclusive tags:

  • app (4 packages): apps/cms, apps/web-next, apps/web-tanstack, apps/storybook
  • core-composition (2 must-have): packages/core-api, packages/core-cms. Plus packages/core-trpc when scaffolded via pnpm turbo gen core-package trpc (optional).
  • core (1 must-have): packages/core-shared. Plus core-ui, core-realtime, core-events, core-audit, core-analytics, core-consent, core-dsr when scaffolded via pnpm turbo gen core-package <name> (optional).
  • feature (5 packages): packages/auth, packages/blog, packages/media, packages/marketing-pages, packages/navigation
  • tooling (3 packages): packages/core-eslint, packages/core-typescript, packages/core-testing

See docs/architecture/template-tiers.md for the must-have/optional split and the scaffold commands.

Allowed dependency directions:

Tag May depend on
app app, core, core-composition, feature, tooling
core-composition core, core-composition, feature, tooling
core core, core-composition, tooling
feature core, feature, tooling
tooling tooling

A feature may import another feature's public exports — its @repo/<feature> contract barrel (types, errors, schemas, event contracts). It must not reach another feature's internals (the exports map seals those), and cross-feature behaviour still flows through IEventBus — a feature never imports and invokes another feature's use cases directly.

Composition exceptions:

  • core-api may import from @repo/<feature>/api subpath exports only
  • core-cms may import from @repo/<feature>/cms subpath exports only
  • core-trpc reaches features transitively through core-api's AppRouter type

Per-feature DI containers

Each feature owns its own InversifyJS Container + symbol table. No shared symbols, no cross-feature DI coupling. Tests rebind per feature without touching others. Apps call bindProduction*(config) per feature at boot to swap the default mock implementations for Payload-backed ones.

Spec reference

docs/architecture/vertical-feature-spec.md is the canonical design.

Interactive explainers

Four single-file HTML walkthroughs sit alongside this overview. They're self-contained (no build step) and best viewed locally in a browser.

The four pages cross-link to each other; entering any of them surfaces the others.