2024-11-05

Clean Architecture: A Deep Dive into Structured Software Design

Software Engineering
Learning & Growth
Project & Product
showing an astronaut in space looking at his tablet and sharing the view on a bigger screen with clean architecture on it
showing a profile picture of Marc

WRITTEN BY

Marc

CONTENT

Clean Architecture is a software design pattern initially introduced by Robert C. Martin (Uncle Bob). It is based on several architectural principles, such as hexagonal and onion architecture. Clean Architecture allows us to deal with and find solutions to a wide range of problems we might encounter during software development or an application's lifecycle. We have discussed these problems in our blog post The Multifaceted Problems of Software Development.

This post will explore Clean Architecture, examining its core principles and how it changes our approach to software development. While we focus here on the design and underlying concepts of Clean Architecture, we also have a companion blog post that addresses its problem-solving aspects. If you're interested in learning how Clean Architecture can help address common challenges in software development, I recommend reading our upcoming article How Clean Architecture Solves Common Software Development Challenges. Stay tuned!

Clean Architecture was also the topic of a talk I gave in 2023 at the code.talks conference. The talk is available on YouTube; you can find by clicking on this link: https://www.youtube.com/watch?v=v3HkasWQppk


Clean Architecture

Clean Architecture is a software design approach that aims to create better software systems in terms of maintainability, testability, and adaptability. Based on other architectural guidelines, such as Hexagonal and Onion Architecture, Clean Architecture structures software systems to isolate the core business logic from external dependencies.

This approach embodies two crucial principles:

  1. Technology and third-party library decisions can and should be deferred until later in the project, allowing the initial focus to remain on the domain.
  2. The capabilities of external libraries should not constrain the domain. Instead, the domain defines the requirements, which helps find libraries that match those requirements afterward. The connection between the domain and libraries is then implemented through so-called adapters.

Clean Architecture achieves these goals by separating the domain and all other components. This separation allows developers to concentrate on core business logic first, ensuring that external factors or implementation details do not compromise the essential functionality of the system.

Clean Architecture organizes software into different layers, with the core business logic at the center, isolated from external concerns. Let's take a look at a simple illustration:

domain architecture image

This basic diagram depicts the key concept of Clean Architecture:

  • The innermost Domain layer contains the core business logic and entities.
  • The Application layer surrounds it, implementing use cases and coordinating data flow.
  • The outer layers, Presentation and Persistence, handle external concerns like user interfaces and data storage.

The arrows pointing inward represent the dependency rule: outer layers depend on inner layers but not vice versa. This ensures that the core business logic remains independent of external technologies and frameworks.

In the following sections, we go into more detail to understand Clean Architecture. Let us first take a look at a more detailed representation. You will notice that while the core principles remain the same, the detailed diagram breaks down the layers further and provides more specific terminology:

showing Clean Architecture framework

[Source: Robert C. Martin (Uncle Bob) - The Clean Code Blog]

This extended diagram expands on our simplified version:

  • The Entities in the center correspond to our Domain layer.
  • Use Cases align with our Application layer.
  • The outer layers are further divided into specific components like Controllers, Presenters, and Gateways, which handle various aspects of the Presentation and Persistence concerns.

As we look at each layer, you will see how this more detailed structure supports the core principles of Clean Architecture while providing a framework for organizing complex software systems.


Core Principles

Clean Architecture is built upon several fundamental principles that guide its structure and implementation:

  1. Domain-Centric Approach: At its core, Clean Architecture focuses on the business domain, placing the essential business logic and rules at the system's center.
  2. Isolation of Domain Logic: A key feature is separating the core business logic from external concerns such as user interfaces, databases, and third-party integrations.
  3. Layered Structure: As illustrated in the detailed diagram above, the architecture organizes code into distinct layers, each with a specific purpose. These layers include Entities, Use Cases, Interface Adapters, and Frameworks & Drivers.
  4. Dependency Rule: Dependencies only point inwards, meaning outer layers can depend on inner layers, not vice versa. This ensures the core remains independent of external changes. This rule is also called the Dependency Inversion Rule.
  5. Use of Abstractions: The architecture promotes the use of interfaces to define boundaries between layers, enhancing flexibility and testability.

By following Clean Architecture's principles, we can create systems that are more maintainable, testable, and adaptable to change.

The domain-centric approach and isolation of core logic enhance maintainability by centralizing business rules. The layered structure and use of abstractions improve testability by allowing components to be tested in isolation. The Dependency Inversion Rule and the use of interfaces contribute to adaptability by reducing the impact of external changes on the core system.

In the following sections, we will examine each layer to understand how these principles better.


Layer Breakdown

