From 49d74adc3544d06876490cc8c908bc8818d01943 Mon Sep 17 00:00:00 2001 From: Danijel Martinek Date: Tue, 5 May 2026 09:33:02 +0200 Subject: [PATCH] docs(guides): rewrite adding-a-feature for vertical canonical pattern --- docs/guides/adding-a-feature.md | 476 +++++++++++++++++++++++++++++--- 1 file changed, 434 insertions(+), 42 deletions(-) diff --git a/docs/guides/adding-a-feature.md b/docs/guides/adding-a-feature.md index a825e4e..baa71fe 100644 --- a/docs/guides/adding-a-feature.md +++ b/docs/guides/adding-a-feature.md @@ -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; +### 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//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; - createComment(input: Comment): Promise; +```json +{ + "name": "@repo/", + "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//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; +``` -### 10. Wire in App +Create `packages//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//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 { + return this.repo.create({ + id: crypto.randomUUID(), + name: input.name, + createdAt: new Date(), + }); + } +} +``` + +### Repository interface and mock + +Create `packages//src/application/repositories/models.repository.interface.ts`: + +```typescript +import type { Model } from "../../entities/index.js"; + +export interface IModelsRepository { + create(model: Model): Promise; + getById(id: string): Promise; +} +``` + +Create `packages//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 = new Map(); + + async create(model: Model): Promise { + this.store.set(model.id, model); + return model; + } + + async getById(id: string): Promise { + return this.store.get(id) ?? null; + } +} +``` + +### DI container: Wire everything + +Create `packages//src/di/symbols.ts`: + +```typescript +export const MODELS_REPOSITORY = Symbol("IModelsRepository"); +``` + +Create `packages//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(MODELS_REPOSITORY).to(MockModelsRepository); + container.bind(CreateModelUseCase).toSelf(); + + return container; +} + +export const container = createContainer(); +``` + +Create `packages//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 { + const repo = new PayloadModelsRepository(config); + container.rebind(MODELS_REPOSITORY).toConstantValue(repo); +} +``` + +### Payload integration (optional) + +Create `packages//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//src/integrations/cms/index.ts`: + +```typescript +export { models } from "./collections/models.collection.js"; +``` + +Create `packages//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 { + const payload = await getPayload({ config: this.config }); + return payload.create({ collection: "models", data: model }); + } + + async getById(id: string): Promise { + const payload = await getPayload({ config: this.config }); + try { + return await payload.findByID({ collection: "models", id }); + } catch { + return null; + } + } +} +``` + +### tRPC router + +Create `packages//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//src/integrations/api/index.ts`: + +```typescript +export { modelsRouter } from "./router.js"; +``` + +### Feature public index + +Create `packages//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//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//cms"; + +export const collections = [models]; +``` + +### Add path aliases + +Edit `tsconfig.base.json` in the repo root: + +```json +{ + "compilerOptions": { + "paths": { + "@repo/": ["packages//src/index.ts"], + "@repo//api": ["packages//src/integrations/api/index.ts"], + "@repo//cms": ["packages//src/integrations/cms/index.ts"], + "@repo//di/bind-production": ["packages//src/di/bind-production.ts"] + } + } +} +``` + +### Add to app bootstrap + +In `apps/web-next/src/app/layout.tsx` or equivalent: + +```typescript +import { bindProductionModels } from "@repo//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/ +pnpm test --filter @repo/ +pnpm build --filter @repo/ +``` + +--- + +## 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
{ + 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)