Requests and responses
Requests and responses as framework-independent messages, and how they enable hexagonal architecture and CQRS.
Requests and responses are the messages that flow through a PyMediate application. They describe what should happen and what came back — and they know nothing about HTTP, CLIs, message queues, or any other delivery mechanism.
# "Create a user" in your domain — no Flask, no FastAPI, no HTTP
@dataclass
class CreateUserRequest(Request[UserCreated]):
username: str
email: str
password: str
@dataclass
class UserCreated:
user_id: int
username: str
created_at: datetimeFramework independence
Traditional web code mixes business logic with delivery concerns:
# Business logic coupled to Flask
@app.route('/users', methods=['POST'])
def create_user():
data = request.get_json()
if not data['username']: # validation mixed with HTTP
return jsonify({'error': 'Username required'}), 400
user_id = database.insert_user(data['username'], data['email']) # DB mixed with HTTP
return jsonify({'user_id': user_id}), 201 # response formation mixed with logicThat logic can't be reused from a CLI, can't be tested without HTTP mocking, and can't move to another framework without a rewrite.
With PyMediate, the business logic lives in a handler that could run anywhere:
class CreateUserHandler(Handler[CreateUserRequest]):
def __init__(self, user_repository: UserRepository):
self.user_repository = user_repository
def __call__(self, request: CreateUserRequest) -> UserCreated:
if not request.username:
raise ValidationError("Username required")
user = self.user_repository.create(
username=request.username,
email=request.email,
password=hash_password(request.password),
)
return UserCreated(user_id=user.id, username=user.username, created_at=user.created_at)Every delivery mechanism becomes a thin adapter that translates its input into a request and sends it:
from fastapi import FastAPI, HTTPException
from pydantic import BaseModel
app = FastAPI()
class CreateUserDTO(BaseModel):
username: str
email: str
password: str
@app.post("/users")
async def create_user_endpoint(dto: CreateUserDTO):
domain_request = CreateUserRequest(
username=dto.username, email=dto.email, password=dto.password
)
try:
result = mediator.send(domain_request)
return {"user_id": result.user_id, "username": result.username}
except ValidationError as e:
raise HTTPException(status_code=400, detail=str(e))from flask import Flask, request, jsonify
app = Flask(__name__)
@app.route('/users', methods=['POST'])
def create_user_endpoint():
data = request.get_json()
domain_request = CreateUserRequest(
username=data['username'], email=data['email'], password=data['password']
)
try:
result = mediator.send(domain_request)
return jsonify({'user_id': result.user_id, 'username': result.username}), 201
except ValidationError as e:
return jsonify({'error': str(e)}), 400import click
@click.command()
@click.option('--username', prompt=True)
@click.option('--email', prompt=True)
@click.password_option()
def create_user(username: str, email: str, password: str):
domain_request = CreateUserRequest(username=username, email=email, password=password)
try:
result = mediator.send(domain_request)
click.echo(f"User created: {result.username} (ID: {result.user_id})")
except ValidationError as e:
click.echo(f"Error: {e}", err=True)import json
def lambda_handler(event, context):
body = json.loads(event['body'])
domain_request = CreateUserRequest(
username=body['username'], email=body['email'], password=body['password']
)
try:
result = mediator.send(domain_request)
return {'statusCode': 201, 'body': json.dumps({'user_id': result.user_id})}
except ValidationError as e:
return {'statusCode': 400, 'body': json.dumps({'error': str(e)})}The handler, its validation rules, and its error handling never change between adapters. This is hexagonal architecture (ports and adapters):
- Core (domain) — requests, handlers, business logic
- Port — the
Mediatorinterface - Adapters — routes, endpoints, commands, functions, consumers
CQRS: commands and queries
CQRS separates operations that change state from operations that read it. With PyMediate this is a naming convention over request types, not extra machinery.
Commands change state and return minimal data:
@dataclass
class CreateUserCommand(Request[UserCreated]):
username: str
email: str
password: str
@dataclass
class DeleteUserCommand(Request[UserDeleted]):
user_id: intQueries read state and return rich data:
@dataclass
class GetUserQuery(Request[UserDetails]):
user_id: int
@dataclass
class SearchUsersQuery(Request[UserList]):
search_term: str
page: int = 1
page_size: int = 20Because each side has its own handlers, reads and writes can be optimized — or scaled — independently:
class CreateUserHandler(Handler[CreateUserCommand]):
def __init__(self, write_db: WriteDatabase):
self.write_db = write_db
class GetUserHandler(Handler[GetUserQuery]):
def __init__(self, read_db: ReadDatabase):
self.read_db = read_dbSee the CQRS example for a complete feature.
Request design patterns
Immutable value objects
Freeze requests so they can't be mutated mid-flight:
@dataclass(frozen=True)
class TransferMoneyRequest(Request[TransferCompleted]):
from_account: str
to_account: str
amount: DecimalValidation at construction
__post_init__ catches invalid requests before they ever reach a handler:
@dataclass
class CreateOrderRequest(Request[OrderCreated]):
customer_id: int
items: list[OrderItem]
discount_code: str | None = None
def __post_init__(self):
if not self.items:
raise ValueError("Order must have at least one item")
if len(self.items) > 100:
raise ValueError("Order cannot exceed 100 items")Composition with nested value objects
@dataclass(frozen=True)
class Address:
street: str
city: str
postal_code: str
country: str
@dataclass(frozen=True)
class CreateOrderRequest(Request[OrderCreated]):
customer_id: int
items: list[OrderItem]
shipping_address: Address
billing_address: AddressResponse design patterns
Rich responses
Return everything the caller plausibly needs, so they don't have to issue a follow-up query:
@dataclass
class UserCreated:
user_id: int
username: str
email: str
created_at: datetime
activation_token: str
profile_url: strExplicit result objects
When failure is a normal outcome rather than an exception, model it in the response:
@dataclass
class PaymentResult:
success: bool
transaction_id: str | None
error_code: str | None
retry_allowed: boolPagination
@dataclass
class PaginatedUsers:
users: list[UserSummary]
total_count: int
page: int
has_next: bool
@dataclass
class ListUsersQuery(Request[PaginatedUsers]):
page: int = 1
page_size: int = 20
sort_by: str = "created_at"Testing without frameworks
Because requests are framework-free, testing the business logic requires no HTTP mocking at all:
def test_create_user():
fake_repo = InMemoryUserRepository()
handler = CreateUserHandler(fake_repo)
result = handler(CreateUserRequest(username="alice", email="alice@example.com", password="secret123"))
assert result.username == "alice"
assert fake_repo.count() == 1Best practices
- Keep requests simple. They're data carriers — no business logic, no side effects in constructors.
- Type every field.
username: str, not**kwargs. The type checker is doing the wiring here. - Organize by feature. Group requests and handlers by business capability (
orders/,users/,payments/), not by kind.
See also
- Handlers — implementing the business logic side
- Dataclasses with PyMediate — patterns for request/response dataclasses
- Testing — testing strategies in depth