Self-hosted service
iktakahiro/dddpy avatar
iktakahiro/dddpy

dddpy shows Onion Architecture in a FastAPI todo service

Python DDD & Onion Architecture Example and Techniques

730 stars76 forksPythonMIT

At a glance

What is it?
dddpy is a worked example of Domain-Driven Design and Onion Architecture in Python, with the layers mapped to directories, entities compared by identity, value objects frozen and validated, and repository ports written as typing.Protocol rather than abstract base classes. It is explicitly an example rather than a framework, and the author says outright that you must add authentication and security before deploying it anywhere real.
Who is it for?
Adopt dddpy as a reference if you are structuring a Python web application around a domain and want a concrete example of how the layers, ports and value objects fit together in FastAPI, since the directory tree and the type signatures show the shape clearly.
Can I use it commercially?
Yes. MIT is a permissive licence: you can use, modify and sell software built on it, as long as you keep its copyright and licence notices.
Is it still maintained?
Yes. The repository last received commits 6 days ago.
What is it written in?
Mainly Python, according to GitHub's language statistics.

Answers come from the project's GitHub data, last synced on October 20, 2026, and from our analysis. They are not legal advice.

Editorial analysis

The whole thing is one todo aggregate, and that is the point

dddpy is described as a Python DDD and Onion Architecture example, and the repository description is even blunter. It is an example to demonstrate how to implement DDD architecture in a Python web application, and if you use it as a reference, ensure to implement authentication and security before deploying it to a real-world environment.

That warning is worth repeating because it shapes how the code should be read. This is a teaching artefact with a security model of zero, and the author is telling you so in the first paragraph rather than in a footnote.

The domain is a todo list, and the choice is deliberate rather than lazy. A todo has an identity, a title, an optional description, a status, and timestamps, which is enough to demonstrate every DDD concept that matters: an entity with a stable identifier, value objects that carry validation, a repository port, use cases, and an infrastructure layer that implements the port. It is enough to show the wiring without being large enough to obscure it.

The stack is FastAPI with SQLAlchemy 2.1.4 and SQLite, uv for dependency management, and Docker. The project version is 2.0.2 and it requires Python 3.13 or higher, with a Japanese translation of the README available alongside the English one. There is a Go implementation of the same ideas at oniongo, and a blog post, plus a DeepWiki page.

The last push was on 2026-10-16 and the repository has no GitHub releases, so it is maintained as a living example rather than versioned as a product.

The directory tree is the architecture

The structure is based on Onion Architecture, and the README shows the tree in full:

tree
โ”œโ”€โ”€ main.py
โ”œโ”€โ”€ dddpy
โ”‚   โ”œโ”€โ”€ domain
โ”‚   โ”‚   โ””โ”€โ”€ todo
โ”‚   โ”‚       โ”œโ”€โ”€ entities
โ”‚   โ”‚       โ”‚   โ””โ”€โ”€ todo.py
โ”‚   โ”‚       โ”œโ”€โ”€ value_objects
โ”‚   โ”‚       โ”‚   โ”œโ”€โ”€ todo_title.py
โ”‚   โ”‚       โ”‚   โ”œโ”€โ”€ todo_description.py
โ”‚   โ”‚       โ”‚   โ”œโ”€โ”€ todo_id.py
โ”‚   โ”‚       โ”‚   โ””โ”€โ”€ todo_status.py
โ”‚   โ”‚       โ”œโ”€โ”€ repositories
โ”‚   โ”‚       โ”‚   โ””โ”€โ”€ todo_repository.py
โ”‚   โ”‚       โ””โ”€โ”€ exceptions
โ”‚   โ”œโ”€โ”€ infrastructure
โ”‚   โ”‚   โ”œโ”€โ”€ di
โ”‚   โ”‚   โ”‚   โ””โ”€โ”€ injection.py
โ”‚   โ”‚   โ””โ”€โ”€ sqlite
โ”‚   โ”‚       โ”œโ”€โ”€ database.py
โ”‚   โ”‚       โ””โ”€โ”€ todo
โ”‚   โ”‚           โ”œโ”€โ”€ todo_repository.py
โ”‚   โ”‚           โ””โ”€โ”€ todo_dto.py
โ”‚   โ”œโ”€โ”€ presentation
โ”‚   โ”‚   โ””โ”€โ”€ api
โ”‚   โ”‚       โ””โ”€โ”€ todo
โ”‚   โ”‚           โ”œโ”€โ”€ handlers
โ”‚   โ”‚           โ”‚   โ””โ”€โ”€ todo_api_route_handler.py
โ”‚   โ”‚           โ”œโ”€โ”€ schemas
โ”‚   โ”‚           โ”‚   โ””โ”€โ”€ todo_schema.py
โ”‚   โ”‚           โ””โ”€โ”€ error_messages
โ”‚   โ”‚               โ””โ”€โ”€ todo_error_message.py
โ”‚   โ””โ”€โ”€ usecase
โ”‚       โ””โ”€โ”€ todo
โ”‚           โ”œโ”€โ”€ create_todo_usecase.py
โ”‚           โ”œโ”€โ”€ update_todo_usecase.py
โ”‚           โ”œโ”€โ”€ start_todo_usecase.py
โ”‚           โ”œโ”€โ”€ find_todos_usecase.py
โ”‚           โ”œโ”€โ”€ find_todo_by_id_usecase.py
โ”‚           โ”œโ”€โ”€ complete_todo_usecase.py
โ”‚           โ””โ”€โ”€ delete_todo_usecase.py
โ””โ”€โ”€ tests

