Clean Architecture Introduction - Dependency Inversion and Layer Design

2025.12.02

What is Clean Architecture

Clean Architecture is a software design principle proposed by Robert C. Martin (Uncle Bob). It separates business logic from external details (frameworks, databases, UI) to build testable and maintainable systems.

flowchart TB
    subgraph Outer["Frameworks & Drivers (Web, DB, UI)"]
        subgraph Adapters["Interface Adapters (Controllers, Gateways)"]
            subgraph App["Application Business (Use Cases)"]
                subgraph Core["Enterprise Business"]
                    Entities["Entities<br/>(Business Rules)"]
                end
            end
        end
    end

Dependency Rule: Outer → Inner (Inner knows nothing about outer)

Dependency Inversion Principle (DIP)

The core of Clean Architecture is the Dependency Inversion Principle.

Traditional Dependencies:

flowchart LR
    Controller --> Service --> Repository["Repository (Impl)"]

High-level depends on low-level

After Dependency Inversion:

flowchart LR
    Controller --> UseCase
    UseCase <-- Interface["Repository Interface"]
    Interface --> Impl["Repository Impl"]

Depend on abstractions (hide implementation details)

Layer Structure

Directory Structure

src/
├── domain/                    # Enterprise Business Rules
│   ├── entities/              # Entities
│   │   ├── User.ts
│   │   ├── Order.ts
│   │   └── Product.ts
│   ├── value-objects/         # Value Objects
│   │   ├── Email.ts
│   │   ├── Money.ts
│   │   └── Address.ts
│   ├── repositories/          # Repository Interfaces
│   │   ├── IUserRepository.ts
│   │   └── IOrderRepository.ts
│   ├── services/              # Domain Services
│   │   └── PricingService.ts
│   └── errors/                # Domain Errors
│       └── DomainError.ts
├── application/               # Application Business Rules
│   ├── use-cases/             # Use Cases
│   │   ├── user/
│   │   │   ├── CreateUserUseCase.ts
│   │   │   └── GetUserByIdUseCase.ts
│   │   └── order/
│   │       ├── CreateOrderUseCase.ts
│   │       └── CancelOrderUseCase.ts
│   ├── dto/                   # Data Transfer Objects
│   │   ├── CreateUserDTO.ts
│   │   └── OrderResponseDTO.ts
│   └── services/              # Application Services
│       └── NotificationService.ts
├── infrastructure/            # Frameworks & Drivers
│   ├── database/
│   │   ├── prisma/
│   │   │   └── PrismaUserRepository.ts
│   │   └── drizzle/
│   │       └── DrizzleOrderRepository.ts
│   ├── external/
│   │   ├── StripePaymentGateway.ts
│   │   └── SendGridEmailService.ts
│   └── config/
│       └── database.ts
└── presentation/              # Interface Adapters
    ├── http/
    │   ├── controllers/
    │   │   ├── UserController.ts
    │   │   └── OrderController.ts
    │   ├── middleware/
    │   │   └── authMiddleware.ts
    │   └── routes/
    │       └── index.ts
    └── graphql/
        ├── resolvers/
        └── schema/

Entity Implementation

// domain/entities/User.ts
import { Email } from '../value-objects/Email';
import { UserId } from '../value-objects/UserId';
import { DomainError } from '../errors/DomainError';

export interface UserProps {
  id: UserId;
  email: Email;
  name: string;
  createdAt: Date;
  updatedAt: Date;
}

export class User {
  private constructor(private readonly props: UserProps) {}

  // Factory method
  static create(input: { email: string; name: string }): User {
    const email = Email.create(input.email);
    const id = UserId.generate();
    const now = new Date();

    return new User({
      id,
      email,
      name: input.name,
      createdAt: now,
      updatedAt: now,
    });
  }

  // Reconstitution (loading from DB)
  static reconstruct(props: UserProps): User {
    return new User(props);
  }

  // Getters
  get id(): UserId {
    return this.props.id;
  }

  get email(): Email {
    return this.props.email;
  }

  get name(): string {
    return this.props.name;
  }

