Skip to main content
โšก Calmops

Software Architecture Patterns: Layered, Hexagonal, and Clean Architecture

Introduction

Software architecture patterns provide proven solutions to recurring design problems. These patterns represent accumulated wisdom from decades of software development, capturing best practices for organizing code, managing dependencies, and building systems that can evolve over time. Choosing the right architecture pattern for your project is one of the most consequential decisions you will make as a software architect.

The evolution from monolithic applications to distributed microservices has made architecture patterns more important than ever. Modern applications must handle millions of users, integrate with numerous external services, and adapt to changing requirements at a rapid pace. Without a clear architectural vision, projects quickly become tangled webs of code that are difficult to understand, test, and maintain.

This guide explores three fundamental architecture patterns: Layered Architecture (the traditional approach), Hexagonal Architecture (also known as Ports and Adapters), and Clean Architecture (popularized by Robert C. Martin). Each pattern addresses different concerns and is suited to different types of projects. Understanding the principles behind these patterns will help you make informed decisions about how to structure your own applications.

Layered Architecture

Overview and Principles

Layered architecture, also known as n-tier architecture, is the most traditional and widely used architectural pattern. In this pattern, the application is organized into horizontal layers, where each layer has a specific responsibility and communicates only with adjacent layers.

The typical layered architecture consists of four main layers:

Presentation Layer (User Interface Layer): This layer handles all user interaction, including web pages, mobile apps, and API endpoints. It receives user input, validates it, and passes it to the business logic layer. The presentation layer is responsible for rendering views and handling HTTP requests in web applications.

Application Layer (Business Logic Layer): This layer contains the core functionality of the application. It implements business rules, processes data, and coordinates between the presentation and data access layers. The application layer is where use cases and workflows are defined.

Domain Layer (Business Entity Layer): This layer contains the domain objects that represent the business entities. It includes entities, value objects, aggregates, and domain events. The domain layer is the heart of the application and should be independent of infrastructure concerns.

Infrastructure Layer (Data Access Layer): This layer handles all interactions with external systems, including databases, file systems, message queues, and external APIs. It provides implementations for persistence, messaging, and other infrastructure concerns.

Implementation Example

Consider a simple e-commerce application implemented with layered architecture:

# Domain Layer - Business Entities
class Product:
    def __init__(self, id: str, name: str, price: float, stock: int):
        self.id = id
        self.name = name
        self.price = price
        self.stock = stock
    
    def is_available(self) -> bool:
        return self.stock > 0
    
    def reduce_stock(self, quantity: int) -> None:
        if quantity > self.stock:
            raise ValueError("Insufficient stock")
        self.stock -= quantity

class Order:
    def __init__(self, id: str, customer_id: str):
        self.id = id
        self.customer_id = customer_id
        self.items = []
        self.status = "pending"
    
    def add_item(self, product: Product, quantity: int) -> None:
        if not product.is_available():
            raise ValueError(f"Product {product.name} is not available")
        self.items.append({"product": product, "quantity": quantity})
    
    def calculate_total(self) -> float:
        return sum(item["product"].price * item["quantity"] for item in self.items)

# Application Layer - Use Cases
class OrderService:
    def __init__(self, order_repository, product_repository, payment_service):
        self.order_repository = order_repository
        self.product_repository = product_repository
        self.payment_service = payment_service
    
    def create_order(self, customer_id: str, items: list) -> Order:
        order = Order(id=str(uuid.uuid4()), customer_id=customer_id)
        
        for item in items:
            product = self.product_repository.find_by_id(item["product_id"])
            order.add_item(product, item["quantity"])
            product.reduce_stock(item["quantity"])
            self.product_repository.save(product)
        
        payment = self.payment_service.process_payment(order.calculate_total())
        if payment.success:
            order.status = "confirmed"
            self.order_repository.save(order)
            return order
        else:
            order.status = "payment_failed"
            self.order_repository.save(order)
            raise PaymentFailedException("Payment processing failed")

# Presentation Layer - API Endpoints
class OrderController:
    def __init__(self, order_service: OrderService):
        self.order_service = order_service
    
    def create_order(self, request):
        try:
            order = self.order_service.create_order(
                customer_id=request["customer_id"],
                items=request["items"]
            )
            return {"status": "success", "order_id": order.id}, 201
        except (ValueError, PaymentFailedException) as e:
            return {"status": "error", "message": str(e)}, 400

