Four checks, six places they were written
The WhatsApp webhook endpoint on Invobot has to verify that a request actually came from Meta's API before trusting a single byte of it, parse the payload into something the rest of the code can use, resolve which conversation the message belongs to, and reject the request if the sending number is over its rate limit. None of that is the endpoint's actual job - its job is deciding what to do with a message once all four checks have passed.
Early on, all four checks lived inside the webhook handler, because it was the only route that needed them. Then the agent console needed conversation lookup and rate limiting for its own send endpoint. Then an admin endpoint needed signature verification for a different upstream integration. Each addition copied the shape of the original checks into a new function, because that was faster than pulling anything out. Six endpoints in, four checks existed in roughly six slightly different versions, and a bug fixed in the webhook handler's rate limiter had no way to reach the copy sitting in the admin endpoint.
What a fix looked like before
The rate limiter is the clearest example. It was implemented as a few lines at the top of whichever handler needed it:
@app.post("/webhooks/whatsapp")
async def whatsapp_webhook(request: Request):
body = await request.body()
if not verify_meta_signature(request.headers, body):
raise HTTPException(403)
count = await redis.incr(f"rate:{sender_number}")
if count == 1:
await redis.expire(f"rate:{sender_number}", 60)
if count > RATE_LIMIT:
raise HTTPException(429)
payload = parse_webhook_payload(body)
conversation = await find_or_create_conversation(payload.sender)
# ... the actual webhook logic starts hereThat block is correct, and it is also the fourth or fifth time a version of it had been typed into this codebase. When the rate-limit key needed a per-tenant prefix - because two Invobot deployments were sharing a Redis instance and stepping on each other's counters - fixing it meant finding every place the pattern had been copied and hoping none had drifted enough to need a different fix. One of them had.
Composing the checks as dependencies instead
The fix was not a shared utility function called from inside each handler - that just moves the copy-paste problem from the check's logic to its call site, and a handler can still forget to call it. It was moving each check into a FastAPI dependency and declaring it in the route signature, so a check that is missing is a missing argument, not a missing line buried in a function body:
async def verified_payload(request: Request) -> WebhookPayload:
body = await request.body()
if not verify_meta_signature(request.headers, body):
raise HTTPException(403)
return parse_webhook_payload(body)
async def rate_limited(sender: str = Depends(get_sender)) -> None:
count = await redis.incr(f"rate:{TENANT_ID}:{sender}")
if count == 1:
await redis.expire(f"rate:{TENANT_ID}:{sender}", 60)
if count > RATE_LIMIT:
raise HTTPException(429)
@app.post("/webhooks/whatsapp")
async def whatsapp_webhook(
payload: WebhookPayload = Depends(verified_payload),
_: None = Depends(rate_limited),
conversation: Conversation = Depends(resolve_conversation),
):
# everything below is specific to this endpointNothing about this is a new idea - it is FastAPI's ordinary dependency injection, used deliberately as a composition mechanism rather than only for the things the framework tutorials use it for, like a database session. The admin endpoint that needs signature verification but not conversation lookup declares Depends(verified_payload) and nothing else. The agent console's send endpoint declares rate_limited and resolve_conversation but not verified_payload, because it is authenticated a different way. The tenant-prefix fix landed in one function, and every route that declared rate_limited picked it up on the next deploy without being touched.
Why not a class-based middleware stack instead
ASGI middleware was the other obvious tool, and it was deliberately not used here. Middleware runs on every request that matches its path, before FastAPI has parsed a route or its parameters, which is right for genuinely global concerns like CORS headers or request logging. Signature verification and conversation lookup are not global - only some routes need them, and the ones that do need different combinations. Composed dependencies are chosen per route, so a route only pays for the checks it declares, and a new route's author sees exactly which checks apply to it by reading its own function signature instead of finding the right middleware class and reading its path filter.
The tradeoff: reading the request now means reading the signature
This costs something real. A handler's actual behavior is no longer fully visible in its body - to know that a route is signature-verified, rate limited, and running against a resolved conversation, you have to read its parameter list, not just its logic. That is a real indirection cost, and it is worse the less consistently a team names its dependencies. It only pays off because the alternative - re-reading a copy-pasted block in every handler to check whether this particular copy has drifted - was already worse, and got worse with every endpoint added.
There is also an ordering cost. FastAPI resolves dependencies in the order they are declared, and rate_limited depending on get_sender which itself depends on the parsed payload means the dependency graph has to be laid out correctly or a check silently runs against stale or default data. Getting that wrong fails quietly rather than loudly, which is the sharper edge of composing behavior this way instead of writing it out linearly in one function where execution order is just reading order.
Where this stops paying off
Not every repeated block is worth extracting into a dependency. A two-line check used in exactly two places is clearer left inline than routed through an extra layer of indirection for a reader to chase down - the payoff here came specifically from four checks reused across six endpoints, growing, with a real history of drift between copies. The signal worth watching for is not duplication itself but a fix landing in one copy and not reaching the others, because that is the moment shared logic stops being shared in anything but appearance.
What actually generalizes
The specific mechanism is FastAPI's Depends(), but the shape of the decision is not tied to that framework. Any time several endpoints need the same handful of pre-conditions checked in a different combination each time, a single monolithic handler function forces a choice between duplicating the checks or running checks an endpoint does not need. Composing them as small, independently declared units - dependencies, middleware, decorators, whatever the framework calls them - lets each route ask for exactly the behavior it needs and nothing else, and it means a fix has exactly one place to land.