Best practices
An index of topic-specific best practices, plus cross-cutting advice on project structure, DI choice, and request versioning.
Each guide page ends with a best-practices section scoped to its own topic. This page indexes those, and adds a few cross-cutting practices that don't belong to any single topic.
Topic-specific best practices
- Requests and responses — keep requests simple, type every field, organize by feature.
- Handlers — single responsibility, dependency injection, stateless by default.
- The mediator — one mediator instance per application, don't mix dispatch styles.
- Pipeline behaviors — keep behaviors focused, mind registration order, short-circuit only intentionally.
- Dataclasses —
frozen=Truefor requests,default_factoryfor mutable defaults, validate in__post_init__. - Error handling — keep domain errors independent of the framework; map them at the edge.
Project structure
Organize by business capability, not by technical layer — keep a feature's requests, handlers, and tests together rather than splitting them into parallel requests/, handlers/, tests/ trees:
app/
orders/
requests.py
handlers.py
test_handlers.py
users/
requests.py
handlers.py
test_handlers.py
payments/
requests.py
handlers.py
test_handlers.pyThis keeps each handler's test file next to the handler it covers, per testing: fixtures and organization.
Choosing between Services and a DI container
Both implement the same ServiceProvider protocol, so Mediator doesn't care which you use — the choice is about how you want to manage object lifetimes and wiring, not about PyMediate itself:
Servicesis enough for most applications: a few lines of explicit registration with no framework to learn. Reach for it first.DependencyInjectorServiceProviderearns its keep once you have real lifetime management to do —Singletonvs.Factoryvs. scoped providers, or dependencies that themselves need building from configuration. See dependency injection.
Don't reach for a DI container just to avoid writing services.add(...) calls — that's not the problem it solves.
Versioning requests
Once a request type ships, treat its fields as a public contract. Add optional fields with defaults rather than changing existing ones; if a change would break existing callers, introduce a new versioned request type and translate at the adapter boundary:
@dataclass
class CreateUserRequestV1(Request[UserCreated]):
username: str
email: str
@dataclass
class CreateUserRequestV2(Request[UserCreated]):
username: str
email: str
phone: str | None = None
def translate_v1_to_v2(v1: CreateUserRequestV1) -> CreateUserRequestV2:
return CreateUserRequestV2(username=v1.username, email=v1.email, phone=None)This keeps version translation in the adapter, not the handler — the handler only ever sees one shape.
Next steps
- Type safety — what mypy checks statically vs. what PyMediate checks at import time
- Testing — testing handlers, the mediator, and pipeline behaviors
- Troubleshooting — common configuration mistakes and fixes