Skip to main content

domino

CI Python 3.12+ Ruff Types: ty License: MIT

A small, dependency-free library for building Domain-Driven Design domains in Python. It gives you clean base classes for the tactical DDD patterns and gets out of your way — value equality, immutability and event plumbing come from the standard library (dataclasses), which the base classes apply for you.

Just subclass a base and declare your fields: ValueObject, DomainEvent, Entity, AggregateRoot and Command turn each subclass into the right kind of dataclass automatically (frozen, frozen keyword-only, or mutable identity-based). No @dataclass decorator to repeat, and full static typing is preserved via PEP 681 @dataclass_transform, so type checkers still see the generated __init__.

Requires Python 3.12+.

Install

uv add pydomino
# or, from a checkout:
uv pip install -e .

The distribution is pydomino (domino was taken on PyPI); the import stays domino:

from domino import AggregateRoot, UnitOfWork

The building blocks

Concept Class Purpose
Value object ValueObject Immutable, compared by value
Entity Entity Compared by identity (id)
Aggregate root AggregateRoot Consistency boundary + records domain events
Identity DomainId UUID- or string-based identifier
Domain event DomainEvent Immutable record of something that happened
Event bus EventBus / EventHandler In-memory publish/subscribe
Repository Repository[T] / AsyncRepository[T] Collection-like persistence port
Unit of work UnitOfWork / AsyncUnitOfWork Transactional boundary around repositories
Domain service DomainService Stateless cross-aggregate logic
Command Command Immutable request (DTO) a use case handles
Use case UseCase[C, R] / AsyncUseCase[C, R] Application entry point (C is a Command)
Errors / Result DomainError, Result Domain failures, as exceptions or in-band values

Quick tour

Value objects — immutable, compared by value

Subclass ValueObject and declare fields; it becomes a frozen dataclass.

from decimal import Decimal

from domino import ValueObject, DomainValidationError


class Money(ValueObject):
    amount: Decimal
    currency: str

    def __post_init__(self) -> None:
        if self.amount < 0:
            raise DomainValidationError("amount cannot be negative")


Money(Decimal("10"), "EUR") == Money(Decimal("10"), "EUR")  # True
Money(Decimal("10"), "EUR").replace(amount=Decimal("20"))  # a new instance

Entities and aggregate roots

Subclass them and declare fields — an entity or aggregate becomes a mutable dataclass with identity-based equality. An aggregate also records domain events; you pull them out to publish after the transaction commits (no field to declare for them — the base manages them).

from dataclasses import field
from datetime import UTC, datetime

from domino import AggregateRoot, DomainEvent, DomainId, DomainStateError


class OrderConfirmed(DomainEvent):
    order_id: DomainId
    # event_id and occurred_on are inherited and auto-filled


class Order(AggregateRoot):
    _id: DomainId = field(default_factory=DomainId.generate)
    status: str = "draft"
    updated_at: datetime = field(default_factory=lambda: datetime.now(UTC))

    def confirm(self) -> None:
        if self.status != "draft":
            raise DomainStateError("only draft orders can be confirmed")
        self.status = "confirmed"
        self._touch()
        self._add_event(OrderConfirmed(order_id=self._id))


order = Order()
order.confirm()
events = order.pull_pending_events()  # [OrderConfirmed(...)]

Event bus

from domino import EventBus, EventHandler, DomainEvent


class NotifyWarehouse(EventHandler):
    def handle(self, event: DomainEvent) -> None:
        if isinstance(event, OrderConfirmed):
            ...  # reserve stock


bus = EventBus()
bus.register(OrderConfirmed, NotifyWarehouse())  # many handlers per event allowed
bus.publish(*order.pull_pending_events())

Handlers are wrapped so a failing one is logged, never propagated to the caller or the other handlers.

Repository and unit of work

