refactor: strip Lazar references from top-level docs + guides

This commit is contained in:
2026-05-13 09:57:19 +02:00
parent 17ae157365
commit 06da37f723
8 changed files with 271 additions and 189 deletions

View File

@@ -5,7 +5,7 @@ tRPC router, CMS collection, DI container, and query builders — all owned
by one package under `packages/<feature>/`.
> **Prefer the generator.** `pnpm turbo gen feature` produces a
> Lazar-conformant single-entity / single-use-case package matching the
> A single-entity / single-use-case package matching the
> `navigation` reference shape (DI, tRPC router with tests, span + capture
> sandwich, dev seed, contract suite). See
> [Scaffolding a Feature](./scaffolding-a-feature.md). Use this guide when
@@ -44,20 +44,20 @@ For the fast path, run `pnpm turbo gen feature <name>` — the generator emits t
Every feature package owns:
| Layer | What lives there |
|---|---|
| `entities/models/` | Zod schemas + inferred TypeScript types |
| `entities/errors/` | Domain error classes (`this.name` required); `common.ts` for `InputParseError` |
| `application/repositories/` | Repository interface (no implementation) |
| `application/use-cases/` | One factory per operation; owns `xInputSchema`, `xOutputSchema`, and `xOutputSchema.parse()` |
| `infrastructure/repositories/` | Real (`<noun>.repository.ts`) and mock (`<noun>.repository.mock.ts`) siblings |
| `interface-adapters/controllers/` | One factory per use case; accepts `unknown`, calls `safeParse`, runs presenter |
| `di/` | `symbols.ts` + `module.ts` + `container.ts` + `bind-production.ts` |
| `integrations/api/` | `procedures.ts` (feature error map) + `router.ts` (uses `xProcedure.input(xInputSchema)`) |
| `integrations/cms/` | Payload collection/global configs |
| `ui/` | Query builders and future React components (behind `./ui` subpath) |
| `__factories__/` | Test data factories |
| `__contracts__/` | Contract suites shared by mock and real repository tests |
| Layer | What lives there |
| --------------------------------- | -------------------------------------------------------------------------------------------- |
| `entities/models/` | Zod schemas + inferred TypeScript types |
| `entities/errors/` | Domain error classes (`this.name` required); `common.ts` for `InputParseError` |
| `application/repositories/` | Repository interface (no implementation) |
| `application/use-cases/` | One factory per operation; owns `xInputSchema`, `xOutputSchema`, and `xOutputSchema.parse()` |
| `infrastructure/repositories/` | Real (`<noun>.repository.ts`) and mock (`<noun>.repository.mock.ts`) siblings |
| `interface-adapters/controllers/` | One factory per use case; accepts `unknown`, calls `safeParse`, runs presenter |
| `di/` | `symbols.ts` + `module.ts` + `container.ts` + `bind-production.ts` |
| `integrations/api/` | `procedures.ts` (feature error map) + `router.ts` (uses `xProcedure.input(xInputSchema)`) |
| `integrations/cms/` | Payload collection/global configs |
| `ui/` | Query builders and future React components (behind `./ui` subpath) |
| `__factories__/` | Test data factories |
| `__contracts__/` | Contract suites shared by mock and real repository tests |
The walkthrough below builds a minimal `comments` feature from scratch.
All concrete code mirrors the `blog` package (the most fully developed
@@ -108,7 +108,7 @@ packages/comments/
api/
procedures.ts # commentsProcedure with feature error map
router.ts # commentsProcedure.input(xInputSchema)
router.test.ts # includes R26 error-mapping assertions
router.test.ts # includes error-mapping assertions
index.ts
cms/
collections/
@@ -245,7 +245,13 @@ describe("commentSchema", () => {
it("rejects an empty body", () => {
expect(() =>
commentSchema.parse({ id: "c-1", articleId: "a-1", body: "", authorId: "u-1", createdAt: new Date() }),
commentSchema.parse({
id: "c-1",
articleId: "a-1",
body: "",
authorId: "u-1",
createdAt: new Date(),
}),
).toThrow();
});
});
@@ -285,7 +291,7 @@ pnpm test --filter @repo/comments -- comment.test.ts # GREEN
export class CommentNotFoundError extends Error {
constructor(message = "Comment not found", options?: ErrorOptions) {
super(message, options);
this.name = "CommentNotFoundError"; // required — R6
this.name = "CommentNotFoundError"; // required — R6
}
}
```
@@ -295,7 +301,7 @@ export class CommentNotFoundError extends Error {
export class InputParseError extends Error {
constructor(message: string, options?: ErrorOptions) {
super(message, options);
this.name = "InputParseError"; // required — R6
this.name = "InputParseError"; // required — R6
}
}
```
@@ -364,6 +370,7 @@ export const commentFactory = defineFactory<Comment>(({ sequence }) => ({
### Step 9: Use case — factory function with input/output schemas (RED → GREEN)
Every use case exports:
- `xInputSchema` — a `z.ZodObject` with `.strict()` (use `z.object({}).strict()` for void inputs)
- `xOutputSchema` — for non-void use cases
- `XInput` / `XOutput` types
@@ -404,14 +411,16 @@ describe("getCommentsUseCase", () => {
});
});
// R25 — output validation
describe("getCommentsUseCase output validation (R25)", () => {
// output validation
describe("getCommentsUseCase output validation", () => {
it("throws ZodError when the repository returns malformed data", async () => {
const repo = new MockCommentsRepository();
(repo as unknown as { _comments: unknown[] })._comments.push({ id: 123 });
const useCase = getCommentsUseCase(repo);
await expect(useCase({ articleId: "a-1" })).rejects.toBeInstanceOf(ZodError);
await expect(useCase({ articleId: "a-1" })).rejects.toBeInstanceOf(
ZodError,
);
});
it("exports getCommentsOutputSchema that validates Comment[]", () => {
@@ -448,7 +457,9 @@ export type IGetCommentsUseCase = ReturnType<typeof getCommentsUseCase>;
export const getCommentsUseCase =
(commentsRepository: ICommentsRepository) =>
async (input: GetCommentsInput): Promise<GetCommentsOutput> => {
const result = await commentsRepository.getCommentsForArticle(input.articleId);
const result = await commentsRepository.getCommentsForArticle(
input.articleId,
);
return getCommentsOutputSchema.parse(result);
};
```
@@ -586,7 +597,9 @@ describe("getCommentsController", () => {
it("throws InputParseError on unknown extra fields (strict)", async () => {
const repo = new MockCommentsRepository();
const ctrl = getCommentsController(getCommentsUseCase(repo));
await expect(ctrl({ articleId: "a-1", extra: true })).rejects.toBeInstanceOf(InputParseError);
await expect(
ctrl({ articleId: "a-1", extra: true }),
).rejects.toBeInstanceOf(InputParseError);
});
});
```
@@ -617,7 +630,9 @@ export const getCommentsController =
async (input: unknown): Promise<ReturnType<typeof presenter>> => {
const parsed = getCommentsInputSchema.safeParse(input);
if (!parsed.success) {
throw new InputParseError("Invalid get-comments input", { cause: parsed.error });
throw new InputParseError("Invalid get-comments input", {
cause: parsed.error,
});
}
const result = await getCommentsUseCase(parsed.data);
return presenter(result);
@@ -659,17 +674,27 @@ import {
import { COMMENTS_SYMBOLS } from "./symbols";
export const CommentsModule = new ContainerModule((bind: interfaces.Bind) => {
bind<ICommentsRepository>(COMMENTS_SYMBOLS.ICommentsRepository).to(MockCommentsRepository);
bind<ICommentsRepository>(COMMENTS_SYMBOLS.ICommentsRepository).to(
MockCommentsRepository,
);
bind<IGetCommentsUseCase>(COMMENTS_SYMBOLS.IGetCommentsUseCase).toDynamicValue((ctx) =>
bind<IGetCommentsUseCase>(
COMMENTS_SYMBOLS.IGetCommentsUseCase,
).toDynamicValue((ctx) =>
getCommentsUseCase(
ctx.container.get<ICommentsRepository>(COMMENTS_SYMBOLS.ICommentsRepository),
ctx.container.get<ICommentsRepository>(
COMMENTS_SYMBOLS.ICommentsRepository,
),
),
);
bind<IGetCommentsController>(COMMENTS_SYMBOLS.IGetCommentsController).toDynamicValue((ctx) =>
bind<IGetCommentsController>(
COMMENTS_SYMBOLS.IGetCommentsController,
).toDynamicValue((ctx) =>
getCommentsController(
ctx.container.get<IGetCommentsUseCase>(COMMENTS_SYMBOLS.IGetCommentsUseCase),
ctx.container.get<IGetCommentsUseCase>(
COMMENTS_SYMBOLS.IGetCommentsUseCase,
),
),
);
});
@@ -695,15 +720,21 @@ import { COMMENTS_SYMBOLS } from "@/di/symbols";
describe("commentsContainer", () => {
it("resolves ICommentsRepository", () => {
expect(commentsContainer.get(COMMENTS_SYMBOLS.ICommentsRepository)).toBeDefined();
expect(
commentsContainer.get(COMMENTS_SYMBOLS.ICommentsRepository),
).toBeDefined();
});
it("resolves IGetCommentsUseCase", () => {
expect(commentsContainer.get(COMMENTS_SYMBOLS.IGetCommentsUseCase)).toBeDefined();
expect(
commentsContainer.get(COMMENTS_SYMBOLS.IGetCommentsUseCase),
).toBeDefined();
});
it("resolves IGetCommentsController", () => {
expect(commentsContainer.get(COMMENTS_SYMBOLS.IGetCommentsController)).toBeDefined();
expect(
commentsContainer.get(COMMENTS_SYMBOLS.IGetCommentsController),
).toBeDefined();
});
});
```
@@ -733,9 +764,10 @@ export const commentsProcedure = t.procedure.use(
---
### Step 14: tRPC router (RED → GREEN, includes R26 error-mapping test)
### Step 14: tRPC router (RED → GREEN, includes error-mapping test)
The router:
- uses `commentsProcedure` (never bare `publicProcedure`)
- calls `.input(xInputSchema)` importing from the use-case file — never redefines the schema inline
- resolves controllers from the container
@@ -758,7 +790,9 @@ describe("commentsRouter", () => {
});
it("exposes getComments procedure", () => {
expect(Object.keys(commentsRouter._def.procedures)).toContain("getComments");
expect(Object.keys(commentsRouter._def.procedures)).toContain(
"getComments",
);
});
it("getComments returns empty array by default", async () => {
@@ -767,8 +801,8 @@ describe("commentsRouter", () => {
});
});
// R26 — error mapping
describe("commentsRouter (R26 error mapping)", () => {
// error mapping
describe("commentsRouter error mapping", () => {
beforeEach(() => {
commentsContainer.unbindAll();
commentsContainer.load(CommentsModule);
@@ -871,7 +905,11 @@ export class CommentsRepository implements ICommentsRepository {
async getComment(id: string): Promise<Comment | undefined> {
const payload = await getPayload({ config: this.config });
try {
const doc = await payload.findByID({ collection: "comments", id, overrideAccess: true });
const doc = await payload.findByID({
collection: "comments",
id,
overrideAccess: true,
});
return mapDoc(doc as PayloadCommentDoc);
} catch {
return undefined;
@@ -892,7 +930,11 @@ export class CommentsRepository implements ICommentsRepository {
const payload = await getPayload({ config: this.config });
const created = await payload.create({
collection: "comments",
data: { articleId: input.articleId, body: input.body, author: input.authorId } as never,
data: {
articleId: input.articleId,
body: input.body,
author: input.authorId,
} as never,
overrideAccess: true,
});
return mapDoc(created as PayloadCommentDoc);
@@ -917,13 +959,17 @@ describe("CommentsRepository", () => {
const store = new Map<string, Record<string, unknown>>();
const stub = {
findByID: vi.fn(async ({ id }: { id: string }) => store.get(id)),
find: vi.fn(async ({ where }: { where?: { articleId?: { equals: string } } }) => {
let docs = Array.from(store.values());
if (where?.articleId) {
docs = docs.filter((d) => d["articleId"] === where.articleId?.equals);
}
return { docs };
}),
find: vi.fn(
async ({ where }: { where?: { articleId?: { equals: string } } }) => {
let docs = Array.from(store.values());
if (where?.articleId) {
docs = docs.filter(
(d) => d["articleId"] === where.articleId?.equals,
);
}
return { docs };
},
),
create: vi.fn(async ({ data }: { data: Record<string, unknown> }) => {
const doc = { id: `stub-${store.size + 1}`, ...data };
store.set(String(doc.id), doc);
@@ -996,7 +1042,12 @@ export const comments: CollectionConfig = {
fields: [
{ name: "articleId", type: "text", required: true },
{ name: "body", type: "textarea", required: true },
{ name: "author", type: "relationship", relationTo: "users", required: true },
{
name: "author",
type: "relationship",
relationTo: "users",
required: true,
},
],
};
```
@@ -1089,7 +1140,9 @@ Add path aliases to `tsconfig.base.json`:
"@repo/comments/api": ["packages/comments/src/integrations/api/index.ts"],
"@repo/comments/ui": ["packages/comments/src/ui/index.ts"],
"@repo/comments/cms": ["packages/comments/src/integrations/cms/index.ts"],
"@repo/comments/di/bind-production": ["packages/comments/src/di/bind-production.ts"]
"@repo/comments/di/bind-production": [
"packages/comments/src/di/bind-production.ts"
]
}
}
}
@@ -1131,14 +1184,14 @@ All must pass before shipping.
## 4. Configuration Checklist
| File | Key items |
|---|---|
| `package.json` | `"type": "module"`; exports map with `.`, `./ui`, `./api`, `./cms`, `./di/bind-production`; `@repo/core-shared`, `inversify`, `zod`, `payload` in deps |
| `tsconfig.json` | `"rootDir": "."` (covers both `src/` and `tests/`); `"outDir": "dist"` |
| `vitest.config.ts` | `resolve.alias: { "@": path.resolve(__dirname, "./src") }` |
| `eslint.config.js` | extends `@repo/core-eslint`; tag set to `"feature"` in Turborepo `turbo.json` |
| `tsconfig.base.json` | path aliases for every subpath export |
| `turbo.json` | feature package must appear (or be glob-matched) in the workspace graph |
| File | Key items |
| -------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `package.json` | `"type": "module"`; exports map with `.`, `./ui`, `./api`, `./cms`, `./di/bind-production`; `@repo/core-shared`, `inversify`, `zod`, `payload` in deps |
| `tsconfig.json` | `"rootDir": "."` (covers both `src/` and `tests/`); `"outDir": "dist"` |
| `vitest.config.ts` | `resolve.alias: { "@": path.resolve(__dirname, "./src") }` |
| `eslint.config.js` | extends `@repo/core-eslint`; tag set to `"feature"` in Turborepo `turbo.json` |
| `tsconfig.base.json` | path aliases for every subpath export |
| `turbo.json` | feature package must appear (or be glob-matched) in the workspace graph |
---
@@ -1168,7 +1221,7 @@ All must pass before shipping.
`Promise<ReturnType<typeof presenter>>`. Identity (`return value`) is
fine, but the function must exist. Skipping it makes adding a view
transform later a structural change instead of a one-line edit
(ADR-013 R11).
(ADR-013).
5. **Adding feature error classes to `core-shared`.** `core-shared` must
stay boundary-clean — it provides `defineErrorMiddleware` but knows
@@ -1194,22 +1247,16 @@ All must pass before shipping.
## 6. Cross-References
- **ADR-012** (`docs/decisions/adr-012-lazar-conformance.md`) — factory-function
- **ADR-012** (`docs/decisions/adr-012-feature-conventions.md`) — factory-function
use cases and controllers, entity layout, file naming, one-controller-per-use-case,
`.toDynamicValue()` DI bindings, direct injection in tests.
- **ADR-013** (`docs/decisions/adr-013-input-output-unification.md`) — use-case
file as single source for `xInputSchema` + `xOutputSchema`; presenter pattern;
per-feature `procedures.ts` error map; public surface split (`./` vs `./ui`).
- **Refactor log — Plan 8** (`docs/superpowers/refactor-logs/2026-05-05-lazar-pattern-conformance.md`) —
file-by-file inventory of every rename, split, and pattern change applied
to all existing features.
- **Refactor log — Plan 9** (`docs/superpowers/refactor-logs/2026-05-06-input-output-unification.md`) —
inventory of schema additions, presenter additions, `procedures.ts` additions,
and `./ui` subpath additions across all 5 features.
- **CLAUDE.md** (root) — Key Conventions section is the quick-reference
summary; this guide is the authoritative walkthrough.
- **Architecture overview** (`docs/architecture/overview.md`) — canonical
data-flow diagram showing the full request path from React component
through tRPC, controller, use case, repository, and back.
- **TDD Workflow** (`docs/guides/tdd-workflow.md`) — required reading on
RED → GREEN discipline, direct factory injection, and R25/R26 test obligations.
RED → GREEN discipline, direct factory injection, and test obligations per layer.