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 operationapplication/repositories/— interface + mock implementationinfrastructure/repositories/— Payload-backed implementation (if needed)di/— InversifyJS container + symbol tableintegrations/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 schemasapplication/use-cases/has business logicapplication/repositories/has interfaces and mock implementationsdi/container.tswires everythingintegrations/api/exports tRPC routerintegrations/cms/exports Payload collection (if applicable)di/bind-production.tsbinds Payload repo (if applicable)- Feature exported from
core-apirouter aggregator - Feature collections exported from
core-cms - Path aliases added to
tsconfig.base.json pnpm install && pnpm typecheck && pnpm test && pnpm lintall pass- ESLint boundaries pass (feature only imports
core-*and tooling)