Clean Architecture
This document provides a deep dive into how Clean Architecture is implemented in 2KRIKA.
The Clean Architecture Model
Clean Architecture, introduced by Robert C. Martin (Uncle Bob), organizes code into concentric layers:
┌─────────────────────────────────────┐
│ Frameworks & Drivers │
│ (Web, DB, External Services) │
└──────────────┬──────────────────────┘
│
┌──────────────┴──────────────────────┐
│ Interface Adapters │
│ (Controllers, Gateways, Presenters)│
└──────────────┬──────────────────────┘
│
┌──────────────┴──────────────────────┐
│ Application Business Rules │
│ (Use Cases) │
└──────────────┬──────────────────────┘
│
┌──────────────┴──────────────────────┐
│ Enterprise Business Rules │
│ (Entities) │
└─────────────────────────────────────┘
Our Implementation
Layer 1: Entities (Domain Layer)
Location: domain/
Contains enterprise-wide business rules and entities.
// domain/user/user.entity.ts
export class User {
constructor(
public id: string,
public email: string,
public passwordHash: string,
// ... other properties
) {}
activate() {
this.isActive = true;
}
hasPermission(permission: Permission): boolean {
if (this.isSuperuser) return true;
return this.permissions?.includes(permission) || false;
}
}
Characteristics: - No framework dependencies - Pure TypeScript classes - Business rules are methods - Immutable where possible
Layer 2: Use Cases (Application Layer)
Location: app/
Contains application-specific business rules.
// app/user/usecases.ts
async function create(
data: any,
userRepository: UserRepository,
notifier: Notifier,
logger: AbstractLogger,
) {
const validator = new UserCreateValidator(userRepository, logger);
const validatedData = await validator.validate(data);
const user = User.create(
userRepository.nextId(),
validatedData.fullName,
validatedData.email,
// ...
);
await userRepository.add(user);
await notifier.sendWelcomeMessage(user);
return toUserDto(user);
}
Characteristics: - Orchestrates entity interactions - Implements use case logic - Depends on domain abstractions - Framework-agnostic
Layer 3: Interface Adapters (Adapters & Web Layers)
Location: adapters/ and web/
Adapters (adapters/)
Implement domain interfaces for external systems:
// adapters/user/user.repository.ts
export class UserSqlRepository extends AbstractRepository<User> {
async add(user: User): Promise<User> {
const model = await db.User.create({
id: user.id,
email: user.email,
// SQL-specific logic
});
return this.loads(model);
}
}
Web Layer (web/)
Handles HTTP requests and responses:
// web/users/users.controller.ts
@Controller('users')
export class UsersController {
constructor(
private readonly userRepository: UserRepository,
private readonly notifier: Notifier,
) {}
@Post()
async create(@Body() body: any) {
return await userUseCases.create(
body,
this.userRepository,
this.notifier,
logger,
);
}
}
Layer 4: Frameworks & Drivers
Location: External libraries and configurations
- NestJS framework
- Sequelize ORM
- Mongoose ODM
- Redis client
- External APIs
Dependency Rule
The most important rule: Dependencies point inward.
- Outer layers depend on inner layers
- Inner layers know nothing about outer layers
- Domain is independent of everything
Example
// ❌ WRONG: Domain depending on framework
import { Injectable } from '@nestjs/common';
export class User {
// Domain shouldn't know about NestJS
}
// ✅ CORRECT: Framework depending on domain
import { User } from '@/domain/user/user.entity';
@Injectable()
export class UsersService {
// Framework layer uses domain
}
Crossing Boundaries
Repository Pattern
Domain defines the contract:
// domain/user/user.repository.ts
export abstract class UserRepository {
abstract add(user: User): Promise<User>;
abstract get(id: string): Promise<User>;
}
Adapter implements it:
// adapters/user/user.repository.ts
export class UserSqlRepository extends UserRepository {
async add(user: User): Promise<User> {
// Implementation
}
}
Web layer uses it via dependency injection:
// web/users/users.module.ts
@Module({
providers: [
{ provide: UserRepository, useClass: UserSqlRepository },
],
})
export class UsersModule {}
Benefits in Practice
1. Framework Independence
Can switch from NestJS to Express without changing domain/application layers:
2. Database Independence
Can switch from Sequelize to TypeORM:
3. External Service Independence
Can switch from Paystack to Stripe:
// Implement new gateway
export class StripeGateway extends PaymentGateway {
// Implementation
}
// Update DI configuration
{ provide: PaymentGateway, useClass: StripeGateway }
4. Testability
Mock repositories easily:
const mockUserRepository = {
add: jest.fn(),
get: jest.fn(),
};
await userUseCases.create(
data,
mockUserRepository,
mockNotifier,
mockLogger,
);
Common Patterns
Entity Creation
Entities use static factory methods:
Repository Operations
Always return domain entities:
class UserSqlRepository {
async get(id: string): Promise<User> {
const model = await db.User.findByPk(id);
return this.loads(model); // Convert to domain entity
}
}
Use Case Functions
Pure functions that orchestrate:
async function someUseCase(
data: any,
dependencies: { repo: Repository, service: Service },
) {
// Validate
// Execute business logic
// Return DTO
}
Anti-Patterns to Avoid
❌ Leaking Framework into Domain
❌ Domain Logic in Controllers
@Post()
async create(@Body() body: any) {
// Validation logic here - NO!
// Business rules here - NO!
// Use use cases instead
}