Zum Hauptinhalt springen

Chapter 29 — Architecture and Production Python Services

1. Opening problem

A team builds a Python API.

The directory looks like this:

app/
├── models.py
├── services.py
├── routes.py
├── database.py
├── utils.py
└── helpers.py

After one year:

  • models.py contains ORM entities, API schemas, and domain rules;
  • services.py contains 8,000 lines;
  • route handlers start transactions;
  • ORM sessions leak into domain methods;
  • tests require a real database;
  • background jobs import HTTP request objects;
  • configuration is read at module import time;
  • every function logs independently;
  • retries duplicate payments;
  • deployment health checks pass while the database is unavailable.

The problem is not Python syntax.

The problem is unclear ownership and unstable boundaries.

Architecture is the structure that lets a system change safely.


2. Architecture is dependency direction

A useful production model separates four broad areas:

Delivery

Application

Domain

Infrastructure adapters

A more concrete view:

HTTP / CLI / Workers / Messaging

Application Use Cases

Domain Model

Database / Queue / Email / Payment

The domain should not depend on:

  • HTTP frameworks;
  • database drivers;
  • ORM sessions;
  • cloud SDKs;
  • message brokers;
  • environment variables;
  • logging implementations.

Infrastructure depends inward.

The core does not depend outward.


3. The four-layer mental model

3.1 Domain

Contains business meaning:

  • entities;
  • value objects;
  • invariants;
  • domain services;
  • domain events;
  • policies;
  • domain exceptions.

Examples:

class Order:
...
@dataclass(frozen=True)
class Money:
...
class OrderAlreadyPaidError(
DomainError
):
...

3.2 Application

Coordinates use cases:

  • commands;
  • queries;
  • handlers;
  • transaction boundaries;
  • authorization orchestration;
  • domain-event collection;
  • ports required by the use case.

Examples:

class PlaceOrderHandler:
...
class OrderRepository(
Protocol
):
...

3.3 Infrastructure

Implements external concerns:

  • PostgreSQL repositories;
  • payment clients;
  • message brokers;
  • object storage;
  • email;
  • caches;
  • clock adapters;
  • telemetry exporters.

3.4 Delivery

Translates transport input and output:

  • HTTP routes;
  • CLI commands;
  • message consumers;
  • scheduled jobs;
  • GraphQL resolvers;
  • administrative interfaces.

Delivery validates transport shape, invokes application use cases, and maps results to transport responses.


4. Framework-neutral core

A framework is an adapter and composition tool.

It should not define your entire architecture.

Bad domain dependency:

from framework.orm import Model


class Order(Model):
...

The domain entity is now coupled to persistence behavior.

A framework-neutral domain:

from dataclasses import (
dataclass,
field,
)
from enum import Enum


class OrderStatus(Enum):
DRAFT = "draft"
PLACED = "placed"
PAID = "paid"
CANCELLED = "cancelled"


@dataclass
class Order:
order_id: str
customer_id: str
status: OrderStatus
_lines: list[
"OrderLine"
] = field(
default_factory=list,
repr=False,
)

An infrastructure mapping converts between domain objects and database rows.

This introduces mapping code, but protects the domain.


5. Domain entities

An entity has identity and lifecycle.

class Order:
def __init__(
self,
*,
order_id: str,
customer_id: str,
) -> None:
if not order_id:
raise ValueError(
"order_id is required"
)

if not customer_id:
raise ValueError(
"customer_id is required"
)

self._order_id = order_id
self._customer_id = (
customer_id
)
self._status = (
OrderStatus.DRAFT
)
self._lines: list[
OrderLine
] = []

The entity owns state transitions:

def add_line(
self,
*,
product_id: str,
quantity: int,
unit_price: Money,
) -> None:
if (
self._status
is not OrderStatus.DRAFT
):
raise OrderNotEditableError(
self._order_id
)

if quantity <= 0:
raise ValueError(
"quantity must be positive"
)

self._lines.append(
OrderLine(
product_id=product_id,
quantity=quantity,
unit_price=unit_price,
)
)

Do not move every rule into a generic service class.

The object that owns the invariant should usually protect it.


6. Value objects

@dataclass(frozen=True)
class Money:
amount_cents: int
currency: str

def __post_init__(
self,
) -> None:
normalized = (
self.currency
.strip()
.upper()
)

if len(normalized) != 3:
raise ValueError(
"currency must use "
"three letters"
)