# Infrastructure Layer - Repositories
class SQLProductRepository:
    def __init__(self, db_connection):
        self.db = db_connection
    
    def find_by_id(self, product_id: str) -> Product:
        row = self.db.query("SELECT * FROM products WHERE id = ?", product_id)
        return Product(row["id"], row["name"], row["price"], row["stock"])
    
    def save(self, product: Product) -> None:
        self.db.execute(
            "UPDATE products SET stock = ? WHERE id = ?",
            product.stock, product.id
        )

Advantages and Disadvantages

Layered architecture offers several significant advantages that have made it the default choice for many applications. The separation of concerns makes the codebase easier to understand and maintain. Each layer has a clear responsibility, and developers can focus on one layer at a time. The pattern is well-understood by most developers, reducing the learning curve for new team members. Testing is straightforward because each layer can be tested in isolation with mocked dependencies.

However, layered architecture also has notable drawbacks. The strict layer dependencies can lead to issues where changes in one layer cascade to others. The domain layer may become anemic, containing only data structures without real business logic. Performance can suffer from the overhead of passing data through multiple layers. Perhaps most importantly, the presentation and infrastructure layers are coupled to the application, making it difficult to reuse business logic in different contexts.

When to Use Layered Architecture

Layered architecture is an excellent choice for many types of applications. It works particularly well for business applications with complex workflows and rules. It is well-suited for teams that are new to architecture patterns or working under time pressure. It is appropriate when the primary delivery mechanism is a web application or API. It is also a good choice when you need clear separation between user interface and business logic.

Layered architecture may not be the best choice when you need to support multiple delivery mechanisms (web, mobile, API, CLI) from the same business logic. It is also less suitable when the domain logic is complex and would benefit from domain-driven design techniques. For applications that need to integrate with many external systems, the infrastructure coupling can become problematic.

Hexagonal Architecture

Overview and Principles

Hexagonal architecture, also known as Ports and Adapters, was introduced by Alistair Cockburn as a way to address the limitations of layered architecture. The core insight of hexagonal architecture is that the application should be at the center, with external concerns (databases, user interfaces, external services) connected through ports and adapters.

The hexagonal metaphor describes the application as a hexagon with multiple ports on its edges. Ports define interfaces for interacting with the outside world, while adapters implement these interfaces for specific technologies. This allows the core application to remain independent of any particular technology choice.

The Core (Application Domain): At the center is the application itself, containing business logic, domain entities, and use cases. This core should be completely independent of any external concerns. It defines the interfaces (ports) that it needs to interact with the outside world.

Inbound Ports (Driving Ports): These ports define how external actors drive the application. They are typically implemented by adapters that translate external protocols (HTTP, CLI, message queue) into internal application calls.

Outbound Ports (Driven Ports): These ports define how the application interacts with external systems. They are implemented by adapters that translate application requests into operations on databases, file systems, or external services.

Adapters: Adapters are implementations of ports for specific technologies. An HTTP adapter implements an inbound port for web requests. A SQL adapter implements an outbound port for database operations.

Implementation Example

from abc import ABC, abstractmethod
from typing import List

# ============== CORE DOMAIN ==============

class Product:
    def __init__(self, id: str, name: str, price: float, stock: int):
        self.id = id
        self.name = name
        self.price = price
        self.stock = stock
    
    def is_available(self, quantity: int = 1) -> bool:
        return self.stock >= quantity
    
    def reserve_stock(self, quantity: int) -> bool:
        if self.is_available(quantity):
            self.stock -= quantity
            return True
        return False

class Order:
    def __init__(self, id: str, customer_id: str):
        self.id = id
        self.customer_id = customer_id
        self.items = []
        self.status = "pending"
    
    def add_item(self, product: Product, quantity: int) -> None:
        if not product.is_available(quantity):
            raise ValueError(f"Insufficient stock for {product.name}")
        self.items.append({"product": product, "quantity": quantity})
    
    def calculate_total(self) -> float:
        return sum(item["product"].price * item["quantity"] for item in self.items)

# ============== PORTS (Interfaces) ==============

# Inbound Port - Driven by external actors
class OrderUseCase(ABC):
    @abstractmethod
    def create_order(self, customer_id: str, items: List[dict]) -> Order:
        pass
    
    @abstractmethod
    def get_order(self, order_id: str) -> Order:
        pass

