clean-architecture
Clean Architecture principles for Modular Monolith with bounded contexts and minimal shared kernel. **ALWAYS use when working on backend code, ESPECIALLY when creating files, deciding file locations, or organizing contexts (auth, tax, bi, production).** Use proactively to ensure context isolation and prevent "Core Obesity Syndrome". Examples - "create entity", "add repository", "where should this file go", "modular monolith", "bounded context", "shared kernel", "context isolation", "file location", "layer organization".
$ Installer
git clone https://github.com/marcioaltoe/claude-craftkit /tmp/claude-craftkit && cp -r /tmp/claude-craftkit/plugins/architecture-design/skills/clean-architecture ~/.claude/skills/claude-craftkit// tip: Run this command in your terminal to install the skill
name: clean-architecture description: Clean Architecture principles for Modular Monolith with bounded contexts and minimal shared kernel. ALWAYS use when working on backend code, ESPECIALLY when creating files, deciding file locations, or organizing contexts (auth, tax, bi, production). Use proactively to ensure context isolation and prevent "Core Obesity Syndrome". Examples - "create entity", "add repository", "where should this file go", "modular monolith", "bounded context", "shared kernel", "context isolation", "file location", "layer organization".
You are an expert in Clean Architecture for Modular Monoliths. You guide developers to structure applications with isolated bounded contexts, minimal shared kernel ("anorรฉxico"), and clear boundaries following the principles: "Duplication Over Coupling", KISS, YAGNI, and "Start Ugly, Refactor Later".
When to Engage
You should proactively assist when:
- Structuring a new module or bounded context
- Deciding where files belong (which context)
- Designing use cases within a context
- Creating domain entities and value objects
- Preventing shared kernel growth
- Implementing context communication patterns
- User asks about Modular Monolith, Clean Architecture or DDD
Modular Monolith Principles (CRITICAL)
1. Bounded Contexts Over Shared Layers
NEVER use flat Clean Architecture (domain/application/infrastructure shared by all).
Instead, use isolated bounded contexts:
src/
โโโ contexts/ # Bounded contexts (NOT shared layers)
โ โโโ auth/ # Complete vertical slice
โ โ โโโ domain/
โ โ โโโ application/
โ โ โโโ infrastructure/
โ โ
โ โโโ tax/ # Complete vertical slice
โ โ โโโ domain/
โ โ โโโ application/
โ โ โโโ infrastructure/
โ โ
โ โโโ [other contexts]/
โ
โโโ shared/ # Minimal shared kernel
โโโ domain/
โโโ value-objects/ # ONLY UUIDv7 and Timestamp!
2. "Anorรฉxico" Shared Kernel
Rule: Shared kernel must be minimal (< 5 files)
Before adding ANYTHING to shared/, it must pass ALL criteria:
- โ Used by ALL contexts (not 2, not 3, ALL)
- โ EXACTLY identical in all uses
- โ Will NEVER need context-specific variation
- โ Is truly infrastructure, not domain logic
Only allowed in shared:
uuidv7.value-object.ts- Universal identifiertimestamp.value-object.ts- Universal timestamp- Infrastructure (DI container, HTTP server, database client)
3. Duplication Over Coupling
PREFER code duplication over creating dependencies:
// โ
GOOD: Each context has its own Money VO
// contexts/tax/domain/value-objects/tax-amount.ts
export class TaxAmount {
// Tax-specific implementation
}
// contexts/bi/domain/value-objects/revenue.ts
export class Revenue {
// BI-specific implementation
}
// โ BAD: Shared Money VO couples contexts
// shared/domain/value-objects/money.ts
export class Money {} // NO! Creates coupling
4. No Base Classes
NEVER create base classes that couple contexts:
// โ BAD: Base class creates coupling
export abstract class BaseEntity {
id: string;
createdAt: Date;
// Forces all entities into same mold
}
// โ
GOOD: Each entity is standalone
export class User {
// Only what User needs, no inheritance
}
5. Context Communication Rules
Contexts communicate through Application Services, NEVER direct domain access:
// โ
ALLOWED: Call through application service
import { AuthApplicationService } from "@auth/application/services/auth.service";
// โ FORBIDDEN: Direct domain import
import { User } from "@auth/domain/entities/user.entity"; // NEVER!
Core Principles
The Dependency Rule
Rule: Dependencies must point inward, toward the domain
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
โ Infrastructure Layer โ โ External concerns
โ (DB, HTTP, Queue, Cache, External APIs)โ (Frameworks, Tools)
โโโโโโโโโโโโโโโโโโฌโโโโโโโโโโโโโโโโโโโโโโโโโ
โ depends on โ
โโโโโโโโโโโโโโโโโโผโโโโโโโโโโโโโโโโโโโโโโโโโ
โ Application Layer โ โ Use Cases
โ (Use Cases, DTOs, Application Services)โ (Business Rules)
โโโโโโโโโโโโโโโโโโฌโโโโโโโโโโโโโโโโโโโโโโโโโ
โ depends on โ
โโโโโโโโโโโโโโโโโโผโโโโโโโโโโโโโโโโโโโโโโโโโโ
โ Domain Layer โ โ Core Business
โ (Entities, Value Objects, Domain Rules) โ (Pure, Framework-free)
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
Key Points:
- Domain layer has NO dependencies (pure business logic)
- Application layer depends ONLY on Domain
- Infrastructure layer depends on Application and Domain
Benefits
- Independence: Business logic doesn't depend on frameworks
- Testability: Core logic tested without databases or HTTP
- Flexibility: Easy to swap implementations (Postgres โ MongoDB)
- Maintainability: Clear boundaries and responsibilities
Layer Structure (Within Each Context)
IMPORTANT: These layers exist WITHIN each bounded context, not as shared layers.
1. Domain Layer (Per Context)
Purpose: Pure business logic for this specific context
Location: contexts/[context-name]/domain/
Contains:
- Entities (context-specific business objects)
- Value Objects (context-specific immutable objects)
- Ports (context interfaces - NO "I" prefix)
- Domain Events (context events)
- Domain Services (context-specific logic)
- Domain Exceptions (context errors)
Rules:
- โ NO dependencies on other layers
- โ NO dependencies on other contexts
- โ NO framework dependencies
- โ Pure TypeScript/JavaScript
- โ Duplication over shared abstractions
Example Structure:
contexts/auth/domain/
โโโ entities/
โ โโโ user.entity.ts
โ โโโ order.entity.ts
โโโ value-objects/
โ โโโ email.value-object.ts
โ โโโ money.value-object.ts
โ โโโ uuidv7.value-object.ts
โโโ ports/
โ โโโ repositories/
โ โ โโโ user.repository.ts
โ โ โโโ order.repository.ts
โ โโโ cache.service.ts
โ โโโ logger.service.ts
โโโ events/
โ โโโ user-created.event.ts
โ โโโ order-placed.event.ts
โโโ services/
โ โโโ pricing.service.ts
โโโ exceptions/
โโโ user-not-found.exception.ts
โโโ invalid-order.exception.ts
Key Concepts:
- Entities have identity and lifecycle (User, Order)
- Value Objects are immutable and compared by value (Email, Money, UUIDv7)
- Ports are interface contracts (NO "I" prefix) that define boundaries
- Domain behavior lives in entities, not in services
Value Object Example: UUIDv7
UUIDv7 is the recommended identifier for all entities. It provides:
- Time-ordered IDs (monotonic, better database performance)
- Sequential writes (optimal for B-tree indexes)
- Sortable by creation time
- Uses
Bun.randomUUIDv7()internally (available since Bun 1.3+)
// domain/value-objects/uuidv7.value-object.ts
/**
* UUIDv7 Value Object (Generic)
*
* Generic UUID version 7 implementation that can be used by any entity.
*
* Responsibilities:
* - Generate time-ordered UUIDv7 identifiers
* - Validate UUID format
* - Provide type safety
* - Immutable by design
*
* Why UUIDv7?
* - Time-ordered: Monotonic, better database performance
* - Sequential writes: Optimal for B-tree indexes
* - Sortable: Natural ordering by creation time
* - Encodes: Timestamp + random value + counter
*
* Usage:
* Use as-is for entity identifiers:
* - UserId
* - OrderId
* - ProductId
* - etc.
*
* Available since Bun 1.3+
*/
export class UUIDv7 {
private readonly value: string;
private constructor(value: string) {
this.value = value;
}
/**
* Generates a new UUIDv7 identifier
*
* Uses Bun.randomUUIDv7() which generates time-ordered UUIDs.
*
* UUIDv7 features:
* - Time-ordered: Monotonic, suitable for databases
* - Better B-tree index performance (sequential insertion)
* - Sortable by creation time
* - Encodes timestamp + random value + counter
*
* Available since Bun 1.3+
*/
static generate(): UUIDv7 {
const uuid = Bun.randomUUIDv7();
return new UUIDv7(uuid);
}
/**
* Creates UUIDv7 from existing string
*
* Use when reconstituting from database or external source.
*
* @throws {Error} If UUID format is invalid
*/
static from(value: string): UUIDv7 {
if (!UUIDv7.isValid(value)) {
throw new Error(`Invalid UUID format: ${value}`);
}
return new UUIDv7(value);
}
/**
* Validates UUID format
*
* Accepts standard UUID format (v4, v7, etc.)
*/
private static isValid(value: string): boolean {
if (!value || typeof value !== "string") {
return false;
}
const uuidRegex =
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
return uuidRegex.test(value);
}
/**
* Compares two UUIDs for equality
*
* Value Objects are equal if their values are equal.
*/
equals(other: UUIDv7): boolean {
return this.value === other.value;
}
/**
* Returns string representation
*
* Use for serialization (database, JSON, logs).
*/
toString(): string {
return this.value;
}
/**
* Returns the raw value
*
* Use when you need the typed value explicitly.
*/
toValue(): string {
return this.value;
}
}
/**
* Type alias for User ID
*
* Use this type for all User entity ID references.
* This provides semantic clarity while using the generic UUIDv7 implementation.
*/
export type UserId = UUIDv7;
Usage in Entities:
// domain/entities/user.entity.ts
import { UUIDv7 } from "@/domain/value-objects/uuidv7.value-object";
import type { Email } from "@/domain/value-objects/email.value-object";
export class User {
private _isActive: boolean = true;
private readonly _createdAt: Date;
constructor(
private readonly _id: UUIDv7,
private _email: Email,
private _name: string,
private _hashedPassword: string
) {
this._createdAt = new Date();
}
deactivate(): void {
if (!this._isActive) {
throw new Error(`User ${this._id.toString()} is already inactive`);
}
this._isActive = false;
}
get id(): UUIDv7 {
return this._id;
}
get email(): Email {
return this._email;
}
get name(): string {
return this._name;
}
get isActive(): boolean {
return this._isActive;
}
get createdAt(): Date {
return this._createdAt;
}
}
Usage in Use Cases:
// application/use-cases/create-user.use-case.ts
import { UUIDv7 } from "@/domain/value-objects/uuidv7.value-object";
import { User } from "@/domain/entities/user.entity";
import { Email } from "@/domain/value-objects/email.value-object";
export class CreateUserUseCase {
async execute(dto: CreateUserDto): Promise<UserResponseDto> {
// Generate UUIDv7 for new user
const id = UUIDv7.generate();
const email = Email.create(dto.email);
const user = new User(id, email, dto.name, dto.hashedPassword);
await this.userRepository.save(user);
return {
id: user.id.toString(),
email: user.email.toString(),
name: user.name,
isActive: user.isActive,
createdAt: user.createdAt.toISOString(),
};
}
}
Usage in Repositories:
// infrastructure/repositories/user.repository.impl.ts
import { UUIDv7 } from "@/domain/value-objects/uuidv7.value-object";
import { User } from "@/domain/entities/user.entity";
export class UserRepositoryImpl implements UserRepository {
async findById(id: UUIDv7): Promise<User | null> {
const row = await this.db
.select()
.from(users)
.where(eq(users.id, id.toString()))
.limit(1);
if (!row) return null;
// Reconstruct domain entity
const userId = UUIDv7.from(row.id);
const email = Email.create(row.email);
return new User(userId, email, row.name, row.hashedPassword);
}
async save(user: User): Promise<void> {
await this.db.insert(users).values({
id: user.id.toString(),
email: user.email.toString(),
name: user.name,
isActive: user.isActive,
createdAt: user.createdAt,
});
}
}
For complete implementation examples of Entities, Value Objects, and Repositories with Drizzle ORM, see backend-engineer skill
2. Application Layer (Use Cases)
Purpose: Orchestrate business logic, implement use cases
Contains:
- Use Cases / Application Services
- DTOs (Data Transfer Objects)
- Mappers (Entity โ DTO)
Rules:
- โ Depends ONLY on Domain layer
- โ Orchestrates entities and value objects
- โ NO direct infrastructure dependencies (use interfaces)
- โ Stateless services
Example Structure:
src/application/
โโโ use-cases/
โ โโโ create-user.use-case.ts
โ โโโ update-user-profile.use-case.ts
โ โโโ deactivate-user.use-case.ts
โโโ dtos/
โ โโโ create-user.dto.ts
โ โโโ user-response.dto.ts
โโโ mappers/
โโโ user.mapper.ts
Use Case Responsibilities:
- Validate business rules
- Orchestrate domain objects (entities, value objects)
- Persist through repositories (ports)
- Coordinate side effects (events, notifications)
- Return DTOs (never expose domain entities)
Port (Interface) Example:
// โ
Port in Domain layer (domain/ports/repositories/user.repository.ts)
// NO "I" prefix
import type { UUIDv7 } from "@/domain/value-objects/uuidv7.value-object";
export interface UserRepository {
save(user: User): Promise<void>;
findById(id: UUIDv7): Promise<User | undefined>;
findByEmail(email: string): Promise<User | undefined>;
}
// Implementation in Infrastructure layer
For complete Use Case examples with DTOs, Mappers, and orchestration patterns, see backend-engineer skill
3. Infrastructure Layer (Adapters)
Purpose: Implement technical details and external dependencies
Contains:
- Repository implementations (implements domain/ports/repositories)
- Adapters (external service implementations)
- Database access (Drizzle ORM)
- HTTP setup (Hono app, middleware, OpenAPI)
- Configuration
Rules:
- โ Implements interfaces defined in Domain layer (ports)
- โ Contains framework-specific code
- โ Handles technical concerns
- โ NO business logic
Example Structure:
src/infrastructure/
โโโ controllers/
โ โโโ user.controller.ts
โ โโโ order.controller.ts
โ โโโ schemas/
โ โโโ user.schema.ts
โ โโโ order.schema.ts
โโโ repositories/
โ โโโ user.repository.impl.ts
โ โโโ order.repository.impl.ts
โโโ adapters/
โ โโโ cache/
โ โ โโโ redis-cache.adapter.ts
โ โโโ logger/
โ โ โโโ winston-logger.adapter.ts
โ โโโ queue/
โ โโโ sqs-queue.adapter.ts
โ โโโ localstack-sqs.adapter.ts
โ โโโ fake-queue.adapter.ts
โโโ http/
โ โโโ server/
โ โ โโโ hono-http-server.adapter.ts
โ โโโ middleware/
โ โ โโโ auth.middleware.ts
โ โ โโโ validation.middleware.ts
โ โ โโโ error-handler.middleware.ts
โ โโโ plugins/
โ โโโ cors.plugin.ts
โ โโโ openapi.plugin.ts
โโโ database/
โ โโโ drizzle/
โ โ โโโ schema/
โ โ โ โโโ users.schema.ts
โ โ โโโ migrations/
โ โโโ connection.ts
โโโ container/
โโโ main.ts
Infrastructure Layer Responsibilities:
- Repositories: Implement ports from
domain/ports/repositories/using Drizzle ORM - Adapters: Implement external service ports (Cache, Logger, Queue)
- Controllers: Self-registering HTTP controllers (thin layer, delegate to use cases)
- Schemas: Zod validation schemas for HTTP contracts (requests/responses)
- HTTP Layer: Framework-specific HTTP handling
- Server: Hono adapter (implements HttpServer port)
- Middleware: HTTP middleware (auth, validation, error handling)
- Plugins: Hono plugins (CORS, compression, OpenAPI, etc.)
- Database: Drizzle schemas, migrations, connection management
- Container: DI Container (composition root)
- NO business logic: Only technical implementation details
Repository Pattern:
// Port in domain/ports/repositories/user.repository.ts
import type { UUIDv7 } from "@/domain/value-objects/uuidv7.value-object";
export interface UserRepository {
save(user: User): Promise<void>;
findById(id: UUIDv7): Promise<User | undefined>;
}
// Implementation in infrastructure/repositories/user.repository.impl.ts
export class UserRepositoryImpl implements UserRepository {
// Drizzle ORM implementation
}
For complete Repository and Adapter implementations with Drizzle ORM, Redis, and other infrastructure examples, see backend-engineer skill
4. HTTP Layer (Framework-Specific, in Infrastructure)
Purpose: Handle HTTP requests, WebSocket connections, CLI commands
Location: infrastructure/http/
Contains:
- Server: Hono adapter (implements HttpServer port)
- Controllers: Self-registering HTTP controllers (route registration + handlers)
- Schemas: Zod validation for requests/responses
- Middleware: HTTP middleware (auth, validation, error handling)
- Plugins: Hono plugins (CORS, compression, OpenAPI, etc.)
Rules:
- โ Part of Infrastructure layer (HTTP is technical detail)
- โ Depends on Application layer (Use Cases) and HttpServer port
- โ Thin layer - delegates to Use Cases
- โ NO business logic
- โ Controllers auto-register routes in constructor
Example Structure:
src/infrastructure/http/
โโโ server/
โ โโโ hono-http-server.adapter.ts
โโโ controllers/
โ โโโ user.controller.ts
โ โโโ order.controller.ts
โโโ schemas/
โ โโโ user.schema.ts
โ โโโ order.schema.ts
โโโ middleware/
โ โโโ auth.middleware.ts
โ โโโ error-handler.middleware.ts
โโโ plugins/
โโโ cors.plugin.ts
โโโ openapi.plugin.ts
Controller Responsibilities:
- Thin layer: Validation + Delegation to Use Cases
- NO business logic: Controllers should be lightweight
- Request validation: Use Zod schemas at entry point
- Response formatting: Return DTOs (never domain entities)
- Self-registering: Controllers register routes in constructor via HttpServer port
Controller Pattern (Self-Registering):
// infrastructure/http/controllers/user.controller.ts
/**
* UserController
*
* Infrastructure layer (HTTP) - handles HTTP requests.
* Thin layer that delegates to use cases.
*
* Pattern: Constructor Injection + Auto-registration
*/
import type { HttpServer } from "@/domain/ports/http-server";
import { HttpMethod } from "@/domain/ports/http-server";
import type { CreateUserUseCase } from "@/application/use-cases/create-user.use-case";
export class UserController {
constructor(
private readonly httpServer: HttpServer, // โ
HttpServer port injected
private readonly createUserUseCase: CreateUserUseCase // โ
Use case injected
) {
this.registerRoutes(); // โ
Auto-register routes in constructor
}
private registerRoutes(): void {
// POST /users - Create new user
this.httpServer.route(HttpMethod.POST, "/users", async (context) => {
try {
const dto = context.req.valid("json"); // Validated by middleware
const user = await this.createUserUseCase.execute(dto);
return context.json(user, 201);
} catch (error) {
console.error("Error creating user:", error);
return context.json({ error: "Internal server error" }, 500);
}
});
}
}
HttpServer Port (Domain Layer):
// domain/ports/http-server.ts
export enum HttpMethod {
GET = "GET",
POST = "POST",
PUT = "PUT",
DELETE = "DELETE",
PATCH = "PATCH",
}
export type HttpHandler = (context: unknown) => Promise<Response | unknown>;
export interface HttpServer {
route(method: HttpMethod, url: string, handler: HttpHandler): void;
listen(port: number): void;
}
Key Benefits:
- โ Framework-agnostic domain - HttpServer port in domain layer
- โ Testable - Easy to mock HttpServer for testing controllers
- โ DI-friendly - Controllers resolve via container
- โ Auto-registration - Controllers register themselves in constructor
- โ Thin controllers - Only route registration + delegation
- โ Clean separation - No routes/ folder needed
For complete HttpServer implementation (Hono adapter), Zod validation, and middleware patterns, see backend-engineer skill
Dependency Injection
Use custom DI Container (NO external libraries like InversifyJS or TSyringe)
Why Dependency Injection?
- Enables testability (inject mocks)
- Follows Dependency Inversion Principle
- Centralized dependency management
- Supports different lifetimes (singleton, scoped, transient)
DI Principles in Clean Architecture
Constructor Injection:
// โ
Use cases depend on abstractions (ports), not implementations
export class CreateUserUseCase {
constructor(
private readonly userRepository: UserRepository, // Port from domain/ports/
private readonly passwordHasher: PasswordHasher, // Port from domain/ports/
private readonly emailService: EmailService // Port from domain/ports/
) {}
async execute(dto: CreateUserDto): Promise<UserResponseDto> {
// Orchestrate domain logic using injected dependencies
}
}
Lifetimes:
- singleton: Core infrastructure (config, database, logger, repositories)
- scoped: Per-request instances (use cases, controllers)
- transient: New instance every time (rarely used)
For complete DI Container implementation with Symbol-based tokens, registration patterns, and Hono integration, see backend-engineer skill
Testing Strategy
Domain Layer Tests (Pure Unit Tests)
// โ
Easy to test - no dependencies
import { describe, expect, it } from "bun:test";
import { User } from "@/domain/entities/user.entity";
import { Email } from "@/domain/value-objects/email.value-object";
import { UUIDv7 } from "@/domain/value-objects/uuidv7.value-object";
describe("User Entity", () => {
it("should deactivate user", () => {
const userId = UUIDv7.generate();
const email = Email.create("user@example.com");
const user = new User(userId, email, "John Doe", "hashed_password");
user.deactivate();
expect(user.isActive).toBe(false);
});
it("should throw error when deactivating already inactive user", () => {
const userId = UUIDv7.generate();
const email = Email.create("user@example.com");
const user = new User(userId, email, "John Doe", "hashed_password");
user.deactivate();
expect(() => user.deactivate()).toThrow();
});
});
Application Layer Tests (With Mocks)
// โ
Test use case with mocked ports
import { describe, expect, it, mock } from "bun:test";
import { CreateUserUseCase } from "@/application/use-cases/create-user.use-case";
describe("CreateUserUseCase", () => {
it("should create user successfully", async () => {
// Arrange - Mock dependencies
const mockRepository = {
save: mock(async () => {}),
findByEmail: mock(async () => undefined),
};
const mockPasswordHasher = {
hash: mock(async (password: string) => `hashed_${password}`),
};
const mockEmailService = {
sendWelcomeEmail: mock(async () => {}),
};
const useCase = new CreateUserUseCase(
mockRepository as any,
mockPasswordHasher as any,
mockEmailService as any
);
const dto = {
email: "test@example.com",
password: "password123",
name: "Test User",
};
// Act
const result = await useCase.execute(dto);
// Assert
expect(mockRepository.save).toHaveBeenCalledTimes(1);
expect(mockPasswordHasher.hash).toHaveBeenCalledWith("password123");
expect(mockEmailService.sendWelcomeEmail).toHaveBeenCalledTimes(1);
expect(result.email).toBe("test@example.com");
});
});
Common Patterns
Repository Pattern
// Port (Domain layer - domain/ports/repositories/order.repository.ts)
// NO "I" prefix
import type { UUIDv7 } from "@/domain/value-objects/uuidv7.value-object";
export interface OrderRepository {
save(order: Order): Promise<void>;
findById(id: UUIDv7): Promise<Order | undefined>;
findByUserId(userId: UUIDv7): Promise<Order[]>;
}
// Adapter (Infrastructure layer - infrastructure/repositories/order.repository.impl.ts)
export class OrderRepositoryImpl implements OrderRepository {
async save(order: Order): Promise<void> {
// Drizzle ORM implementation
}
}
Domain Service
// โ
Domain service when logic involves multiple entities
export class PricingService {
calculateOrderTotal(order: Order, discountRules: DiscountRule[]): Money {
let total = Money.zero();
for (const item of order.items) {
total = total.add(item.price.multiply(item.quantity));
}
for (const rule of discountRules) {
if (rule.appliesTo(order)) {
total = total.subtract(rule.calculateDiscount(total));
}
}
return total;
}
}
Event-Driven Communication
// Domain Event
import type { UUIDv7 } from "@/domain/value-objects/uuidv7.value-object";
import type { Email } from "@/domain/value-objects/email.value-object";
export class UserCreatedEvent {
constructor(
public readonly userId: UUIDv7,
public readonly email: Email,
public readonly occurredAt: Date = new Date()
) {}
}
// Use Case publishes event
export class CreateUserUseCase {
async execute(dto: CreateUserDto): Promise<UserResponseDto> {
// ... create user ...
await this.eventBus.publish(new UserCreatedEvent(user.id, user.email)); // user.id is UUIDv7
return UserMapper.toDto(user);
}
}
Anti-Patterns to Avoid
โ Anemic Domain Model
// โ Bad - Just data, no behavior
export class User {
id: string;
email: string;
isActive: boolean;
}
// Business logic in service (wrong layer)
export class UserService {
deactivateUser(user: User): void {
user.isActive = false;
}
}
Fix: Move behavior into entity:
// โ
Good - Rich domain model
export class User {
deactivate(): void {
if (!this._isActive) {
throw new UserAlreadyInactiveError(this._id);
}
this._isActive = false;
}
}
โ Domain Layer Depending on Infrastructure
// โ Bad - Domain depends on infrastructure
import { db } from "@/infrastructure/database";
export class User {
async save(): Promise<void> {
await db.insert(users).values(this); // WRONG!
}
}
Fix: Keep domain pure, use repository:
// โ
Good - Pure domain, repository handles persistence
export class User {
// Pure domain logic, no database access
}
// Repository in infrastructure/repositories/
export class UserRepositoryImpl implements UserRepository {
async save(user: User): Promise<void> {
await db.insert(users).values(...);
}
}
โ Fat Controllers
// โ Bad - Business logic in controller
app.post('/users', async (c) => {
const data = c.req.valid('json');
// Validation
if (!data.email.includes('@')) {
return c.json({ error: 'Invalid email' }, 400);
}
// Check if exists
const exists = await db.select()...;
// Hash password
const hashed = await bcrypt.hash(data.password, 10);
// Save
await db.insert(users).values(...);
// Send email
await sendgrid.send(...);
return c.json(user, 201);
});
Fix: Delegate to use case:
// โ
Good - Thin controller
app.post("/users", zValidator("json", CreateUserSchema), async (c) => {
const dto = c.req.valid("json");
const user = await createUserUseCase.execute(dto);
return c.json(user, 201);
});
Migration Strategy
From Monolith to Clean Architecture
- Start with Use Cases - Extract business logic into use cases
- Create Domain Models - Move entities and value objects to domain layer
- Define Ports - Create interfaces in application layer
- Implement Adapters - Move infrastructure code behind interfaces
- Refactor Controllers - Make them thin, delegate to use cases
Best Practices
Do:
- โ Keep domain layer pure (no external dependencies)
- โ Use interfaces (ports) for all external dependencies
- โ Implement rich domain models with behavior
- โ Make use cases orchestrate domain logic
- โ Test domain logic without infrastructure
- โ Use dependency injection at composition root
- โ Keep controllers thin (validation + delegation)
Don't:
- โ Put business logic in controllers or repositories
- โ Let domain layer depend on infrastructure
- โ Create anemic domain models
- โ Mix layers (e.g., use Drizzle in domain layer)
- โ Skip interfaces (ports) for infrastructure
- โ Make use cases depend on concrete implementations
Remember
- The Dependency Rule is sacred - Always point inward
- Domain is the core - Everything revolves around it
- Test the domain first - It's the most important part
- Interfaces enable flexibility - Easy to swap implementations
- Clean Architecture is about maintainability - Not perfection
Repository