object.__setattr__(
self,
"currency",
normalized,
)

def __add__(
self,
other: object,
) -> "Money":
if not isinstance(
other,
Money,
):
return NotImplemented

if (
self.currency
!= other.currency
):
raise CurrencyMismatchError(
self.currency,
other.currency,
)

return Money(
self.amount_cents
+ other.amount_cents,
self.currency,
)

A value object:

  • is defined by its values;
  • is usually immutable;
  • validates at construction;
  • centralizes meaning;
  • reduces primitive obsession.

Use value objects for concepts such as:

  • money;
  • email address;
  • order ID;
  • date range;
  • percentage;
  • quantity;
  • postal address;
  • version.

7. Aggregate boundaries

An aggregate is a consistency boundary.

For example:

Order
├── OrderLine
└── Domain events

Code outside the aggregate should not mutate order lines directly.

order.lines.append(
line
)

is dangerous.

Expose controlled operations:

order.add_line(...)
order.remove_line(...)
order.place(...)
order.mark_paid(...)

The aggregate root protects invariants across contained objects.

Avoid huge aggregates. Large consistency boundaries increase lock time, transaction size, and contention.


8. Domain services

A domain service contains business logic that does not naturally belong to one entity or value object.

Example:

class PricingPolicy(
Protocol
):
def calculate(
self,
*,
customer: Customer,
product: Product,
quantity: int,
) -> Money:
...

Use a domain service when:

  • several domain objects participate;
  • no single object owns the rule;
  • the rule remains domain logic;
  • infrastructure is not involved.

Do not name every application class SomethingService.

Specific names communicate better:

  • PricingPolicy;
  • CreditLimitPolicy;
  • OrderCancellationPolicy;
  • TaxCalculator.

9. Commands and queries

Command:

@dataclass(frozen=True)
class PlaceOrder:
order_id: str
customer_id: str
lines: tuple[
"PlaceOrderLine",
...
]

Query:

@dataclass(frozen=True)
class GetOrder:
order_id: str

Commands change state.

Queries return information.

The separation is conceptual, not a requirement for two frameworks or databases.

Benefits:

  • explicit use-case input;
  • testable handlers;
  • stable application boundary;
  • transport independence;
  • easier audit and authorization.

10. Application handlers

class PlaceOrderHandler:
def __init__(
self,
*,
unit_of_work: UnitOfWork,
product_catalog: ProductCatalog,
clock: Clock,
) -> None:
self._unit_of_work = (
unit_of_work
)
self._product_catalog = (
product_catalog
)
self._clock = clock

def handle(
self,
command: PlaceOrder,
) -> OrderId:
with self._unit_of_work:
order = Order(
order_id=command.order_id,
customer_id=(
command.customer_id
),
)

for line in command.lines:
product = (
self._product_catalog
.get(line.product_id)
)

order.add_line(
product_id=(
product.product_id
),
quantity=line.quantity,
unit_price=(
product.price
),
)

order.place(
placed_at=(
self._clock.now()
)
)

self._unit_of_work.orders.add(
order
)

self._unit_of_work.commit()

return OrderId(
command.order_id
)

The handler:

  • coordinates;
  • defines transaction scope;
  • calls domain behavior;
  • uses ports;
  • does not know HTTP;
  • does not know SQL.

11. Ports

A port is a contract required by the core.

class ProductCatalog(
Protocol
):
def get(
self,
product_id: str,
) -> Product:
...
class Clock(
Protocol
):
def now(
self,
) -> datetime:
...
class PaymentGateway(
Protocol
):
def authorize(
self,
request: PaymentRequest,
) -> PaymentAuthorization:
...

The consumer owns the port.

Infrastructure implements it.

Keep ports small and use-case-oriented.

Avoid a universal 50-method repository.


12. Adapters

Database adapter:

class PostgresOrderRepository:
def __init__(
self,
session,
) -> None:
self._session = session

def get(
self,
order_id: str,
) -> Order | None:
row = self._session.fetch_order(
order_id
)

if row is None:
return None

return map_row_to_order(
row
)

Payment adapter:

class RemotePaymentGateway:
def __init__(
self,
client,
) -> None:
self._client = client

def authorize(
self,
request: PaymentRequest,
) -> PaymentAuthorization:
response = self._client.post(
"/authorizations",
json=serialize_request(
request
),
)

return parse_authorization(
response
)