The four layers under dddpy/ are domain, infrastructure, presentation and usecase, and the dependency direction is the point of the exercise. The domain layer sits at the centre and knows nothing about the others. The use case layer holds the seven operations the todo domain supports, create, update, start, find, find by id, complete and delete, one file each. The infrastructure layer holds the dependency injection wiring and the SQLite implementation. The presentation layer holds the API handlers, the request and response schemas, and the error messages.

One file per class is the convention, and the naming is mechanical: the domain files are todo_title.py, todo_description.py, todo_id.py and todo_status.py, the use case files are create_todo_usecase.py and so on. A reader can find any class by name without opening anything, which is the practical benefit of the style over grouping by type.

The todo_dto.py file in the infrastructure layer is the one worth noticing, because it shows where the mapping between the domain and the database lives, separated from both the entity and the repository.

Entities compare by identity, and value objects refuse to be invalid

The domain layer contains the core business logic and rules, and it includes entities, value objects and repository interfaces.

The Todo entity is a domain model with a unique identifier, and the constructor takes the value objects rather than raw values:

python
class Todo:
    def __init__(
        self,
        id: TodoId,
        title: TodoTitle,
        description: Optional[TodoDescription] = None,
        status: TodoStatus = TodoStatus.NOT_STARTED,
        created_at: datetime = datetime.now(),
        updated_at: datetime = datetime.now(),
        completed_at: Optional[datetime] = None,
    ):
        self._id = id
        self._title = title
        self._description = description
        self._status = status
        self._created_at = created_at
        self._updated_at = updated_at
        self._completed_at = completed_at

The characteristics listed for entities are that they have a unique identifier, can change state through methods such as `update_title`, `update_description`, `start` and `complete`, have identity determined by the identifier through the `__eq__` implementation, and may be created through factory methods such as `create`.

The identity implementation is the part that is easy to get wrong and is shown twice, once with the private attribute and once with a note that it accesses via property:

python
def __eq__(self, obj: object) -> bool:
    if isinstance(obj, Todo):
        return self.id == obj.id  # Note: Accessing via property
    return False

The key points are that identity is determined solely by the identifier and that type safety is ensured with an isinstance check.

The value objects are a different story. They are immutable domain models without identifiers, and the example enforces both:

python
@dataclass(frozen=True)
class TodoTitle:
    value: str

    def __post_init__(self):
        if not self.value:
            raise ValueError('Title is required')
        if len(self.value) > 100:
            raise ValueError('Title must be 100 characters or less')

Immutability is guaranteed by the frozen dataclass, the validation logic lives in `__post_init__`, there is no identifier, and identity is determined by the value content. That last point is the inversion of the entity rule, and seeing both in one small domain is the clearest way to understand the difference.

Repository ports are Protocols, and the reason is a table

Repositories are abstraction layers responsible for entity persistence, and the domain defines the TodoRepository interface, called a port, as a typing.Protocol per PEP 544:

python
from typing import Protocol


class TodoRepository(Protocol):
    def save(self, todo: Todo) -> None:
        """Save a Todo"""

    def find_by_id(self, todo_id: TodoId) -> Todo | None:
        """Find a Todo by ID"""

    def find_all(self) -> list[Todo]:
        """Get all Todos"""

    def delete(self, todo_id: TodoId) -> None:
        """Delete a Todo by ID"""

The abstract characteristics are that they abstract entity persistence, define the boundaries between the domain and infrastructure layers, and get concrete implementations in the infrastructure layer.

