Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 5 additions & 2 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ Commit messages are enforced by a `commit-msg` pre-commit hook using Conventiona

This is a **library** (`src/seedwork/`), not an application. It ships DDD and Hexagonal Architecture building blocks. Consuming projects import from `seedwork` (everything is re-exported from the top-level `__init__.py`).

The library is split into three layers that mirror DDD's dependency rule — domain has no outward imports, application depends only on domain, infrastructure depends on both:
The library is split into three layers that enforce the dependency rule of Hexagonal Architecture — domain has no outward imports, application depends only on domain, infrastructure depends on both:

```text
seedwork.domain → pure Python, no framework
Expand All @@ -54,6 +54,8 @@ seedwork.infrastructure → concrete bus/repository implementations

**`DomainEventPublishingRepository` is a repository decorator.** Do not publish events inside command handlers. Wrap the concrete repository at composition time; it reads `aggregate.domain_events` and calls `publisher.publish` after every `save`.

**Domain events use a `create()` factory.** Domain event classes expose a `create()` classmethod that accepts plain data and constructs the payload internally. Aggregate methods call `EventClass.create(...)` rather than the constructor directly — this decouples the aggregate from the payload structure.
Comment thread
aseguragonzalez marked this conversation as resolved.
Comment thread
aseguragonzalez marked this conversation as resolved.

## Type-checking constraints

- `pyright` runs in `typeCheckingMode = "strict"` — no `# type: ignore` in `src/` or `tests/`.
Expand All @@ -63,6 +65,7 @@ seedwork.infrastructure → concrete bus/repository implementations
## Testing patterns

- Domain: unit-test aggregates and value objects directly — no mocks needed.
- Command handlers: use `InMemoryRepository[TId, TAggregate]` (ships with the library) + a spy `DomainEventPublisher`.
- Command handlers: use `InMemoryRepository[TId, TAggregate]` from `seedwork.testing` — assert on `repo.find_by_id()` and `aggregate.domain_events` directly.
- Query handlers: use an inline in-memory read repository (a plain class satisfying the read `Protocol`).
- Integration and task side-effects: use `InMemoryIntegrationEventPublisher` and `InMemoryTaskScheduler` from `seedwork.testing` — both expose spy attributes (`published`, `scheduled`) and a `reset()` method.
- Coverage gate is 90% on `src/seedwork/` — running `make test` will fail if it drops below.
839 changes: 691 additions & 148 deletions docs/coding-standards.md

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
Expand Up @@ -6,5 +6,6 @@
@dataclass(frozen=True, kw_only=True)
class OpenAccountCommand(Command):
account_id: str
owner_id: str
initial_balance: float
currency: str
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
from bank_account.domain.bank_account_id import BankAccountId
from bank_account.domain.bank_account_repository import BankAccountRepository
from bank_account.domain.money import Money
from bank_account.domain.user_id import UserId

from seedwork.application.commands import CommandHandler

Expand All @@ -14,6 +15,7 @@ def __init__(self, repository: BankAccountRepository) -> None:
async def handle(self, command: OpenAccountCommand) -> None:
account = BankAccount.open(
id=BankAccountId(command.account_id),
owner_id=UserId(command.owner_id),
initial_balance=Money(amount=command.initial_balance, currency=command.currency),
)
await self._repository.save(account)
69 changes: 33 additions & 36 deletions docs/examples/bank_account/domain/bank_account.py
Original file line number Diff line number Diff line change
@@ -1,72 +1,69 @@
from __future__ import annotations

from dataclasses import dataclass
from typing import Self

from bank_account.domain.bank_account_id import BankAccountId
from bank_account.domain.errors import CurrencyMismatchError, InsufficientFundsError
from bank_account.domain.events.account_credited import (
AccountCredited,
AccountCreditedPayload,
)
from bank_account.domain.events.account_debited import (
AccountDebited,
AccountDebitedPayload,
)
from bank_account.domain.events.account_opened import AccountOpened, AccountOpenedPayload
from bank_account.domain.events.account_credited import AccountCredited
from bank_account.domain.events.account_debited import AccountDebited
from bank_account.domain.events.account_opened import AccountOpened
from bank_account.domain.money import Money
from bank_account.domain.user_id import UserId

from seedwork.domain.aggregate_root import AggregateRoot


@dataclass(frozen=True, eq=False, kw_only=True)
class BankAccount(AggregateRoot[BankAccountId]):
owner_id: UserId
balance: Money

@classmethod
def reconstitute(cls, id: BankAccountId, balance: Money) -> Self:
return cls(id=id, balance=balance)
def reconstitute(cls, id: BankAccountId, owner_id: UserId, balance: Money) -> Self:
return cls(id=id, owner_id=owner_id, balance=balance)