Clean Architecture is divided into four layers:

  1. Entities: This layer represents the enterprise business rules and, as such, the core business logic of the system and business data. This layer is the most abstract one and is less likely to change. The entities layer consists of domain objects and is kept separate from all other concerns in the application. This separation often results in easier maintenance and testing of the system, as many core business logic modifications can be made without affecting other parts of the system. However, it's important to note that changes to interfaces, which define the requirements of the domain layer, can still significantly impact other layers. Another significant benefit of separating the core business logic and business data in this layer is that it can be reused in different systems, even if the systems use other external technologies.
  2. Use Cases: This layer holds application-specific business rules that fulfill the system's requirements and orchestrate data flow to and from the entities. It contains the core application logic and describes how the system should respond to various use cases or scenarios, often involving interactions with external actors.
  3. Interface Adapters: This layer contains implementations of interfaces defined in the use case layer and adapters that convert data between the formats of the inner and outer layers. It includes components like controllers, presenters, and gateways that mediate between use cases and the external environment, such as user interfaces, databases, and external services. These components ensure that the use cases remain isolated from the specifics of external technologies.
  4. Frameworks & Drivers: This outermost layer contains the external frameworks and tools that the application interacts with, such as databases, web frameworks, UI, and other external systems. It consists of adapters and implementations that connect these external elements to the inner layers. The layer's primary function is to isolate all details, such as the infrastructure, ensuring that changes to external technologies have minimal impact on the application's core. It acts as a boundary between the external environment and the application's inner layers, as shown by the blue outermost circle in the Clean Architecture diagram.

Crossing Boundaries

When looking at the Clean Architecture diagram in this post, you may have observed the arrows pointing from the outside to the inside. These arrows represent the flow of control in Clean Architecture, moving from the outer layers to the inner layers. This means the higher-level components (inner circles) do not depend on lower-level components (outer circles). For example, the entities do not know about the use cases, but the use cases are aware of and understand the entities.

However, this raises an essential question: How can a use case interact with a presenter or a database if it doesn't know about these outer layer components? The answer lies in two key concepts:

  • Interfaces: An interface defines an agreement or contract between the different layers of our system. The code in an inner layer can interact with the code in an outer layer via interfaces defined by the inner layer. This is known as the Dependency Inversion Principle, one of Clean Architecture's core principles.
  • Dependency Injection: The domain and use cases don't know about specific implementations at compile time, but they must work with concrete implementations at runtime. This is where dependency injection comes in.

To understand this, let us look at the following code example:

// Interface defined by the Use Case layer
interface OrderRepository {
  save(order: Order): Promise<void>;
}

// Use Case
class CreateOrderUseCase {
  constructor(private orderRepository: OrderRepository) {}

  async execute(order: Order): Promise<void> {
    await this.orderRepository.save(order);
  }
}

// Concrete implementation in the Frameworks & Drivers layer
class DatabaseOrderRepository implements OrderRepository {
  async save(order: Order): Promise<void> {
    // Database-specific code
  }
}

// Main entry point of the application
const orderRepository = new DatabaseOrderRepository();
const createOrderUseCase = new CreateOrderUseCase(orderRepository);

In the above example, the CreateOrderUseCase depends on the OrderRepository interface but not any specific implementation. The concrete DatabaseOrderRepository is injected into the use case at runtime.

It's important to understand that there needs to be a part of the application that wires everything together. This part of the software might seem less "clean" since it needs to know all the layers to connect them correctly. Typically, this wiring happens in the application's main entry point or a configuration file.

By using interfaces and dependency injection, we maintain the principle that inner layers don't depend on outer layers at compile time while still allowing the necessary connections at runtime. This approach enables the flexibility and testability that Clean Architecture aims to provide, allowing us to easily change implementations or mock dependencies for testing.


A Real-World Example

A good example of the benefits of Clean Architecture is an e-commerce application. In such a system, Clean Architecture allows us to isolate the core business logic - such as product management, order processing, and customer management - from external systems like payment gateways and inventory services.

This separation provides several advantages:

  • Flexibility: Developers can change external components, like a payment gateway, without affecting the core business logic.
  • Adaptability: The system can easily adapt to new requirements or technologies by modifying the outer layers without impacting the core.
  • Testability: Core business rules can be tested independently of external frameworks or UI.

Let us look at a simplified code example to illustrate these principles:

// Entities Layer
class Product {
  constructor(public id: string, public name: string, public price: number) {}
}

class Order {
  constructor(public id: string, public products: Product[], public total: number) {}
}

// Use Cases Layer
interface OrderRepository {
  save(order: Order): Promise<void>;
}

class CreateOrderUseCase {
  constructor(private orderRepository: OrderRepository) {}

  async execute(products: Product[]): Promise<Order> {
    const total = products.reduce((sum, product) => sum + product.price, 0);
    const order = new Order(Date.now().toString(), products, total);
    await this.orderRepository.save(order);
    return order;
  }
}