Repository[T] is a port you implement against your store. UnitOfWork is a thin transaction boundary: it exposes repositories and commits on a clean exit, rolls back on error. Wire the actual persistence through the commit / rollback hooks (e.g. a SQLAlchemy session), and pass an event_bus to let the unit of work publish domain events once the transaction is durable.

from domino import UnitOfWork

uow = UnitOfWork(
    {"orders": order_repo},
    event_bus=bus,  # optional: publishes the queued events after commit
    commit=session.commit,
    rollback=session.rollback,
)

with uow:
    order = uow.orders.get_by_id(order_id)
    order.confirm()
    uow.orders.save(order)
    uow.enqueue_events(*order.pull_pending_events())  # published on commit
    # commit runs automatically here; rollback runs if the block raises

AsyncRepository[T] and AsyncUnitOfWork are the async twins: the same API, awaited, driven with async with.

See examples/order_domain.py for a full, runnable tour that wires every piece together.

Persistence with SQLAlchemy (optional)

The core is dependency-free. An optional extra wires the infrastructure layer to SQLAlchemy 2.0 via imperative mapping, so your aggregates stay pristine:

uv add "pydomino[sqlalchemy]"
from domino.integrations.sqlalchemy import (
    DomainIdType,
    SqlAlchemyRepository,
    SqlAlchemyUnitOfWork,
)


class OrderRepository(
    SqlAlchemyRepository[Order]
):  # get_by_id / save / delete for free
    ...

Async works the same way — AsyncSqlAlchemyRepository, AsyncSqlAlchemyUnitOfWork and AsyncFilterable over an AsyncSession. See the SQLAlchemy guide, examples/order_sqlalchemy.py and examples/order_sqlalchemy_async.py.

Serving over HTTP with FastAPI (optional)

The domino.integrations.fastapi extra wires the presentation layer: a per-request unit of work, a correlation id per request, DomainError → HTTP status mapping, and domain-event dispatch after commit — one call to install_domino. You pass a factory building the unit of work, so any UnitOfWork / AsyncUnitOfWork works, not just the SQLAlchemy one.

uv add "pydomino[fastapi]" "domino[sqlalchemy]" aiosqlite
from domino.integrations.fastapi import UnitOfWorkDep, install_domino
from domino.integrations.sqlalchemy import AsyncSqlAlchemyUnitOfWork

install_domino(
    app,
    # a factory: each request gets its own unit of work
    unit_of_work=lambda: AsyncSqlAlchemyUnitOfWork(
        session_factory=session_factory,
        repositories={"orders": OrderRepository},
        event_bus=bus,
    ),
)


@app.post("/orders", status_code=201)
async def place_order(body: PlaceOrderBody, uow: UnitOfWorkDep) -> dict[str, str]:
    async with uow:  # the transaction scope for this request
        order_id = await PlaceOrder(uow).execute(PlaceOrderCommand(...))
    return {"id": str(order_id)}

See the FastAPI guide and examples/order_fastapi.py.

Correlation ids — automatic, no plumbing

Every use case runs inside an ambient correlation scope (a contextvars context), and every DomainEvent captures that id when it is created. You never pass it around: one id is generated per execute call and flows to every event produced along the way, so you can trace a whole operation across logs and handlers.

class PlaceOrder(UseCase[PlaceOrderCommand, DomainId]):
    # the base __init__ takes the unit of work and exposes it as self._uow
    def execute(self, command: PlaceOrderCommand) -> DomainId:
        order = Order(customer_id=command.customer_id)
        order.confirm()  # the OrderConfirmed event captures the current id
        ...


# event.correlation_id is set automatically; get_correlation_id() reads it

A nested use case reuses the caller's id, and if the command carries a correlation_id (e.g. from an upstream service) that trace is continued instead of a new one being started. At other boundaries — web middleware, a message consumer — open the scope yourself:

from domino import correlation_scope

with correlation_scope(incoming_id):  # or no argument to generate one
    handle(message)

Contextual logging — self.log