@classmethod
def open(cls, id: BankAccountId, initial_balance: Money) -> Self:
event = AccountOpened(
aggregate_id=id,
payload=AccountOpenedPayload(
def open(cls, id: BankAccountId, owner_id: UserId, initial_balance: Money) -> Self:
return cls(id=id, owner_id=owner_id, balance=initial_balance)._record(
AccountOpened.create(
initial_balance=initial_balance.amount,
currency=initial_balance.currency,
),
aggregate_id=str(id),
)
)
return cls(id=id, balance=initial_balance, domain_events=(event,))

def credit(self, amount: Money) -> Self:
if amount.currency != self.balance.currency:
raise CurrencyMismatchError(self.balance.currency, amount.currency)
new_amount = self.balance.amount + amount.amount
new_balance = Money(amount=new_amount, currency=self.balance.currency)
new_balance = Money(
amount=self.balance.amount + amount.amount,
currency=self.balance.currency,
)
return self._evolve(balance=new_balance)._record(
AccountCredited(
aggregate_id=self.id,
payload=AccountCreditedPayload(
amount=amount.amount,
currency=amount.currency,
),
AccountCredited.create(
amount=amount.amount,
currency=amount.currency,
aggregate_id=str(self.id),
)
)

def validate(self) -> None:
pass

def debit(self, amount: Money) -> Self:
if amount.currency != self.balance.currency:
raise CurrencyMismatchError(self.balance.currency, amount.currency)
if amount.amount > self.balance.amount:
raise InsufficientFundsError()
new_amount = self.balance.amount - amount.amount
new_balance = Money(amount=new_amount, currency=self.balance.currency)
new_balance = Money(
amount=self.balance.amount - amount.amount,
currency=self.balance.currency,
)
return self._evolve(balance=new_balance)._record(
AccountDebited(
aggregate_id=self.id,
payload=AccountDebitedPayload(
amount=amount.amount,
currency=amount.currency,
),
AccountDebited.create(
amount=amount.amount,
currency=amount.currency,
aggregate_id=str(self.id),
)
)

def validate(self) -> None:
pass
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
from __future__ import annotations

from dataclasses import dataclass

from seedwork.domain.domain_event import BaseDomainEvent
Expand All @@ -11,4 +13,9 @@ class AccountCreditedPayload:

@dataclass(frozen=True)
class AccountCredited(BaseDomainEvent[AccountCreditedPayload]):
pass
@classmethod
def create(cls, amount: float, currency: str, aggregate_id: str) -> AccountCredited:
return cls(
payload=AccountCreditedPayload(amount=amount, currency=currency),
aggregate_id=aggregate_id,
)
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
from __future__ import annotations

from dataclasses import dataclass

from seedwork.domain.domain_event import BaseDomainEvent
Expand All @@ -11,4 +13,9 @@ class AccountDebitedPayload:

@dataclass(frozen=True)
class AccountDebited(BaseDomainEvent[AccountDebitedPayload]):
pass
@classmethod
def create(cls, amount: float, currency: str, aggregate_id: str) -> AccountDebited:
return cls(
payload=AccountDebitedPayload(amount=amount, currency=currency),
aggregate_id=aggregate_id,
)
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
from __future__ import annotations

from dataclasses import dataclass

from seedwork.domain.domain_event import BaseDomainEvent
Expand All @@ -11,4 +13,9 @@ class AccountOpenedPayload:

@dataclass(frozen=True)
class AccountOpened(BaseDomainEvent[AccountOpenedPayload]):
pass
@classmethod
def create(cls, initial_balance: float, currency: str, aggregate_id: str) -> AccountOpened:
return cls(
payload=AccountOpenedPayload(initial_balance=initial_balance, currency=currency),
aggregate_id=aggregate_id,
)
3 changes: 3 additions & 0 deletions docs/examples/bank_account/domain/user_id.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
from typing import NewType

UserId = NewType("UserId", str)
Original file line number Diff line number Diff line change
Expand Up @@ -6,15 +6,18 @@
from bank_account.domain.events.account_debited import AccountDebited
from bank_account.domain.events.account_opened import AccountOpened
from bank_account.domain.money import Money
from bank_account.domain.user_id import UserId


def make_account(
account_id: str = "acc-1",
owner_id: str = "user-1",
amount: float = 100.0,
currency: str = "EUR",
) -> BankAccount:
return BankAccount.open(
id=BankAccountId(account_id),
owner_id=UserId(owner_id),
initial_balance=Money(amount=amount, currency=currency),
)

Expand Down
12 changes: 9 additions & 3 deletions docs/examples/bank_account/tests/test_composition_root.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,9 @@ async def test_compose_open_account_publishes_integration_event() -> None:
command_bus, _ = compose(integration_publisher=publisher)

await command_bus.dispatch(
OpenAccountCommand(account_id="acc-1", initial_balance=100.0, currency="EUR")
OpenAccountCommand(
account_id="acc-1", owner_id="user-1", initial_balance=100.0, currency="EUR"
)
)

assert len(publisher.published) == 1
Expand All @@ -27,7 +29,9 @@ async def test_compose_deposit_money() -> None:
command_bus, _ = compose()

await command_bus.dispatch(
OpenAccountCommand(account_id="acc-1", initial_balance=100.0, currency="EUR")
OpenAccountCommand(
account_id="acc-1", owner_id="user-1", initial_balance=100.0, currency="EUR"
)
)
result = await command_bus.dispatch(
DepositMoneyCommand(account_id="acc-1", amount=50.0, currency="EUR")
Expand All @@ -40,7 +44,9 @@ async def test_compose_withdraw_money() -> None:
command_bus, _ = compose()

await command_bus.dispatch(
OpenAccountCommand(account_id="acc-1", initial_balance=200.0, currency="EUR")
OpenAccountCommand(
account_id="acc-1", owner_id="user-1", initial_balance=200.0, currency="EUR"
)
)
result = await command_bus.dispatch(
WithdrawMoneyCommand(account_id="acc-1", amount=80.0, currency="EUR")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
from bank_account.domain.bank_account_id import BankAccountId
from bank_account.domain.errors import AccountNotFoundError
from bank_account.domain.money import Money
from bank_account.domain.user_id import UserId
from bank_account.infrastructure.in_memory_bank_account_repository import (
InMemoryBankAccountRepository,
)
Expand All @@ -14,6 +15,7 @@ async def test_deposit_increases_balance() -> None:
repo = InMemoryBankAccountRepository()
account = BankAccount.open(
id=BankAccountId("acc-1"),
owner_id=UserId("user-1"),
initial_balance=Money(amount=100.0, currency="EUR"),
)
await repo.save(account)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,9 @@ async def test_open_account_saves_account_with_initial_balance() -> None:
handler = OpenAccountHandler(repo)

await handler.handle(
OpenAccountCommand(account_id="acc-1", initial_balance=100.0, currency="EUR")
OpenAccountCommand(
account_id="acc-1", owner_id="user-1", initial_balance=100.0, currency="EUR"
)
)

account = await inner_repo.find_by_id(BankAccountId("acc-1"))
Expand All @@ -42,7 +44,9 @@ async def handle(self, event: AccountOpened) -> None:
handler = OpenAccountHandler(repo)

await handler.handle(
OpenAccountCommand(account_id="acc-3", initial_balance=200.0, currency="EUR")
OpenAccountCommand(
account_id="acc-3", owner_id="user-1", initial_balance=200.0, currency="EUR"
)
)
await event_bus.dispatch()

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,9 @@ async def test_compose_open_account_schedules_welcome_email_task() -> None:
command_bus, _ = compose(task_scheduler=scheduler)

await command_bus.dispatch(
OpenAccountCommand(account_id="acc-1", initial_balance=100.0, currency="EUR")
OpenAccountCommand(
account_id="acc-1", owner_id="user-1", initial_balance=100.0, currency="EUR"
)
)

assert len(scheduler.scheduled) == 1
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
from bank_account.domain.bank_account_id import BankAccountId
from bank_account.domain.errors import AccountNotFoundError, InsufficientFundsError
from bank_account.domain.money import Money
from bank_account.domain.user_id import UserId
from bank_account.infrastructure.in_memory_bank_account_repository import (
InMemoryBankAccountRepository,
)
Expand All @@ -14,6 +15,7 @@ async def test_withdraw_decreases_balance() -> None:
repo = InMemoryBankAccountRepository()
account = BankAccount.open(
id=BankAccountId("acc-1"),
owner_id=UserId("user-1"),
initial_balance=Money(amount=200.0, currency="EUR"),
)
await repo.save(account)
Expand All @@ -38,6 +40,7 @@ async def test_withdraw_insufficient_funds_raises() -> None:
repo = InMemoryBankAccountRepository()
account = BankAccount.open(
id=BankAccountId("acc-1"),
owner_id=UserId("user-1"),
initial_balance=Money(amount=30.0, currency="EUR"),
)
await repo.save(account)
Expand Down
2 changes: 2 additions & 0 deletions tests/application/test_deposit_money_handler.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
from bank_account.domain.bank_account_id import BankAccountId
from bank_account.domain.errors import AccountNotFoundError
from bank_account.domain.money import Money
from bank_account.domain.user_id import UserId

from seedwork.testing import InMemoryRepository

Expand All @@ -17,6 +18,7 @@ async def test_deposit_increases_balance() -> None:
repo = BankAccountInMemoryRepository()
account = BankAccount.open(
id=BankAccountId("acc-1"),
owner_id=UserId("user-1"),
initial_balance=Money(amount=100.0, currency="EUR"),
)
await repo.save(account)
Expand Down
8 changes: 6 additions & 2 deletions tests/application/test_open_account_handler.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,9 @@ async def test_open_account_saves_account_with_initial_balance() -> None:
handler = OpenAccountHandler(repo)

await handler.handle(
OpenAccountCommand(account_id="acc-1", initial_balance=100.0, currency="EUR")
OpenAccountCommand(
account_id="acc-1", owner_id="user-1", initial_balance=100.0, currency="EUR"
)
)

account = await repo.find_by_id(BankAccountId("acc-1"))
Expand All @@ -29,7 +31,9 @@ async def test_open_account_records_domain_event() -> None:
handler = OpenAccountHandler(repo)

await handler.handle(
OpenAccountCommand(account_id="acc-2", initial_balance=50.0, currency="USD")
OpenAccountCommand(
account_id="acc-2", owner_id="user-1", initial_balance=50.0, currency="USD"
)
)

account = await repo.find_by_id(BankAccountId("acc-2"))
Expand Down
Loading
Loading