Skip to content

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.

Frameworks → Adapters → Use Cases → Entities
  • 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:

// Domain and use cases remain the same
// Only web layer changes

2. Database Independence

Can switch from Sequelize to TypeORM:

// Change adapters/user/user.repository.ts
// Domain and use cases unchanged

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:

const user = User.create(
  id,
  fullName,
  email,
  passwordHash,
);

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/user/user.entity.ts
import { Injectable } from '@nestjs/common'; // NO!

❌ Domain Logic in Controllers

@Post()
async create(@Body() body: any) {
  // Validation logic here - NO!
  // Business rules here - NO!
  // Use use cases instead
}

❌ Use Cases Depending on Framework

// app/user/usecases.ts
import { Request } from 'express'; // NO!

Further Reading