Use cases, event handlers and aggregate roots expose self.log, a logger that stamps every line with the class doing the logging and the current correlation id — no plumbing, no arguments to pass.

class PlaceOrder(UseCase[PlaceOrderCommand, DomainId]):
    def execute(self, command: PlaceOrderCommand) -> DomainId:
        self.log.info("placing order for %s", command.customer_id)
        ...


# INFO domino [PlaceOrder] [cid=8f3e…] placing order for 7ff8…

The class and id are also attached to the record as domino_context and correlation_id fields for structured handlers. Domino never configures logging itself — enable it in your app (logging.basicConfig(level="INFO")) and tune the domino logger. Mix LoggerMixin into your own classes to get the same self.log, or call get_logger("MyThing") directly.

Configuration

A few cross-cutting behaviours are tuned in one place. Call configure(...) once at startup; only what you pass changes.

from uuid import uuid4
from domino import configure

# 16-char correlation ids instead of a 32-char uuid hex
configure(correlation_id_factory=lambda: uuid4().hex[:16])

# or a third-party generator such as NanoID — for domain ids too
from nanoid import generate

configure(
    correlation_id_factory=lambda: generate(size=16),
    id_factory=generate,  # used by DomainId.generate()
)

correlation_id_factory feeds new_correlation_id() (hence every correlation scope and event.correlation_id); id_factory feeds DomainId.generate(). get_config() reads the current settings and reset_config() restores the defaults (handy in tests).

Documentation

Full documentation — a Domain-Driven Design primer and a build-with-Domino guide — lives in docs/, built with MkDocs + Material:

uv sync --group docs     # install the docs toolchain
uv run mkdocs serve      # live preview at http://127.0.0.1:8000
uv run mkdocs gh-deploy  # publish to GitHub Pages

Development

uv sync            # install dev dependencies (pytest, ruff, ty)
uv run pytest      # run the test suite
uv run ruff check  # lint
uv run ruff format # format
uv run ty check    # type-check

Scope

domino currently covers the tactical DDD patterns and the domain events pattern (an aggregate records events for you to publish). It is not an event-sourcing framework — there is no event store and aggregates are not rebuilt from an event stream. Event sourcing is a possible future addition.

Release files for pydomino 0.2.0

For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.

Source distribution (sdist)

Source distribution for pydomino 0.2.0
File Size Uploaded
pydomino-0.2.0.tar.gz 143.5 kB Details

Built distribution (wheel)

Table of built distributions (wheels) for pydomino 0.2.0
File Interpreter ABI Platform
pydomino-0.2.0-py3-none-any.whl Python 3 none any Details

Total release size: 191.6 kB

Release files / pydomino-0.2.0.tar.gz

Download URL pydomino-0.2.0.tar.gz
Size 143.5 kB
Tags Source
SHA-256 checksum
How to use checksums
d6858cf2bfd209cbfc4b4f27e16a956f139813bebf17f711732c8b76b79e2f37
BLAKE2b-256 checksum
How to use checksums
e3c9c603c66bf2997369e92d5aa25efac8028f651197ed43da12432c28d0e473
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via uv/0.12.5 {"installer":{"name":"uv","version":"0.12.5","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"Ubuntu","version":"24.04","id":"noble","libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":true}

Release files / pydomino-0.2.0-py3-none-any.whl

Download URL pydomino-0.2.0-py3-none-any.whl
Size 48.1 kB
Tags Python 3
SHA-256 checksum
How to use checksums
aa306f94b3fe1688d2732c6f03c5137bbd46f3e99e8ec87cf087c582ff41853c
BLAKE2b-256 checksum
How to use checksums
9075d8b5ec0e282d7172d83cf3f59d86d4eae00414048a6b79c2454e37788f17
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via uv/0.12.5 {"installer":{"name":"uv","version":"0.12.5","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"Ubuntu","version":"24.04","id":"noble","libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":true}

Release history Release notifications | RSS feed

This release

0.2.0 This release

2 release files

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page