Curated, product-agnostic snapshot of the post-story-04 tree: demo content deleted, auth-only reference feature, web-next shell, all gates green. Product-specific docs, ADRs 027-029, PRDs/epics/archive, editor library traces, and product naming are curated out; generic template repairs (coverage provider devDeps, root test:coverage script, live lint fixes, root-only release-please) are kept. See TEMPLATE.md for provenance, curation list, and usage. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016j8z4VHjedXDTjEDNg7qHK
1264 lines
41 KiB
Markdown
1264 lines
41 KiB
Markdown
# Adding a New Feature — End-to-End Guide
|
|
|
|
A feature is a vertical slice: domain entities, use cases, repositories,
|
|
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
|
|
> 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
|
|
> the generator's default scope doesn't fit — multi-entity layouts, custom
|
|
> shapes, or extending an existing feature — or when you need to understand
|
|
> what the generator emits and why.
|
|
|
|
**New feature or extension?** A new capability gets a new package (e.g.,
|
|
`packages/comments`). Adding an operation to an existing feature (e.g.,
|
|
a `publishArticle` procedure to `packages/blog`) means extending that
|
|
package — create new files alongside the existing ones, following the same
|
|
per-use-case patterns below.
|
|
|
|
> **TDD Order Required.** Write a failing test before each implementation
|
|
> file. Advance to the next layer only after the current layer is green.
|
|
> See [TDD Workflow](./tdd-workflow.md).
|
|
|
|
---
|
|
|
|
## Workflow ordering
|
|
|
|
For any new use case, follow these four steps in order:
|
|
|
|
1. **Manifest entry** — declare the use case in `src/feature.manifest.ts` with its `mutates` flag and (initially empty) `audits` / `publishes` / `consumes` / `reads` arrays.
|
|
2. **Contracts** — export `xInputSchema`, `xOutputSchema`, and the `IXUseCase` type alias from the use-case file. Factory body starts as `throw new Error("not implemented")`.
|
|
3. **Tests (red)** — write the failing test that exercises the contract via the factory + a mock repository.
|
|
4. **Implementation (green)** — fill the factory body until the tests pass.
|
|
|
|
The `feature-must-have-manifest` ESLint rule will catch step 1 omissions; `usecase-must-have-test-file` catches step 3. The boot assertion (`assertFeatureConformance` at the tail of `bindProductionX`) catches forgotten wrappers at startup.
|
|
|
|
For the fast path, run `pnpm turbo gen feature <name>` — the generator emits the manifest + contracts + bind-production with the assertion already wired in.
|
|
|
|
---
|
|
|
|
## 1. Overview
|
|
|
|
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 |
|
|
| `integrations/readers/` | `I<Feature>Reader` interface + implementation (when feature exposes cross-feature queries) |
|
|
| `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
|
|
feature) — read `packages/blog/src/` alongside this guide.
|
|
|
|
---
|
|
|
|
## 2. Canonical Folder Layout
|
|
|
|
```
|
|
packages/comments/
|
|
src/
|
|
entities/
|
|
models/
|
|
comment.ts # Zod schema + Comment type
|
|
comment.test.ts
|
|
errors/
|
|
comment.ts # CommentNotFoundError (this.name required)
|
|
common.ts # InputParseError (this.name required)
|
|
errors.test.ts
|
|
application/
|
|
repositories/
|
|
comments.repository.interface.ts
|
|
use-cases/
|
|
get-comments.use-case.ts # getCommentsInputSchema + getCommentsOutputSchema + parse
|
|
get-comments.use-case.test.ts
|
|
create-comment.use-case.ts
|
|
create-comment.use-case.test.ts
|
|
infrastructure/
|
|
repositories/
|
|
comments.repository.mock.ts # MockCommentsRepository
|
|
comments.repository.mock.test.ts # runs contract suite
|
|
comments.repository.ts # CommentsRepository (Payload-backed)
|
|
comments.repository.test.ts # runs contract suite against Payload stub
|
|
interface-adapters/
|
|
controllers/
|
|
get-comments.controller.ts # factory + presenter
|
|
get-comments.controller.test.ts
|
|
create-comment.controller.ts
|
|
create-comment.controller.test.ts
|
|
di/
|
|
symbols.ts
|
|
module.ts
|
|
container.ts
|
|
container.test.ts
|
|
bind-production.ts
|
|
integrations/
|
|
api/
|
|
procedures.ts # commentsProcedure with feature error map
|
|
router.ts # commentsProcedure.input(xInputSchema)
|
|
router.test.ts # includes error-mapping assertions
|
|
index.ts
|
|
cms/
|
|
collections/
|
|
comments.collection.ts
|
|
index.ts
|
|
ui/
|
|
query.ts # query builders
|
|
index.ts # re-exports query builders
|
|
__factories__/
|
|
comment.factory.ts
|
|
index.ts
|
|
__contracts__/
|
|
comments-repository.contract.ts
|
|
index.ts # contracts only: types, errors, schemas, IUseCase/IController aliases
|
|
tests/
|
|
comments.feature.test.ts # cross-layer integration (no container)
|
|
package.json
|
|
tsconfig.json
|
|
vitest.config.ts
|
|
eslint.config.js
|
|
```
|
|
|
|
---
|
|
|
|
## 3. Step-by-Step Walkthrough
|
|
|
|
### Step 1: Create the package scaffold
|
|
|
|
```bash
|
|
mkdir -p packages/comments/src/{entities/{models,errors},application/{repositories,use-cases},infrastructure/repositories,interface-adapters/controllers,di,integrations/{api,cms/collections},ui,__factories__,__contracts__}
|
|
mkdir -p packages/comments/tests
|
|
```
|
|
|
|
### Step 2: `package.json`
|
|
|
|
```json
|
|
{
|
|
"name": "@repo/comments",
|
|
"private": true,
|
|
"version": "0.0.0",
|
|
"type": "module",
|
|
"exports": {
|
|
".": "./src/index.ts",
|
|
"./ui": "./src/ui/index.ts",
|
|
"./cms": "./src/integrations/cms/index.ts",
|
|
"./api": "./src/integrations/api/index.ts",
|
|
"./di/bind-production": "./src/di/bind-production.ts"
|
|
},
|
|
"scripts": {
|
|
"build": "tsc --noEmit",
|
|
"lint": "eslint .",
|
|
"test": "vitest run --passWithNoTests",
|
|
"typecheck": "tsc --noEmit"
|
|
},
|
|
"dependencies": {
|
|
"@repo/core-shared": "workspace:*",
|
|
"@trpc/server": "^11.0.0",
|
|
"inversify": "^6.2.0",
|
|
"payload": "^3.14.0",
|
|
"reflect-metadata": "^0.2.2",
|
|
"zod": "^3.24.0"
|
|
},
|
|
"devDependencies": {
|
|
"@repo/core-eslint": "workspace:*",
|
|
"@repo/core-testing": "workspace:*",
|
|
"@repo/core-typescript": "workspace:*",
|
|
"@types/node": "^22.0.0",
|
|
"@vitest/coverage-v8": "^3.2.4",
|
|
"vitest": "^3.1.0"
|
|
}
|
|
}
|
|
```
|
|
|
|
### Step 3: `tsconfig.json`
|
|
|
|
```json
|
|
{
|
|
"extends": "@repo/core-typescript/base.json",
|
|
"compilerOptions": {
|
|
"rootDir": ".",
|
|
"outDir": "dist",
|
|
"lib": ["ES2022", "DOM"],
|
|
"jsx": "preserve"
|
|
},
|
|
"include": ["src/**/*", "tests/**/*"],
|
|
"exclude": ["node_modules", "dist"]
|
|
}
|
|
```
|
|
|
|
### Step 4: `vitest.config.ts`
|
|
|
|
```typescript
|
|
import { defineConfig } from "vitest/config";
|
|
import path from "path";
|
|
|
|
export default defineConfig({
|
|
test: {
|
|
environment: "node",
|
|
globals: true,
|
|
include: ["src/**/*.test.ts", "tests/**/*.test.ts"],
|
|
},
|
|
resolve: {
|
|
alias: {
|
|
"@": path.resolve(__dirname, "./src"),
|
|
},
|
|
},
|
|
});
|
|
```
|
|
|
|
Run `pnpm install` to link the new package into the workspace.
|
|
|
|
---
|
|
|
|
### Step 5: Entity model (RED → GREEN)
|
|
|
|
Write the test first:
|
|
|
|
```typescript
|
|
// src/entities/models/comment.test.ts
|
|
import { describe, expect, it } from "vitest";
|
|
import { commentSchema } from "./comment";
|
|
|
|
describe("commentSchema", () => {
|
|
it("accepts a valid comment", () => {
|
|
const result = commentSchema.parse({
|
|
id: "c-1",
|
|
articleId: "a-1",
|
|
body: "Great post",
|
|
authorId: "u-1",
|
|
createdAt: new Date(),
|
|
});
|
|
expect(result.body).toBe("Great post");
|
|
});
|
|
|
|
it("rejects an empty body", () => {
|
|
expect(() =>
|
|
commentSchema.parse({
|
|
id: "c-1",
|
|
articleId: "a-1",
|
|
body: "",
|
|
authorId: "u-1",
|
|
createdAt: new Date(),
|
|
}),
|
|
).toThrow();
|
|
});
|
|
});
|
|
```
|
|
|
|
```bash
|
|
pnpm test --filter @repo/comments -- comment.test.ts # RED — module not found
|
|
```
|
|
|
|
Implement:
|
|
|
|
```typescript
|
|
// src/entities/models/comment.ts
|
|
import { z } from "zod";
|
|
|
|
export const commentSchema = z.object({
|
|
id: z.string(),
|
|
articleId: z.string(),
|
|
body: z.string().min(1).max(2000),
|
|
authorId: z.string(),
|
|
createdAt: z.date(),
|
|
});
|
|
|
|
export type Comment = z.infer<typeof commentSchema>;
|
|
```
|
|
|
|
```bash
|
|
pnpm test --filter @repo/comments -- comment.test.ts # GREEN
|
|
```
|
|
|
|
---
|
|
|
|
### Step 6: Domain errors
|
|
|
|
```typescript
|
|
// src/entities/errors/comment.ts
|
|
export class CommentNotFoundError extends Error {
|
|
constructor(message = "Comment not found", options?: ErrorOptions) {
|
|
super(message, options);
|
|
this.name = "CommentNotFoundError"; // required — R6
|
|
}
|
|
}
|
|
```
|
|
|
|
```typescript
|
|
// src/entities/errors/common.ts
|
|
export class InputParseError extends Error {
|
|
constructor(message: string, options?: ErrorOptions) {
|
|
super(message, options);
|
|
this.name = "InputParseError"; // required — R6
|
|
}
|
|
}
|
|
```
|
|
|
|
Test both (colocated `errors.test.ts`):
|
|
|
|
```typescript
|
|
// src/entities/errors/errors.test.ts
|
|
import { describe, expect, it } from "vitest";
|
|
import { CommentNotFoundError } from "./comment";
|
|
import { InputParseError } from "./common";
|
|
|
|
describe("CommentNotFoundError", () => {
|
|
it("has name CommentNotFoundError", () => {
|
|
const e = new CommentNotFoundError();
|
|
expect(e.name).toBe("CommentNotFoundError");
|
|
expect(e).toBeInstanceOf(Error);
|
|
});
|
|
});
|
|
|
|
describe("InputParseError", () => {
|
|
it("has name InputParseError", () => {
|
|
const e = new InputParseError("bad");
|
|
expect(e.name).toBe("InputParseError");
|
|
});
|
|
});
|
|
```
|
|
|
|
---
|
|
|
|
### Step 7: Repository interface
|
|
|
|
Interfaces have no implementation and no test. They are contracts.
|
|
|
|
```typescript
|
|
// src/application/repositories/comments.repository.interface.ts
|
|
import type { Comment } from "../../entities/models/comment";
|
|
|
|
export interface ICommentsRepository {
|
|
getComment(id: string): Promise<Comment | undefined>;
|
|
getCommentsForArticle(articleId: string): Promise<Comment[]>;
|
|
createComment(input: Comment): Promise<Comment>;
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
### Step 8: Test factory
|
|
|
|
```typescript
|
|
// src/__factories__/comment.factory.ts
|
|
import { defineFactory } from "@repo/core-testing/factory";
|
|
import type { Comment } from "../entities/models/comment";
|
|
|
|
export const commentFactory = defineFactory<Comment>(({ sequence }) => ({
|
|
id: `comment-${sequence}`,
|
|
articleId: "article-1",
|
|
body: `Comment body ${sequence}`,
|
|
authorId: "user-1",
|
|
createdAt: new Date("2026-01-01T00:00:00Z"),
|
|
}));
|
|
```
|
|
|
|
---
|
|
|
|
### 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
|
|
- `IXUseCase` alias (`ReturnType<typeof xUseCase>`)
|
|
|
|
The body ends with `xOutputSchema.parse(result)` before returning.
|
|
|
|
Write the test first (direct injection — no container):
|
|
|
|
```typescript
|
|
// src/application/use-cases/get-comments.use-case.test.ts
|
|
import { describe, expect, it } from "vitest";
|
|
import { ZodError } from "zod";
|
|
import {
|
|
getCommentsUseCase,
|
|
getCommentsOutputSchema,
|
|
} from "@/application/use-cases/get-comments.use-case";
|
|
import { MockCommentsRepository } from "@/infrastructure/repositories/comments.repository.mock";
|
|
import { commentFactory } from "@/__factories__/comment.factory";
|
|
|
|
describe("getCommentsUseCase", () => {
|
|
it("returns comments for an article", async () => {
|
|
const repo = new MockCommentsRepository();
|
|
commentFactory.reset();
|
|
await repo.createComment(commentFactory.build({ articleId: "a-1" }));
|
|
|
|
const useCase = getCommentsUseCase(repo);
|
|
const result = await useCase({ articleId: "a-1" });
|
|
expect(result).toHaveLength(1);
|
|
expect(result[0]?.articleId).toBe("a-1");
|
|
});
|
|
|
|
it("returns empty array when no comments exist", async () => {
|
|
const repo = new MockCommentsRepository();
|
|
const useCase = getCommentsUseCase(repo);
|
|
const result = await useCase({ articleId: "missing" });
|
|
expect(result).toEqual([]);
|
|
});
|
|
});
|
|
|
|
// 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,
|
|
);
|
|
});
|
|
|
|
it("exports getCommentsOutputSchema that validates Comment[]", () => {
|
|
expect(getCommentsOutputSchema.safeParse([]).success).toBe(true);
|
|
});
|
|
});
|
|
```
|
|
|
|
```bash
|
|
pnpm test --filter @repo/comments -- get-comments.use-case.test.ts # RED
|
|
```
|
|
|
|
Implement:
|
|
|
|
```typescript
|
|
// src/application/use-cases/get-comments.use-case.ts
|
|
import { z } from "zod";
|
|
import { commentSchema } from "../../entities/models/comment";
|
|
import type { ICommentsRepository } from "../repositories/comments.repository.interface";
|
|
|
|
// ── Input ────────────────────────────────────────────────────────────────
|
|
export const getCommentsInputSchema = z
|
|
.object({ articleId: z.string() })
|
|
.strict();
|
|
export type GetCommentsInput = z.infer<typeof getCommentsInputSchema>;
|
|
|
|
// ── Output ───────────────────────────────────────────────────────────────
|
|
export const getCommentsOutputSchema = z.array(commentSchema);
|
|
export type GetCommentsOutput = z.infer<typeof getCommentsOutputSchema>;
|
|
|
|
// ── Use case ─────────────────────────────────────────────────────────────
|
|
export type IGetCommentsUseCase = ReturnType<typeof getCommentsUseCase>;
|
|
|
|
export const getCommentsUseCase =
|
|
(commentsRepository: ICommentsRepository) =>
|
|
async (input: GetCommentsInput): Promise<GetCommentsOutput> => {
|
|
const result = await commentsRepository.getCommentsForArticle(
|
|
input.articleId,
|
|
);
|
|
return getCommentsOutputSchema.parse(result);
|
|
};
|
|
```
|
|
|
|
```bash
|
|
pnpm test --filter @repo/comments -- get-comments.use-case.test.ts # GREEN
|
|
```
|
|
|
|
---
|
|
|
|
### Step 10: Mock repository + contract suite (RED → GREEN)
|
|
|
|
Define the contract once and run it against both the mock and the real
|
|
Payload-backed repository:
|
|
|
|
```typescript
|
|
// src/__contracts__/comments-repository.contract.ts
|
|
import { beforeEach, expect, it } from "vitest";
|
|
import { defineContractSuite } from "@repo/core-testing/contract";
|
|
import type { ICommentsRepository } from "../application/repositories/comments.repository.interface";
|
|
import { commentFactory } from "../__factories__/comment.factory";
|
|
|
|
export const commentsRepositoryContract =
|
|
defineContractSuite<ICommentsRepository>(
|
|
"ICommentsRepository",
|
|
({ buildSubject }) => {
|
|
let repo: ICommentsRepository;
|
|
|
|
beforeEach(async () => {
|
|
commentFactory.reset();
|
|
repo = await buildSubject();
|
|
});
|
|
|
|
it("createComment returns the created comment", async () => {
|
|
const seed = commentFactory.build({ body: "Hello" });
|
|
const created = await repo.createComment(seed);
|
|
expect(typeof created.id).toBe("string");
|
|
expect(created.body).toBe("Hello");
|
|
});
|
|
|
|
it("getCommentsForArticle returns comments for the given articleId", async () => {
|
|
await repo.createComment(commentFactory.build({ articleId: "a-1" }));
|
|
const results = await repo.getCommentsForArticle("a-1");
|
|
expect(results).toHaveLength(1);
|
|
expect(results[0]?.articleId).toBe("a-1");
|
|
});
|
|
|
|
it("getCommentsForArticle returns empty array for unknown articleId", async () => {
|
|
const results = await repo.getCommentsForArticle("no-such");
|
|
expect(results).toHaveLength(0);
|
|
});
|
|
},
|
|
);
|
|
```
|
|
|
|
Implement the mock:
|
|
|
|
```typescript
|
|
// src/infrastructure/repositories/comments.repository.mock.ts
|
|
import "reflect-metadata";
|
|
import { injectable } from "inversify";
|
|
import type { ICommentsRepository } from "../../application/repositories/comments.repository.interface";
|
|
import type { Comment } from "../../entities/models/comment";
|
|
|
|
@injectable()
|
|
export class MockCommentsRepository implements ICommentsRepository {
|
|
_comments: Comment[] = [];
|
|
|
|
async getComment(id: string): Promise<Comment | undefined> {
|
|
return this._comments.find((c) => c.id === id);
|
|
}
|
|
|
|
async getCommentsForArticle(articleId: string): Promise<Comment[]> {
|
|
return this._comments.filter((c) => c.articleId === articleId);
|
|
}
|
|
|
|
async createComment(input: Comment): Promise<Comment> {
|
|
this._comments.push(input);
|
|
return input;
|
|
}
|
|
}
|
|
```
|
|
|
|
Run the contract against the mock:
|
|
|
|
```typescript
|
|
// src/infrastructure/repositories/comments.repository.mock.test.ts
|
|
import { describe } from "vitest";
|
|
import { commentsRepositoryContract } from "@/__contracts__/comments-repository.contract";
|
|
import { MockCommentsRepository } from "./comments.repository.mock";
|
|
|
|
describe("MockCommentsRepository", () => {
|
|
commentsRepositoryContract.run(async () => new MockCommentsRepository());
|
|
});
|
|
```
|
|
|
|
```bash
|
|
pnpm test --filter @repo/comments -- comments.repository.mock.test.ts # GREEN
|
|
```
|
|
|
|
---
|
|
|
|
### Step 11: Controller — `unknown` input + presenter (RED → GREEN)
|
|
|
|
Controllers import `xInputSchema` from the use-case file — never redefine it.
|
|
Every non-void controller defines a top-level `function presenter` and returns
|
|
`Promise<ReturnType<typeof presenter>>`. Identity is fine.
|
|
|
|
```typescript
|
|
// src/interface-adapters/controllers/get-comments.controller.test.ts
|
|
import { describe, expect, it } from "vitest";
|
|
import { getCommentsController } from "@/interface-adapters/controllers/get-comments.controller";
|
|
import { getCommentsUseCase } from "@/application/use-cases/get-comments.use-case";
|
|
import { MockCommentsRepository } from "@/infrastructure/repositories/comments.repository.mock";
|
|
import { InputParseError } from "@/entities/errors/common";
|
|
import { commentFactory } from "@/__factories__/comment.factory";
|
|
|
|
describe("getCommentsController", () => {
|
|
it("returns comments on valid input", async () => {
|
|
const repo = new MockCommentsRepository();
|
|
commentFactory.reset();
|
|
await repo.createComment(commentFactory.build({ articleId: "a-1" }));
|
|
|
|
const ctrl = getCommentsController(getCommentsUseCase(repo));
|
|
const result = await ctrl({ articleId: "a-1" });
|
|
expect(result).toHaveLength(1);
|
|
});
|
|
|
|
it("throws InputParseError when articleId is missing", async () => {
|
|
const repo = new MockCommentsRepository();
|
|
const ctrl = getCommentsController(getCommentsUseCase(repo));
|
|
await expect(ctrl({})).rejects.toBeInstanceOf(InputParseError);
|
|
});
|
|
|
|
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);
|
|
});
|
|
});
|
|
```
|
|
|
|
```bash
|
|
pnpm test --filter @repo/comments -- get-comments.controller.test.ts # RED
|
|
```
|
|
|
|
Implement:
|
|
|
|
```typescript
|
|
// src/interface-adapters/controllers/get-comments.controller.ts
|
|
import { InputParseError } from "../../entities/errors/common";
|
|
import {
|
|
getCommentsInputSchema,
|
|
type GetCommentsOutput,
|
|
type IGetCommentsUseCase,
|
|
} from "../../application/use-cases/get-comments.use-case";
|
|
|
|
function presenter(value: GetCommentsOutput) {
|
|
return value;
|
|
}
|
|
|
|
export type IGetCommentsController = ReturnType<typeof getCommentsController>;
|
|
|
|
export const getCommentsController =
|
|
(getCommentsUseCase: IGetCommentsUseCase) =>
|
|
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,
|
|
});
|
|
}
|
|
const result = await getCommentsUseCase(parsed.data);
|
|
return presenter(result);
|
|
};
|
|
```
|
|
|
|
```bash
|
|
pnpm test --filter @repo/comments -- get-comments.controller.test.ts # GREEN
|
|
```
|
|
|
|
---
|
|
|
|
### Step 12: DI — symbols, module, container
|
|
|
|
```typescript
|
|
// src/di/symbols.ts
|
|
export const COMMENTS_SYMBOLS = {
|
|
ICommentsRepository: Symbol.for("comments:ICommentsRepository"),
|
|
// Use cases
|
|
IGetCommentsUseCase: Symbol.for("comments:IGetCommentsUseCase"),
|
|
// Controllers
|
|
IGetCommentsController: Symbol.for("comments:IGetCommentsController"),
|
|
} as const;
|
|
```
|
|
|
|
```typescript
|
|
// src/di/module.ts
|
|
import { ContainerModule, type interfaces } from "inversify";
|
|
import type { ICommentsRepository } from "../application/repositories/comments.repository.interface";
|
|
import { MockCommentsRepository } from "../infrastructure/repositories/comments.repository.mock";
|
|
import {
|
|
getCommentsUseCase,
|
|
type IGetCommentsUseCase,
|
|
} from "../application/use-cases/get-comments.use-case";
|
|
import {
|
|
getCommentsController,
|
|
type IGetCommentsController,
|
|
} from "../interface-adapters/controllers/get-comments.controller";
|
|
import { COMMENTS_SYMBOLS } from "./symbols";
|
|
|
|
export const CommentsModule = new ContainerModule((bind: interfaces.Bind) => {
|
|
bind<ICommentsRepository>(COMMENTS_SYMBOLS.ICommentsRepository).to(
|
|
MockCommentsRepository,
|
|
);
|
|
|
|
bind<IGetCommentsUseCase>(
|
|
COMMENTS_SYMBOLS.IGetCommentsUseCase,
|
|
).toDynamicValue((ctx) =>
|
|
getCommentsUseCase(
|
|
ctx.container.get<ICommentsRepository>(
|
|
COMMENTS_SYMBOLS.ICommentsRepository,
|
|
),
|
|
),
|
|
);
|
|
|
|
bind<IGetCommentsController>(
|
|
COMMENTS_SYMBOLS.IGetCommentsController,
|
|
).toDynamicValue((ctx) =>
|
|
getCommentsController(
|
|
ctx.container.get<IGetCommentsUseCase>(
|
|
COMMENTS_SYMBOLS.IGetCommentsUseCase,
|
|
),
|
|
),
|
|
);
|
|
});
|
|
```
|
|
|
|
```typescript
|
|
// src/di/container.ts
|
|
import "reflect-metadata";
|
|
import { Container } from "inversify";
|
|
import { CommentsModule } from "./module";
|
|
|
|
export const commentsContainer = new Container({ defaultScope: "Singleton" });
|
|
commentsContainer.load(CommentsModule);
|
|
```
|
|
|
|
Verify DI wiring:
|
|
|
|
```typescript
|
|
// src/di/container.test.ts
|
|
import { describe, it, expect } from "vitest";
|
|
import { commentsContainer } from "@/di/container";
|
|
import { COMMENTS_SYMBOLS } from "@/di/symbols";
|
|
|
|
describe("commentsContainer", () => {
|
|
it("resolves ICommentsRepository", () => {
|
|
expect(
|
|
commentsContainer.get(COMMENTS_SYMBOLS.ICommentsRepository),
|
|
).toBeDefined();
|
|
});
|
|
|
|
it("resolves IGetCommentsUseCase", () => {
|
|
expect(
|
|
commentsContainer.get(COMMENTS_SYMBOLS.IGetCommentsUseCase),
|
|
).toBeDefined();
|
|
});
|
|
|
|
it("resolves IGetCommentsController", () => {
|
|
expect(
|
|
commentsContainer.get(COMMENTS_SYMBOLS.IGetCommentsController),
|
|
).toBeDefined();
|
|
});
|
|
});
|
|
```
|
|
|
|
---
|
|
|
|
### Step 13: `procedures.ts` — feature-scoped error map
|
|
|
|
Each feature has exactly one `procedures.ts`. It exports an `xProcedure`
|
|
that wraps `defineErrorMiddleware` with the feature's own error constructors.
|
|
`core-shared` provides the factory but knows nothing about feature errors.
|
|
|
|
```typescript
|
|
// src/integrations/api/procedures.ts
|
|
import { t } from "@repo/core-shared/trpc/init";
|
|
import { defineErrorMiddleware } from "@repo/core-shared/trpc/define-error-middleware";
|
|
import { CommentNotFoundError } from "../../entities/errors/comment";
|
|
import { InputParseError } from "../../entities/errors/common";
|
|
|
|
export const commentsProcedure = t.procedure.use(
|
|
defineErrorMiddleware([
|
|
[InputParseError, "BAD_REQUEST"],
|
|
[CommentNotFoundError, "NOT_FOUND"],
|
|
]),
|
|
);
|
|
```
|
|
|
|
---
|
|
|
|
### 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
|
|
|
|
```typescript
|
|
// src/integrations/api/router.test.ts
|
|
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
|
import { TRPCError } from "@trpc/server";
|
|
import { commentsContainer } from "@/di/container";
|
|
import { CommentsModule } from "@/di/module";
|
|
import { commentsRouter } from "@/integrations/api/router";
|
|
|
|
describe("commentsRouter", () => {
|
|
beforeEach(() => {
|
|
commentsContainer.unbindAll();
|
|
commentsContainer.load(CommentsModule);
|
|
});
|
|
afterEach(() => {
|
|
commentsContainer.unbindAll();
|
|
});
|
|
|
|
it("exposes getComments procedure", () => {
|
|
expect(Object.keys(commentsRouter._def.procedures)).toContain(
|
|
"getComments",
|
|
);
|
|
});
|
|
|
|
it("getComments returns empty array by default", async () => {
|
|
const caller = commentsRouter.createCaller({});
|
|
expect(await caller.getComments({ articleId: "a-1" })).toEqual([]);
|
|
});
|
|
});
|
|
|
|
// error mapping
|
|
describe("commentsRouter error mapping", () => {
|
|
beforeEach(() => {
|
|
commentsContainer.unbindAll();
|
|
commentsContainer.load(CommentsModule);
|
|
});
|
|
afterEach(() => {
|
|
commentsContainer.unbindAll();
|
|
});
|
|
|
|
it("translates zod parse failure → BAD_REQUEST", async () => {
|
|
const caller = commentsRouter.createCaller({});
|
|
try {
|
|
await caller.getComments({} as unknown as { articleId: string });
|
|
throw new Error("expected throw");
|
|
} catch (e) {
|
|
expect(e).toBeInstanceOf(TRPCError);
|
|
expect((e as TRPCError).code).toBe("BAD_REQUEST");
|
|
}
|
|
});
|
|
});
|
|
```
|
|
|
|
```bash
|
|
pnpm test --filter @repo/comments -- router.test.ts # RED
|
|
```
|
|
|
|
Implement:
|
|
|
|
```typescript
|
|
// src/integrations/api/router.ts
|
|
import { router } from "@repo/core-shared/trpc/init";
|
|
import { commentsContainer } from "../../di/container";
|
|
import { COMMENTS_SYMBOLS } from "../../di/symbols";
|
|
import { getCommentsInputSchema } from "../../application/use-cases/get-comments.use-case";
|
|
import type { IGetCommentsController } from "../../interface-adapters/controllers/get-comments.controller";
|
|
import { commentsProcedure } from "./procedures";
|
|
|
|
export const commentsRouter = router({
|
|
getComments: commentsProcedure
|
|
.input(getCommentsInputSchema)
|
|
.query(({ input }) => {
|
|
const ctrl = commentsContainer.get<IGetCommentsController>(
|
|
COMMENTS_SYMBOLS.IGetCommentsController,
|
|
);
|
|
return ctrl(input);
|
|
}),
|
|
});
|
|
|
|
export type CommentsRouter = typeof commentsRouter;
|
|
```
|
|
|
|
```typescript
|
|
// src/integrations/api/index.ts
|
|
export { commentsRouter } from "./router";
|
|
export type { CommentsRouter } from "./router";
|
|
```
|
|
|
|
```bash
|
|
pnpm test --filter @repo/comments -- router.test.ts # GREEN
|
|
```
|
|
|
|
---
|
|
|
|
### Step 15: Real Payload-backed repository
|
|
|
|
Implement `CommentsRepository` and run the same contract suite against it
|
|
via a Payload stub (`vi.mock("payload", ...)`). Mirror the pattern from
|
|
`packages/blog/src/infrastructure/repositories/articles.repository.ts`.
|
|
|
|
```typescript
|
|
// src/infrastructure/repositories/comments.repository.ts
|
|
import "reflect-metadata";
|
|
import { injectable } from "inversify";
|
|
import { getPayload } from "payload";
|
|
import type { SanitizedConfig } from "payload";
|
|
import type { ICommentsRepository } from "../../application/repositories/comments.repository.interface";
|
|
import type { Comment } from "../../entities/models/comment";
|
|
|
|
type PayloadCommentDoc = {
|
|
id: string | number;
|
|
articleId?: string | null;
|
|
body?: string | null;
|
|
author?: string | number | null;
|
|
createdAt?: string | null;
|
|
};
|
|
|
|
function mapDoc(doc: PayloadCommentDoc): Comment {
|
|
return {
|
|
id: String(doc.id),
|
|
articleId: doc.articleId ?? "",
|
|
body: doc.body ?? "",
|
|
authorId: doc.author != null ? String(doc.author) : "",
|
|
createdAt: doc.createdAt ? new Date(doc.createdAt) : new Date(0),
|
|
};
|
|
}
|
|
|
|
@injectable()
|
|
export class CommentsRepository implements ICommentsRepository {
|
|
constructor(private config: SanitizedConfig) {}
|
|
|
|
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,
|
|
});
|
|
return mapDoc(doc as PayloadCommentDoc);
|
|
} catch {
|
|
return undefined;
|
|
}
|
|
}
|
|
|
|
async getCommentsForArticle(articleId: string): Promise<Comment[]> {
|
|
const payload = await getPayload({ config: this.config });
|
|
const result = await payload.find({
|
|
collection: "comments",
|
|
where: { articleId: { equals: articleId } } as never,
|
|
overrideAccess: true,
|
|
});
|
|
return result.docs.map((d) => mapDoc(d as PayloadCommentDoc));
|
|
}
|
|
|
|
async createComment(input: Comment): Promise<Comment> {
|
|
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,
|
|
overrideAccess: true,
|
|
});
|
|
return mapDoc(created as PayloadCommentDoc);
|
|
}
|
|
}
|
|
```
|
|
|
|
Contract test with a Payload stub (see
|
|
`packages/blog/src/infrastructure/repositories/articles.repository.test.ts`
|
|
for the full `vi.mock("payload", ...)` pattern):
|
|
|
|
```typescript
|
|
// src/infrastructure/repositories/comments.repository.test.ts
|
|
import { describe, vi } from "vitest";
|
|
import { commentsRepositoryContract } from "@/__contracts__/comments-repository.contract";
|
|
import { CommentsRepository } from "./comments.repository";
|
|
|
|
vi.mock("payload", () => ({ getPayload: vi.fn() }));
|
|
|
|
describe("CommentsRepository", () => {
|
|
commentsRepositoryContract.run(async () => {
|
|
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 };
|
|
},
|
|
),
|
|
create: vi.fn(async ({ data }: { data: Record<string, unknown> }) => {
|
|
const doc = { id: `stub-${store.size + 1}`, ...data };
|
|
store.set(String(doc.id), doc);
|
|
return doc;
|
|
}),
|
|
};
|
|
const { getPayload } = await import("payload");
|
|
(getPayload as ReturnType<typeof vi.fn>).mockResolvedValue(stub);
|
|
return new CommentsRepository({} as never);
|
|
});
|
|
});
|
|
```
|
|
|
|
---
|
|
|
|
### Step 16: `bind-production.ts`
|
|
|
|
The full canonical shape includes instrumentation (ADR-014) and the
|
|
event-bus + job-queue parameters (ADR-015) — see any feature's
|
|
`bind-production.ts` for a complete reference. Minimal sketch:
|
|
|
|
```typescript
|
|
// src/di/bind-production.ts
|
|
import type { SanitizedConfig } from "payload";
|
|
import {
|
|
withSpan,
|
|
withCapture,
|
|
type ITracer,
|
|
type ILogger,
|
|
} from "@repo/core-shared/instrumentation";
|
|
import type { IEventBus } from "@repo/core-events";
|
|
import type { IJobQueue } from "@repo/core-shared/jobs";
|
|
import { commentsContainer } from "./container";
|
|
import { COMMENTS_SYMBOLS } from "./symbols";
|
|
import { CommentsRepository } from "../infrastructure/repositories/comments.repository";
|
|
|
|
export function bindProductionComments(
|
|
config: SanitizedConfig,
|
|
tracer: ITracer,
|
|
logger: ILogger,
|
|
bus: IEventBus,
|
|
queue: IJobQueue,
|
|
): void {
|
|
if (commentsContainer.isBound(COMMENTS_SYMBOLS.ICommentsRepository)) {
|
|
commentsContainer.unbind(COMMENTS_SYMBOLS.ICommentsRepository);
|
|
}
|
|
const repo = new CommentsRepository(config, tracer, logger);
|
|
commentsContainer
|
|
.bind(COMMENTS_SYMBOLS.ICommentsRepository)
|
|
.toConstantValue(repo);
|
|
// Use cases + controllers are wrapped with withSpan(withCapture(...))
|
|
// at bind time. bus + queue are accept-and-forward until you add an
|
|
// event handler or job — at which point the gen event consume / gen job
|
|
// generators inject usage at the // <gen:event-handlers> / // <gen:jobs>
|
|
// anchors at the end of the function body.
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
### Step 17: Payload collection
|
|
|
|
```typescript
|
|
// src/integrations/cms/collections/comments.collection.ts
|
|
import type { CollectionConfig } from "payload";
|
|
|
|
export const comments: CollectionConfig = {
|
|
slug: "comments",
|
|
admin: { useAsTitle: "body" },
|
|
fields: [
|
|
{ name: "articleId", type: "text", required: true },
|
|
{ name: "body", type: "textarea", required: true },
|
|
{
|
|
name: "author",
|
|
type: "relationship",
|
|
relationTo: "users",
|
|
required: true,
|
|
},
|
|
],
|
|
};
|
|
```
|
|
|
|
```typescript
|
|
// src/integrations/cms/index.ts
|
|
export { comments } from "./collections/comments.collection";
|
|
```
|
|
|
|
---
|
|
|
|
### Step 18: Public API — `src/index.ts` and `src/ui/index.ts`
|
|
|
|
The feature root exports contracts only (types, errors, schemas, type
|
|
aliases). UI artifacts live behind `./ui`.
|
|
|
|
```typescript
|
|
// src/index.ts
|
|
export type { Comment } from "./entities/models/comment";
|
|
export { CommentNotFoundError } from "./entities/errors/comment";
|
|
export { InputParseError } from "./entities/errors/common";
|
|
export type { CommentsRouter } from "./integrations/api/router";
|
|
|
|
// Use case schemas + types
|
|
export {
|
|
getCommentsInputSchema,
|
|
getCommentsOutputSchema,
|
|
type GetCommentsInput,
|
|
type GetCommentsOutput,
|
|
type IGetCommentsUseCase,
|
|
} from "./application/use-cases/get-comments.use-case";
|
|
|
|
// Controller type aliases
|
|
export type { IGetCommentsController } from "./interface-adapters/controllers/get-comments.controller";
|
|
```
|
|
|
|
```typescript
|
|
// src/ui/query.ts
|
|
type TrpcClient = {
|
|
comments: {
|
|
getComments: {
|
|
queryOptions: (input: { articleId: string }) => unknown;
|
|
};
|
|
};
|
|
};
|
|
|
|
export function getCommentsQuery(client: TrpcClient, articleId: string) {
|
|
return client.comments.getComments.queryOptions({ articleId });
|
|
}
|
|
```
|
|
|
|
```typescript
|
|
// src/ui/index.ts
|
|
export { getCommentsQuery } from "./query";
|
|
```
|
|
|
|
---
|
|
|
|
### Step 19: Wire into `core-api` and `core-cms`
|
|
|
|
These are composition packages — this is the only time a feature-level
|
|
symbol crosses a package boundary.
|
|
|
|
**`packages/core-api/src/root.ts`** (or equivalent aggregator):
|
|
|
|
```typescript
|
|
import { commentsRouter } from "@repo/comments/api";
|
|
|
|
export const appRouter = t.router({
|
|
comments: commentsRouter,
|
|
// ... other features
|
|
});
|
|
```
|
|
|
|
**`packages/core-cms/src/collections/index.ts`** (or equivalent):
|
|
|
|
```typescript
|
|
import { comments } from "@repo/comments/cms";
|
|
|
|
export const collections = [...existingCollections, comments];
|
|
```
|
|
|
|
Add path aliases to `tsconfig.base.json`:
|
|
|
|
```json
|
|
{
|
|
"compilerOptions": {
|
|
"paths": {
|
|
"@repo/comments": ["packages/comments/src/index.ts"],
|
|
"@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"
|
|
]
|
|
}
|
|
}
|
|
}
|
|
```
|
|
|
|
Also add `@repo/comments: workspace:*` to `packages/core-api/package.json`
|
|
and `packages/core-cms/package.json` dependencies.
|
|
|
|
---
|
|
|
|
### Step 20: App bootstrap
|
|
|
|
Each app calls `bindProduction*` per feature at startup to swap mock
|
|
implementations for real Payload-backed ones. For example, in
|
|
`apps/web-next/src/server/bind-production.ts`:
|
|
|
|
```typescript
|
|
import { bindProductionComments } from "@repo/comments/di/bind-production";
|
|
|
|
// called once at startup with the resolved Payload config
|
|
await bindProductionComments(resolvedConfig);
|
|
```
|
|
|
|
---
|
|
|
|
### Step 21: Final validation
|
|
|
|
```bash
|
|
pnpm install
|
|
pnpm typecheck --filter @repo/comments
|
|
pnpm test --filter @repo/comments
|
|
pnpm lint --filter @repo/comments
|
|
pnpm turbo boundaries
|
|
```
|
|
|
|
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 |
|
|
|
|
---
|
|
|
|
## 5. Common Pitfalls
|
|
|
|
1. **Forgetting `this.name` in domain error constructors.** Without it,
|
|
`instanceof` checks in `defineErrorMiddleware` still work, but
|
|
serialized stack traces label the error as `"Error"` instead of
|
|
`"CommentNotFoundError"`, making debugging significantly harder. Set
|
|
`this.name = "CommentNotFoundError"` in every domain error and
|
|
`InputParseError` constructor (ADR-013 R6).
|
|
|
|
2. **Redefining the input schema in the controller.** The controller must
|
|
`import { xInputSchema } from "../../application/use-cases/x.use-case"`.
|
|
A local `const inputSchema = z.object({...})` in the controller
|
|
silently diverges from the tRPC procedure's schema — this was the exact
|
|
bug Plans 8 and 9 fixed. One schema, one source.
|
|
|
|
3. **Typing controller input as `Partial<...>` or `z.infer<...>`** instead of
|
|
`unknown`. The controller receives unvalidated data from an external
|
|
boundary; `unknown` is the correct type. Using a TypeScript type here
|
|
bypasses the runtime `safeParse` guard entirely when callers are
|
|
within the same TypeScript project.
|
|
|
|
4. **Skipping the presenter on a non-void controller.** Every non-void
|
|
controller must define `function presenter(value: XOutput)` and return
|
|
`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).
|
|
|
|
5. **Adding feature error classes to `core-shared`.** `core-shared` must
|
|
stay boundary-clean — it provides `defineErrorMiddleware` but knows
|
|
nothing about `CommentNotFoundError` or any other feature error class.
|
|
Feature packages pass their own constructors to `defineErrorMiddleware`
|
|
in their own `procedures.ts`. Adding feature errors to `core-shared`
|
|
violates the `feature → core` dependency direction enforced by ESLint
|
|
boundaries and Turborepo.
|
|
|
|
6. **Re-exporting query builders from the feature root `src/index.ts`.**
|
|
Query builders import React Query, which is a UI concern. The feature
|
|
root (`.`) is a contract-only export consumed by apps, other features,
|
|
and server-side code. UI artifacts go behind `./ui` (ADR-013 §Decision
|
|
point 4). Apps import from `@repo/comments/ui`, not `@repo/comments`.
|
|
|
|
7. **Forgetting to add the `./ui` subpath to `package.json`.** If
|
|
`src/ui/index.ts` exists but the `"./ui"` entry is missing from the
|
|
`exports` map, TypeScript resolves the import in workspace mode but
|
|
the build fails. Always keep the exports map in sync with
|
|
`tsconfig.base.json` path aliases.
|
|
|
|
---
|
|
|
|
## 6. Cross-References
|
|
|
|
- **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`).
|
|
- **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 test obligations per layer.
|