Adapters translate between external contracts and internal contracts.


13. Repository design

A repository provides aggregate persistence semantics.

class OrderRepository(
Protocol
):
def get(
self,
order_id: str,
) -> Order | None:
...

def add(
self,
order: Order,
) -> None:
...

Avoid exposing database details:

def query(
self,
sql: str,
) -> list[dict[str, object]]:
...

The application should not know SQL or ORM query expressions.

For read-heavy reporting, a query adapter may return specialized read models rather than reconstructing aggregates.


14. Unit of work

class UnitOfWork(
Protocol
):
orders: OrderRepository

def __enter__(
self,
) -> "UnitOfWork":
...

def __exit__(
self,
exception_type,
exception,
traceback,
) -> bool:
...

def commit(self) -> None:
...

The unit of work coordinates:

  • transaction;
  • repositories sharing the transaction;
  • commit;
  • rollback;
  • possibly domain-event collection.

It should not become a global service locator.


15. Explicit transaction boundaries

Bad:

repository.save(order)
payment_gateway.charge(...)
repository.save(payment)

No visible transaction or failure strategy.

Better:

with unit_of_work:
order = (
unit_of_work.orders
.get(order_id)
)

authorization = (
payment_gateway
.authorize(request)
)

order.mark_paid(
authorization_id=(
authorization.id
)
)

unit_of_work.commit()

But note:

A database transaction cannot atomically include a remote payment service.

This requires distributed-failure design.


16. Distributed boundaries

External systems do not share one local transaction.

Examples:

  • database plus message broker;
  • database plus payment provider;
  • database plus email;
  • object storage plus database.

Possible strategies:

  • transactional outbox;
  • inbox deduplication;
  • saga or process manager;
  • idempotency keys;
  • reconciliation jobs;
  • compensating actions;
  • eventual consistency.

Do not pretend that one decorator creates distributed atomicity.


17. Transactional outbox

Inside one database transaction:

  1. update domain state;
  2. insert an outbox message;
  3. commit.

Later, a publisher reads the outbox and sends messages.

with unit_of_work:
order.mark_paid(
authorization_id
)

unit_of_work.outbox.add(
OrderPaidMessage(
order_id=order.order_id,
occurred_at=clock.now(),
)
)

unit_of_work.commit()

Publisher:

messages = (
outbox_repository
.claim_batch(limit=100)
)

for message in messages:
broker.publish(
message
)

outbox_repository.mark_sent(
message.message_id
)

Failure between publish and mark-sent can cause duplicates.

Consumers must be idempotent or deduplicate.


18. Idempotency

An idempotent operation can be repeated without unintended additional effects.

Payment command:

@dataclass(frozen=True)
class AuthorizePayment:
idempotency_key: str
order_id: str
amount: Money

Store command result by key:

existing = (
idempotency_store
.get(
command.idempotency_key
)
)

if existing is not None:
return existing

Challenges:

  • concurrent duplicate requests;
  • result storage;
  • key scope;
  • expiration;
  • payload mismatch under same key;
  • failure after external side effect;
  • transaction placement.

Idempotency is a system design, not only a dictionary lookup.


19. Domain events

@dataclass(frozen=True)
class OrderPlaced:
order_id: str
customer_id: str
occurred_at: datetime

The entity can record events:

self._events.append(
OrderPlaced(
order_id=self._order_id,
customer_id=(
self._customer_id
),
occurred_at=placed_at,
)
)

Domain events describe meaningful completed facts.

Do not publish directly from the entity.

The application or unit of work extracts and persists events.


20. Event handling

In-process handler:

class ReserveInventory:
def handle(
self,
event: OrderPlaced,
) -> None:
...

External event:

@dataclass(frozen=True)
class OrderPlacedMessage:
message_id: str
order_id: str
occurred_at: datetime
schema_version: int

Do not assume domain events and integration events must be identical.

Integration events need:

  • stable schema;
  • versioning;
  • identifiers;
  • serialization;
  • compatibility;
  • privacy review.

21. API boundary

Transport payload:

class PlaceOrderPayload(
TypedDict
):
customer_id: str
lines: list[
"PlaceOrderLinePayload"
]

Parser:

def parse_place_order(
payload: object,
) -> PlaceOrder:
...

Route:

def place_order_route(
request,
handler: PlaceOrderHandler,
):
try:
command = parse_place_order(
request.json
)