  // Business logic
  changeName(newName: string): void {
    if (newName.length < 2 || newName.length > 100) {
      throw new DomainError('Name must be between 2 and 100 characters');
    }
    this.props.name = newName;
    this.props.updatedAt = new Date();
  }

  changeEmail(newEmail: string): void {
    this.props.email = Email.create(newEmail);
    this.props.updatedAt = new Date();
  }
}

Value Object Implementation

// domain/value-objects/Email.ts
import { DomainError } from '../errors/DomainError';

export class Email {
  private constructor(private readonly value: string) {}

  static create(value: string): Email {
    if (!this.isValid(value)) {
      throw new DomainError(`Invalid email format: ${value}`);
    }
    return new Email(value.toLowerCase());
  }

  private static isValid(email: string): boolean {
    const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
    return emailRegex.test(email);
  }

  getValue(): string {
    return this.value;
  }

  equals(other: Email): boolean {
    return this.value === other.value;
  }

  toString(): string {
    return this.value;
  }
}

// domain/value-objects/Money.ts
import { DomainError } from '../errors/DomainError';

export class Money {
  private constructor(
    private readonly amount: number,
    private readonly currency: string
  ) {}

  static create(amount: number, currency: string = 'USD'): Money {
    if (amount < 0) {
      throw new DomainError('Amount cannot be negative');
    }
    return new Money(amount, currency);
  }

  getAmount(): number {
    return this.amount;
  }

  getCurrency(): string {
    return this.currency;
  }

  add(other: Money): Money {
    this.ensureSameCurrency(other);
    return Money.create(this.amount + other.amount, this.currency);
  }

  subtract(other: Money): Money {
    this.ensureSameCurrency(other);
    const result = this.amount - other.amount;
    if (result < 0) {
      throw new DomainError('Insufficient funds');
    }
    return Money.create(result, this.currency);
  }

  multiply(factor: number): Money {
    return Money.create(Math.round(this.amount * factor), this.currency);
  }

  private ensureSameCurrency(other: Money): void {
    if (this.currency !== other.currency) {
      throw new DomainError('Currency mismatch');
    }
  }

  equals(other: Money): boolean {
    return this.amount === other.amount && this.currency === other.currency;
  }
}

Repository Interface

// domain/repositories/IUserRepository.ts
import { User } from '../entities/User';
import { UserId } from '../value-objects/UserId';
import { Email } from '../value-objects/Email';

export interface IUserRepository {
  save(user: User): Promise<void>;
  findById(id: UserId): Promise<User | null>;
  findByEmail(email: Email): Promise<User | null>;
  findAll(options?: { limit?: number; offset?: number }): Promise<User[]>;
  delete(id: UserId): Promise<void>;
  exists(id: UserId): Promise<boolean>;
}

// domain/repositories/IOrderRepository.ts
import { Order } from '../entities/Order';
import { OrderId } from '../value-objects/OrderId';
import { UserId } from '../value-objects/UserId';

export interface IOrderRepository {
  save(order: Order): Promise<void>;
  findById(id: OrderId): Promise<Order | null>;
  findByUserId(userId: UserId): Promise<Order[]>;
  findPending(): Promise<Order[]>;
}

Use Case Implementation

// application/use-cases/user/CreateUserUseCase.ts
import { User } from '../../../domain/entities/User';
import { IUserRepository } from '../../../domain/repositories/IUserRepository';
import { Email } from '../../../domain/value-objects/Email';
import { ApplicationError } from '../../errors/ApplicationError';

export interface CreateUserInput {
  email: string;
  name: string;
}

export interface CreateUserOutput {
  id: string;
  email: string;
  name: string;
  createdAt: Date;
}

export class CreateUserUseCase {
  constructor(private readonly userRepository: IUserRepository) {}

  async execute(input: CreateUserInput): Promise<CreateUserOutput> {
    // Duplicate check
    const email = Email.create(input.email);
    const existingUser = await this.userRepository.findByEmail(email);

    if (existingUser) {
      throw new ApplicationError('User with this email already exists');
    }

    // Create entity
    const user = User.create({
      email: input.email,
      name: input.name,
    });

    // Persist
    await this.userRepository.save(user);

    // Transform response
    return {
      id: user.id.getValue(),
      email: user.email.getValue(),
      name: user.name,
      createdAt: user.createdAt,
    };
  }
}

