Skip to content

A FastAPI Skeleton of Layer Contracts, Not Empty Folders

Adityo Guni Waluyo

Four folders with empty __init__.py files carry real contracts: layer boundaries, a unit of work per request, JWT tenant claims, and the 72-byte bcrypt gate.

TL;DR

Four seemingly empty folders establish layered contracts in a FastAPI skeleton, keeping business logic, technical details, and HTTP translation cleanly separated. A yield-based dependency handles transactions per request, committing only on success so multi-table use cases stay atomic. Multi-tenancy rides in JWT claims, bcrypt limits get enforced at the validation gate, and swappable signal sources await future needs.

I spent the bootstrap night for DemandScope creating four folders: app/domain, app/application, app/infrastructure, app/api. The contents? Empty __init__.py files, every one of them. From the outside this looked like the most optional work of the night. It was actually the most expensive part: the contract each folder promises.

The wrong first guess

My first guess: this is just a folder template, run uvicorn and watch the code flow. What I missed: without a one-sentence contract per layer, business logic leaks into routes, queries sprout in the wrong places, and auth rules spread everywhere. So every layer here is bound to one sentence. Domain holds data shapes and contracts: frozen dataclass entities plus SignalSource, the abstract interface for a signal source. Application holds use cases, with AuthService as the concrete example. Infrastructure owns the technical details: ORM, repositories, bcrypt, JWT, down to NoopSource as the placeholder signal source. Api is a pure HTTP translator with Pydantic validation at its gate. The database schema stays out of all four; that is Alembic’s job, with migration scripts of its own [16].

Unit of work per request

The side that makes these contracts useful is FastAPI’s dependency injection, which was designed to share logic, database connections, and auth rules [9]. The pattern I use: a dependency with yield. The get_db function in app/core/di.py opens a session, the route runs, and the commit only happens if the route succeeds to the very end.

def get_db() -> Iterator[Session]:
    db = SessionLocal()
    try:
        yield db
        db.commit()
    except Exception:
        db.rollback()
        raise
    finally:
        db.close()

Any exception raised while a dependency is in use reaches its try block [10], and that is where the rollback executes; Session.rollback cancels the running transaction [15]. The practical effect shows up in AuthService.register: the tenant and its owner user are created inside the same transaction. Fail halfway and there is no orphan tenant left without an owner.

Why not commit in each repository instead? Because the transaction boundary must belong to one place. If every repository commits on its own, two tables touched by one use case can never be atomic. Moving the commit to the edge of the request frees a use case to compose several changes at once without wondering when data lands in the database.

Small bolts of multi-tenancy

Multi-tenancy in this skeleton arrives through small bolts, not a grand framework. First, tenant identity rides inside the JWT: the claims sub, tenant_id, role, encoded with HS256 following PyJWT’s basic usage [11]. Every later request knows its tenant without an extra query.

Second, the bcrypt limit is fenced at the gate. Bcrypt only handles passwords up to 72 bytes, and since bcrypt 5.0 hashpw raises ValueError for anything longer; older versions silently truncated it [13]. Pyca itself calls the library acceptable while pointing at argon2id and scrypt as alternatives [12]; I keep that as a future evaluation, not tonight’s decision. In RegisterRequest the limit is written explicitly:

class RegisterRequest(BaseModel):
    company_name: str = Field(min_length=1, max_length=200)
    email: str = Field(min_length=3, max_length=320, pattern=_EMAIL_PATTERN)
    # bcrypt operates on at most 72 bytes; enforce at the boundary.
    password: str = Field(min_length=8, max_length=72)

A violation produces a 422 the client can actually read, not a ValueError surfacing late inside the hash function. Other errors carry codes too: a taken email answers 409, wrong credentials answer 401. A colliding tenant slug automatically gets a uuid suffix so the second registration does not blow up.

Third, the ingest seam: SignalSource as an ABC plus NoopSource returning an empty list. Tomorrow, when a real signal source exists, one DI binding changes, not the application.

Filtering by tenant in queries is the reasonable first step. If enforcement at the database level is ever needed, PostgreSQL has Row-Level Security: by default a table without policies leaves all rows available to anyone with access [14], which means per-tenant policies have to be created deliberately, never assumed as a default. This skeleton is not canonical hexagonal architecture and does not pretend to be; what keeps it healthy is the one-sentence contract per layer. Those empty folders turned out to be the place where all of these decisions live.

Sources

  1. [9] Dependencies (FastAPI)
  2. [10] Dependencies with yield (FastAPI)
  3. [11] PyJWT usage examples
  4. [12] pyca/bcrypt
  5. [13] bcrypt README: maximum password length
  6. [14] PostgreSQL row security policies
  7. [15] SQLAlchemy Session basics
  8. [16] Alembic tutorial

Related articles