Systems & Architecture

The Validation Layer That's Trapped Inside Your HTTP Framework

Pydantic and FastAPI make request validation feel solved. It isn't — it's just solved for the one caller you originally had in mind. The moment a worker, an agent, or a gRPC service needs the same business logic, the coupling you didn't know you had starts costing you.

The ticket read something like: "Clearing a user's middle name from their profile wipes their last name too." The endpoint was an unremarkable PATCH /users/{id}. The bug was one line — update.dict() includes default None values, so a field the client never sent looked identical to a field the client explicitly cleared. The fix was one keyword, exclude_unset=True. Ticket closed.

Except it wasn't really about that endpoint. It was the first visible symptom of something structural: two more gaps in the Pydantic/FastAPI story share the same root cause. Validators can't run async, so a business rule needing I/O has to move into the route body, after "validation" has technically already passed. And FastAPI's response_model re-validates on the way out, so a mismatch surfaces as an opaque 500 after a database write has already committed. None of this is really about validating shapes — Pydantic is excellent at that. It's that business-rule enforcement, error formatting, and response shaping have quietly become the HTTP layer's job, written under the assumption that a single waiting browser is the only caller that will ever exist.

That assumption is invisible for as long as HTTP is the only caller. We formalized the fix as a decision the first time it stopped being invisible.

System shape

   thin api_route         event_queue worker         security agent
       │                        │                          │
       └────────────────────────┼──────────────────────────┘
                                 │
                                 ▼
                      ┌────────────────────┐
                      │     Controller      │   one call path
                      │  (BaseController)   │   one error taxonomy
                      └──────────┬─────────┘    (inherited, not routed through)
                                 │
                                 ▼
                      ┌────────────────────┐
                      │  Services / Repo    │
                      └────────────────────┘
    
ADR AD-001

Make the Controller Itself the Enforced Call Path, Not the Router

main @ 6e21f0a Accepted
Problem
Business-rule validation, error formatting, and response shaping have quietly become the HTTP layer's job — coupled to the assumption that only one HTTP client will ever call this logic. That coupling is invisible until a worker, an agent, or another service needs the same business rules.
Solution
Subclass a BaseController that wraps every public async method automatically, at class-definition time — the try/except, response formatting, and error vocabulary are enforced by inheritance, not by routing every call through a separate gateway object every caller has to remember to construct.

improves trade-off to watch

Cognitive Load

One call path and one error vocabulary to learn, instead of three: what the route does, what the worker copies, and what almost inevitably drifts between them six months later.

Performance

Removes the temptation to have a worker call its own REST API over HTTP just to reuse validation — no network hop, no serialization round trip for what's really a function call.

Reliability

Every failure carries a real retryable flag every caller can check via is_retryable(code) — a structured signal instead of a message string to guess at. Deciding what to do with it (backoff, dead-letter, escalate) is left to the caller, deliberately, rather than hidden inside a policy engine.

Evolvability

A new caller — gRPC service, agent — needs zero adapter code: import the same controller class and call it directly. There's no gateway object to remember to construct, so there's no separate adoption step to skip.

Decision Rule

Business logic will be called by more than one kind of caller — REST plus a worker, REST plus an agent, and so on — → build or reuse a BaseController subclass. Never duplicate the logic per caller.

Logic is genuinely single-caller and will realistically stay that way → a direct implementation is fine. Don't reach for a base class for something that will never have a second caller.

Secondary signal: the moment a second kind of caller needs the same partial-update semantics or business rule, that's the trigger to move the logic into a shared controller — don't wait for a third.

A Context

The pattern started with a small, almost embarrassing bug: a PATCH /users/{id} endpoint that wiped a user's last name whenever the frontend only meant to clear their middle name. The cause was one line — update.dict() includes default None values, so an unset field and an explicitly-cleared field became indistinguishable. The one-keyword fix (exclude_unset=True) closed the ticket, but it wasn't really about that endpoint.

Two more gaps turned out to share the same root cause. Pydantic validators can't run async, so any business rule needing I/O — is this email already registered? — has to move into the route body, after "validation" has technically already passed. And FastAPI's response_model re-validates on the way out, so a mismatch surfaces as an opaque 500 after a database write has already committed. None of these are about validating shapes — Pydantic is excellent at that. They're about business-rule enforcement, error formatting, and response shaping quietly becoming the HTTP layer's job, written under the assumption that a single waiting HTTP client is the only caller that will ever exist.