# Outbound Ports - Driven by the application
class ProductRepository(ABC):
    @abstractmethod
    def find_by_id(self, product_id: str) -> Product:
        pass
    
    @abstractmethod
    def save(self, product: Product) -> None:
        pass

class OrderRepository(ABC):
    @abstractmethod
    def save(self, order: Order) -> None:
        pass
    
    @abstractmethod
    def find_by_id(self, order_id: str) -> Order:
        pass

class PaymentGateway(ABC):
    @abstractmethod
    def process_payment(self, amount: float) -> bool:
        pass

# ============== APPLICATION (Use Cases) ==============

class OrderApplicationService(OrderUseCase):
    def __init__(
        self,
        product_repository: ProductRepository,
        order_repository: OrderRepository,
        payment_gateway: PaymentGateway
    ):
        self.product_repository = product_repository
        self.order_repository = order_repository
        self.payment_gateway = payment_gateway
    
    def create_order(self, customer_id: str, items: List[dict]) -> Order:
        order = Order(id=str(uuid.uuid4()), customer_id=customer_id)
        
        for item in items:
            product = self.product_repository.find_by_id(item["product_id"])
            order.add_item(product, item["quantity"])
            product.reserve_stock(item["quantity"])
            self.product_repository.save(product)
        
        if self.payment_gateway.process_payment(order.calculate_total()):
            order.status = "confirmed"
        else:
            order.status = "payment_failed"
        
        self.order_repository.save(order)
        return order
    
    def get_order(self, order_id: str) -> Order:
        return self.order_repository.find_by_id(order_id)

# ============== ADAPTERS ==============

# Inbound Adapter - HTTP
class HTTPOrderController:
    def __init__(self, order_use_case: OrderUseCase):
        self.order_use_case = order_use_case
    
    def handle_request(self, request: dict) -> dict:
        try:
            order = self.order_use_case.create_order(
                customer_id=request["customer_id"],
                items=request["items"]
            )
            return {"status": "success", "order_id": order.id}, 201
        except Exception as e:
            return {"status": "error", "message": str(e)}, 400

# Outbound Adapter - SQL Database
class SQLProductRepository(ProductRepository):
    def __init__(self, db_connection):
        self.db = db_connection
    
    def find_by_id(self, product_id: str) -> Product:
        row = self.db.query("SELECT * FROM products WHERE id = ?", product_id)
        return Product(row["id"], row["name"], row["price"], row["stock"])
    
    def save(self, product: Product) -> None:
        self.db.execute(
            "UPDATE products SET stock = ? WHERE id = ?",
            product.stock, product.id
        )

# Outbound Adapter - Stripe Payment
class StripePaymentGateway(PaymentGateway):
    def __init__(self, api_key: str):
        self.api_key = api_key
    
    def process_payment(self, amount: float) -> bool:
        # Stripe API integration
        return True  # Simplified

Advantages and Disadvantages

Hexagonal architecture provides several important benefits over traditional layered architecture. The core application is completely independent of external concerns, making it easy to test business logic without any infrastructure. Multiple adapters can implement the same port, allowing the application to work with different databases, message systems, or user interfaces. The architecture supports evolution of external systems without requiring changes to the core application.

The pattern does introduce some complexity. The number of interfaces and adapters can be overwhelming for simple applications. Dependency injection becomes essential, requiring additional framework support. The learning curve can be steeper than layered architecture for developers unfamiliar with the pattern.

When to Use Hexagonal Architecture

Hexagonal architecture is ideal for applications that need to support multiple delivery mechanisms or external integrations. It is excellent for complex domains where business logic should be isolated from infrastructure concerns. It is well-suited for long-lived applications that will evolve significantly over time. It is also a good choice when you want to defer technology decisions (database, framework, etc.) until later in the project.

Clean Architecture

Overview and Principles

Clean Architecture, popularized by Robert C. Martin (Uncle Bob), builds on the principles of hexagonal architecture and other patterns to create a more explicit structure for organizing code. The key insight of clean architecture is that dependencies should always point inward, toward the business logic.

Clean architecture defines several concentric layers, with the most important business rules at the center:

Entities (Enterprise Business Rules): The innermost layer contains enterprise-wide business rules. These are the most stable and general rules that apply across the entire organization. Entities are typically domain objects with their fundamental business logic.

