Featured Image - Hexagonal Architecture
Hexagonal architecture has gained popularity in recent years. Unfortunately, there are few practical courses available online that show how to implement it with code snippets. In this course, we will introduce the Hexagonal Architecture, build a Python application using this architecture, and create a GitHub workflow that ensures developers code respects the architectural decisions made by the architects.
Hexagonal Architecture, also known as the Ports and Adapters design pattern, has a main goal of separating domain logic from external dependencies. The following image illustrates the basic components of the Hexagonal Architecture. In this section, we will dig deeper into each part of the pattern and provide Python code examples.
Components of Hexagonal architecture (aka ports and adapters pattern)
Ports are the contracts the application settles for the interaction with the external actors. There as two type of ports: Driver ports define how external components or actors interact with the application. Driven ports define how the application will interact with the external actors.
Adapters are components that connect the core application to external systems or services. They allow the core domain logic to remain focused on business rules, while the technical details are handled separately at the edges of the application.
The domain layer contains the business logic and must run without any dependency of external code. We can test the entire HA application without setting up external component like DBs..
As we will see later the domain logic use Interfaces which are not concrete implementation, so to be able to run our application we need to wire everything together, this is why we need the configurator. An example of a configurator could be a FastAPI or a Flask application setup code.
As I mentioned earlier, there are many resources on the Internet that discuss this architecture, but they do not provide enough code examples. This is why many developers cannot fully understand it. In this section I will try to implement it in a pythonic way, if you have any concerns, questions or suggestions please send us an email to contact@workflows.guru
We will implement a task scheduler application that we will call tscheduler. The application allow the user to create a task, read a task and execute a task. The full code is available in github at https://github.com/workflows-guru/hexagonal-architecture
Let's start by defining our ports. We should have a storage system to store the tasks, so the domain logic will communicate with this storage system using a port. Let's call this port StorageInterface. This interface has two abstract methods that must be implemented inside the storage adapters.
1from abc import ABC, abstractmethod23class StorageInterface(ABC):4 """Storage interface that should be implemented by our storage systems"""56 @abstractmethod7 def create_task(self, name: str, description: str, data: str) -> dict:8 raise NotImplementedError910 @abstractmethod11 def read_task(self, name) -> dict | None:12 raise NotImplementedError
To be able to use a storage system in our application, we should create an adapters that implement this interface. This LocalStorage class implement the StorageInterface.
1from tscheduler.ports.storage import StorageInterface23class LocalStorage(StorageInterface):4 def __init__(self):5 self.tasks = []67 def create_task(self, name: str, description: str, data: str) -> dict:8 task = {"name": name, "description": description, "data": data}9 self.tasks.append(task)10 return task1112 def read_task(self, name: str) -> dict | None:13 for item in self.tasks:14 if item.get("name") == name:15 return item
To execute the tasks we need a runner or executor. So let's create another port for this external component and let's call it ExecutorInterface.
1from abc import ABC, abstractmethod23class ExecutorInterface(ABC):4 """Executor interface that should be implemented by our executors"""56 @abstractmethod7 def execute_task(self, data: str) -> str:8 raise NotImplementedError
A possible adapter for this port could be a celery or airflow executor. In our example we will just implement a local executor that will print the task data and return a string as follow:
1import uuid23from tscheduler.ports.executor import ExecutorInterface45class LocalExecutor(ExecutorInterface):67 def execute_task(self, data: str) -> str:8 print("Executing:", data)9 execution_id = uuid.uuid4().hex10 return execution_id
We have created two ports and their implementation in the adapters. Let's now see how we will use this in our business logic.
1from tscheduler.domain.models import Task2from tscheduler.ports.executor import ExecutorInterface3from tscheduler.ports.storage import StorageInterface45class TaskController:6 # Here we use Constructor injection we can as well use method injection7 def __init__(8 self,9 storage: StorageInterface | None = None,10 executor: ExecutorInterface | None = None,11 ):12 self.storage = storage13 self.executor = executor1415 def execute_task(self, name: str) -> str:16 task = self.storage.read_task(name=name)17 task = Task(**task)1819 # Our business logic is implemented here2021 # For demonstration purpose our executor accept any str and return any str22 execution_id = self.executor.execute_task(data=task.data)23 return execution_id24
As you can see, the domain code does not depends on external component, instead it use the interfaces defined in ports.
Let's now wire everything together and run our application.
12from tscheduler.adapters.local_executor import LocalExecutor3from tscheduler.adapters.local_storage import LocalStorage4from tscheduler.domain.core import TaskController56controller = TaskController(storage=LocalStorage(), executor=LocalExecutor())7execution_id = controller.execute_task(name="my_task")8
This script show how to execute a task, but you must first create the task. In the gihtub repository you will find an example on how to set up the application using FastAPI.
Testing the application is similar to this simple script, we only need to setup our application with the test class. For example: InMemoryStorage and LocalExecutor
Fitness Functions are used to ensure that a system's architecture continuously evolves in a controlled manner.
These functions evaluates whether an architecture is meeting its desired characteristics and help teams continuously validate architectural decisions rather than relying on manual PR reviews.
In this section, we will create fitness functions and add these functions to our CI workflow. The application code is hosted on github, so we will use github workflow to automate our fitness functions.
The are some core principales that we must follow when implementing this architecture. Some of these principales are:
To check that we don't breaks decoupling, we will implement a fitness function to ensure that we don't add forbidden dependencies in our code. The following test will fail if the domain core depends on adapters repository.
1import ast23def is_dependant(source_file: str, destination: str):4 with open(source_file, "r") as f:5 source = ast.parse(f.read())67 for statement in source.body:8 if isinstance(statement, ast.Import) or isinstance(9 statement, ast.ImportFrom10 ):11 if hasattr(statement, "module") and destination in statement.module:12 return True13 if any(name for name in statement.names if destination in name.name):14 return True1516 return False171819def test_domain_core_independant_of_all_adapters():20 assert is_dependant("./tscheduler/domain/core.py", "adapters") is False21
Feel free to add more functions to check other principales.
We have created our fitness functions. We will now add these functions to our GitHub workflow. In your .github/workflows directory, create a YAML file and add the following job to your pipeline
12jobs:3 architecture:4 runs-on: ubuntu-latest56 steps:7 - uses: actions/checkout@v48 - name: Set up Python 3.119 uses: actions/setup-python@v310 with:11 python-version: "3.11"12 - name: Install pytest13 run: |14 python -m pip install --upgrade pip15 pip install pytest16 - name: Fitness functions to validate Hexagonal Architecture17 run: |18 pytest tests/test_check_architecture.py19
In this course, we introduced Hexagonal Architecture, created a demonstration application, and added fitness functions to our CI workflow to validate the architecture. Fitness functions are a great way to ensure that everything works as expected and to evaluate whether the architecture is meeting its desired characteristics.