This article is about how we efficiently master large and complex applications with FastAPI and domain-driven design. The fact that microservices are especially suitable for organizing large developer teams and that the real goal of decoupling cannot be simply bought, has been painfully identified by many teams in recent years. Rather, the complexity often increased while losing productivity. At ActiDoo we have therefore long favored modular monoliths for typical enterpise applications in many cases. At the same time we rely on modern tooling such as fast reactive frameworks (FastAPI, Quarkus) and containers (Docker, Kubernetes), because they can accelerate development and provide security benefits when used correctly. A simple and typical development stack is in our backend, for example FastAPI, Postgres, Docker. We do not want to develop after months or years Big Ball-of-Mud where no one dares to refactor or throw away code for fear of regression errors. In order to avoid this, some rules should be set at the beginning and consider how to enforce them sustainably.
Our goal
We want to create a project structure that promotes and promotes sustainable decoupling.
Breakdown of project into domains
In our fictitious example, it should be a B2B online shop. In a workshop we analysed the information flows and discussed them technically. Subsequently, we came to the following division of domains (in DDD language "Bounded Contexts"):
1. Authentication & Authorization
Two. Catalogue No
3. Shopping & Checkout
The project structure looks like the picture. In the domains folder there is a separate folder for each domain.
What is a domain?
A domain is a professionally separated area, in our program a separate module that has no communication with other domains as standard. Desired interfaces must be explicitly defined.
Structure of the app: Multiple apps or multiple routers?
In FastAPI there are two ways to structure large apps: Either by several sub-apps mounted in a large app - or by a division into several routers.
Docs are available under a common URL
(/docs, ...)
Own middleware per domain does not go
If we do not want to separate the API documentation or there are other reasons (e.g. middleware or completely different authentication methods), we usually start with variant 2. If necessary, this decision can also be changed later.
Which code comes into the domain modules? Which is global?
Possible little global!
We usually try to move as much code as possible into the domains. There is little common code that we define at global level. Why do we do that? The possibility of making self-safe changes is more important to us than reducing the lines to source code. The DRY principle (Don't repeat yourself) is not about not writing the same code twice. Rather, it is about not writing code twice with the same task. In our B2B shop, the function "get_display_username" can be given twice if it is defined once in the Cart context and once in the Catalog context. If we want to change the display of the username in the Cart context at a later time, so that e.g. the email is additionally included, we know very quickly what places this is all about. The principle is equivalent to data retention. Only with global initialization tasks, such as setting up a database connection pool, we make exceptions.
In our example we use the following modules for each domain:
myshop.domains.*.model
All database models of the domain
myshop.domains.*.service
The business logic of the domain
myshop.domains.*.domain_api
Interfaces to other domains
myshop.domains.*.web_api
Web interfaces
Everything is as separate as possible in data keeping
Products in the catalogue have a different purpose than products in the shopping cart
The data model is simplified here and would be somewhat more complex in a real application. The point is: Due to the strong separation into the data model, we can make self-safe changes. The catalog becomes too slow and parts are to be transferred to a distributed NoSQL database?
> No problem! The other domains are not affected. The transaction security in the shopping cart is still secured. We want to migrate gradually into the cloud?
> Let's start with the shopping cart!
How do the products from the catalogue come to your shopping cart?
The domains may communicate with each other via well-defined interfaces.
Restriction of import between domains
How do we prevent a developer from importing objects from other domains? Of course with a linter! We use the project import-linter by David Seddon . By definition Contracts you can define which modules can be imported from where.
In our example, this looks as follows:
It is best to consider this call directly in the CI route or in the Git Post-Commit Hook. Allowed communication must explicitly pass through the export/import modules defined in the rules. Depending on the number of domains, it makes sense to divide them further.
Two examples of explicitly permitted communication:
The cart domain could be a function add_to_cart export that is called from the catalog.
The Auth domain could provide a function to check access rights.
You can even make finer settings in the contracts - e.g. Layer architecture . It can thus be defined that layers may depend only on layers among them.
Summary
We have shown a project structure to separate domains from each other in a FastAPI project. The approach is relatively easy to transfer to other Python-based application servers. The domains are defined in modules that can only communicate with one another via fixed paths. Each domain has at least its own router or sub-app.
Subdivision of application by professional domains
In FastAPI per domain a sub-app or a separate router with prefix
Possible strong decoupling of the domains by separating the data models and logic
Linter restricts import between domain modules
Targeted permitting exceptions for intended interfaces
Marcel Sander
About the author
Marcel is managing director and co-founder of ActiDoo GmbH. He has a technical background as a full-stack developer and a Master Of Science in Computer Science from the University of Paderborn.