Infrastructure Layer Implementation

// infrastructure/database/prisma/PrismaUserRepository.ts
import { PrismaClient } from '@prisma/client';
import { IUserRepository } from '../../../domain/repositories/IUserRepository';
import { User } from '../../../domain/entities/User';
import { UserId } from '../../../domain/value-objects/UserId';
import { Email } from '../../../domain/value-objects/Email';

export class PrismaUserRepository implements IUserRepository {
  constructor(private readonly prisma: PrismaClient) {}

  async save(user: User): Promise<void> {
    await this.prisma.user.upsert({
      where: { id: user.id.getValue() },
      create: {
        id: user.id.getValue(),
        email: user.email.getValue(),
        name: user.name,
        createdAt: user.createdAt,
        updatedAt: user.updatedAt,
      },
      update: {
        email: user.email.getValue(),
        name: user.name,
        updatedAt: user.updatedAt,
      },
    });
  }

  async findById(id: UserId): Promise<User | null> {
    const record = await this.prisma.user.findUnique({
      where: { id: id.getValue() },
    });

    if (!record) return null;

    return User.reconstruct({
      id: UserId.create(record.id),
      email: Email.create(record.email),
      name: record.name,
      createdAt: record.createdAt,
      updatedAt: record.updatedAt,
    });
  }

  async findByEmail(email: Email): Promise<User | null> {
    const record = await this.prisma.user.findUnique({
      where: { email: email.getValue() },
    });

    if (!record) return null;

    return this.toDomain(record);
  }

  private toDomain(record: PrismaUser): User {
    return User.reconstruct({
      id: UserId.create(record.id),
      email: Email.create(record.email),
      name: record.name,
      createdAt: record.createdAt,
      updatedAt: record.updatedAt,
    });
  }
}

Dependency Injection Setup

// infrastructure/di/container.ts
import { PrismaClient } from '@prisma/client';
import { PrismaUserRepository } from '../database/prisma/PrismaUserRepository';
import { CreateUserUseCase } from '../../application/use-cases/user/CreateUserUseCase';

// Simple DI Container
export class Container {
  private static instance: Container;
  private readonly prisma: PrismaClient;

  private constructor() {
    this.prisma = new PrismaClient();
  }

  static getInstance(): Container {
    if (!Container.instance) {
      Container.instance = new Container();
    }
    return Container.instance;
  }

  // Repositories
  getUserRepository(): IUserRepository {
    return new PrismaUserRepository(this.prisma);
  }

  // Use Cases
  getCreateUserUseCase(): CreateUserUseCase {
    return new CreateUserUseCase(this.getUserRepository());
  }
}

Testability

// __tests__/application/CreateUserUseCase.test.ts
import { CreateUserUseCase } from '../../src/application/use-cases/user/CreateUserUseCase';
import { InMemoryUserRepository } from '../__mocks__/InMemoryUserRepository';

describe('CreateUserUseCase', () => {
  let useCase: CreateUserUseCase;
  let userRepository: InMemoryUserRepository;

  beforeEach(() => {
    userRepository = new InMemoryUserRepository();
    useCase = new CreateUserUseCase(userRepository);
  });

  it('should create a new user', async () => {
    const input = {
      email: 'test@example.com',
      name: 'Test User',
    };

    const result = await useCase.execute(input);

    expect(result.email).toBe(input.email);
    expect(result.name).toBe(input.name);
    expect(result.id).toBeDefined();
  });

  it('should throw error for duplicate email', async () => {
    const input = {
      email: 'test@example.com',
      name: 'Test User',
    };

    await useCase.execute(input);

    await expect(useCase.execute(input)).rejects.toThrow(
      'User with this email already exists'
    );
  });
});

Summary

LayerResponsibilityDependency Direction
DomainBusiness rules, EntitiesNo dependencies
ApplicationUse cases, Application logicDomain
InfrastructureDB, External service implementationsDomain, Application
PresentationControllers, RoutingApplication
← Back to list