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
@propertyhacks, 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"raisesMutationForbiddenException— try that with a dataclass) - Business invariants via decorators that turn
ValueErrorintoInvarianceExceptionat construction time - CQRS that's built-in, not bolted on —
Command[TEntity, TResult]andQuery[TEntity, TResult]with compile-time generic validation - Event collection that Just Works — emit from entities, VOs, services; UseCases collect them automatically;
EventCollectorcontext manager catches cross-aggregate events - Ports & adapters —
CommandPort[T]/QueryPort[T]on UseCases, infrastructure handlers implement them,AdapterContainerwires 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 = 0outside 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
Release history Release notifications | RSS feed
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
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
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
c6844d95ad89253eaafe964265ef2836c41cc94a5b4a9e9a3d4c772107fde83c
|
|
| MD5 |
8ecbd7d824ed978a66f406a04d1cb3b2
|
|
| BLAKE2b-256 |
91ee66fdf238ec0b964286375ddfd58c725532ed24d726867906803625c62a31
|
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
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
a9178a158e0c256c0a94843f04d16066347424027f7960069c45afdba9836372
|
|
| MD5 |
08ff575be6af79edd0924f74a968319b
|
|
| BLAKE2b-256 |
2b55f44a35587113009b1d74dc23011afb0e2143f754dfc767bce0850e2c64fd
|