B The Worker Case

A background worker draining a UserUpdated event queue needs the exact same partial-update semantics and business rules as the PATCH route. Duplicating that logic drifts the moment someone adds a rule to the API route and forgets the worker exists. Routing the worker through its own REST API to reuse the logic turns a same-process function call into a network operation — connection handling, retries, an auth story for a boundary that shouldn't need one.

The fix: import the same UserController the route already uses, and call it directly — no gateway to construct around it.

from atlasboxpy_controller.exceptions import is_retryable

controller = UserController(user_service)

async def drain_event_queue(queue):
    async for event in queue:
        result = await controller.apply_update(event.user_id, event.patch)
        if result.status == "error":
            if is_retryable(result.error.code):
                await queue.retry_later(event)
            else:
                await queue.dead_letter(event)
        else:
            await queue.ack(event)

Not every failure here should dead-letter the event — a temporarily-down search index should retry, not fail permanently. is_retryable(code) gives the worker a real, structured answer instead of a string to guess at. The retry loop, the backoff, the dead-letter threshold, stay the worker's own code — deliberately not something a hidden policy engine owns on its behalf.

C The Agent Case

An autonomous agent scanning logs for exposed credit card numbers and PII needs the same deduplication, PCI-scoping, and rate-limiting logic behind a human analyst's "flag a finding" button — not a reimplementation of it.

result = await controller.flag_finding(
    finding_type=finding.kind,
    log_ref=finding.log_ref,
    masked_value=finding.masked_value,  # raw value never leaves the detector
)
if result.status == "error" and result.error.code == "permission_denied":
    await escalate_to_security_team(finding, result.error)

The payoff is sharpest here. An agent running unattended can't read an error message and decide what to do the way a person can — it needs a structured signal. already_exists means move on, not a failure. is_retryable("upstream_error") returns True, so the agent knows it's safe to back off and try again — the backoff loop itself is still the agent's own code. permission_denied is not retryable, ever — DomainError subclasses carry that as a literal retryable flag, and is_retryable("permission_denied") reflects it, so the agent's own decision logic knows to stop and escalate rather than burn its loop budget on something that can't succeed.

D Consequences
What gets easier
  • One call path enforced structurally, not by convention — a controller method is never unwrapped, so there's nothing for a route or worker to "forget."
  • One response shape for every caller, browser, queue consumer, or agent.
  • One error vocabulary carrying HTTP/gRPC status mappings and a real retryable flag.
  • One place a failure gets logged — automatically, through self.logger — with nothing for a caller to wire up.
What we give up
  • Only sound if callers actually call the controller's public methods rather than reaching around it into a service directly — but there's no separate gateway object to forget to construct anymore, which removes the whole failure mode the previous design had.
  • A small amount of ceremony (a base class to inherit from) for logic that will genuinely only ever have one caller.
E Alternatives Considered
  1. Duplicate the validation and business logic per caller. Rejected — works on day one, drifts silently the first time one copy is updated and the others are forgotten.
  2. Have new callers reach the existing REST API over HTTP. Rejected — turns a same-process function call into a distributed-systems operation to avoid writing logic twice.
  3. A separate ValidatorGateway object wrapping the controller, called via .handle(). Superseded — an earlier version of this decision. It gave every caller the same guarantee, but added a second object every call site had to construct and remember to route through; a caller could still reach the raw controller and skip it entirely. It also grew an attached policy-driven recovery engine (retry/redirect/queue), which turned out to duplicate decisions that belonged to the caller, not the package.
  4. Controller subclasses a base class that wraps its own methods automatically. (Chosen.) No duplication, no network hop, no separate object to construct — the guarantee is structural because there's no unwrapped version of the method to accidentally call.
F References

One contract, every caller

The insight underneath all of this is small, but it compounds: put the enforced validation and error-handling contract inside the controller itself, not behind the router, and not behind a separate object every caller has to remember to construct. Do that, and a background worker, an autonomous agent, and eventually a gRPC servicer all get identical guarantees — one call path, one response shape, one error vocabulary — without a single line of adapter code written on their behalf. They just import the same controller class and call it directly.

The alternative — the thing that got that PATCH bug filed in the first place — is validation logic that only knows how to be an HTTP framework's helper. It works fine right up until something other than a browser needs to ask your business logic a question. Increasingly, something else always does.