docs(guides): rewrite adding-a-feature for vertical canonical pattern
This commit is contained in:
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user