| name | new-feature-architecture |
| description | Use BEFORE implementing any new domain/feature to ensure proper layered architecture with dependency injection. Prevents mixed concerns and tight coupling. |
New Feature Architecture
Overview
Design features with clean architecture BEFORE writing implementation code. This skill ensures proper separation of concerns, dependency injection, and testability.
Following the letter IS following the spirit. The workflow below is not negotiable—skipping steps to "save time" creates technical debt.
When to Use
Use this skill when:
- Adding a new domain concept (e.g., comments, subscriptions, analytics)
- Creating functionality that needs database, storage, or external APIs
- User asks to "add a feature" or "implement X"
- Building anything more complex than a simple UI change
Architecture Overview
┌─────────────────────────────────────────────────────────────┐
│ UI Layer (app/) │
│ Routes, Components, Server Actions (THIN) │
│ │ │
│ imports from │
│ ▼ │
│ ┌─────────────────────┐ │
│ │ lib/container.ts │ │
│ │ (DI Wiring) │ │
│ └─────────────────────┘ │
│ │ │
│ instantiates & injects │
│ ▼ │
├─────────────────────────────────────────────────────────────┤
│ Domain Layer (lib/domain/) │
│ Business Logic, Validation, Services │
│ │ │
│ depends on (interfaces only) │
│ ▼ │
│ ┌─────────────────────┐ │
│ │ lib/infra/interfaces│ │
│ │ (Contracts) │ │
│ └─────────────────────┘ │
│ ▲ │
│ implements │
│ │ │
├─────────────────────────────────────────────────────────────┤
│ Infra Layer (lib/infra/) │
│ Database, Storage, Email, External APIs │
└─────────────────────────────────────────────────────────────┘
The Three Laws
- Domain layer (
lib/domain/) MUST NOT import fromlib/infra/orlib/db/ - Services MUST receive dependencies via constructor injection
- UI layer MUST only import from
lib/container.ts, never directly from infra
Required Workflow
digraph architecture_flow {
"Feature request" [shape=doublecircle];
"Define domain entity" [shape=box];
"Design repository interface" [shape=box];
"Design other interfaces" [shape=box];
"Create service signature" [shape=box];
"Plan container wiring" [shape=box];
"Ready to implement" [shape=doublecircle];
"Feature request" -> "Define domain entity";
"Define domain entity" -> "Design repository interface";
"Design repository interface" -> "Design other interfaces";
"Design other interfaces" -> "Create service signature";
"Create service signature" -> "Plan container wiring";
"Plan container wiring" -> "Ready to implement";
}
Step 1: Define the Domain Entity
Before ANY code, answer these questions:
Entity Definition:
- What is the entity name? (e.g., Comment, Subscription, Analytics)
- What properties does it have?
- What are the validation rules?
- What errors can occur?
Create lib/domain/{entity}/{entity}.entity.ts:
// Types - no external dependencies
export interface MyEntity {
id: string;
// ... properties
}
// Pure validation functions - NO I/O, NO database calls
export function validateX(value: string): ValidationResult {
// validation logic
return { valid: true } | { valid: false, error: 'message' };
}
// Constants
export const MAX_LENGTH = 1000;
Step 2: Design Repository Interface
Create lib/infra/interfaces/{entity}.repository.ts:
import type { MyEntity } from '@/lib/domain/{entity}/{entity}.entity';
export interface IMyEntityRepository {
findById(id: string): Promise<MyEntity | null>;
findByUserId(userId: string): Promise<MyEntity[]>;
create(input: CreateInput): Promise<MyEntity>;
update(id: string, input: UpdateInput): Promise<MyEntity>;
delete(id: string): Promise<void>;
}
Key principle: Define what operations you NEED, not what the database CAN do.
Step 3: Design Other Interfaces (if needed)
Does your feature need:
- File storage? →
IStorageAdapter - Email sending? →
IEmailAdapter - External API? →
IExternalServiceAdapter
Create interfaces in lib/infra/interfaces/ for each.
Step 4: Create Service Signature
Create lib/domain/{entity}/{entity}.service.ts:
import type { IMyEntityRepository } from '@/lib/infra/interfaces/{entity}.repository';
import type { IStorageAdapter } from '@/lib/infra/interfaces/storage.adapter';
import { validateX } from './{entity}.entity';
import { MyEntityError } from './{entity}.errors';
export class MyEntityService {
constructor(
private repo: IMyEntityRepository,
private storage: IStorageAdapter, // if needed
) {}
async createEntity(userId: string, input: CreateInput): Promise<MyEntity> {
// 1. Validate (pure functions)
const result = validateX(input.value);
if (!result.valid) throw new MyEntityError('INVALID', result.error);
// 2. Business rules
// ...
// 3. Orchestrate (via injected deps)
return this.repo.create({ userId, ...input });
}
}
Step 5: Plan Container Wiring
Update lib/container.ts:
import { MyEntityService } from './domain/{entity}/{entity}.service';
import { DrizzleMyEntityRepository } from './infra/db/{entity}.repository';
let myEntityService: MyEntityService | null = null;
export function getMyEntityService(): MyEntityService {
if (!myEntityService) {
myEntityService = new MyEntityService(
new DrizzleMyEntityRepository(),
new VercelBlobAdapter(), // if needed
);
}
return myEntityService;
}
Checklist Before Implementation
- Entity types defined in
lib/domain/{entity}/{entity}.entity.ts - Validation functions are pure (no I/O, no async)
- Domain errors defined in
lib/domain/{entity}/{entity}.errors.ts - Repository interface defined in
lib/infra/interfaces/ - Service class signature created with constructor injection
- Container wiring planned
- NO direct imports from infra in domain layer
Anti-Patterns to Avoid
| Anti-Pattern | Why Bad | Correct Approach |
|---|---|---|
import { db } from '../db' in service |
Couples to Drizzle | Inject IRepository |
import { uploadFile } from '../storage' |
Couples to Vercel Blob | Inject IStorageAdapter |
| Validation with database calls | Side effects in validation | Pure validation, async checks in service |
| Service methods directly in actions | No reusability, hard to test | Create service class |
new Repository() in service |
Hard to test, tight coupling | Constructor injection |
Red Flags - STOP and Fix
These thoughts mean you're about to violate architecture:
- "I'll just import the db directly, it's faster"
- "This is simple, I don't need a repository"
- "I'll add the interface later"
- "Constructor injection is overkill for this"
- "Let me just get it working first, then refactor"
- "The validation needs to check the database"
- "I'll call the storage directly, it's just one place"
All of these mean: STOP. Design the architecture first. No shortcuts.
Example: Adding Analytics Feature
1. Entity (lib/domain/analytics/analytics.entity.ts)
export interface PageView {
id: string;
siteId: string;
path: string;
referrer: string | null;
userAgent: string;
timestamp: Date;
}
export interface AnalyticsSummary {
totalViews: number;
uniqueVisitors: number;
topPages: { path: string; views: number }[];
}
export function validatePath(path: string): ValidationResult {
if (!path.startsWith('/')) return { valid: false, error: 'Path must start with /' };
if (path.length > 2000) return { valid: false, error: 'Path too long' };
return { valid: true };
}
2. Interface (lib/infra/interfaces/analytics.repository.ts)
export interface IAnalyticsRepository {
recordPageView(siteId: string, data: PageViewInput): Promise<void>;
getSummary(siteId: string, dateRange: DateRange): Promise<AnalyticsSummary>;
getPageViews(siteId: string, options: QueryOptions): Promise<PageView[]>;
}
3. Service (lib/domain/analytics/analytics.service.ts)
export class AnalyticsService {
constructor(private analytics: IAnalyticsRepository) {}
async recordView(siteId: string, input: PageViewInput): Promise<void> {
const pathResult = validatePath(input.path);
if (!pathResult.valid) return; // Silently ignore invalid paths
await this.analytics.recordPageView(siteId, input);
}
async getDashboardData(siteId: string, userId: string): Promise<AnalyticsSummary> {
// Business logic: verify user owns site (would need site repo too)
return this.analytics.getSummary(siteId, { days: 30 });
}
}
Quick Reference
New feature requested
│
▼
1. lib/domain/{entity}/{entity}.entity.ts ← Types + pure validation
2. lib/domain/{entity}/{entity}.errors.ts ← Domain errors
3. lib/infra/interfaces/{entity}.repository.ts ← Contract
4. lib/domain/{entity}/{entity}.service.ts ← Business logic (injected deps)
5. lib/infra/db/{entity}.repository.ts ← Implementation
6. lib/container.ts ← Wire it up
7. app/... ← UI calls container
Why This Matters
Without this workflow:
- Business logic couples to specific database (can't swap Drizzle for Prisma)
- Storage couples to Vercel Blob (can't use S3 or local files in tests)
- Testing requires mocking database internals
- Changing infrastructure requires changing business logic
With this workflow:
- Swap database by implementing new repository
- Test services with mock repositories (no database needed)
- Business logic is pure and understandable
- Infrastructure changes don't touch domain code