Designing requests and responses
Define request inputs and typed responses without coupling them to an entry-point framework.
A request describes one operation and declares the type returned by Mediator.send(). A response is an ordinary Python type produced by that operation's handler.
Define the response before the request
Python evaluates base classes when it defines a class, so the response type must already exist when the request refers to it:
from dataclasses import dataclass
from pymediate import Request
@dataclass(frozen=True)
class OrderReceipt:
order_id: int
summary: str
@dataclass(frozen=True)
class PlaceOrder(Request[OrderReceipt]):
customer_id: int
item: str
quantity: intPlaceOrder(Request[OrderReceipt]) means that sending PlaceOrder returns an OrderReceipt. PyMediate reads the type relationship from the base class; it does not inspect the dataclass fields for routing.
Responses do not inherit from a PyMediate class. They can be dataclasses, named tuples, scalar values, collections, or another type appropriate to the operation.
Name the operation
Use a request name that describes the work being requested:
PlaceOrderCancelOrderGetOrderExportOrders
Use a response name that describes the returned value:
OrderReceiptCancellationResultOrderDetailsExportResult
Suffixes such as Request and Response are optional. Choose one convention within an application and apply it consistently.
Keep transport types at the entry point
A request can be constructed by HTTP, a command-line interface, a scheduled job, or another in-process caller. Keeping framework types out of the request lets those entry points share it:
@dataclass(frozen=True)
class PlaceOrder(Request[OrderReceipt]):
customer_id: int
item: str
quantity: intAn HTTP route can translate its input before dispatch:
request = PlaceOrder(
customer_id=current_user.id,
item=body.item,
quantity=body.quantity,
)
receipt = await mediator.send(request)A command-line command can construct the same request from parsed arguments. Each entry point remains responsible for converting its input and response formats.
This separation can support hexagonal architecture, but PyMediate does not require that architecture or choose the application's boundaries.
Validate values at an appropriate boundary
A dataclass can reject invalid values in __post_init__:
@dataclass(frozen=True)
class PlaceOrder(Request[OrderReceipt]):
customer_id: int
item: str
quantity: int
def __post_init__(self) -> None:
if self.quantity < 1:
raise ValueError("quantity must be at least 1")This is useful for rules that must hold for every instance, regardless of caller. Validation that depends on stored state or another service belongs in the handler or a dependency used by it.
Web frameworks may perform their own input validation before constructing the request. The error-handling guide shows how to keep framework responses at the entry point.
Prefer immutable message values when practical
@dataclass(frozen=True) prevents field reassignment after construction. It does not make mutable field values immutable, and it produces a usable hash only when all compared fields are hashable.
For collection fields, an immutable value such as a tuple usually matches a frozen request:
@dataclass(frozen=True)
class PlaceBulkOrder(Request[OrderReceipt]):
customer_id: int
items: tuple[str, ...]If a mutable field is required, use field(default_factory=...) rather than a shared list or dictionary default. See Python's dataclasses documentation for field, inheritance, equality, and hashing behavior.
Choose a response boundary deliberately
Returning an explicit response type can prevent internal entity fields from becoming part of every entry point's output by accident:
@dataclass(frozen=True)
class OrderSummary:
order_id: int
accepted_items: int
total_pence: intReturning a domain object directly can also be reasonable when callers are trusted in-process code and that object is the intended contract. The mediator does not impose either choice.
Avoid putting HTTP status codes, framework response objects, or serialization methods on a response solely for one entry point. Translate those details where the response crosses that boundary.
Commands and queries are optional conventions
CQRS distinguishes operations that change state from operations that read state. PyMediate does not enforce CQRS; applications that use it can express both sides as requests:
@dataclass(frozen=True)
class PlaceOrderCommand(Request[OrderReceipt]):
customer_id: int
items: tuple[str, ...]
@dataclass(frozen=True)
class GetOrderQuery(Request[OrderDetails]):
order_id: intThe Command and Query suffixes communicate the application's convention. They have no special behavior in PyMediate.
The runnable 130-cqrs example uses separate write and read stores with an outbox-backed projection worker.
Durable messages need a separate contract
PyMediate requests are in-process Python objects. If a request will cross a queue or process boundary, the application must define serialization, schema versioning, compatibility, delivery, and idempotency rules for that boundary.
A worker may decode a durable message into a PyMediate request after delivery. PyMediate itself does not serialize or deliver the message.
Continue
- Implement the operation in a request handler.
- Translate requests and responses in FastAPI.
- Test message validation and handlers with the testing guide.