Add a backend¶
PyFr ships a deliberately narrow set of adapters: PostgreSQL, Redis, and S3-compatible object storage. MySQL, MongoDB, Memcached, Google Cloud Storage and Azure Blob are not included — the reasoning is here.
If you need one of those, you write one adapter. This guide is the shape of that work.
Worked examples in the codebase
Three real adapters follow the structure below exactly, and are worth
reading beside it: infrastructure/db/order_repository.py
(PostgresOrderRepository, an ordinary adapter over SQLAlchemy — the
version of this guide to copy from for a plain storage port),
infrastructure/storage/receipt_store.py (S3ReceiptStore, the same
shape over aioboto3, and the worked example of "one adapter per
S3-compatible provider" rather than one adapter per vendor), and
infrastructure/cache/order_repository.py (CachedOrderRepository, a
decorating adapter — it satisfies OrderRepository by holding another
OrderRepository rather than a driver. See what a port
buys
for why that shape is worth understanding even if the backend you are
adding is not a cache).
What you are actually writing¶
You are not extending a framework. You are writing a class that satisfies an interface the domain layer already declared.
The domain owns the interface, called a port. Your class is an adapter. Nothing above the storage layer changes when you add one — that is the whole point of the dependency rule.
1. Read the port¶
In domain/repositories.py:
@runtime_checkable
class OrderRepository(Protocol):
async def get(self, order_id: OrderId) -> Order | None:
"""Return the order, or None when it does not exist."""
async def save(self, order: Order) -> None:
"""Persist the order, creating or replacing it."""
That is your specification. Note that it is stated in domain terms —
Order, not rows; get, not SELECT.
get returning None for a missing order, rather than raising, is part of
the contract. Deciding that a missing order is an error is the service
layer's decision, and it makes it by raising OrderNotFoundError.
2. Write the adapter¶
Put it in infrastructure/<technology>/. For example
infrastructure/mysql/order_repository.py.
Do not inherit from the Protocol. A Protocol is satisfied structurally — by having methods with the right signatures. Inheriting would make your adapter import from the domain in order to subclass it, and the dependency would point the wrong way.
class MySQLOrderRepository:
def __init__(self, pool: Pool) -> None:
self._pool = pool
async def get(self, order_id: OrderId) -> Order | None:
...
async def save(self, order: Order) -> None:
...
Two rules for this file:
- Translate at the boundary. Rows go in; domain objects come out. A database row must never travel further up than this module, and a domain object must never carry a driver's type.
- Never let a driver exception escape. A
MySQLErrorreaching the service layer makes that layer depend on MySQL, which is exactly what the port exists to prevent. Catch it and raise a domain error instead.
3. Add its settings¶
In settings.py, as a nested model:
class MySQLSettings(BaseModel):
model_config = ConfigDict(frozen=True)
dsn: SecretStr
pool_size: int = 10
Use SecretStr for anything that is a credential. Its printed form is
**********, so it cannot reach a log line or a traceback by accident.
Set frozen=True on the nested model explicitly. Freezing the top-level
settings does not freeze the models nested inside it — see
Configuration.
Add the variables to .env.example with a comment each, and to the table in
Configuration. The documentation
freshness check will remind you if you forget.
4. Register it in the composition root¶
container.py is the single place that decides which adapter a running
service actually uses:
def build_container(settings: Settings) -> Container:
pool = create_pool(settings.mysql.dsn.get_secret_value())
return Container(settings=settings, orders=MySQLOrderRepository(pool))
Release the resource in close_container. It runs at shutdown, after
in-flight requests have finished.
5. Register a readiness check — and choose the right tier¶
A dependency that can be unavailable belongs in
/readyz, but which of its
two tiers depends on what losing the dependency actually costs, not on how
important it feels.
register is gating: a failure returns 503 and takes the instance out of
load balancing.
container.readiness.register("mysql", check_mysql)
register_informational is reported but never changes the status code —
the choice made for both the cache and the object store:
container.readiness.register_informational("cache", check_redis)
Ask two questions before picking. Does losing it make this instance unable
to do useful work? If yes — the primary datastore, say — gate on it. Is
it shared across every pod, and does the code already survive its loss? If
so, gating is actively harmful: every pod fails the check in the same
instant, and a degradation the service was built to tolerate becomes a total
outage instead. That is exactly the cache's situation —
CachedOrderRepository fails open, so PostgreSQL already answers every
request when Redis is down — and it is why the cache is informational, not
gating. The object store is informational for a related but distinct reason:
losing it breaks one endpoint, not the whole instance, so taking all traffic
off the pod to protect that one endpoint would cost more than it saves.
Checks in both tiers run concurrently, each under a short timeout, so registering in either never multiplies the endpoint's worst-case latency.
Do not add either tier to /healthz. A liveness probe that checks a
dependency restarts every instance at once when that dependency hiccups,
turning a small problem into an outage — the same reasoning that keeps a
gating /readyz check off Redis, one level more severe.
6. Test it¶
- Unit tests keep using the fake repository. They must not change at all — if adding an adapter forces a domain or service test to change, something has leaked upward, and that is the signal to look for.
- Integration tests exercise the real adapter against the real technology, started in Docker for the test session. Test through the port: save an order, get it back, and check it round-trips unchanged.
The round-trip test is the one that matters. It catches the errors adapters
actually make — a Decimal silently becoming a float, a timezone dropped, a
None stored as the string "None".
7. Check the rule still holds¶
just imports
If your adapter accidentally imported from api/ or services/, this fails
with the contract that broke and the import chain that broke it.