463 lines
12 KiB
Markdown
463 lines
12 KiB
Markdown
# Adding a New Feature — End-to-End Guide
|
|
|
|
A feature is a vertical slice: entities, use cases, repositories, tRPC router, CMS integration, DI container, and UI components — all owned by one package.
|
|
|
|
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`).
|
|
|
|
## Part 1: New Feature Scaffold
|
|
|
|
### 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}
|
|
```
|
|
|
|
### Step 3: Create `package.json`
|
|
|
|
```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:*"
|
|
}
|
|
}
|
|
```
|
|
|
|
### Step 4: Create `tsconfig.json`
|
|
|
|
```json
|
|
{
|
|
"extends": "@repo/typescript-config/base.json",
|
|
"compilerOptions": {
|
|
"rootDir": ".",
|
|
"outDir": "dist",
|
|
"lib": ["ES2022", "DOM"],
|
|
"jsx": "preserve"
|
|
},
|
|
"include": ["src/**/*"],
|
|
"exclude": ["node_modules", "dist", "**/*.test.ts"]
|
|
}
|
|
```
|
|
|
|
### Step 5: Create `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"],
|
|
},
|
|
resolve: {
|
|
alias: {
|
|
"@": path.resolve(__dirname, "./src"),
|
|
},
|
|
},
|
|
});
|
|
```
|
|
|
|
### Step 6: Add to root `pnpm-workspace.yaml` (if not already included)
|
|
|
|
```yaml
|
|
packages:
|
|
- "packages/*"
|
|
```
|
|
|
|
Run `pnpm install` — the new package is now part of the workspace.
|
|
|
|
## Part 2: Build the Layers
|
|
|
|
### Entities: Define types
|
|
|
|
Create `packages/<feature-name>/src/entities/model.ts`:
|
|
|
|
```typescript
|
|
import { z } from "zod";
|
|
|
|
export const modelSchema = z.object({
|
|
id: z.string(),
|
|
name: z.string().min(1).max(255),
|
|
createdAt: z.date(),
|
|
});
|
|
|
|
export type Model = z.infer<typeof modelSchema>;
|
|
```
|
|
|
|
Create `packages/<feature-name>/src/entities/index.ts`:
|
|
|
|
```typescript
|
|
export { modelSchema, type Model } from "./model.js";
|
|
```
|
|
|
|
### Use cases: Implement business logic
|
|
|
|
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)
|