order_id = handler.handle(
command
)
except ValidationError as error:
return bad_request(
serialize_validation(
error
)
)
except ProductNotFoundError as error:
return unprocessable_entity(
{
"code": (
"product_not_found"
),
"product_id": (
error.product_id
),
}
)

return created(
{
"order_id": (
order_id.value
),
}
)

The route remains thin.


22. Transport error mapping

Create one explicit mapping layer.

def map_error(
error: Exception,
) -> ErrorResponse:
match error:
case ValidationError():
...
case OrderNotFoundError():
...
case InfrastructureError():
...
case _:
...

Unexpected exceptions should:

  • be logged once;
  • include correlation context;
  • avoid leaking internals;
  • return a generic server error;
  • trigger monitoring where appropriate.

Do not expose raw tracebacks to clients.


23. Configuration

Configuration sources may include:

  • environment variables;
  • command-line flags;
  • mounted files;
  • secret stores;
  • platform metadata.

Parse once at startup:

@dataclass(frozen=True)
class Settings:
database_url: str
payment_base_url: str
request_timeout_seconds: float
def load_settings(
environment: Mapping[
str,
str,
],
) -> Settings:
...

Do not read environment variables throughout domain code.

Configuration parsing is a boundary.


24. Secrets

Secrets include:

  • database passwords;
  • API tokens;
  • private keys;
  • signing secrets;
  • encryption keys.

Rules:

  • never commit them;
  • never log them;
  • avoid putting them in exception messages;
  • minimize process exposure;
  • rotate them;
  • restrict access;
  • separate secret and non-secret configuration;
  • test redaction.

A repr=False field reduces accidental display but is not complete secret management.


25. Composition root

def create_application(
settings: Settings,
) -> Application:
database = create_database(
settings.database_url
)

payment_client = (
create_payment_client(
base_url=(
settings.payment_base_url
),
timeout_seconds=(
settings
.request_timeout_seconds
),
)
)

unit_of_work_factory = (
PostgresUnitOfWorkFactory(
database
)
)

payment_gateway = (
RemotePaymentGateway(
payment_client
)
)

handler = (
AuthorizePaymentHandler(
unit_of_work_factory=(
unit_of_work_factory
),
payment_gateway=(
payment_gateway
),
)
)

return Application(
authorize_payment=handler
)

The composition root owns object wiring.

Do not hide wiring behind module globals unless lifecycle and testing are intentionally global.


26. Dependency injection

Python often needs no dependency-injection framework.

Constructor injection:

class Service:
def __init__(
self,
repository: Repository,
clock: Clock,
) -> None:
...

Function injection:

def process(
command: Command,
*,
load_user: LoadUser,
send_email: SendEmail,
) -> Result:
...

Use a framework only when it adds clear value:

  • lifecycle management;
  • plugin registration;
  • scope handling;
  • large composition graph;
  • framework integration.

Avoid invisible magic that makes dependencies difficult to trace.


27. Logging

Prefer structured logs:

logger.info(
"order_placed",
extra={
"order_id": (
order.order_id
),
"customer_id": (
order.customer_id
),
},
)

Useful fields:

  • timestamp;
  • severity;
  • event name;
  • request ID;
  • trace ID;
  • tenant ID;
  • operation;
  • outcome;
  • duration;
  • safe business identifiers.

Avoid:

  • passwords;
  • tokens;
  • full payment data;
  • private personal details;
  • raw request bodies by default.

Log once at meaningful boundaries.


28. Metrics

Common categories:

Counters

  • requests;
  • failures;
  • retries;
  • messages processed;
  • payments authorized.

Histograms

  • request duration;
  • database latency;
  • message processing time;
  • payload size.

Gauges

  • queue depth;
  • active workers;
  • open connections;
  • pending outbox rows.

Labels must have bounded cardinality.

Do not label metrics with:

  • user ID;
  • order ID;
  • request ID;
  • arbitrary exception message.

29. Tracing

Distributed tracing connects operations across services.

Useful spans:

  • incoming request;
  • application use case;
  • database call;
  • external payment call;
  • broker publish;
  • background consumer.

Do not create one span for every tiny function.

Propagate trace context through messages and HTTP headers according to the selected observability standard.

Tracing does not replace logs or metrics.


30. Correlation and request context

A request ID can be stored in a context variable for logging.

request_id = ContextVar[
str
](
"request_id"
)

Set at delivery boundary and reset after completion.

Do not use context variables to hide business dependencies.

