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
BaseControllerthat 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
retryableflag. -
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
- 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.
- 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.
-
A separate
ValidatorGatewayobject 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. - 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
-
atlasboxpy_controllerpackage, formerlyvalidator_gateway— see itsCHANGELOG.mdfor the fullValidatorGateway→BaseControllermigration history. - Related: AD-014, "Reserve MCP for Genuine Ownership Boundaries" — extends this decision once a tool needs to cross an ownership boundary, not just a transport boundary.