Then there is a section asking why Protocol instead of ABC, and the answer is a comparison table across six aspects. The typing model differs: ABC plus abstractmethod is nominal, inheritance required, while typing.Protocol is structural, matching shape is enough. Violations are detected at runtime for an ABC, as a TypeError on instantiation, and statically for a Protocol, by a type checker. The adapter dependency differs too, since an ABC must import and inherit the ABC while inheriting a Protocol is optional. Test fakes differ, since with an ABC they must inherit while any object with the right shape works with a Protocol. Shared implementation is possible with an ABC, for example a template method, and is not a good fit for a Protocol.

The conclusion is that since Pyrefly runs in CI, a Protocol enforces the contract at least as strictly as an ABC, and you should reach for an ABC only when you need runtime enforcement or shared implementation.

One weakness is acknowledged. A purely structural Protocol means a drifting adapter is only reported where it is used as the port type, for example in the DI wiring, and the project keeps the relationship explicit by having implementations import the protocol and inherit from it. The documentation is cut off at that point, so the exact mechanism is worth reading in the source.

The build runs lint, types and tests in a strict order

The Makefile is short and every target depends on the one before it, so a broken check stops the chain before you get to a false pass.

code
sync:
	uv sync --frozen --extra dev

venv:
	uv venv $(VENV) --allow-existing --seed

install: sync

lint: install
	$(RUFF) check . $(RUFF_FLAGS)

typecheck: install
	$(PYREFLY) check $(PYREFLY_FLAGS)

test: typecheck
	$(PYTEST) -vv 

format: install
	$(RUFF) format

dev: install
	uv run fastapi dev

So lint depends on install, typecheck depends on install, test depends on typecheck, and dev depends on install. The test target running after the type check is the notable choice, since it means a type error fails the build before you spend time on the test run.

The setup instructions for a user are two make targets:

bash
make install

and then:

bash
make dev

The first installs dependencies using uv, the second runs the web app through fastapi dev. The `sync` target uses uv with the frozen flag and the dev extra, so the lock file is respected and the tooling is installed.

The tooling itself is deliberately modern. Pyrefly is the type checker, ruff does the linting with a long rule selection covering pyflakes, isort, naming, bugbear, complexity, try, performance and more, and pytest runs the tests. The project configuration sets ruff's line length to 88 with single quotes, targets Python 3.13, and pins the runtime dependencies to exact versions, sqlalchemy at 2.1.4 and fastapi with the standard extra at 0.143.0.

The uv configuration includes exclude-newer set to a week, which is a supply chain precaution that stops a fresh resolve pulling in a package published in the last seven days. The repository also carries a .mise.toml for tool versions, a .devcontainer/ directory, a logging.conf file, a db/ directory and an AGENTS.md.

Editorial conclusion

Adopt dddpy as a reference if you are structuring a Python web application around a domain and want a concrete example of how the layers, ports and value objects fit together in FastAPI, since the directory tree and the type signatures show the shape clearly. Skip it if you want a DDD framework to build on, because this is a demo application rather than a library, and do not deploy it as-is, since the author states that authentication and security must be implemented before it goes anywhere real. Verify first that you are on Python 3.13 or newer with uv installed, that you accept the package's two pinned runtime dependencies and its deliberate choice of Protocol over ABC, and that you understand that the example covers a single todo aggregate rather than a realistic domain.

Frequently asked questions

What is dddpy and what is it for?

It is a Python example demonstrating how to implement DDD architecture in a Python web application, using FastAPI, SQLAlchemy with SQLite, uv and Docker, with the directory structure based on Onion Architecture. The author notes it should be treated as a reference and that authentication and security must be implemented before deploying it anywhere real.

How do I set up and run dddpy?

You need Python 3.13 or higher and uv. Run `make install` to install the dependencies, then `make dev` to run the web app through fastapi dev. The Makefile also has lint, typecheck, test and format targets, with the test target depending on the type check.

Why does dddpy use typing.Protocol instead of abc.ABC for its repository ports?

Because a Protocol is structural rather than nominal, so any object with the right shape satisfies the port, test fakes need no inheritance, and violations are caught statically by Pyrefly in CI rather than at runtime. The author recommends an ABC only when you need runtime enforcement or shared implementation.

Official sources

  1. iktakahiro/dddpy on GitHub
  2. Issues
  3. License: MIT
  4. README
Add this badge to your README

If you maintain this project, the badge below links readers to this analysis and shows its maintenance status from the daily GitHub snapshot. Paste the markdown into your README; add ?metric=license or ?metric=stars to the image URL for a different field.

Add this badge to your README

markdown
[![Hysen Labs](https://hysenlabs.com/badge/iktakahiro-dddpy.svg)](https://hysenlabs.com/projects/iktakahiro-dddpy)

Was this useful?