Use them for ambient execution metadata.


31. Health checks

Different checks answer different questions.

Liveness

Is the process alive and not irrecoverably stuck?

Readiness

Can the instance receive traffic?

Startup

Has initialization completed?

Readiness may consider:

  • database connectivity;
  • required migrations;
  • critical configuration;
  • queue connection;
  • warm-up completion.

Do not make liveness depend on every downstream service. An external outage should not cause endless process restarts.


32. Graceful shutdown

Shutdown should:

  1. stop accepting new work;
  2. signal workers;
  3. allow in-flight work to finish within a deadline;
  4. cancel remaining work;
  5. flush telemetry;
  6. close clients;
  7. return database connections;
  8. terminate subprocesses;
  9. exit with clear status.

For async code, own tasks through task groups or explicit service scopes.

Detached tasks make shutdown unreliable.


33. Migrations

Database schema changes require versioned migrations.

Safe deployment patterns may include:

  • expand;
  • migrate data;
  • switch readers and writers;
  • contract.

Example:

  1. add nullable column;
  2. deploy code writing both fields;
  3. backfill;
  4. deploy code reading new field;
  5. stop writing old field;
  6. remove old field later.

Avoid destructive schema changes in the same release that removes compatibility.


34. Background jobs

A background job should define:

  • input schema;
  • retry policy;
  • idempotency;
  • timeout;
  • concurrency limit;
  • ownership;
  • observability;
  • dead-letter or failure strategy;
  • shutdown behavior;
  • version compatibility.

Do not pass arbitrary ORM objects through queues.

Publish explicit immutable messages.


35. Messaging delivery semantics

Common broker semantics may include:

  • at-most-once;
  • at-least-once;
  • effectively-once through idempotency;
  • ordered within a partition or key;
  • unordered delivery.

Design consumers for duplicates unless the broker and whole pipeline provide stronger guarantees.

A message acknowledged before durable state change can be lost.

A message acknowledged after state change can be duplicated.


36. Timeouts

Every remote dependency should have explicit timeout policy.

Consider:

  • connection timeout;
  • read timeout;
  • total deadline;
  • pool acquisition timeout;
  • queue wait timeout;
  • transaction timeout.

A request deadline should be propagated or budgeted across downstream operations.

Default infinite waits are dangerous.


37. Retries

Retry only when:

  • failure is likely transient;
  • operation is idempotent or protected;
  • deadline permits;
  • retry load will not amplify outage;
  • backoff and jitter exist;
  • attempt count is bounded;
  • telemetry records attempts.

Do not layer retries blindly across:

  • client library;
  • service method;
  • job runner;
  • reverse proxy.

Multiplicative retries can cause a retry storm.


38. Circuit breaking and load shedding

When a dependency is failing, continuously sending full traffic can worsen the incident.

Possible protections:

  • circuit breaker;
  • concurrency limit;
  • queue bound;
  • fast failure;
  • cached fallback;
  • feature degradation;
  • request rejection;
  • priority handling.

These mechanisms require operational tuning and visibility.

Do not hide errors without making degradation observable.


39. Security boundaries

At minimum:

  • validate all external input;
  • authorize every protected operation;
  • separate authentication from authorization;
  • parameterize database queries;
  • protect secrets;
  • limit payload size;
  • set timeouts;
  • validate file types;
  • control plugin installation;
  • review deserialization;
  • log security events safely;
  • rotate credentials;
  • minimize privileges.

Security is not one middleware function.

Domain authorization may need business context unavailable at the route layer.


40. Data privacy

Classify data:

  • public;
  • internal;
  • confidential;
  • personal;
  • highly sensitive.

Define:

  • collection purpose;
  • retention;
  • deletion;
  • encryption;
  • access;
  • audit;
  • masking;
  • export;
  • regional constraints.

Do not log full request and response bodies by default.

A production architecture includes data lifecycle, not only code structure.


41. Testing architecture

A strong portfolio:

Unit
Domain rules
Application handlers
Parsers
Serializers

Component
Handler + fake adapters
In-process event processing

Integration
PostgreSQL repositories
HTTP payment adapter
Outbox publisher
Migration compatibility

Contract
Payment provider schema
Message schema
Repository behavior

Acceptance
REST endpoint
Background processing

End-to-end
Deployed service path

Each layer protects different risks.


42. Architecture decision records

Example structure:

