What is Hexagonal Architecture
The Hexagonal Architecture, also known as the Ports and Adapters pattern, is a software architectural pattern that promotes the separation of concerns by viewing an application as a hexagon. Each side of the hexagon represents a port that interacts with the external world via adapters.
Hexagonal Architecture seeks to centralize the application's core business logic and isolate it from peripheral concerns, maintaining a clear boundary that external factors cannot cross. This isolation allows changes on one side of the application (e.g., database or user interface) to occur independently of the other, ensuring the application's stability and easing the process of testing and maintenance.
Principles of Hexagonal Architecture
In this chapter, I'll delve into the principles that underpin the hexagonal architecture. We'll explore the key concepts of application, ports, adapters, and the hexagonal layers.
Hexagonal Architecture Explained
Center: The Application
At the heart of the hexagonal architecture lies the application, also known as the domain. This encapsulates the business rules and the core logic of the software. The application doesn't have any dependencies on external concerns, such as databases, user interfaces, or any third-party services. This feature is crucial because it enables us to make changes in one part of the system without affecting the core application.
Ports: Inward and Outward
The application interacts with the outside world through ports, which can be categorized into primary (or driving) ports and secondary (or driven) ports.
Primary ports are interfaces that expose the operations provided by the application to the outside world. They represent use-cases the application supports, defining what the application can do. Typically, these are the APIs that the application developers will design and implement.
Secondary ports, on the other hand, are interfaces that the application uses to interact with external services or components. These are things the application needs from the outside world to function, like a persistence mechanism, notifications, or any external APIs.
Adapters: The Intermediaries
While ports define what kind of interactions are possible, adapters handle those interactions. Adapters are the link between the application and the outside world. They convert data from the format that the outside world uses to one the application can understand, and vice versa. There can be different types of adapters for different kinds of inputs and outputs, like a web adapter to handle HTTP requests or a database adapter to interact with a database.
Hexagonal Layers
Each side of the hexagon in the architecture represents a port. The adapters that interact with these ports sit on the edges of the hexagon. This arrangement brings about the physical representation of the architecture as a hexagon, giving this pattern its name.
The hexagonal architecture differs from traditional layered architectures in that there is no 'top' or 'bottom' layer. Instead, all inputs and outputs are treated as equal and placed around the edge of the hexagon, ensuring the central application remains isolated and unaffected by external changes.
Practical Implementation of Hexagonal Architecture
A Python-based application employing hexagonal architecture can be organized into a directory structure as follows:
/bookstore
/application_core
__init__.py
application_core.py
/ports_and_adapters
__init__.py
ports_and_adapters.py
/interfaces
__init__.py
interfaces.py
/tests
__init__.py
tests.py
main.py
Designing the Application Core
The application core encapsulates the business rules and logic of the system. Consider a simplified bookstore application, where we will have a Book
entity and an Inventory
service.
class Book:
def __init__(self, id, title, author, price):
self.id = id
self.title = title
self.author = author
self.price = price
class Inventory:
def __init__(self, repository):
self.repository = repository
def add_book(self, book):
return self.repository.save(book)
Creating Ports and Adapters
Here we create the ports and adapters for the system. We define a BookRepository
interface (secondary port) and a concrete implementation of the interface InMemoryBookRepository
(adapter).
from abc import ABC, abstractmethod
from application_core.application_core import Book
class BookRepository(ABC):
@abstractmethod
def save(self, book: Book):
pass
class InMemoryBookRepository(BookRepository):
def __init__(self):
self.books = {}
def save(self, book: Book):
self.books[book.id] = book
return book
Developing Interfaces
Interfaces represent the primary ports in the hexagonal architecture. These are essentially the APIs that the application exposes to the outside world. For our bookstore application, we might have a simple command-line interface.
from application_core.application_core import Book, Inventory
from ports_and_adapters.ports_and_adapters import InMemoryBookRepository
def command_line_interface():
repository = InMemoryBookRepository()
inventory = Inventory(repository)
book_id = input("Enter book id: ")
title = input("Enter book title: ")
author = input("Enter book author: ")
price = float(input("Enter book price: "))
book = Book(book_id, title, author, price)
inventory.add_book(book)
print(f"Added book {title} to inventory.")
Implementing Tests
Tests are easier to write and maintain in a hexagonal architecture because dependencies can be replaced with test doubles. Below is a simple test case for adding a book to the inventory.
import unittest
from application_core.application_core import Book, Inventory
from ports_and_adapters.ports_and_adapters import InMemoryBookRepository
class TestInventory(unittest.TestCase):
def test_add_book(self):
repository = InMemoryBookRepository()
inventory = Inventory(repository)
book = Book('1', 'Test Book', 'Test Author', 9.99)
inventory.add_book(book)
self.assertEqual(repository.books['1'], book)
if __name__ == '__main__':
unittest.main()
In the root directory, you might have a main.py
file which could look something like this:
from interfaces.interfaces import command_line_interface
if __name__ == '__main__':
command_line_interface()
References