// Interface Adapters Layer
class OrderController {
  constructor(private createOrderUseCase: CreateOrderUseCase) {}

  async createOrder(productIds: string[]): Promise<Order> {
    // In a real application, you would fetch products based on IDs here...
    const products = productIds.map(id => new Product(id, `Product ${id}`, 10));
    return this.createOrderUseCase.execute(products);
  }
}

// Frameworks & Drivers Layer
class DatabaseOrderRepository implements OrderRepository {
  async save(order: Order): Promise<void> {
    console.log(`Saving order ${order.id} to database`);
    // The actual database operation would go here...
  }
}

// Usage
const orderRepository = new DatabaseOrderRepository();
const createOrderUseCase = new CreateOrderUseCase(orderRepository);
const orderController = new OrderController(createOrderUseCase);

// Simulated API call
orderController.createOrder(['1', '2', '3']).then(order => {
  console.log(`Order created with total: ${order.total}`);
});

The above code demonstrates how Clean Architecture separates concerns in an e-commerce system, aligning with the layers shown in the diagrams:

  • Entities Layer: The Product and Order classes represent the core business objects and are part of the Entities layer.
  • Use Cases Layer: The CreateOrderUseCase class implements the application-specific business rules, matching the Use Cases layer.
  • Interface Adapters Layer: The OrderController class acts as an adapter between the external dependencies and the use cases, similar to the "Controllers" shown in the diagram.
  • Frameworks & Drivers Layer: The DatabaseOrderRepository class represents an implementation of external frameworks or tools corresponding to the outermost layer in the diagram.

This structure allows for a flexible, maintainable, and testable application:

  • The core business logic (ProductOrderCreateOrderUseCase) remains isolated from external concerns.
  • The OrderController adapts external input to the use case without exposing internal details.
  • The DatabaseOrderRepository handles database interactions without affecting the core logic.

The dependency rule is maintained: outer layers depend on inner layers, not vice versa. For example, CreateOrderUseCase depends on the Order entity but not the DatabaseOrderRepository.

This separation allows us to easily change components (e.g., changing the database implementation) without affecting the core business logic, demonstrating the flexibility and adaptability of Clean Architecture.


Benefits of Clean Architecture

Developers following the Clean Architecture pattern can create more maintainable and testable systems. As illustrated by the circles in the diagrams, the clear separation of concerns allows for modular and reusable code, making it easier to adapt to changing requirements or integrate new technologies. Here are some key benefits:

  • Improved Maintainability: Changes to one layer are less likely to affect the other layers; thus, modifying the system without breaking it becomes easier. This is ensured by the dependency rule, shown by the arrows pointing inward in the diagrams.
  • Enhanced Testability: The separation of layers allows us to test the core business logic (Entities and Use Cases) independently, making it easier to write unit and integration tests without relying on external frameworks or UI.
  • Better Adaptability: The outer layers (Interface Adapters and Frameworks & Drivers) can be modified without affecting the business logic in the core, making the system more adaptable to new requirements or technologies.
  • Reusability and Framework Independence: The business logic in the Entities and Use Cases layers is not dependent on external frameworks (shown in the outermost blue layer), allowing reuse in different systems and easier updates of external components.

These benefits are achieved through Clean Architecture's structured layering and dependency rule, as represented in the diagrams.


Conclusion

The Clean Architecture pattern provides an excellent framework for creating software systems that are maintainable, testable, and highly adaptive to change.

By following this pattern, we can create a solid foundation for our applications, ensuring that the core business logic remains unaffected by external changes and dependencies. The separation of concerns across the pattern's different layers, as illustrated by the circles in the diagram, makes both maintenance and testing easier. It also enhances the system's flexibility for adopting new technologies or adapting to changing requirements.

Considering the various challenges we can encounter throughout application development and maintenance, the strengths of Clean Architecture become apparent. By following Clean Architecture principles, we can effectively manage issues such as code duplication, tight coupling, scalability problems, and testing complexities. This leads to software that is easy to operate and evolve and more resilient to changing demands.

The application of Clean Architecture in overcoming software development challenges underscores the importance of thoughtful architectural design when creating software systems. It is crucial to build systems that are not only functional in the short term but also sustainable and adaptable for the future.

You can learn more about the various issues in software development in our blog post, The Multifaceted Issues of Software Development. Our future upcoming post, How Clean Architecture Solves Common Software Development Challenges, explains how Clean Architecture helps us address these issues.

By implementing Clean Architecture, as demonstrated in the layered diagrams and code examples given in this post, we can create robust, flexible, and maintainable software systems that are functional in the long term and adaptable to changing requirements.