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