A small, type-safe asynchronous request dispatcher for Python 3.13+ applications that already use flow-res and injector.
flow-med maps each registered request type to one handler type. Dispatch can
also use a compatible handler registered for a base request type. It resolves
the selected handler through Injector and returns the flow-res.Result
declared by the request. It is deliberately narrow: it makes this integration
explicit without trying to be a general application framework.
Consider flow-med when an application already uses both flow-res and
Injector, has asynchronous request/handler pairs, and benefits from keeping
their registration in one application-owned registry.
It is not intended to be a general-purpose event bus, CQRS framework, or replacement for direct function calls. It does not provide publish/subscribe, pipeline behaviors, handler discovery, synchronous dispatch, or framework integrations. For a small application with a few direct dependencies, adding a mediator is usually unnecessary.
- Explicit registration: Application-owned registries map requests to handlers without global state or automatic discovery.
flow-rescontracts: Requests and handlers declare the sameResult[T, E]type, which can be checked with Pyright and is validated during registration.Injectorresolution: Handler construction and lifetime remain under the application's existing Injector configuration.- Async dispatch:
send_async()returnsAwaitableResultso callers can use the result operations provided byflow-res.
pip install flow-medfrom flow_res import Result
from flow_med import Request
class GetUserRequest(Request[Result[str, Exception]]):
def __init__(self, user_id: int):
self.user_id = user_idRegister concrete handlers with an application-owned HandlerRegistry. Use
@override to ensure correct implementation.
from typing import override
from flow_res import Ok, Result
from flow_med import HandlerRegistry, RequestHandler
registry = HandlerRegistry()
@registry.handler
class GetUserHandler(RequestHandler[GetUserRequest, Result[str, Exception]]):
@override
async def handle(self, request: GetUserRequest) -> Result[str, Exception]:
# Fetch the user here.
return Ok(f"User {request.user_id}")import asyncio
from injector import Injector
from flow_med import Mediator
async def main():
# Each application owns its registry, mediator, and Injector.
mediator = Mediator(Injector(), registry)
# Dispatch the request; flow-res supplies the result helpers.
result = await (
mediator.send_async(GetUserRequest(user_id=1))
.map(lambda name: f"Hello, {name}!")
.unwrap()
)
print(result) # Hello, User 1!
if __name__ == "__main__":
asyncio.run(main())For a handler with injected dependencies, see the DI example.
HandlerRegistry and Mediator expose explicit registration operations:
registry.handler(Handler)infers the request type from the concreteRequestHandler[RequestType, ResultType]contract. It can be used as@registry.handleror called directly.registry.register(RequestType, Handler)is useful when the request type should be explicit.mediator.register(...)delegates to the mediator's registry.registry.replace(RequestType, Handler)intentionally changes an existing mapping.mediator.replace(...)delegates to the same operation. Replacement fails if no mapping exists, while either registration form fails if one already exists.
Registration validates that the handler is concrete, is declared for the given
request type, declares the same generic result type as the request, and uses a
flow_res.Result[T, E] result contract. Plain result values are rejected by
Pyright and raise InvalidHandlerError during dynamic registration. This is a
declaration-level check: flow-med does not inspect or validate the value
returned by handle() at runtime. Static type checking and the handler
implementation remain responsible for honoring the declared result contract.
When sending a request, Mediator asks its Injector for the registered
handler type. The injector therefore controls handler construction, dependency
injection, and lifetime according to its bindings and scopes; Mediator does
not cache handler instances.
A registry is a live object: handlers added or explicitly replaced after a
Mediator is created are visible to that mediator. Separate registries are
isolated; sharing a registry explicitly shares its handler mappings.
Treat registry setup and mutation as an application-startup concern even though
the individual handler-map operations are synchronized. The lock does not
freeze the live registry or provide a dispatch snapshot: a request may use the
handler selected before a concurrent replace(). It also does not make
resolved handlers or their dependencies thread-safe; that remains determined
by those objects and their injector scopes.
-
Sending an unregistered request raises
HandlerNotFoundErrorwhen the returnedAwaitableResultis awaited. -
Registering a second handler for the same request type raises
DuplicateHandlerError. Usereplace()for an intentional replacement; the request type must already be registered. -
Handler lookup first checks the exact
type(request). If no exact handler is registered, it walks that request class's Python MRO from the concrete class toward its bases and uses the first registered request handler. Therefore an exact handler always wins over a base handler. For multiple inheritance, Python's MRO is the selection rule: forclass Combined(Left, Right), a handler registered forLeftwins over one registered forRightwhen no exactCombinedhandler exists. Registration order does not affect this choice. If no class in the MRO has a handler,HandlerNotFoundErroris raised. -
A base handler can handle subclasses that preserve the request's
flow_res.Result[T, E]contract. Dispatch validates the selected handler's result contract against the concrete request and raisesInvalidHandlerErrorrather than returning a mismatched result for an incompatible hierarchy. -
By default, exceptions raised by
Injectoror a handler propagate unchanged. To opt into Result-based handling at this boundary, pass the keyword-onlyexception_mapper; exceptions from handler resolution or execution are then returned asErrvalues:class RequestFailure(Exception): pass result = await mediator.send_async( GetUserRequest(user_id=1), exception_mapper=lambda exc: RequestFailure(str(exc)), )
The mapper is not called for an already-returned
Err, an unregistered request still raisesHandlerNotFoundError, and cancellation/system-level exceptions are not converted. If no mapper is supplied, the existing propagation behavior is preserved.
v0.2 replaces the process-wide class API with instance-owned mediators and scoped registries:
# v0.1
Mediator.initialize(Injector())
result = await Mediator.send_async(GetUserRequest(user_id=1)).unwrap()
# v0.2
registry = HandlerRegistry()
registry.handler(GetUserHandler)
mediator = Mediator(Injector(), registry)
result = await mediator.send_async(GetUserRequest(user_id=1)).unwrap()For test-specific overrides, create a separate registry or call
mediator.register(...) and mediator.replace(...).
See the changelog for the complete v0.2 change summary.
Both runtime dependencies are still in the 0.x series, where a minor release
may include breaking API changes. The bounds keep PyPI installations within
the API lines verified by flow-med while allowing compatible patch releases;
the bounds are part of the published package metadata, not only uv.lock.
MIT License