Files
agentic-dev/docs/guides/adding-a-feature.md

12 KiB

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

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

{
  "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

{
  "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

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)

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:

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:

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:

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:

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:

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:

export const MODELS_REPOSITORY = Symbol("IModelsRepository");

Create packages/<feature-name>/src/di/container.ts:

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):

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:

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:

export { models } from "./collections/models.collection.js";

Create packages/<feature-name>/src/infrastructure/repositories/payload-models.repository.ts:

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:

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:

export { modelsRouter } from "./router.js";

Feature public index

Create packages/<feature-name>/src/index.ts:

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:

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:

import { models } from "@repo/<feature-name>/cms";

export const collections = [models];

Add path aliases

Edit tsconfig.base.json in the repo root:

{
  "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:

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

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:

@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:

container.bind(UnapproveArticleUseCase).toSelf();

2. Add tRPC procedure

Edit packages/blog/src/integrations/api/router.ts:

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

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)