In today’s fast-paced software development landscape, building applications that are easy to maintain, adapt, and scale is crucial. Hexagonal Architecture (also known as Ports and Adapters) and Domain-Driven Design (DDD) are an effective combo for addressing these challenges. Hexagonal Architecture promotes clean separation of concerns, making it easier to replace, test, or enhance parts of the system without disrupting the core logic. Meanwhile, DDD focuses on aligning your code with real-world business concepts, ensuring your system is both intuitive and resilient. Together, these approaches enable developers to build systems that are robust, resilient, and designed to seamlessly adapt to changing requirements and future growth.
1. Introduction to Hexagonal Architecture
Hexagonal Architecture, also known as the Ports and Adapters pattern, was introduced by Alistair Cockburn to address the rigidity and complexity of traditional layered architecture. Its primary goal is to make the application’s core logic (domain) independent of external systems, enabling easier testing, maintenance, and adaptability.
At its core, Hexagonal Architecture divides the application into three main layers:
Core (Business Logic/Domain): The heart of the system where business rules and domain logic reside. This layer is independent and does not rely on external libraries or frameworks.
Example: Calculating interest on a loan or validating a user’s action against business rules.-
Ports (Interfaces): Abstract definitions (e.g., interfaces or protocols) for the ways the core interacts with the outside world. Ports represent use cases or application-specific APIs. They define what needs to be done without specifying how.
Example: Repository Port defines methods to interact with data sources like:-
get(id: ID): Entity
: Retrieve an entity by its unique identifier. -
insert(entity: Entity): void
: Add a new entity. -
update(entity: Entity): void
: Update an existing entity.
-
src/ports/repository.py
from abc import ABC, abstractmethod
from typing import List
from src.entities import Entity
class Repository(ABC):
@abstractmethod
def get(self, id: str) -> Entity:
pass
@abstractmethod
def insert(self, entity: Entity) -> None:
pass
@abstractmethod
def update(self, entity: Entity) -> None:
pass
- Adapters (Implementations): Concrete implementations of the ports. They handle the actual interaction with external systems like databases, APIs, or UI. Example: PostgresRepository Adapter implements the Repository Port for PostgreSQL using SQLAlchemy.
# src/adapters/postgres_repository.py
from sqlalchemy import create_engine, Column, String
from sqlalchemy.orm import declarative_base, sessionmaker
from src.entities import Entity
from src.ports.repository import Repository
Base = declarative_base()
# Define the database table for Entity
class EntityModel(Base):
__tablename__ = "entities"
id = Column(String, primary_key=True)
name = Column(String, nullable=False)
description = Column(String)
class PostgresRepository(Repository):
def __init__(self, db_url: str):
"""
Initialize the repository with the PostgreSQL connection URL.
Example db_url: "postgresql+psycopg2://username:password@host:port/dbname"
"""
self.engine = create_engine(db_url)
Base.metadata.create_all(self.engine)
self.Session = sessionmaker(bind=self.engine)
def get(self, id: str) -> Entity:
session = self.Session()
try:
entity_model = session.query(EntityModel).filter_by(id=id).first()
if not entity_model:
raise ValueError(f"Entity with id {id} not found")
return Entity(id=entity_model.id, name=entity_model.name, description=entity_model.description)
finally:
session.close()
def insert(self, entity: Entity) -> None:
session = self.Session()
try:
entity_model = EntityModel(id=entity.id, name=entity.name, description=entity.description)
session.add(entity_model)
session.commit()
finally:
session.close()
def update(self, entity: Entity) -> None:
session = self.Session()
try:
entity_model = session.query(EntityModel).filter_by(id=entity.id).first()
if not entity_model:
raise ValueError(f"Entity with id {entity.id} not found")
entity_model.name = entity.name
entity_model.description = entity.description
session.commit()
finally:
session.close()
The architecture is often visualized as a hexagon, symbolizing multiple ways to interact with the core, with each side representing a different adapter or port.
2. Introduction to Domain-Driven Design (DDD)
Domain-Driven Design (DDD) is a software design approach that emphasizes a close alignment between business goals and the software being built to achieve them. This methodology was introduced by Eric Evans in his book Domain-Driven Design: Tackling Complexity in the Heart of Software.
At its core, DDD focuses on understanding and modeling the domain (the business problem space) with the help of domain experts and translating that understanding into the software system. DDD promotes the decoupling of domains, ensuring that different parts of the system remain independent, clear, and easy to manage.
Key Concepts of Domain-Driven Design:
Domain: The specific area of knowledge or activity that the software addresses. For example, in a banking application, the domain includes concepts like accounts, transactions, and customers.
Ubiquitous Language: A common language developed collaboratively by developers and domain experts. This shared vocabulary ensures clear communication and consistent understanding across all stakeholders.
-
Entities and Value Objects:
- Entities: Objects that have a distinct identity and lifecycle, such as a customer or an order.
- Value Objects: Immutable objects that are defined by their attributes rather than a unique identity, like a date or a monetary amount.
Aggregates: Clusters of related entities and value objects treated as a single unit for data changes. Each aggregate has a root entity that ensures the integrity of the entire cluster.
Repositories: Mechanisms for retrieving and storing aggregates, providing a layer of abstraction over data access.
Services: Operations or processes that don't naturally fit within entities or value objects but are essential to the domain, such as processing a payment.
domain/
│
├── user/
│ ├── entities/
│ │ └── user.py
│ │
│ ├── value_objects/
│ │ └── email.py
│ │
│ ├── events/
│ │ └── user_created_event.py
│ │
│ ├── services/
│ │ └── user_service.py
│ │
│ └── repositories/
│ └── user_repository_interface.py
│
├── product/
│ ├── entities/
│ │ └── product.py
│ │
│ ├── value_objects/
│ │ └── price.py
│ │
│ ├── events/
│ │ └── product_created_event.py
│ │
│ ├── services/
│ │ └── product_service.py
│ │
│ └── repositories/
│ └── product_repository_interface.py
In this section, I do not provide a detailed example of implementing Domain-Driven Design (DDD) because it is a comprehensive methodology primarily focused on addressing complex business logic challenges. DDD excels at structuring and managing intricate business rules, but to fully realize its potential and address other coding concerns, it is best utilized within a complementary architectural framework. So, in the following section, Domain-Driven Design will be combined with Hexagonal Architecture to highlights it strengths and provide a solid foundation for solving additional coding problems beyond business logic, accompanied by a detailed example.
3. How Hexagonal Architecture and Domain-Driven Design Complement Each Other
Why Hexagonal Architecture and Domain-Driven Design?
Domain-Driven Design (DDD) and Hexagonal Architecture complement each other by emphasizing clear boundaries and aligning software with business needs. DDD focuses on modeling the core domain and isolating business logic, while Hexagonal Architecture ensures this logic remains independent of external systems through ports and adapters. They address distinct but complementary concerns:
-
Hexagonal Architecture as the Framework:
- Hexagonal Architecture defines how the overall system is organized and how different parts (e.g., domain, infrastructure, user interfaces) interact.
- It provides the environment where the domain logic can function independently of external concerns, offering freedom from infrastructure details.
-
Domain-Driven Design as the Core Logic:
- DDD enriches the core domain defined by Hexagonal Architecture by ensuring that the business logic is not only encapsulated but also reflective of real-world business needs.
- It focuses on how to design and implement the domain layer effectively, ensuring it remains meaningful and adaptable.
Together, they enable scalable, testable, and flexible systems where the domain remains the central focus, insulated from changes in infrastructure or technology. This synergy ensures a robust design that adapts easily to evolving business requirements.
The following section offers a practical example of how Domain-Driven Design (DDD) and Hexagonal Architecture work together to create robust, maintainable, and adaptable software systems.
Practical example
This project applies Hexagonal Architecture and Domain-Driven Design (DDD) to create scalable and maintainable systems, providing a modern and robust foundation for application development. Built with Python, it uses FastAPI as the web framework and DynamoDB as the database.
The project is organized as follows:
python-hexagonal-ddd/
├── src/ # Source code for the application
│ ├── __init__.py # Package marker
│ ├── main.py # Entry point for the application
│ ├── application/ # Application services layer
│ │ ├── __init__.py
│ │ ├── product_service.py # Service for product-related operations
│ │ └── user_service.py # Service for user-related operations
│ ├── domains/ # Domain layer
│ │ ├── product/ # Product domain
│ │ │ ├── __init__.py
│ │ │ ├── aggregates.py # Aggregate roots for product domain
│ │ │ ├── entities.py # Entities for product domain
│ │ │ ├── exceptions.py # Exceptions for product domain
│ │ │ └── value_objects.py # Value objects for product domain
│ │ ├── user/ # User domain
│ │ │ ├── __init__.py
│ │ │ ├── aggregates.py # Aggregate roots for user domain
│ │ │ ├── entities.py # Entities for user domain
│ │ │ ├── exceptions.py # Exceptions for user domain
│ │ │ └── value_objects.py # Value objects for user domain
│ ├── infrastructure/ # Infrastructure layer
│ │ ├── logging/ # Logging infrastructure
│ │ │ └── logging_adapter.py# Adapter for logging
│ │ ├── middleware/ # Middleware components
│ │ │ └── trace_id.py # Middleware for trace ID
│ │ ├── utils/ # Utility functions
│ │ │ └── context.py # Context utilities
│ │ ├── __init__.py
│ │ ├── dynamodb_product_repository.py # DynamoDB repository for products
│ │ └── dynamodb_user_repository.py # DynamoDB repository for users
│ ├── ports/ # Ports layer
│ │ ├── logger/ # Logger port
│ │ │ └── logger_port.py # Logger port interface
│ │ ├── __init__.py
│ │ ├── product_repository.py # Product repository interface
│ │ └── user_repository.py # User repository interface
│ ├── routers/ # API routers
│ │ ├── product.py # Router for product endpoints
│ │ └── user.py # Router for user endpoints
├── tests/ # Test suite
│ ├── test_product_service.py # Tests for product service
│ └── test_user_service.py # Tests for user service
├── .env.demo # Example environment variables file
├── requirements.txt # Project dependencies
└── README.md # Project documentation
You can find the source code in my GitHub repository.
4. Conclusion
Incorporating Hexagonal Architecture and Domain-Driven Design (DDD) into Python applications fosters the development of systems that are maintainable, adaptable, and closely aligned with business objectives. Hexagonal Architecture ensures a clear separation between the core business logic and external systems, promoting flexibility and ease of testing. DDD emphasizes modeling the domain accurately, resulting in software that truly reflects business processes and rules. By integrating these methodologies, developers can create robust applications that not only meet current requirements but are also well-prepared to evolve with future business needs.
Connect me if you enjoyed this article!
Top comments (0)