docs(guides): rewrite adding-a-feature for vertical canonical pattern

This commit is contained in:
2026-05-05 09:33:02 +02:00
parent 590543fb65
commit 49d74adc35

View File

@@ -1,70 +1,462 @@
# Adding a New Feature — End-to-End Guide
This guide walks through adding a complete feature from entity to UI.
A feature is a vertical slice: entities, use cases, repositories, tRPC router, CMS integration, DI container, and UI components — all owned by one package.
## Example: Adding a "Comments" feature
Decide upfront: **Is this a new feature or an extension of an existing one?** New features get a new package (e.g., `packages/comments`). Extensions add to an existing feature (e.g., adding an `unapprove-article` procedure to `packages/blog`).
### 1. Define Entity
## Part 1: New Feature Scaffold
Create `packages/core/src/entities/models/comment.ts`:
```typescript
import { z } from "zod";
export const commentSchema = z.object({
id: z.string(),
content: z.string().min(1),
articleId: z.string(),
authorId: z.string(),
createdAt: z.date(),
});
export type Comment = z.infer<typeof commentSchema>;
### Step 1: Decide shape
The smallest viable feature has:
- `entities/` — type definitions and schemas (Zod)
- `application/use-cases/` — one business operation
- `application/repositories/` — interface + mock implementation
- `infrastructure/repositories/` — Payload-backed implementation (if needed)
- `di/` — InversifyJS container + symbol table
- `integrations/api/` — tRPC router (optional if no read API)
- `integrations/cms/` — Payload collection/global (if Payload-backed)
- `ui/` — feature-specific components (atoms/molecules/organisms)
### Step 2: Create the package
```bash
mkdir -p packages/<feature-name>/src/{entities,application/{use-cases,repositories},infrastructure/repositories,di,integrations/{api,cms},ui,interface-adapters/controllers}
```
Export from `entities/models/index.ts`.
### 2. Define Repository Interface
### Step 3: Create `package.json`
Create `packages/core/src/application/repositories/comments.repository.interface.ts`:
```typescript
import type { Comment } from "@/entities/models/comment.js";
export interface ICommentsRepository {
getCommentsByArticle(articleId: string): Promise<Comment[]>;
createComment(input: Comment): Promise<Comment>;
```json
{
"name": "@repo/<feature-name>",
"version": "0.0.1",
"private": true,
"type": "module",
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.js"
},
"./api": {
"types": "./dist/integrations/api/index.d.ts",
"import": "./dist/integrations/api/index.js"
},
"./cms": {
"types": "./dist/integrations/cms/index.d.ts",
"import": "./dist/integrations/cms/index.js"
},
"./di/bind-production": {
"types": "./dist/di/bind-production.d.ts",
"import": "./dist/di/bind-production.js"
}
},
"dependencies": {
"@repo/core-shared": "workspace:*"
},
"devDependencies": {
"@repo/typescript-config": "workspace:*"
}
}
```
Export from `application/repositories/index.ts`.
### 3. Write Use Case (TDD)
### Step 4: Create `tsconfig.json`
Write test first in `packages/core/tests/unit/use-cases/content/create-comment.use-case.test.ts`, then implement in `packages/core/src/application/use-cases/content/create-comment.use-case.ts`.
```json
{
"extends": "@repo/typescript-config/base.json",
"compilerOptions": {
"rootDir": ".",
"outDir": "dist",
"lib": ["ES2022", "DOM"],
"jsx": "preserve"
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist", "**/*.test.ts"]
}
```
### 4. Write Controller
### Step 5: Create `vitest.config.ts`
Create `packages/core/src/interface-adapters/controllers/content/comments.controller.ts` — validates input with Zod, calls use case.
```typescript
import { defineConfig } from "vitest/config";
import path from "path";
### 5. Write Mock Implementation
export default defineConfig({
test: {
environment: "node",
globals: true,
include: ["src/**/*.test.ts"],
},
resolve: {
alias: {
"@": path.resolve(__dirname, "./src"),
},
},
});
```
Create `packages/core/src/infrastructure/repositories/mock-comments.repository.ts` with `@injectable()`.
### Step 6: Add to root `pnpm-workspace.yaml` (if not already included)
### 6. Register in DI
```yaml
packages:
- "packages/*"
```
Add `ICommentsRepository` symbol to `di/types.ts`, create binding in `di/modules/content.module.ts`, load in `di/container.ts`.
Run `pnpm install` — the new package is now part of the workspace.
### 7. Add tRPC Router
## Part 2: Build the Layers
Create `packages/api/src/router/comments.router.ts`, add to `appRouter` in `router/index.ts`.
### Entities: Define types
### 8. (Optional) Add Payload Collection
Create `packages/<feature-name>/src/entities/model.ts`:
If comments are stored in Payload CMS, create `packages/cms-core/src/collections/comments/`.
```typescript
import { z } from "zod";
### 9. Build UI
export const modelSchema = z.object({
id: z.string(),
name: z.string().min(1).max(255),
createdAt: z.date(),
});
Create component in `packages/ui/src/organisms/comment-list/` with co-located `.stories.tsx`.
export type Model = z.infer<typeof modelSchema>;
```
### 10. Wire in App
Create `packages/<feature-name>/src/entities/index.ts`:
Use `useTRPC()` in app pages to fetch and display comments.
```typescript
export { modelSchema, type Model } from "./model.js";
```
### 11. Write Tests
### Use cases: Implement business logic
- Unit: use case + controller tests in `packages/core/tests/`
- E2E: Playwright test in `tests/e2e/`
Create `packages/<feature-name>/src/application/use-cases/create-model.use-case.ts`:
```typescript
import { injectable, inject } from "inversify";
import type { IModelsRepository } from "../repositories/models.repository.interface.js";
import { MODELS_REPOSITORY } from "../../di/symbols.js";
import type { Model } from "../../entities/index.js";
@injectable()
export class CreateModelUseCase {
constructor(
@inject(MODELS_REPOSITORY) private repo: IModelsRepository,
) {}
async execute(input: { name: string }): Promise<Model> {
return this.repo.create({
id: crypto.randomUUID(),
name: input.name,
createdAt: new Date(),
});
}
}
```
### Repository interface and mock
Create `packages/<feature-name>/src/application/repositories/models.repository.interface.ts`:
```typescript
import type { Model } from "../../entities/index.js";
export interface IModelsRepository {
create(model: Model): Promise<Model>;
getById(id: string): Promise<Model | null>;
}
```
Create `packages/<feature-name>/src/infrastructure/repositories/mock-models.repository.ts`:
```typescript
import { injectable } from "inversify";
import type { IModelsRepository } from "../../application/repositories/models.repository.interface.js";
import type { Model } from "../../entities/index.js";
@injectable()
export class MockModelsRepository implements IModelsRepository {
private store: Map<string, Model> = new Map();
async create(model: Model): Promise<Model> {
this.store.set(model.id, model);
return model;
}
async getById(id: string): Promise<Model | null> {
return this.store.get(id) ?? null;
}
}
```
### DI container: Wire everything
Create `packages/<feature-name>/src/di/symbols.ts`:
```typescript
export const MODELS_REPOSITORY = Symbol("IModelsRepository");
```
Create `packages/<feature-name>/src/di/container.ts`:
```typescript
import { Container } from "inversify";
import { MockModelsRepository } from "../infrastructure/repositories/mock-models.repository.js";
import { CreateModelUseCase } from "../application/use-cases/create-model.use-case.js";
import { MODELS_REPOSITORY } from "./symbols.js";
import type { IModelsRepository } from "../application/repositories/models.repository.interface.js";
export function createContainer(): Container {
const container = new Container({ defaultScope: "Singleton" });
container.bind<IModelsRepository>(MODELS_REPOSITORY).to(MockModelsRepository);
container.bind(CreateModelUseCase).toSelf();
return container;
}
export const container = createContainer();
```
Create `packages/<feature-name>/src/di/bind-production.ts` (if using Payload):
```typescript
import type { Container } from "inversify";
import type { Config as PayloadConfig } from "payload";
import { PayloadModelsRepository } from "../infrastructure/repositories/payload-models.repository.js";
import { MODELS_REPOSITORY } from "./symbols.js";
import type { IModelsRepository } from "../application/repositories/models.repository.interface.js";
export async function bindProductionModels(
container: Container,
config: PayloadConfig,
): Promise<void> {
const repo = new PayloadModelsRepository(config);
container.rebind<IModelsRepository>(MODELS_REPOSITORY).toConstantValue(repo);
}
```
### Payload integration (optional)
Create `packages/<feature-name>/src/integrations/cms/collections/models.collection.ts`:
```typescript
import type { CollectionConfig } from "payload";
export const models: CollectionConfig = {
slug: "models",
admin: { useAsTitle: "name" },
fields: [
{
name: "name",
type: "text",
required: true,
},
],
};
```
Create `packages/<feature-name>/src/integrations/cms/index.ts`:
```typescript
export { models } from "./collections/models.collection.js";
```
Create `packages/<feature-name>/src/infrastructure/repositories/payload-models.repository.ts`:
```typescript
import type { Config } from "payload";
import type { IModelsRepository } from "../../application/repositories/models.repository.interface.js";
import type { Model } from "../../entities/index.js";
export class PayloadModelsRepository implements IModelsRepository {
constructor(private config: Config) {}
async create(model: Model): Promise<Model> {
const payload = await getPayload({ config: this.config });
return payload.create({ collection: "models", data: model });
}
async getById(id: string): Promise<Model | null> {
const payload = await getPayload({ config: this.config });
try {
return await payload.findByID({ collection: "models", id });
} catch {
return null;
}
}
}
```
### tRPC router
Create `packages/<feature-name>/src/integrations/api/router.ts`:
```typescript
import { t } from "@repo/core-shared/trpc/init";
import { container } from "../../di/container.js";
import { CreateModelUseCase } from "../../application/use-cases/create-model.use-case.js";
import { modelSchema } from "../../entities/index.js";
export const modelsRouter = t.router({
create: t.procedure.input(modelSchema.pick({ name: true })).mutation(async ({ input }) => {
const useCase = container.get(CreateModelUseCase);
return useCase.execute(input);
}),
getById: t.procedure.input(modelSchema.pick({ id: true })).query(async ({ input }) => {
const useCase = container.get(CreateModelUseCase);
return useCase.getById(input.id);
}),
});
```
Create `packages/<feature-name>/src/integrations/api/index.ts`:
```typescript
export { modelsRouter } from "./router.js";
```
### Feature public index
Create `packages/<feature-name>/src/index.ts`:
```typescript
export { modelSchema, type Model } from "./entities/index.js";
export { CreateModelUseCase } from "./application/use-cases/create-model.use-case.js";
export { container } from "./di/container.js";
```
## Part 3: Integrate with Core
### Wire into core-api
Edit `packages/core-api/src/routers.ts`:
```typescript
import { modelsRouter } from "@repo/<feature-name>/api";
import { t } from "@repo/core-shared/trpc/init";
export const appRouter = t.router({
models: modelsRouter,
// ... other feature routers
});
```
### Wire into core-cms
Edit `packages/core-cms/src/collections/index.ts`:
```typescript
import { models } from "@repo/<feature-name>/cms";
export const collections = [models];
```
### Add path aliases
Edit `tsconfig.base.json` in the repo root:
```json
{
"compilerOptions": {
"paths": {
"@repo/<feature-name>": ["packages/<feature-name>/src/index.ts"],
"@repo/<feature-name>/api": ["packages/<feature-name>/src/integrations/api/index.ts"],
"@repo/<feature-name>/cms": ["packages/<feature-name>/src/integrations/cms/index.ts"],
"@repo/<feature-name>/di/bind-production": ["packages/<feature-name>/src/di/bind-production.ts"]
}
}
}
```
### Add to app bootstrap
In `apps/web-next/src/app/layout.tsx` or equivalent:
```typescript
import { bindProductionModels } from "@repo/<feature-name>/di/bind-production";
import { config } from "@/payload.config";
// At app startup, after creating feature containers:
await bindProductionModels(featureContainer, config);
```
### Test, typecheck, build
```bash
pnpm install
pnpm typecheck --filter @repo/<feature-name>
pnpm test --filter @repo/<feature-name>
pnpm build --filter @repo/<feature-name>
```
---
## Part 4: Modifying an Existing Feature
Example: Adding an `unapprove-article` procedure to `packages/blog`.
### 1. Add use case
Create `packages/blog/src/application/use-cases/unapprove-article.use-case.ts`:
```typescript
@injectable()
export class UnapproveArticleUseCase {
constructor(@inject(ARTICLES_REPOSITORY) private repo: IArticlesRepository) {}
async execute(articleId: string): Promise<Article> {
const article = await this.repo.getById(articleId);
if (!article) throw new ArticleNotFoundError();
return this.repo.update(articleId, { status: "draft" });
}
}
```
Register it in `packages/blog/src/di/container.ts`:
```typescript
container.bind(UnapproveArticleUseCase).toSelf();
```
### 2. Add tRPC procedure
Edit `packages/blog/src/integrations/api/router.ts`:
```typescript
export const blogRouter = t.router({
// ... existing
unapproveArticle: t.procedure
.input(z.object({ articleId: z.string() }))
.mutation(async ({ input }) => {
const useCase = container.get(UnapproveArticleUseCase);
return useCase.execute(input.articleId);
}),
});
```
### 3. Test and lint
```bash
pnpm test --filter @repo/blog
pnpm lint --filter @repo/blog
```
---
## Done Criteria
- Package created with correct folder structure
- `entities/` has Zod schemas
- `application/use-cases/` has business logic
- `application/repositories/` has interfaces and mock implementations
- `di/container.ts` wires everything
- `integrations/api/` exports tRPC router
- `integrations/cms/` exports Payload collection (if applicable)
- `di/bind-production.ts` binds Payload repo (if applicable)
- Feature exported from `core-api` router aggregator
- Feature collections exported from `core-cms`
- Path aliases added to `tsconfig.base.json`
- `pnpm install && pnpm typecheck && pnpm test && pnpm lint` all pass
- ESLint boundaries pass (feature only imports `core-*` and tooling)