First slice of the combined Plan 8 + Plan 9 doc-update pass: - CLAUDE.md Key Conventions: append schema-in-use-case, presenter, controller unknown input, feature-scoped tRPC error mapping, public surface split (./ui) - packages/core-shared/AGENTS.md: document defineErrorMiddleware export + t re-export from trpc/init - docs/superpowers/plans/2026-05-05-plan-8-*.md and matching spec: one-line note that some controller/router patterns shifted in Plan 9; link to the Plan 9 refactor log - docs/architecture/overview.md: data-flow box now shows xProcedure + xInputSchema + xOutputSchema.parse + presenter + middleware lanes; three explanatory paragraphs added (schemas, presenter, error mapping) - docs/architecture/dependency-flow.md: app-side ./ui subpath note, allowed/disallowed examples updated for Plan 9 paths Remaining doc-pass items (root AGENTS.md, per-feature AGENTS.md ×5, core-testing AGENTS.md, adding-a-feature.md, tdd-workflow.md, testing-strategy.md, vertical-feature-spec.md) follow in subsequent commits — to be dispatched in parallel.
124 lines
6.3 KiB
Markdown
124 lines
6.3 KiB
Markdown
# Architecture Overview
|
|
|
|
A vertical-feature monorepo. Business capabilities are top-level packages; non-business foundations are `core-*`.
|
|
|
|
## Package map
|
|
|
|
```
|
|
packages/
|
|
# Foundation (no business logic)
|
|
core-shared/ Generic primitives — Payload field/block helpers, tRPC init/context, lib utilities
|
|
core-cms/ Composition only: assembles feature CMS exports into one Payload config
|
|
core-api/ Composition only: aggregates feature tRPC routers into one appRouter
|
|
core-trpc/ Frontend tRPC client + per-framework providers (Next.js, TanStack)
|
|
core-ui/ Design-system primitives (atoms, molecules, generic organisms, templates)
|
|
|
|
# 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 + boundary rules
|
|
core-typescript/ Shared tsconfig + vitest base
|
|
```
|
|
|
|
## 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** (Plan 9): 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** (Plan 9): 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** (Plan 9): 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 package exposes a small public surface (`.`, `./cms`, `./api`, `./di/bind-production`)
|
|
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** (3 packages): `packages/core-api`, `packages/core-cms`, `packages/core-trpc`
|
|
- **core** (2 packages): `packages/core-shared`, `packages/core-ui`
|
|
- **feature** (5 packages): `packages/auth`, `packages/blog`, `packages/media`, `packages/marketing-pages`, `packages/navigation`
|
|
- **tooling** (2 packages): `packages/core-eslint`, `packages/core-typescript`
|
|
|
|
**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, tooling |
|
|
| tooling | tooling |
|
|
|
|
**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.
|