Title:
Status:
Context:
Decision:
Alternatives:
Consequences:
Migration:
Review date:

Possible decisions:

  • use transactional outbox;
  • use separate domain and ORM models;
  • support Python 3.12+;
  • use process workers for CPU jobs;
  • adopt src layout;
  • choose at-least-once messaging;
  • use explicit composition root.

ADRs preserve reasoning.


43. Modular monolith

A modular monolith can provide:

  • one deployment;
  • one repository;
  • clear internal modules;
  • local transactions;
  • simpler observability;
  • lower operational cost.

Example:

src/application/
├── orders/
├── payments/
├── inventory/
└── customers/

Each module owns:

  • domain;
  • application;
  • interfaces;
  • adapters.

Do not start with microservices merely because the organization might grow.

Extract services when independent deployment, scaling, ownership, or isolation justifies the cost.


44. Microservices trade-offs

Benefits:

  • independent deployment;
  • independent scaling;
  • fault isolation;
  • team ownership;
  • technology specialization.

Costs:

  • network failures;
  • distributed transactions;
  • message compatibility;
  • observability;
  • deployments;
  • security boundaries;
  • data duplication;
  • operational staffing;
  • local development complexity.

A distributed monolith combines microservice cost with monolith coupling.

Boundaries must be real.


45. TypeScript comparison

Familiar TypeScript concepts map imperfectly.

TypeScript/Node.jsPython
interfaceProtocol
readonly value objectfrozen dataclass or custom immutable class
dependency injection tokenprotocol, concrete type, or explicit factory
NestJS modulepackage plus composition root, not a direct equivalent
middlewaredelivery adapter or cross-cutting boundary
DTOTypedDict, dataclass, or validation model
service classspecific handler, policy, repository, or adapter
async promise workflowcoroutine/task workflow
npm workspacePython monorepo with distributions and shared tooling

Do not reproduce a NestJS architecture by renaming decorators.

Use Python's strengths:

  • explicit composition;
  • protocols;
  • context managers;
  • dataclasses;
  • simple modules;
  • standard iteration;
  • structured concurrency.

46. Common mistakes

Framework as domain model

Keep core rules independent.

Repository returns ORM entities

Map at the boundary when domain independence matters.

Universal service class

Use specific responsibilities.

Global unit of work

Scope transactions explicitly.

Remote call inside local transaction without analysis

Long transactions and inconsistent outcomes result.

Publishing message before commit

The message can describe state that never committed.

Publishing after commit without outbox

The state can commit while the message is lost.

Logging everywhere

Log meaningful boundaries.

Health check restarts during dependency outage

Separate liveness and readiness.

Retrying non-idempotent actions

Protect them first.

Microservices too early

Start with clear modules.


47. English vocabulary

TermMeaning
boundaryplace where responsibility or data ownership changes
portcontract required by the application core
adapterimplementation connecting an external system
aggregateconsistency boundary protecting related entities
unit of worktransaction and repository coordination abstraction
outboxdurable table of messages committed with state
idempotencysafe repetition without unintended duplicate effects
composition rootplace where application objects are wired
readinessability to receive traffic
eventual consistencystate becoming consistent after asynchronous steps

Useful sentences:

  • “The application layer coordinates the use case but does not contain transport logic.”
  • “The repository returns aggregates rather than database rows.”
  • “The outbox closes the gap between local state and message publication.”
  • “The operation requires an idempotency key because the payment side effect is external.”
  • “Readiness should fail without forcing the liveness probe to restart the process.”
  • “The framework is an adapter, not the architecture.”

48. Speaking task

Explain for fifteen minutes:

How would you design a production Python service from domain rules to deployment?

Include layers, ports, adapters, transactions, messaging, observability, configuration, and shutdown.


49. Writing task

Write a 700-word architecture proposal for a multi-tenant SaaS order-management service.


50. Exercises

Exercise 1

Design domain, application, infrastructure, and delivery packages for an order service.

Exercise 2

Create a small order aggregate with state transitions.

Exercise 3

Define repository, clock, payment, and message-publisher ports.

Exercise 4

Design a unit of work and transaction boundary.

Exercise 5

Model an outbox and idempotent consumer.

Exercise 6

Design liveness, readiness, and startup checks.

Exercise 7

Write one architecture decision record.


51. Complete solutions

Solution 1

