Hexagonal Architecture in Python
Hexagonal architecture is an architectural pattern in software engineering where certain boundaries/interfaces are defined as edges of a hexagon. The pattern is also known as the ports and adapters pattern, which is more descriptive to the actual implementation.
Hexagonal architecture is an implementation of a subset of the SOLID principles, especially the D of “Dependency inversion”, but also the L of “Liskov substitution”.
Together with Domain Driven Design (DDD), hexagonal architecture (and SOLID principles) fit very well microservice architectures. With DDD you define the service boundaries, and with hexagonal architecture you implement interfaces of the domain. The domain itself is then clean of dependencies and specific implementation, but does contain the business logic of what the service is about — why it has reason for existence in the first place.
Going back to the metaphor of hexagons, the domain of DDD can be seen as the center hexagon. Each adapter that implements one of the interfaces (ports) of the domain can be seen as another hexagon that sticks to the center hexagon with one edge. The complete microservice is a collection of hexagons together where the domain itself is encapsulated by its adapters.
Before we get into some code, it’s important we first talk a little bit more in more depth about dependency inversion. It’s one of the hardest concepts to grasp, especially in Python. This is because in Python there is no such thing as an interface, like languages as Java do have. You can get it working, but it’s not a first-class citizen and it’s just not Pythonic. Though, with “newer” versions of Python it does come with abstract base classes and methods and also (even) typing (hinting). I think this shows that Python in a way is trying to keep up with its competition, for example with TypeScript.
As the name suggests, with dependency inversion the dependencies need to be inversed. Good luck. Again, languages like Java have dependency injection frameworks, which make life easier, but in Python these don’t (really) exist. No worries, not that they need to exist, it’s just some convenience (or obfuscation).
To make it easy, with DDD in Python we define a package called domain
and inside of the domain, it’s not allowed to import anything which is not defined in that same package. Now this is where interfaces come in handy.
As a case study, we’ll define a microservice that is responsible to keep track of votes. In the public interface you can make a vote, get a list of all votes and the total amount of votes.
Our domain looks as follows:
domain/
├── vote.py
└── vote_repository.py
The vote_repository
knows about a vote
, so both are part of the domain.
The code of vote.py
:
import uuid
from dataclasses import dataclass, field
from typing import TYPE_CHECKING
if TYPE_CHECKING:
# This is necessary to prevent circular imports
from app.domain.vote_repository import VoteRepository
@dataclass
class Vote:
vote_id: str = field(default_factory=lambda: str(uuid.uuid4()))
def save(self, vote_repository: 'VoteRepository'):
return vote_repository.add(self)
def __hash__(self):
return hash(self.vote_id)
When you’re into DDD, you know Vote is the aggregate root so when you want to make a vote you need to create a Vote object and save it.
Note that in the code example we need to do some strange if
and add a comment to explain what is happening. Here you see that Python isn’t made to be strongly typed, in a way typing is “hacked” into it. By the way, the necessity of comments in code is considered a “smell”, a.k.a. an anti-pattern.
Also note the weird syntax to define a default value for vote_id
. Let’s just say it’s a feature of the language. This is the most Pythonic way to instantiate every new Vote object with a unique uuid string, except when you supply one upon creation yourself.
The save(...)
function of the Vote will store itself to the Vote-repository, we’ll talk about this later on in this article.
Ignore the __hash__()
function for now, we’ll talk about it later as well.
Let’s define the code of vote_repository.py
itself:
import abc
from typing import List
from app.domain.vote import Vote
class VoteRepository(metaclass=abc.ABCMeta):
@abc.abstractmethod
def add(self, vote: Vote) -> Vote:
raise NotImplementedError
@abc.abstractmethod
def all(self) -> List[Vote]:
raise NotImplementedError
@abc.abstractmethod
def total(self) -> int:
raise NotImplementedError
Having both our Vote entity and its repository defined, we’ve got our first hexagon ready, the center hexagon, the domain. Now we can start adding adapters. But not before we’ve added tests, because we do TDD (test-driven development) of course!
For the test, test_vote.py
will look like this:
import uuidfrom app.domain.vote import Vote
def test_vote_existing_vote_id():
vote_id = str(uuid.uuid4()) assert Vote(vote_id).vote_id == vote_id
def test_vote_defaults():
vote_id = str(uuid.uuid4()) assert Vote().vote_id != vote_id
We can’t test the save()
function of the Vote yet, because we don’t have an implementation of the Vote-repository yet and interfaces itself can’t be tested — they have no implementation. Abstract classes without any concrete functions neither.
By nature, DDD and TDD fit very well together. The clear boundaries of DDD define exactly what you need to test. Actually, this isn’t a trait of DDD, it’s baked into the foundation of the SOLID principles (especially the L and the D) and thus also in hexagonal architecture.
Knowing the domain, we can add the adapter package and the main entry point to the application (and tests). The full application structure is as follows:
voting-system/
├── app/
│ ├── adapter/
│ │ └── inmemory_vote_repository.py
│ └── domain/
│ ├── vote.py
│ └── vote_repository.py
├── main.py
└── tests/
├── adapter/
│ └── test_inmemory_vote_repository.py
└── domain/
└── test_vote.py
The inmemory_vote_repository
is an implementation of the domain’s vote_repository
and has a dependency pointing to the domain, which is allowed.
The code of inmemory_vote_repository.py
:
from typing import List
from app.domain.vote import Vote
from app.domain.vote_repository import VoteRepository
class InMemoryVoteRepository(VoteRepository):
def __init__(self):
self.votes = []
def add(self, vote: Vote) -> Vote:
self.votes.append(vote)
return vote
def all(self) -> List[Vote]:
return self.votes
def total(self) -> int:
return len(self.votes)
Let’s directly define a test for the adapter:
from app.adapter.inmemory_vote_repository import InMemoryVoteRepository
from app.domain.vote import Vote
def test_vote_save():
vote = Vote()
vote_repository = InMemoryVoteRepository()
assert vote.save(vote_repository).vote_id == vote.vote_id
def test_vote_repository_all():
vote_repository = InMemoryVoteRepository()
vote1 = Vote().save(vote_repository)
vote2 = Vote().save(vote_repository)
assert set(vote_repository.all()) == {vote1, vote2}
def test_vote_repository_total():
vote_repository = InMemoryVoteRepository()
Vote().save(vote_repository)
Vote().save(vote_repository)
assert vote_repository.total() == 2
I think this is pretty self-explanatory code. Notice the set()
and the set literal with {}
. To be able to do this we needed to implement the __hash__()
function as ignored earlier in this article.
The file main.py
glues everything together and is the main entry point to our application:
from app.adapter.inmemory_vote_repository import InMemoryVoteRepository
from app.domain.vote import Vote
def main():
vote_repository = InMemoryVoteRepository()
Vote().save(vote_repository)
Vote().save(vote_repository)
print(vote_repository.all())
print(f'Total votes: {vote_repository.total()}')
if __name__ == '__main__':
main()
This concludes the initial case study. Right now we have three hexagons:
- The domain
- The adapter
- The main entry point
Tests are not part of the application.
The code we’ve discussed can be found on GitHub: https://github.com/douwevandermeij/voting-system/tree/initial
Let’s add a REST interface to our application.
In the public REST interface you can call POST
on /vote
and there you go, you’ve voted. You don’t know what you’ve voted for but for this example this is not relevant. When you call GET
on /votes
you get the total number of votes. We’ll leave out the initial requirement of a list of all votes.
Just create app/main.py
(this location is a convention of the FastAPI library we’re using):
from app.adapter.inmemory_vote_repository import InMemoryVoteRepository
from app.domain.vote import Vote
from fastapi import FastAPI
app = FastAPI()
vote_repository = InMemoryVoteRepository()
@app.post("/vote", response_model=Vote)
def vote() -> Vote:
return Vote().save(vote_repository)
@app.get("/votes", response_model=int)
def votes() -> int:
return vote_repository.total()
For simplicity we’ve left out the async/await. This is a great feature of FastAPI but doesn’t add anything to this article.
At this point we have our fourth hexagon. Although, having a proper REST interface we could drop our original main entry point, with which we’re back to “just” three hexagons.
You might think right now, don’t we just have a layered architecture? Or plain old MVC? We might, I think all these terms boil down to the same thing of targeting a clean architecture (credits to Uncle Bob). They all just have their own quirks.
Of course this REST layer isn’t complete without tests. But first I’ll show you the full application structure including the REST interface and tests. The new files are bold:
voting-system/
├── app/
│ ├── adapter/
│ │ └── inmemory_vote_repository.py
│ ├── domain/
│ │ ├── vote.py
│ │ └── vote_repository.py
│ └── main.py
├── main.py
└── tests/
├── adapter/
│ └── test_inmemory_vote_repository.py
├── api/
│ ├── test_get_votes.py
│ └── test_post_vote.py
├── conftest.py
├── domain/
│ └── test_vote.py
└── fixtures/
└── client.py
For the tests I’ve added two API tests, a configuration file and a fixture.
The configuration conftest.py
is necessary for Pytest to know where to find, for example, fixtures. It’s just a single line there:
from tests.fixtures import * # NOQA
Notice the comment # NOQA
, this is meant to prevent deletion of this import by automatic — or manual — QA steps. This could be happening because in this file nothing is being used from the import itself. Furthermore import *
is considered a “smell” and should be prevented. In this case it’s deliberate to tell Pytest where to find the fixtures.
About the fixture(s), we have one and that’s meant to be able to test the REST interface. client.py
looks as follows:
import pytest
from starlette.testclient import TestClient
@pytest.fixture
def client():
from app.main import app
return TestClient(app)
It’s providing a FastAPI compatible test-client for Pytest to use.
Now the tests, test_post_vote.py
:
def test_post_vote(client):
response = client.post("/vote")
assert response.status_code == 200
And test_get_votes.py
:
def test_get_votes_0(client):
response = client.get("/votes")
assert response.status_code == 200
assert response.json() == 0
def test_get_votes_1(client):
client.post("/vote")
response = client.get("/votes")
assert response.status_code == 200
assert response.json() == 1
def test_get_votes_10(client):
for i in range(1, 10):
client.post("/vote")
response = client.get("/votes")
assert response.status_code == 200
assert response.json() == 10
The tests are pretty straightforward, though there’s a bit of magic in Pytest with the way these fixtures are injected. When you give a function parameter of a test the exact same name as a fixture (its function name), this fixture-function is being called right before the test (function) is executed and you’ll get the result of that fixture-function as a parameter of the test. In our case, TestClient(app)
object will be passed in the client
parameter, so we can use it in the test(s).
The full code can be found on GitHub: https://github.com/douwevandermeij/voting-system/tree/rest
What we’ve did with the REST interface, we could also do to the repository adapter. We can add multiple different adapters each responsible for persisting a Vote all in their own way while respecting the interface.
When we do add multiple implementations, this also forces us to think of ways how and when to use which. A common pattern here is dependency injection. This means that you define which implementation to be used outside of the application itself and make it part of the configuration, for example with environment variables.
In another article I’ll talk about this specific topic.
This article tries to give a hands-on example of how to implement hexagonal architecture in Python, among other architectural patterns and design principles.
The concepts used are very close to the language itself but not bound to it. In other words, the approach can’t be fetched in a generic framework — therefore it also doesn’t exist or I just couldn’t find it — but it can be ported to any other language, as I did myself, back and forth, with Kotlin. In Kotlin you do have proper interfaces and static typing and to be honest, that’s great to rely on.
Feel free to reach out if you have any questions regarding this article. I’m happy to help. You can reach out to me here or on my personal website.
If you like personal, 30 minutes, hands-on advice, please schedule a free meeting with me via Calendly.