From 9c9d01b9f6e7f9077c623f6d94402b7fdbaf49d6 Mon Sep 17 00:00:00 2001 From: Danijel Martinek Date: Wed, 6 May 2026 16:45:29 +0200 Subject: [PATCH] docs(agents): root AGENTS.md updated for Plan 8 + Plan 9 conventions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per-Package Conventions now reflect post-Plan-9 reality: - Source-file relative-import example uses entities/models/ path - New subsections for use-case schemas (R1–R5), controller presenter (R7–R12), feature-scoped tRPC error mapping (R13–R17), and the per-feature public-API split (R18–R21) - bindProduction* + repository class examples align with the post-Plan-8 naming (no Payload prefix, dropped .js extensions in import samples) Refactor log doc-update checklist: AGENTS.md (root) item ticked. --- AGENTS.md | 179 +++++++++++++++++++++++++++++++++++++++++++++++------- 1 file changed, 156 insertions(+), 23 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 635e993..4bfd2ed 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -107,17 +107,25 @@ pnpm test --filter @repo/blog # Only blog unit/integration tests ## Per-Package Conventions +> These conventions reflect the post-Plan-8 (Lazar conformance) and post-Plan-9 (input/output unification) state. +> Canonical summary: `CLAUDE.md` § Key Conventions. +> Refactor logs: `docs/superpowers/refactor-logs/2026-05-05-lazar-pattern-conformance.md` (Plan 8) and `docs/superpowers/refactor-logs/2026-05-06-input-output-unification.md` (Plan 9). +> Decision records: `docs/decisions/adr-012-lazar-conformance.md` and `docs/decisions/adr-013-input-output-unification.md`. + ### Source files use RELATIVE imports (not @/) -Inside `src/` files, import from sibling layers using relative paths: +Inside `src/` files, import from sibling layers using relative paths (no `.js` extension — modern Node/Vitest resolves without it): ```typescript -// packages/blog/src/application/use-cases/get-article.use-case.ts -import type { IArticlesRepository } from "../repositories/articles.repository.interface.js"; -import { ARTICLES_REPOSITORY } from "../../di/symbols.js"; -import type { Article } from "../../entities/article.js"; +// packages/blog/src/application/use-cases/get-articles.use-case.ts +import type { IArticlesRepository } from "../repositories/articles.repository.interface"; +import { BLOG_SYMBOLS } from "../../di/symbols"; +import type { Article } from "../../entities/models/article"; ``` +Entity models live at `entities/models/.ts`; domain errors at `entities/errors/.ts`; the shared `InputParseError` at `entities/errors/common.ts`. +Mock siblings use the `.mock.ts` suffix (`.repository.mock.ts`); real repository impls drop the `Payload` prefix (`articles.repository.ts`); interface filenames are dot-separated (`articles.repository.interface.ts`). + This keeps source code portable and avoids circular alias issues. ### Test files use @/ alias @@ -125,8 +133,8 @@ This keeps source code portable and avoids circular alias issues. Test files (`*.test.ts`) use the `@/` alias to import from `src/`: ```typescript -// packages/blog/src/application/use-cases/get-article.use-case.test.ts -import { getArticleUseCase } from "@/application/use-cases/get-article.use-case.js"; +// packages/blog/src/application/use-cases/get-articles.use-case.test.ts +import { getArticlesUseCase } from "@/application/use-cases/get-articles.use-case"; ``` ### vitest.config.ts MUST declare @/ alias @@ -163,40 +171,165 @@ TypeScript configs must set `"rootDir": "."` to allow both `src/` and test files } ``` -### Payload-backed features use constructor injection +### Use cases own input + output schemas (Plan 9, R1–R5) -Feature packages that need Payload (e.g., `@repo/blog/infrastructure/repositories/payload-articles.repository.ts`) receive the Payload config via constructor, not via `@repo/core-cms` dependency: +Every use-case file exports its Zod schemas and inferred types. The use case body validates its output before returning — a misbehaving repository fails loudly at the layer that owns the contract. ```typescript -export class PayloadArticlesRepository implements IArticlesRepository { - constructor(private config: Config) {} +// packages/blog/src/application/use-cases/get-articles.use-case.ts +import { z } from "zod"; +import { articleSchema } from "../../entities/models/article"; +import type { IArticlesRepository } from "../repositories/articles.repository.interface"; - async getById(id: string): Promise
{ +// ── Input ──────────────────────────────────────────────────────────────── +export const getArticlesInputSchema = z + .object({ status: z.string().optional(), limit: z.number().int().optional() }) + .strict(); +export type GetArticlesInput = z.infer; + +// ── Output ─────────────────────────────────────────────────────────────── +export const getArticlesOutputSchema = z.array(articleSchema); +export type GetArticlesOutput = z.infer; + +// ── Use case ───────────────────────────────────────────────────────────── +export type IGetArticlesUseCase = ReturnType; + +export const getArticlesUseCase = + (articlesRepository: IArticlesRepository) => + async (input: GetArticlesInput): Promise => { + const result = await articlesRepository.getArticles(input); + return getArticlesOutputSchema.parse(result); + }; +``` + +Void-input use cases use `z.object({}).strict()` and accept `_input: XInput`. Void-output use cases (e.g. `signOutUseCase`, `deleteMediaUseCase`) export only `xInputSchema` — no `xOutputSchema`. + +Tests inject mocks directly — no container rebinding: + +```typescript +const repo = new MockArticlesRepository([]); +const useCase = getArticlesUseCase(repo); +const articles = await useCase({ status: "published" }); +``` + +### Controllers receive `unknown` + presenter (Plan 9, R7–R12) + +Controllers `safeParse(xInputSchema)` from the use-case file and throw `InputParseError` on failure. Every non-void controller defines a top-level `function presenter(value: XOutput)` and returns `Promise>`. Identity is fine — `return value` — but the function form is always present so adding a transform later is a one-line edit. + +```typescript +// packages/blog/src/interface-adapters/controllers/get-articles.controller.ts +import { InputParseError } from "../../entities/errors/common"; +import { + getArticlesInputSchema, + type GetArticlesOutput, + type IGetArticlesUseCase, +} from "../../application/use-cases/get-articles.use-case"; + +function presenter(value: GetArticlesOutput) { + return value; +} + +export type IGetArticlesController = ReturnType; + +export const getArticlesController = + (getArticlesUseCase: IGetArticlesUseCase) => + async (input: unknown): Promise> => { + const parsed = getArticlesInputSchema.safeParse(input); + if (!parsed.success) { + throw new InputParseError("Invalid input", { cause: parsed.error }); + } + return presenter(await getArticlesUseCase(parsed.data)); + }; +``` + +Void controllers (e.g. `signOutController`, `deleteMediaController`) return `Promise` and skip the presenter entirely. One controller file per use case — no multi-method controller files. + +DI binds each factory with `.toDynamicValue()`: + +```typescript +bind(BLOG_SYMBOLS.IGetArticlesUseCase) + .toDynamicValue((ctx) => getArticlesUseCase(ctx.container.get(BLOG_SYMBOLS.IArticlesRepository))); +``` + +### Feature-scoped tRPC error mapping (Plan 9, R13–R17) + +Each feature owns `integrations/api/procedures.ts` that wires domain errors to tRPC codes. `core-shared` provides the `defineErrorMiddleware` factory but never enumerates feature error classes. + +```typescript +// packages/blog/src/integrations/api/procedures.ts +import { t } from "@repo/core-shared/trpc/init"; +import { defineErrorMiddleware } from "@repo/core-shared/trpc/define-error-middleware"; +import { ArticleNotFoundError } from "../../entities/errors/article"; +import { InputParseError } from "../../entities/errors/common"; + +export const blogProcedure = t.procedure.use( + defineErrorMiddleware([ + [InputParseError, "BAD_REQUEST"], + [ArticleNotFoundError, "NOT_FOUND"], + ]), +); +``` + +The router then uses `blogProcedure.input(xInputSchema)` for every procedure — schemas are imported from the use-case file, never redefined inline. Unmapped errors still surface as `TRPCError(code: INTERNAL_SERVER_ERROR)`; the original domain error is preserved as `.cause`. + +### Per-feature public-API surface (Plan 9, R18–R21) + +Each feature package exposes exactly these subpath exports: + +| Subpath | What it exports | Who consumes | +|---|---|---| +| `.` (root) | Contracts only: types, errors, schemas, `IUseCase` / `IController` aliases, router type, constants | Any consumer | +| `./ui` | Query builders (`queryOptions`), UI components | App packages | +| `./api` | tRPC router (`xRouter` + `XRouter` type) | `@repo/core-api` only | +| `./cms` | Payload collections | `@repo/core-cms` only | +| `./di/bind-production` | App boot side-effect | App packages only | + +Apps import schemas/types from `@repo/` (root) and React Query builders from `@repo//ui`. Deep source paths are not accessible — the `exports` map enforces this. + +### Payload-backed features use constructor injection + +Feature packages that need Payload receive the `SanitizedConfig` via constructor, not via `@repo/core-cms` dependency: + +```typescript +// packages/blog/src/infrastructure/repositories/articles.repository.ts +@injectable() +export class ArticlesRepository implements IArticlesRepository { + constructor(private config: SanitizedConfig) {} + + async getArticles(options?: { status?: string; limit?: number }): Promise { const payload = await getPayload({ config: this.config }); - return payload.findByID({ collection: "articles", id }); + // ... } } ``` -The config comes from the app at boot time (see below). +Class names carry no `Payload` prefix — `ArticlesRepository`, `PagesRepository`, `HeaderRepository`, etc. The config comes from the app at boot time (see below). ### Apps call `bindProduction*()` per feature at boot -Each app (`web-next`, `web-tanstack`, `cms`) imports feature containers and binds production Payload repos at startup: +Each app (`web-next`, `web-tanstack`, `cms`) imports feature `bindProduction*` functions and calls them at startup to swap mock implementations for Payload-backed ones: ```typescript -// apps/web-next/src/app/layout.tsx (Next.js) -import { bindProductionArticles } from "@repo/blog/di/bind-production"; -import { bindProductionUsers } from "@repo/auth/di/bind-production"; +// apps/web-next/src/server/bind-production.ts +import { bindProductionBlog } from "@repo/blog/di/bind-production"; +import { bindProductionAuth } from "@repo/auth/di/bind-production"; +import { bindProductionMarketingPages } from "@repo/marketing-pages/di/bind-production"; +import { bindProductionNavigation } from "@repo/navigation/di/bind-production"; import { bindProductionMedia } from "@repo/media/di/bind-production"; -import { payloadConfig } from "@repo/core-cms"; +import config from "@repo/core-cms"; -// At app boot: -await bindProductionArticles(blogContainer, payloadConfig); -await bindProductionUsers(authContainer, payloadConfig); -// ... etc for each feature +export async function bindAllProduction(): Promise { + const resolvedConfig = await config; + bindProductionAuth(resolvedConfig); + bindProductionBlog(resolvedConfig); + bindProductionMarketingPages(resolvedConfig); + bindProductionNavigation(resolvedConfig); + bindProductionMedia(resolvedConfig); +} ``` +Actual function names: `bindProductionAuth`, `bindProductionBlog`, `bindProductionMarketingPages`, `bindProductionNavigation`, `bindProductionMedia`. + --- ## Specification & Guides