DEV Community

Cover image for One API, Three Clients, and the Validation Rule That Only One of Them Obeyed
Den
Den

Posted on

One API, Three Clients, and the Validation Rule That Only One of Them Obeyed

The validation logic had been solid for months. Pydantic schema on the request, a service-layer check for the actual business rule, tests green across the board. Then I added a CLI tool that talked to the same API the web app used, and within a week a support ticket came in: a record existed in the database that should have been structurally impossible.

It wasn't a validation bug in the sense of "the code checks the wrong thing." The check was correct. It just wasn't reachable by every path that could create that record.

The setup

Three clients hit one FastAPI backend: a React web app, a mobile app, and a small internal CLI I'd written for bulk imports. All three eventually call the same endpoint:

@router.post("/orders", response_model=OrderOut)
async def create_order(
    payload: OrderCreate,
    service: OrderService = Depends(get_order_service),
):
    order = await service.create(payload)
    return order
Enter fullscreen mode Exit fullscreen mode

OrderCreate is a Pydantic model. The service checks stock, applies discount rules, all the things I described in my last post on how I structure a FastAPI service. On paper, every client goes through the same endpoint, so every client gets the same validation. That was the assumption I hadn't actually tested.

Where the assumption broke

The CLI tool didn't call /orders. It called a lower-level /orders/bulk endpoint I'd added later for the import job, because bulk creation needed to skip some per-request overhead and batch the database writes. I wrote /orders/bulk fast, under deadline, and had it call OrderService.create directly for each row — except I'd copy-pasted an earlier version of the service before a discount-rule fix had landed. The endpoint had its own BulkOrderCreate schema, structurally similar to OrderCreate, validated by Pydantic just as strictly. It looked, at the schema level, exactly as safe as the main endpoint.

The gap wasn't in what got validated. It was in which code path validated it. Two endpoints, two services (one stale), one business rule that existed in only one of them.

/orders        → OrderService.create()       (current rules)
/orders/bulk   → OrderService.create() [old]  (missing a rule added later)
Enter fullscreen mode Exit fullscreen mode

Nothing here shows up in a Pydantic schema diff, because both schemas were fine. It doesn't show up in a service unit test either, unless you happen to test both call sites against the same rule — and I hadn't, because I'd been thinking of "the service" as one thing when it was actually two copies that had quietly diverged.

The actual failure mode

Multi-client APIs don't usually break validation by having a client skip it outright — most frameworks make that hard to do by accident. They break it by having a second path to the same write, added later, under different pressure (performance, batch size, a deadline), that re-implements or re-imports the logic instead of routing through the one place it's defined.

Each new client is a reason to add a new endpoint shape: the mobile app wants a lighter payload, the CLI wants batching, an internal script wants to skip auth. Every one of those is a legitimate reason. None of them is a reason to duplicate the rule that decides whether the write is allowed — only the rule that decides how the request is shaped.

What actually fixed it

The fix wasn't more validation. It was fewer entry points to the rule:

class OrderService:
    async def create(self, data: OrderCreate) -> Order:
        return await self._create_validated(data)

    async def create_bulk(self, items: list[OrderCreate]) -> list[Order]:
        # same rule, batched — not a second copy of the rule
        return [await self._create_validated(item) for item in items]

    async def _create_validated(self, data: OrderCreate) -> Order:
        if not await self.inventory.has_stock(data.sku, data.quantity):
            raise InsufficientStockError(data.sku)
        return await self.orders.save(Order.from_create(data))
Enter fullscreen mode Exit fullscreen mode

/orders/bulk now calls create_bulk, which funnels every item through the exact same _create_validated, not a re-implementation of it. The batching problem — do the writes efficiently — got solved without touching the validation problem — is this write allowed. They'd been tangled together in the original bulk endpoint because both were solved at the same time under the same deadline, which is usually when this kind of split happens.

The question I ask now

When I add a new endpoint for a new client, I ask one thing before writing it: is this a new way to shape the request, or a new way to reach the write? If it's the former, a new schema and a thin adapter into the existing service is fine. If it's the latter — if I'm about to write _create a second time because the existing one doesn't quite fit — that's the moment the rule needs one home, not two.

Pydantic will happily validate two different schemas that both feed the same broken assumption. It has no way to know they were supposed to agree.


I write about things that break while I build Boolflow and RealFeedApp. If you've dealt with divergent validation across clients differently — a shared validation service, contract testing across endpoints — I'd like to hear how.

Top comments (0)