src/order_service/
├── domain/
│ ├── orders.py
│ ├── money.py
│ ├── events.py
│ └── errors.py
├── application/
│ ├── commands.py
│ ├── handlers.py
│ └── ports.py
├── infrastructure/
│ ├── postgres/
│ ├── payments/
│ ├── messaging/
│ └── telemetry/
├── delivery/
│ ├── http/
│ ├── workers/
│ └── cli/
├── bootstrap.py
└── __main__.py

Solution 2

class OrderStatus(Enum):
DRAFT = "draft"
PLACED = "placed"
PAID = "paid"
CANCELLED = "cancelled"


class Order:
def __init__(
self,
*,
order_id: str,
customer_id: str,
) -> None:
self._order_id = order_id
self._customer_id = (
customer_id
)
self._status = (
OrderStatus.DRAFT
)
self._lines: list[
OrderLine
] = []

def add_line(
self,
line: OrderLine,
) -> None:
if (
self._status
is not OrderStatus.DRAFT
):
raise OrderNotEditableError(
self._order_id
)

self._lines.append(line)

def place(self) -> None:
if not self._lines:
raise EmptyOrderError(
self._order_id
)

if (
self._status
is not OrderStatus.DRAFT
):
raise InvalidOrderStateError(
self._order_id
)

self._status = (
OrderStatus.PLACED
)

def mark_paid(self) -> None:
if (
self._status
is not OrderStatus.PLACED
):
raise InvalidOrderStateError(
self._order_id
)

self._status = (
OrderStatus.PAID
)

Solution 3

class OrderRepository(
Protocol
):
def get(
self,
order_id: str,
) -> Order | None:
...

def add(
self,
order: Order,
) -> None:
...


class Clock(
Protocol
):
def now(
self,
) -> datetime:
...


class PaymentGateway(
Protocol
):
def authorize(
self,
request: PaymentRequest,
) -> PaymentAuthorization:
...


class MessagePublisher(
Protocol
):
def publish(
self,
message: IntegrationMessage,
) -> None:
...

Solution 4

class UnitOfWork(
Protocol
):
orders: OrderRepository
outbox: OutboxRepository

def __enter__(
self,
) -> "UnitOfWork":
...

def __exit__(
self,
exception_type,
exception,
traceback,
) -> bool:
...

def commit(self) -> None:
...

Application handler:

with unit_of_work:
order = (
unit_of_work.orders
.get(command.order_id)
)

if order is None:
raise OrderNotFoundError(
command.order_id
)

order.mark_paid()

unit_of_work.outbox.add(
OrderPaidMessage(...)
)

unit_of_work.commit()

Solution 5

Outbox fields:

message_id
aggregate_id
message_type
schema_version
payload
occurred_at
available_at
attempt_count
claimed_at
sent_at
last_error

Consumer inbox fields:

consumer_name
message_id
processed_at
result

The consumer first checks whether the message ID has already been processed inside the same transaction as its state update.

Solution 6

Liveness:

Process event loop or worker heartbeat is functioning.
No downstream dependency check.

Readiness:

Configuration loaded.
Database connection available.
Required migrations applied.
Critical worker initialization completed.

Startup:

One-time initialization finished.
Caches or schemas loaded where required.

Solution 7

Title:
Use transactional outbox for order events

Status:
Accepted

Context:
Order state and broker publication cannot share one local transaction.
Publishing before commit can emit false events.
Publishing after commit can lose events.

Decision:
Write order changes and outbox rows in one database transaction.
Publish asynchronously.
Consumers must be idempotent.

Alternatives:
Direct publish before commit.
Direct publish after commit.
Distributed transaction.

Consequences:
Additional table, publisher, cleanup, monitoring, and duplicate handling.
Improved durability and recoverability.

52. Chapter checkpoint

You should now be able to explain:

  1. dependency direction;
  2. domain, application, infrastructure, and delivery layers;
  3. framework-neutral design;
  4. entities and value objects;
  5. aggregates;
  6. domain services;
  7. commands and queries;
  8. ports and adapters;
  9. repositories;
  10. unit of work;
  11. distributed boundaries;
  12. outbox;
  13. idempotency;
  14. domain and integration events;
  15. API boundaries;
  16. configuration and secrets;
  17. composition roots;
  18. logging, metrics, and tracing;
  19. health checks;
  20. graceful shutdown;
  21. migrations;
  22. messaging semantics;
  23. retries and circuit breaking;
  24. security and privacy;
  25. modular monoliths and microservices.