Use Cases (Application Business Rules): This layer contains application-specific business rules. Use cases orchestrate the flow of data to and from entities and implement the specific workflows of the application.

Interface Adapters: This layer converts data from the use cases and entities into formats convenient for external systems. It includes controllers, presenters, gateways, and adapters.

Frameworks and Drivers: The outermost layer contains details about specific frameworks, databases, web frameworks, and other external concerns. This layer should be as thin as possible, delegating all work to the inner layers.

The dependency rule states that source code dependencies can only point inward. Nothing in an inner layer can know anything about an outer layer. This ensures that business rules are independent of any framework, database, or delivery mechanism.

Implementation Example

from abc import ABC, abstractmethod
from typing import List, Dict, Any

# ============== ENTITIES (Enterprise Business Rules) ==============

class User:
    """Enterprise business entity - represents a user in the system."""
    
    def __init__(self, id: str, email: str, name: str, roles: List[str]):
        self.id = id
        self.email = email
        self.name = name
        self.roles = roles
    
    def has_role(self, role: str) -> bool:
        return role in self.roles
    
    def is_admin(self) -> bool:
        return self.has_role("admin")

class Article:
    """Enterprise business entity - represents a blog article."""
    
    def __init__(self, id: str, title: str, content: str, author_id: str, status: str):
        self.id = id
        self.title = title
        self.content = content
        self.author_id = author_id
        self.status = status  # draft, published, archived
    
    def can_be_edited_by(self, user: User) -> bool:
        return user.id == self.author_id or user.is_admin()
    
    def can_be_published_by(self, user: User) -> bool:
        return user.is_admin() or user.id == self.author_id

# ============== USE CASES (Application Business Rules) ==============

class ArticleRepository(ABC):
    """Interface for article persistence - defined in use case layer."""
    
    @abstractmethod
    def save(self, article: Article) -> None:
        pass
    
    @abstractmethod
    def find_by_id(self, article_id: str) -> Article:
        pass
    
    @abstractmethod
    def find_by_author(self, author_id: str) -> List[Article]:
        pass

class ArticleUseCases:
    """Application use cases for article management."""
    
    def __init__(self, article_repository: ArticleRepository):
        self.repository = article_repository
    
    def create_article(
        self,
        author: User,
        title: str,
        content: str
    ) -> Article:
        """Create a new article as a draft."""
        if not title or not content:
            raise ValueError("Title and content are required")
        
        article = Article(
            id=str(uuid.uuid4()),
            title=title,
            content=content,
            author_id=author.id,
            status="draft"
        )
        self.repository.save(article)
        return article
    
    def update_article(
        self,
        article_id: str,
        user: User,
        title: str = None,
        content: str = None
    ) -> Article:
        """Update an existing article."""
        article = self.repository.find_by_id(article_id)
        
        if not article:
            raise ValueError("Article not found")
        
        if not article.can_be_edited_by(user):
            raise PermissionError("Cannot edit this article")
        
        if title is not None:
            article.title = title
        if content is not None:
            article.content = content
        
        self.repository.save(article)
        return article
    
    def publish_article(self, article_id: str, user: User) -> Article:
        """Publish an article."""
        article = self.repository.find_by_id(article_id)
        
        if not article:
            raise ValueError("Article not found")
        
        if not article.can_be_published_by(user):
            raise PermissionError("Cannot publish this article")
        
        article.status = "published"
        self.repository.save(article)
        return article
    
    def get_articles_by_author(self, author_id: str) -> List[Article]:
        """Get all articles by an author."""
        return self.repository.find_by_author(author_id)

# ============== INTERFACE ADAPTERS ==============

# Web Controller (Inbound Adapter)
class ArticleController:
    """HTTP controller for article endpoints."""
    
    def __init__(self, article_use_cases: ArticleUseCases, auth_service):
        self.use_cases = article_use_cases
        self.auth = auth_service
    
    def create(self, request: dict) -> dict:
        user = self.auth.get_current_user(request)
        article = self.use_cases.create_article(
            author=user,
            title=request["title"],
            content=request["content"]
        )
        return {"id": article.id, "status": "draft"}, 201
    
    def update(self, request: dict, article_id: str) -> dict:
        user = self.auth.get_current_user(request)
        article = self.use_cases.update_article(
            article_id=article_id,
            user=user,
            title=request.get("title"),
            content=request.get("content")
        )
        return {"id": article.id, "status": article.status}, 200
    
    def publish(self, request: dict, article_id: str) -> dict:
        user = self.auth.get_current_user(request)
        article = self.use_cases.publish_article(article_id, user)
        return {"id": article.id, "status": "published"}, 200

