Domain-driven design with Python and FastAPI

14.03.2023

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.
FastAPI DDD Projektstruktur

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.

Version 1: Multiple apps

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
from fastapi import FastAPI
from fastapi.router import APIRouter

app = FastAPI()

app_auth = FastAPI()
app_cart = FastAPI()
app_catalog = FastAPI()

app.mount("/auth", app_auth)
app.mount("/cart", app_cart)
app.mount("/catalog", app_catalog)
Follow:
  • Docs are available for each domain under another URL (/auth/docs, ...)
  • It is possible to define your own middleware for each app

Variant 2: Multiple routers

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
from fastapi import FastAPI
from fastapi.router import APIRouter

app = FastAPI()

router_auth = APIRouter(prefix="/auth")
router_cart = APIRouter(prefix="/cart")
router_catalog = APIRouter(prefix="/catalog")

app.include_router(router_auth)
app.include_router(router_cart)
app.include_router(router_catalog)
Follow:
  • 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
FastAPI DDD Projektstruktur

Everything is as separate as possible in data keeping

Products in the catalogue have a different purpose than products in the shopping cart

Data model for domain: Catalog

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
# myshop.domains.catalog.model

from sqlalchemy import Column
import sqlalchemy.types as ty

from myshop.database import Base


class CatalogUser(Base):
    __tablename__ = "catalog_users"
    id = Column(ty.UUID, primary_key=True)
    email = Column(ty.Text)
    firstname = Column(ty.Text)
    lastname = Column(ty.Text)
    country = Column(ty.Text)


class CatalogCategory(Base):
    __tablename__ = "catalog_category"
    id = Column(ty.UUID, primary_key=True)
    name =  Column(ty.Text)


class CatalogProduct(Base):
    __tablename__ = "catalog_products"
    id = Column(ty.UUID, primary_key=True)
    name =  Column(ty.Text)
    price = Column(ty.Float)
    description = Column(ty.Text)

Data model for domain: shopping cart & checkout

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
# myshop.domains.cart.model

from sqlalchemy import Column
import sqlalchemy.types as ty

from myshop.database import Base


class CartUser(Base):
    __tablename__ = "cart_users"
    id = Column(ty.UUID, primary_key=True)
    email = Column(ty.Text)
    firstname = Column(ty.Text)
    lastname = Column(ty.Text)
    street = Column(ty.Text)
    zip = Column(ty.Text)
    city = Column(ty.Text)
    country = Column(ty.Text)


class CartProduct(Base):
    __tablename__ = "cart_products"
    id = Column(ty.UUID, primary_key=True)
    name =  Column(ty.Text)
    price = Column(ty.Float)
    amount = Column(ty.Integer)
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:
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
[tool.importlinter]
root_packages = [
    "myshop"
]

[[tool.importlinter.contracts]]
name = "Domain contract"
type = "independence"
modules = [
    "myshop.domains.auth",
    "myshop.domains.cart",
    "myshop.domains.catalog",
]
ignore_imports = [
    "myshop.domains.catalog.domain_api.imports -> myshop.domains.auth.domain_api.exports",
    "myshop.domains.catalog.domain_api.imports -> myshop.domains.cart.domain_api.exports",
    "myshop.domains.cart.domain_api.imports -> myshop.domains.auth.domain_api.exports"
]
We define here:
The verification of the contract takes place with a call from lint-imports . If everything is okay, it looks as follows:
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
$ lint-imports 
=============
Import Linter
=============

---------
Contracts
---------

Analyzed 23 files, 16 dependencies.
-----------------------------------

Domain contract KEPT

Contracts: 1 kept, 0 broken.
Let us now try directly in the module myshop.domains.cart.service an object from myshop.domains.auth.model Importing module, we get an error:
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
$ lint-imports 
=============
Import Linter
=============

---------
Contracts
---------

Analyzed 23 files, 17 dependencies.
-----------------------------------

Domain contract BROKEN

Contracts: 0 kept, 1 broken.


----------------
Broken contracts
----------------

Domain contract
---------------

myshop.domains.cart is not allowed to import myshop.domains.auth:

- myshop.domains.cart.service -> myshop.domains.auth.model (l.3)
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:
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.
Marcel Sander

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.

free, online >
Make an appointment

We use cookies

We use cookies to provide you with the best possible experience on our website. Analysis tools help us to identify and improve the most popular content. We also want to find out how well our advertisements work. Details can be found in the Data Protection section. Please select which cookies you want to accept: