DEV Community

Sanskar Kharya
Sanskar Kharya

Posted on

Why I Don't Put Tenant-Specific Validation in My FastAPI Endpoints

Multi-tenant applications have a way of turning simple endpoints into surprisingly complicated pieces of code.

At first, tenant-specific validation seems harmless.

You have two tenants with different limits, so you write something like:

if tenant == "acme":
    max_quantity = 100
elif tenant == "globex":
    max_quantity = 10
Enter fullscreen mode Exit fullscreen mode

It works.

The problem isn't the first if.

The problem is what happens after there are ten more rules.

When the endpoint starts knowing too much

I prefer an endpoint to answer one main question:

What should the application do with this request?

I don't want the route handler to also be responsible for figuring out:

  • which tenant made the request
  • whether that tenant is active
  • what limits the tenant has
  • which validation rules apply
  • how those validation failures should be returned

None of those things are really the endpoint's job.

They are context and validation concerns.

Once those responsibilities start accumulating inside a route, even a small endpoint can become difficult to reason about.

FastAPI dependencies are a natural boundary

FastAPI's dependency injection provides a useful place to resolve tenant information.

For example, a request can contain:

X-Tenant-ID: globex
Enter fullscreen mode Exit fullscreen mode

A dependency can turn that header into a strongly typed TenantConfig.

Now the rest of the request-processing pipeline doesn't need to repeatedly parse the header or look up the tenant.

But that still leaves an interesting problem.

The request body doesn't contain the information we need for every validation rule.

Some validation needs context

Consider a simple Pydantic model:

class OrderCreate(BaseModel):
    product_id: str = Field(..., min_length=1)
    quantity: int = Field(..., gt=0)
Enter fullscreen mode Exit fullscreen mode

The model can determine that quantity=0 is invalid without knowing anything about the tenant.

But suppose:

acme   → maximum quantity = 100
globex → maximum quantity = 10
Enter fullscreen mode Exit fullscreen mode

Now the validity of the same request depends on information outside the request body.

That's where Pydantic v2's validation context becomes useful.

Instead of hard-coding tenant names into the model, the application can pass the resolved tenant into validation:

OrderCreate.model_validate(
    payload,
    context={"tenant": tenant},
)
Enter fullscreen mode Exit fullscreen mode

The validator can access that information through ValidationInfo.

The important part for me isn't the specific API. It's the separation of responsibilities:

FastAPI dependency
        ↓
Resolve tenant
        ↓
TenantConfig
        ↓
Pydantic validation
        ↓
Validated request
        ↓
Endpoint
Enter fullscreen mode Exit fullscreen mode

The endpoint doesn't need to know how any of that happened.

But there's a trade-off

I don't think this pattern is automatically the right answer for every FastAPI application.

To make tenant information available before Pydantic validation, the example intentionally receives the request body as raw data and calls model_validate() itself.

That gives us the context we need, but we give up some of FastAPI's automatic request-body handling.

One noticeable consequence is OpenAPI documentation.

With a normal Pydantic request model, FastAPI can automatically generate a detailed request schema. With the raw-body approach, the generated request schema is less descriptive.

That's a real trade-off.

If automatically generated API documentation were the most important requirement, I'd investigate a different integration.

What I actually care about

The goal isn't to eliminate every if statement from an application.

It's to keep responsibilities somewhere they make sense.

I'd rather have an endpoint that looks like:

def create_order(
    ctx: TenantOrderContext = Depends(get_validated_order),
) -> OrderResponse:
    ...
Enter fullscreen mode Exit fullscreen mode

than one that starts doing all of this:

def create_order(request, tenant):
    # resolve tenant
    # check tenant status
    # determine limits
    # validate request
    # handle validation failures
    # process order
Enter fullscreen mode Exit fullscreen mode

The second approach isn't inherently broken.

It's just carrying more responsibility than the endpoint needs to.

The broader lesson

The interesting part of this exercise wasn't really multi-tenancy.

It was thinking about where validation gets its information from.

Some rules depend only on the object being validated.

Others depend on the context surrounding that object.

When validation depends on external context, putting everything inside the route handler is an easy solution. But it isn't necessarily the cleanest boundary.

FastAPI dependencies and Pydantic v2 validation context give you another option.

I built a small working example of this approach with FastAPI, Pydantic v2, and pytest, including tests for tenant-specific limits, invalid requests, inactive tenants, unknown tenants, and malformed payloads.

The complete implementation is available on GitHub.

Type-Safe Multi-Tenant Request Validation with FastAPI & Pydantic v2

A minimal, production-inspired educational Python example demonstrating type-safe multi-tenant request validation using FastAPI dependencies and Pydantic v2 validation context (info.context).


Purpose

Multi-tenant applications often need to validate incoming API payloads against tenant-specific rules (such as max order quantities, allowed product categories, or rate limits).

A common anti-pattern is placing if tenant == "acme": ... conditional branching inside route handlers. This pollutes endpoint logic and duplicates validation code.

This project demonstrates how to move tenant resolution and tenant-specific schema validation into FastAPI dependencies before the endpoint receives the request. The endpoint stays clean and receives a strongly-typed TenantOrderContext.


Request Flow & Architecture

Client
  │ (POST /orders, Header: X-Tenant-ID, JSON Body)
  ▼
1. get_tenant Dependency
  │ Reads X-Tenant-ID header & resolves TenantConfig
  │ Rejects missing header (422), unknown tenant (404), or inactive tenant (403)
  ▼
2. get_validated_order Dependency
  │ Receives

Top comments (0)