# 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//src/{entities,application/{use-cases,repositories},infrastructure/repositories,di,integrations/{api,cms},ui,interface-adapters/controllers} ``` ### Step 3: Create `package.json` ```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:*" } } ``` ### 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//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; ``` Create `packages//src/entities/index.ts`: ```typescript export { modelSchema, type Model } from "./model.js"; ``` ### Use cases: Implement business logic 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)