Skip to main content

What I love about Clean Architecture (and why I always end up regretting when I don’t use it)

Ah, Clean Architecture—the holy grail of software structure, where your code doesn’t just work, it sings. Well, at least until you decide to skip it on a small project, thinking, “This is just a quick thing, I don’t need to over-engineer it.” Famous last words. Let me tell you why I love Clean Architecture, how it saved me from countless headaches, and why I always regret not applying it from day one.

Decoupling core logic from external dependencies

The core idea behind Clean Architecture is to keep your business logic—the heart of your application—independent of frameworks, databases, and other external services. You know, the stuff that loves to change.

Why? Because the moment you tie your core logic to an external dependency, you’ve made your app a ticking time bomb. Need to switch from MySQL to PostgreSQL? Boom. Want to swap out that third-party API? Good luck with that.

Clean Architecture makes your core logic oblivious to these changes. The business rules don’t care whether you’re using a fancy cloud database or a local SQLite file. They just do what they’re supposed to do.

The Dependency Rule

This rule is what makes Clean Architecture clean. Dependencies point inward, never outward. Your core logic should never know about external details like UI frameworks, databases, or other infrastructure. It keeps things neat, predictable, and easy to change.


Key Patterns in Clean Architecture

1. Use Case Pattern

The Use Case represents a specific piece of business logic. It should handle only one action or process.

Example: Let’s say we need a CreateOrder use case:

// ICreateOrderUseCase.ts (Interface)
export interface ICreateOrderUseCase {
  execute(request: CreateOrderRequest): Promise<OrderResponse>;
}

// CreateOrderUseCase.ts (Implementation)
import { ICreateOrderUseCase } from './ICreateOrderUseCase';
import { IOrderRepository } from '../repositories/IOrderRepository';

export class CreateOrderUseCase implements ICreateOrderUseCase {
  constructor(private orderRepository: IOrderRepository) {}

  async execute(request: CreateOrderRequest): Promise<OrderResponse> {
    const order = await this.orderRepository.create(request);
    return { id: order.id, status: 'created' };
  }
}

Here, CreateOrderUseCase depends on the IOrderRepository interface, not on any specific database implementation.

2. Repository Pattern

Repositories handle data persistence. They abstract away the details of how data is stored and retrieved.

Example:

// IOrderRepository.ts (Interface)
export interface IOrderRepository {
  create(request: CreateOrderRequest): Promise<Order>;
  findById(id: string): Promise<Order | null>;
}

// OrderRepository.ts (Implementation)
import { IOrderRepository } from './IOrderRepository';
import { Order } from '../entities/Order';

export class OrderRepository implements IOrderRepository {
  async create(request: CreateOrderRequest): Promise<Order> {
    // Simulate database interaction
    const order = new Order(request.id, request.items);
    // Save to DB (simulated)
    return order;
  }

  async findById(id: string): Promise<Order | null> {
    // Simulate DB lookup
    return new Order(id, []);
  }
}

Repositories make it easy to switch from one data storage solution to another without affecting the core logic.

3. Service Pattern

Services handle domain-specific logic that doesn’t belong to a specific entity or repository.

Example:

// EmailService.ts
export class EmailService {
  async sendOrderConfirmation(email: string, orderId: string): Promise<void> {
    // Simulate sending an email
    console.log(`Sending order confirmation to ${email} for order ${orderId}`);
  }
}

A service like EmailService can be injected into a use case to handle tasks like sending notifications.


Use Cases: My favorite topic at parties

Seriously, I can’t shut up about Use Cases.

Use Cases are the core operations of your app. They represent the things your app does. Each use case handles one specific piece of business logic. For example:

  • CreateOrderUseCase: Takes an order request and processes it.
  • CancelSubscriptionUseCase: Handles subscription cancellations.

Example:

// CancelSubscriptionUseCase.ts
import { ISubscriptionRepository } from '../repositories/ISubscriptionRepository';

export class CancelSubscriptionUseCase {
  constructor(private subscriptionRepository: ISubscriptionRepository) {}

  async execute(subscriptionId: string): Promise<void> {
    const subscription = await this.subscriptionRepository.findById(subscriptionId);
    if (!subscription) throw new Error('Subscription not found');

    subscription.cancel();
    await this.subscriptionRepository.update(subscription);
  }
}

The beauty of Use Cases is their clarity. You know exactly where to look when something breaks. They make your codebase scream “I’m organized!”


Value Objects from DDD: The secret sauce

I love mixing Value Objects from Domain-Driven Design (DDD) into my Clean Architecture. Why? Because they make your code feel right.

Example:

// EmailAddress.ts
export class EmailAddress {
  private readonly value: string;

  constructor(email: string) {
    if (!this.validate(email)) {
      throw new Error('Invalid email format');
    }
    this.value = email;
  }

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

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

Instead of passing around raw strings, you use EmailAddress objects that ensure valid data.


Why Clean Architecture is worth it

In the end, Clean Architecture is all about making your life easier in the long run. It’s about creating a codebase that you can change, extend, and maintain without tearing your hair out.

Sure, it takes discipline to set up. But the peace of mind it brings? Absolutely worth it.

So, next time you’re tempted to skip Clean Architecture on a new project, remember this: Today’s quick hack is tomorrow’s legacy nightmare. Choose wisely.