Skip to main content

DDD building blocks: entities, value objects, bounded contexts, and validation helpers.

Project description

attack-on-domain

Domain-Driven Design building blocks for Python 3.14+ — entities, value objects, aggregates, CQRS, ports and adapters, use cases, domain events, invariants, and dependency injection. All running on Pydantic v2, fully typed, with mutation guards that actually work.

No ORM. No framework lock-in. No "just pip install django and pray."


Why This Exists

You have a complex domain. Your codebase is turning into a big ball of mud. Your entities are anemic, your services are god objects, and somehow User extends AbstractBaseModelMixinFactory in six different ways.

attack-on-domain gives you real DDD primitives:

  • Entities with enforced identity fields (one, exactly one, or the class doesn't compile)
  • Value Objects that are actually immutable — no @property hacks, no __setattr__ tricks
  • Root Entities that know they're the aggregate root and refuse to be nested inside anything else
  • Mutation guards that block writes from outside your methods (user.name = "hacker" raises MutationForbiddenException — try that with a dataclass)
  • Business invariants via decorators that turn ValueError into InvarianceException at construction time
  • CQRS that's built-in, not bolted on — Command[TEntity, TResult] and Query[TEntity, TResult] with compile-time generic validation
  • Event collection that Just Works — emit from entities, VOs, services; UseCases collect them automatically; EventCollector context manager catches cross-aggregate events
  • Ports & adaptersCommandPort[T] / QueryPort[T] on UseCases, infrastructure handlers implement them, AdapterContainer wires everything. No service locator, no global state, no singletons.

AI-Native by Design

LLMs are great at generating code. They're terrible at maintaining implicit invariants. This framework makes invariants explicit:

  • Every entity identity is declared up front — the LLM can't "forget" which field is the ID
  • Every mutation boundary is enforced at runtime — the LLM can't accidentally write self.total = 0 outside a method
  • Every event emission is tracked — the LLM can't emit events that go nowhere
  • Every port is an interface — the LLM generates adapters against a contract, not implementation details

The result: an AI agent can safely generate, refactor, and extend your domain code without silently breaking business rules. Try that with a plain Pydantic model.

Built-in Agent Skill

This repo ships with a skill at skills/attack-on-domain/ — a comprehensive reference that teaches agents how to use the library correctly. Load it with /load attack-on-domain or install it with skills library:

npx skills add alfonsocuesta/attack-on-domain

It will ask questions you haven't thought of. It's annoying on purpose. Your domain will be better for it.

Quick Example

from aod.domain import RootEntity, ValueObject, Field
from aod.events import Event
from aod.application import UseCase, Command, CommandPort
from aod.infrastructure import CommandHandler, Session, AdapterContainer


class SqlSession(Session):
    def execute(self, operation: object) -> None: ...
    def begin(self) -> None: ...
    def commit(self) -> None: ...
    def rollback(self) -> None: ...
    def close(self) -> None: ...
    def is_dirty(self) -> bool:
        return False


class OrderId(ValueObject):
    value: str


class OrderPlaced(Event):
    order_id: str
    total: float


class Order(RootEntity):
    id: OrderId = Field(id=True)
    total: float

    def place(self) -> None:
        self._event_emitter.emit(
            OrderPlaced(order_id=self.id.value, total=self.total)
        )


class PlaceOrder(Command[Order, None]):
    order_id: str
    total: float


class PlaceOrderHandler(CommandHandler[PlaceOrder]):
    session: SqlSession

    def handle(self, command: PlaceOrder) -> None:
        self.session.execute(command)


class PlaceOrderUseCase(UseCase):
    place_order: CommandPort[PlaceOrder]

    def run(self, order_id: str, total: float) -> None:
        order = Order(id=OrderId(value=order_id), total=total)
        order.place()
        self.place_order.handle(PlaceOrder(order_id=order_id, total=total))


container = AdapterContainer(
    sessions={SqlSession},
    handlers=[PlaceOrderHandler],
)
use_case = container.adapt(PlaceOrderUseCase)
use_case.run(order_id="1", total=99.99)
# Events are auto-collected: use_case.events -> [OrderPlaced(...)]

FastAPI? You Bet.

Because the framework keeps infrastructure out of your domain, wiring it into FastAPI is a few lines:

from functools import lru_cache
from fastapi import FastAPI, Depends
from aod.application import UseCase
from aod.application.async_ import UseCase as AsyncUseCase
from aod.infrastructure import AdapterContainer

from yourdomain import CreateUserUseCase, CreateUserInput
from yourinfra import PostgresSession, CreateUserHandler

app = FastAPI()

@lru_cache
def get_container() -> AdapterContainer:
    return AdapterContainer(
        sessions={PostgresSession},
        handlers=[CreateUserHandler],
    )

def get_use_case(
    use_case: type[UseCase | AsyncUseCase],
    container: AdapterContainer = Depends(get_container),
):
    return container.adapt(use_case)

@app.post("/users")
def create_user(
    payload: CreateUserInput,
    use_case: CreateUserUseCase = Depends(get_use_case(CreateUserUseCase)),
):
    return use_case.run(payload)

The same use case works with CLI scripts, background workers, and tests. No @app decorators in your domain. No async def leaks into entities. Just ports, adapters, and a container.

Install

uv add attack-on-domain

Or with pip:

pip install attack-on-domain

Requires Python 3.14+.

Documentation

Full docs at alfonsocuesta.github.io/attack-on-domain

You Want To Go Here
Install & first 5 minutes Getting Started
Learn the building blocks Domain Layer
Orchestrate with use cases Application Layer
Write database adapters Infrastructure Layer
Test without mocks Testing
Map DDD concepts to code DDD to AoD
Full API reference API Reference

License

Apache 2.0

Project details


Download files

Download the file for your platform. If you're not sure which to choose, learn more about installing packages.

Source Distribution

attack_on_domain-0.0.36.tar.gz (60.6 kB view details)

Uploaded Source

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

attack_on_domain-0.0.36-py3-none-any.whl (98.9 kB view details)

Uploaded Python 3

File details

Details for the file attack_on_domain-0.0.36.tar.gz.

File metadata

  • Download URL: attack_on_domain-0.0.36.tar.gz
  • Upload date:
  • Size: 60.6 kB
  • Tags: Source
  • Uploaded using Trusted Publishing? No
  • Uploaded via: uv/0.11.16 {"installer":{"name":"uv","version":"0.11.16","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":null}

File hashes

Hashes for attack_on_domain-0.0.36.tar.gz
Algorithm Hash digest
SHA256 c6844d95ad89253eaafe964265ef2836c41cc94a5b4a9e9a3d4c772107fde83c
MD5 8ecbd7d824ed978a66f406a04d1cb3b2
BLAKE2b-256 91ee66fdf238ec0b964286375ddfd58c725532ed24d726867906803625c62a31

See more details on using hashes here.

File details

Details for the file attack_on_domain-0.0.36-py3-none-any.whl.

File metadata

  • Download URL: attack_on_domain-0.0.36-py3-none-any.whl
  • Upload date:
  • Size: 98.9 kB
  • Tags: Python 3
  • Uploaded using Trusted Publishing? No
  • Uploaded via: uv/0.11.16 {"installer":{"name":"uv","version":"0.11.16","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":null}

File hashes

Hashes for attack_on_domain-0.0.36-py3-none-any.whl
Algorithm Hash digest
SHA256 a9178a158e0c256c0a94843f04d16066347424027f7960069c45afdba9836372
MD5 08ff575be6af79edd0924f74a968319b
BLAKE2b-256 2b55f44a35587113009b1d74dc23011afb0e2143f754dfc767bce0850e2c64fd

See more details on using hashes here.

Supported by

AWS Cloud computing and Security Sponsor Datadog Monitoring Depot Continuous Integration Fastly CDN Google Download Analytics Pingdom Monitoring Sentry Error logging StatusPage Status page