# Database Adapter (Outbound Adapter)
class SQLArticleRepository(ArticleRepository):
    """SQL implementation of article repository."""
    
    def __init__(self, db_connection):
        self.db = db_connection
    
    def save(self, article: Article) -> None:
        self.db.execute(
            """INSERT INTO articles (id, title, content, author_id, status)
               VALUES (?, ?, ?, ?, ?)
               ON CONFLICT(id) DO UPDATE SET
               title=excluded.title, content=excluded.content,
               status=excluded.status""",
            article.id, article.title, article.content,
            article.author_id, article.status
        )
    
    def find_by_id(self, article_id: str) -> Article:
        row = self.db.query(
            "SELECT * FROM articles WHERE id = ?", article_id
        )
        if not row:
            return None
        return Article(
            row["id"], row["title"], row["content"],
            row["author_id"], row["status"]
        )
    
    def find_by_author(self, author_id: str) -> List[Article]:
        rows = self.db.query_all(
            "SELECT * FROM articles WHERE author_id = ?", author_id
        )
        return [
            Article(r["id"], r["title"], r["content"], r["author_id"], r["status"])
            for r in rows
        ]

Advantages and Disadvantages

Clean architecture provides the strongest isolation of business rules from external concerns. The dependency rule ensures that business logic can be tested independently of any framework or database. The architecture scales well for complex applications and supports multiple delivery mechanisms. The explicit layer structure makes it clear where different types of code should live.

The pattern requires more upfront design and creates more files and interfaces than simpler approaches. The learning curve can be steep for developers accustomed to other patterns. The additional indirection can make tracing through code more difficult for simple operations.

When to Use Clean Architecture

Clean architecture is ideal for applications with complex business logic that needs to be protected from infrastructure changes. It is excellent for long-lived applications where the technology stack may evolve over time. It is well-suited for products that will have multiple delivery mechanisms (web, mobile, API). It is also a good choice when you want to maximize testability and maintainability.

Comparing the Patterns

Quick Comparison

Aspect Layered Hexagonal Clean
Complexity Low Medium High
Coupling High (adjacent layers) Low (through ports) Very Low (dependency rule)
Testability Good Very Good Excellent
Flexibility Low High Very High
Learning Curve Easy Moderate Steep
Best For Simple CRUD apps Multi-channel apps Complex domains

Decision Framework

Choose Layered Architecture when:

  • Your application is relatively simple with straightforward business logic
  • Your team is new to architecture patterns
  • You are building a traditional web application with a single delivery mechanism
  • Time-to-market is critical

Choose Hexagonal Architecture when:

  • You need to support multiple delivery mechanisms
  • Your application integrates with many external systems
  • You want to defer technology decisions
  • Your domain logic is moderately complex

Choose Clean Architecture when:

  • Your application has complex business rules
  • The application will live for many years and evolve significantly
  • You need maximum testability and maintainability
  • You are building a product that may have multiple platforms

Conclusion

Software architecture patterns provide proven solutions for organizing code in maintainable, testable, and evolvable ways. Layered, hexagonal, and clean architecture each offer different trade-offs between simplicity and flexibility. The best choice depends on your specific context: the complexity of your domain, the experience of your team, and the expected lifespan of your application.

Remember that architecture patterns are tools, not goals. The purpose of any architecture is to support the development and maintenance of the software effectively. Sometimes the best architecture is the simplest one that gets the job done. As your application grows and evolves, you may find yourself migrating from one pattern to another or combining elements of multiple patterns.

The investment in understanding these fundamental patterns will pay dividends throughout your career. They provide a shared vocabulary for discussing design decisions and a foundation for making informed choices about how to structure your code.

Resources

  • “Clean Architecture” by Robert C. Martin
  • “Domain-Driven Design” by Eric Evans
  • “Patterns of Enterprise Application Architecture” by Martin Fowler
  • Alistair Cockburn’s Hexagonal Architecture article

Comments