YC

How I built a multi-tenant financial dispute portal without database bloat

Managing thousands of bank claims with request-scoped ContextVar isolation in FastAPI.

System: Financial Desk & B2B Portal
Category: Backend · Multi-Tenancy
Reading Time: 7 min read
Engineering: Python · FastAPI · Upstream Frappe ODR · Docker · Nginx · Linux · ContextVar Isolation
01. WHY I BUILT IT

The Trigger & Underlying Problem

The Trigger: IO2i was taking on institutional lenders and banks that needed to manage high-volume arbitration claims simultaneously.

The Core Problem: Each banking client required total isolation of their dispute data, custom notice templates, and strict audit logs. Spinning up and maintaining 30 separate database clusters was financially out of reach and an operational nightmare for a small team.

Why Standard Solutions Were Insufficient: Standard SaaS multi-tenancy frameworks either forced a shared database with naive WHERE clauses (prone to developer leakage bugs) or demanded separate databases per tenant. We needed structural isolation enforced at the request level without infrastructure bloat.

02. PRACTICAL IMPACT

What It Solves in Practice

PRACTICAL PROBLEM

Institutional lenders need to upload batches of 500+ delinquent loan claims, generate legally valid arbitration notices, and monitor hearing stages without seeing another bank's files.

BENEFICIARIES

Banking legal operations teams, arbitrators, and IO2i dispute coordinators.

BEFORE THIS SYSTEM

Teams exchanged sensitive claims via unencrypted spreadsheets over email, manually ran Word mail-merges for notices, and had no unified audit trail.

03. IMPLEMENTATION

Architecture & Key Engineering Decisions

A containerized FastAPI gateway acting as an authenticated proxy. Inbound requests are tagged at the ASGI middleware boundary based on the Host header or X-Tenant-Host token. The middleware resolves the tenant credentials and stores them inside a Python ContextVar.

Key Decisions:
  • Used Python ContextVar primitives rather than thread-locals so tenant context propagates safely across asynchronous coroutines without cross-request bleeding.
  • Asynchronous spreadsheet ingestion: bulk Excel/CSV uploads are parsed with validation schemas in background tasks, emitting immediate upload receipts to the UI.
  • Docker container deployment behind an Nginx reverse proxy running on a production Linux host, enforcing payload limits and TLS termination.
File: tenant_context.py
from contextvars import ContextVar
from fastapi import Request, HTTPException

_current_tenant: ContextVar[dict | None] = ContextVar("current_tenant", default=None)

async def tenant_middleware(request: Request, call_next):
    tenant_host = request.headers.get("x-tenant-host") or request.headers.get("host", "")
    tenant_config = resolve_tenant_by_host(tenant_host)
    if not tenant_config:
        raise HTTPException(status_code=403, detail="Unrecognized institution")
    
    token = _current_tenant.set(tenant_config)
    try:
        response = await call_next(request)
        return response
    finally:
        _current_tenant.reset(token)
04. CHALLENGES & SOLUTIONS

What Failed in Practice & How It Was Addressed

WHAT WENT WRONG
  • Initially, background worker tasks lost their tenant context because asyncio.create_task() detached from the parent request context in an earlier Python version.
  • Institutions uploaded massive 80MB spreadsheets filled with corrupted dates, causing memory spikes and worker crashes.
  • Nginx dropped large file uploads with 413 Request Entity Too Large because client_max_body_size was still set to the 1MB default.
HOW I SOLVED IT
  • Explicitly passed the ContextVar snapshot into background worker tasks using contextvars.copy_context().run(), guaranteeing the background worker operates under the exact same tenant boundary.
  • Streamed file uploads directly to temporary disk storage and used chunked pandas/openpyxl parsing with strict Pydantic row validation instead of loading full workbooks into memory.
  • Configured client_max_body_size 100M in Nginx and added explicit buffer limits to prevent memory exhaustion.
05. REFLECTION & LESSONS

Takeaways & Future Iterations

What I Learned:
  • Multi-tenancy should be structural, not conditional. If isolation relies on remembering to type `tenant_id=current_tenant` in every SQL query, a bug will eventually leak data.
  • Asynchronous Python needs careful attention when dealing with task-scoped state across coroutines.
WHAT I WOULD CHANGE NOW:

Today, I would enforce PostgreSQL Row Level Security (RLS) as an additional defense-in-depth layer at the database level, using a session setting (`SET LOCAL app.current_tenant = ...`) alongside the application ContextVar.