FastAPI – Project Structure That Survives Growth

September 11, 202614 min readUpdated 8/21/2026

The official documentation shows a single main.py, and it is right to — the framework is easier to learn without a directory tree in the way. But every real project outgrows that file, and FastAPI has no opinion about what comes next. This is the question people get wrong most often, and the cost is paid slowly rather than loudly.

What going wrong looks like

The single file does not fail dramatically. It degrades, in a recognisable order.

First, endpoints start doing business logic, because that is where the data is. Then two endpoints need the same rule and one of them gets a slightly different copy. Then a route builds a database query inline, so reading it requires knowing the schema. Then a bug is fixed in one of the three places the rule now lives. By the time anyone proposes restructuring, every endpoint is load-bearing and none of it is testable without HTTP.

None of those steps is unreasonable on its own. That is what makes it happen.

The same endpoint, both ways

Concretely, here is creating a booking as it tends to get written first — everything in the route, because everything is to hand:

@app.post("/bookings", status_code=201)
def create_booking(payload: BookingCreateRequest, user: CurrentUser, db: DbSession):
    prop = db.execute(
        select(Property).where(Property.public_id == payload.property_id)
    ).scalar_one_or_none()
    if prop is None or prop.deleted:
        raise HTTPException(404, "Listing not found.")
    if prop.status != "PUBLISHED":
        raise HTTPException(400, "This listing is not accepting bookings.")
    if prop.host_id == user.id:
        raise HTTPException(400, "You cannot book your own listing.")
    if payload.guests > prop.max_guests:
        raise HTTPException(400, f"This place sleeps up to {prop.max_guests} guests.")

    clash = db.execute(
        select(Booking).where(
            Booking.property_id == prop.id,
            Booking.status.in_(["PENDING", "CONFIRMED", "COMPLETED"]),
            Booking.check_in < payload.check_out,
            Booking.check_out > payload.check_in,
        )
    ).first()
    if clash:
        raise HTTPException(409, "Those dates are no longer available.")

    nights = (payload.check_out - payload.check_in).days
    subtotal = prop.price_per_night * nights
    service_fee = (subtotal * Decimal("0.12")).quantize(Decimal("0.01"))
    total = subtotal + prop.cleaning_fee + service_fee

    booking = Booking(
        property_id=prop.id, guest_id=user.id, nights=nights,
        subtotal=subtotal, service_fee=service_fee, total=total, status="PENDING",
    )
    db.add(booking)
    db.commit()
    db.refresh(booking)
    return booking

Nothing there is stupid. It is readable, it works, and writing it took ten minutes. The problems are all things that have not happened yet:

  • The 12% service fee is now a number in a route. The quote endpoint needs the same number, and will get its own copy.
  • The status strings "PENDING", "CONFIRMED", "COMPLETED" are duplicated from wherever else they appear. Adding a fourth blocking status means finding every list like this one.
  • Testing "you cannot book your own listing" requires an HTTP client, a route, a token and a database.
  • The overlap query is the availability rule. The calendar endpoint needs it too.
  • db.commit() in a route means this can never be part of a larger transaction.

The layered version says the same thing:

@router.post("", response_model=BookingResponse, status_code=status.HTTP_201_CREATED)
def create_booking(
    payload: BookingCreateRequest, user: CurrentUser, db: DbSession, background: BackgroundTasks
) -> BookingResponse:
    """Hold the dates. The booking is PENDING until payment succeeds.

    ⚠️ The body carries no price. Every figure is computed server-side from the listing.
    """
    booking = BookingService(db).create(user, payload)

The rules did not disappear — they moved somewhere they can be reused and tested. The fee became settings.service_fee_rate. The status strings became an enum with a blocking() classmethod that both the availability query and the database constraint read, so the two cannot disagree. And the overlap check became a repository method the calendar endpoint also calls.

The layout

StayHub's, which is a fairly standard shape for a FastAPI service of this size:

app/
├── main.py         the FastAPI object: middleware, error handlers, routers, health
├── core/           config · security · deps · exceptions · logging · middleware
├── db/             declarative base and mixins · session · async_session
├── models/         SQLAlchemy entities and enums      — what is STORED
├── schemas/        pydantic DTOs                      — what is SENT
├── repositories/   the only place that knows SQLAlchemy exists
├── services/       business rules — pricing, booking, cancellation, payments
├── search/         client · index mapping · indexer · queries
└── api/v1/routes/  auth · properties · bookings · payments · search · admin · uploads

The two directories people merge, and should not, are models/ and schemas/. One is the database's idea of a booking; the other is the API's. They are similar today and diverge the moment you add a column you do not publish, or publish a value you do not store. Lesson 3 covered why that separation is a security boundary.

The rule for each layer

A structure is only worth having if you can say what does not belong in each box. Four rules, in priority order.

1. Routes own HTTP, and nothing else

A route's job is to translate: take an HTTP request, call something, turn the result into a response. If a route computes a price, it is in the wrong layer.

@router.post("/quote", response_model=PriceBreakdown)
def quote(payload: QuoteRequest, db: DbSession) -> PriceBreakdown:
    """What would this stay cost? Creates nothing.

    Runs the same pricing code the booking runs, so a quote is always honoured.
    """
    _, breakdown = BookingService(db).quote(payload)
    return breakdown

Three lines, and only one of them does anything. That is the target. The test for whether a route is too fat is simple: could this logic be needed by something that is not an HTTP request — a CLI command, a scheduled job, a message consumer? If yes, it does not belong here.

2. Services own the rules

Everything that is true about your domain regardless of transport lives in a service. Prices, eligibility, state transitions, the order operations must happen in.

    def create(self, guest: User, req: BookingCreateRequest) -> Booking:
        prop = self._bookable_property(req.property_id)
        self._validate_stay(prop, req.check_in, req.check_out, req.guests)

        if prop.host_id == guest.id:
            raise ApiException("You cannot book your own listing.")

"You cannot book your own listing" is a fact about the business. It is not a validation rule — the request is perfectly well formed — and it is not an HTTP concern. It belongs exactly here, where it is reachable by every path that creates a booking and testable without a server.

Services are also where a transaction spans more than one thing, which brings us to the rule that matters most.

3. Repositories never commit

    def add(self, entity: ModelT) -> ModelT:
        self.db.add(entity)
        self.db.flush()
        return entity

flush(), not commit(). The distinction is the whole design: flush sends the INSERT so the database assigns an id, while leaving the transaction open. commit ends the transaction.

The reason is that only the caller knows where a transaction ends. "Create a booking AND its payment, or neither" is one transaction spanning two repositories. If each repository committed, the first would be durable before the second was attempted, and a failure would leave a booking with no payment and no way to know it happened. A repository cannot see far enough to make that call.

The corollary is that services commit, and routes do not touch transactions at all.

4. One rule, one home — even if it is awkward

StayHub's cancellation policy is three lines of logic in a file of its own:

app/services/cancellation_policy.py

That looks like over-engineering until you see why. Both the schema layer and the service layer need it — the response DTO computes isCancellable for the UI, and the booking service enforces the same rule when a cancellation arrives. If it lived in booking_service.py, the schema would have to import the service, and the service already imports the schema. A circular import.

The general form: when two layers need the same rule, it moves down, not sideways. A module with no dependencies of its own can be imported by anything. Reaching for a local import inside a function to break the cycle works, but it is a signal you have put something in the wrong place, not a solution.

Configuration

Reading os.environ scattered through the code is the thing this replaces. A typo in a variable name becomes a None that surfaces three layers deep at request time; typed settings turn it into a startup error.

class Settings(BaseSettings):
    model_config = SettingsConfigDict(
        env_file=".env",
        env_prefix="STAYHUB_",
        extra="ignore",
    )

    app_name: str = "StayHub API"
    api_v1_prefix: str = "/api/v1"

    database_url: str = "postgresql+psycopg://stayhub:stayhub@localhost:5433/stayhub"

env_prefix earns its place on a machine running more than one thing: STAYHUB_DATABASE_URL cannot collide with another service's DATABASE_URL. And the settings object is cached, so the file is parsed once per process rather than once per request:

@lru_cache
def get_settings() -> Settings:
    """Cached so the .env file is parsed once per process, not once per request."""
    return Settings()


settings = get_settings()

The gotcha that costs an afternoon

pydantic-settings parses .env into the object and never touches os.environ. So this returns an empty string for anything that lives only in the file:

os.getenv("STAYHUB_STRIPE_PUBLISHABLE_KEY")   # "" — the .env is not in os.environ

StayHub hit exactly this. The failure is quiet: a payment intent is created fine and the browser simply receives an empty publishable key, with no error logged anywhere. The rule is absolute — once you have a settings object, os.getenv is a bug.

Defaults deserve a moment too. Every field above has one, which makes the app runnable with no configuration at all — good for a demo. For a real deployment, secrets should have no default, so a missing one fails at boot rather than starting with "dev-only-change-me". StayHub's JWT secret has a default and says so in a comment; a production service would drop it and let pydantic refuse to construct.

What main.py is for

Assembly, in a deliberate order, and nothing else:

"""The FastAPI application.

Responsibilities, in order: CORS, error handling, routes, health. Business logic lives in
`app/services`, persistence in `app/repositories`. Nothing in this file should know what a booking
costs.
"""

That last sentence is the test. If main.py knows a domain fact, it has grown something it should not have.

The one piece of real logic that does belong there is the health check, and only because it is about the process rather than the domain:

@app.get("/health", response_model=Health, tags=["health"])
def health() -> Health:
    """Reports each dependency separately.

    A single boolean would answer "is it up?" but not "what is broken?", which is the only thing
    anyone actually wants from a health check at 3am.
    """

Which way the imports point

The single most useful property of a layered structure is that dependencies only ever point one way:

routes  ──>  services  ──>  repositories  ──>  models
   │             │                                 ▲
   └──> schemas ─┘─────────────────────────────────┘
                      core/  (imported by everyone, imports nobody)

Nothing below imports something above. A repository never imports a route; a model never imports a service. When that holds, you can reason about a layer in isolation and, more practically, a circular import becomes impossible rather than merely unlikely.

When it does not hold, Python tells you — but late, and unhelpfully. ImportError: cannot import name X from partially initialized module names the two files at the ends of the cycle, not the design decision in the middle that caused it. Two ways out are legitimate: move the shared thing down into a leaf module (the cancellation policy), or use TYPE_CHECKING when the import exists only for annotations:

if TYPE_CHECKING:
    from app.models.property import Property
    from app.models.user import User

That is how StayHub's models reference each other in type hints without importing at runtime. The quoted forward reference — Mapped["Property"] — is resolved by SQLAlchemy later, and the annotation is still there for the editor.

The service shape

Services in StayHub are classes constructed with a session:

class BookingService:
    def __init__(self, db: Session) -> None:
        self.db = db
        self.bookings = BookingRepository(db)
        self.properties = PropertyRepository(db)

The constructor takes the one thing that varies per request and builds its repositories from it. Every method then reads as domain language: BookingService(db).create(user, payload).

Plain functions work just as well when there is no shared state to carry. StayHub uses both — pricing_service and cancellation_policy are modules of functions, because pricing needs no session and the cancellation rule needs nothing at all:

from app.services import pricing_service
from app.services.cancellation_policy import cancellation_deadline, is_cancellable

The choice is not stylistic. A function with no dependencies can be imported by anything, which is precisely the property that let the cancellation rule be shared between two layers that cannot import each other. Reach for a class when there is per-request state; reach for a function when there is not.

Where the enum lives, and why it matters

A small example of "one rule, one home" that pays for itself repeatedly:

class BookingStatus(StrEnum):
    PENDING = "PENDING"      # dates held, not yet paid
    CONFIRMED = "CONFIRMED"  # paid
    CANCELLED = "CANCELLED"
    COMPLETED = "COMPLETED"

    @classmethod
    def blocking(cls) -> tuple["BookingStatus", ...]:
        """The statuses that occupy a property's calendar.

        Used by both the availability service and the database's exclusion constraint — they must
        agree, so they read the same definition.
        """
        return (cls.PENDING, cls.CONFIRMED, cls.COMPLETED)

"Which statuses block a calendar" is a domain fact with two consumers, one of them a database constraint. Putting it on the enum means there is exactly one answer. In the fat-route version above it was an inline list, which is fine right up until the day somebody adds a status and updates two of the three places it appears.

Testability is the real payoff

The argument for layers is usually made aesthetically, which is unconvincing. The concrete version: this is a test of a real business rule, with no HTTP anywhere.

def book(db, listing, guest, start_offset: int, nights: int, status=BookingStatus.CONFIRMED):
    check_in = TODAY + timedelta(days=start_offset)
    check_out = check_in + timedelta(days=nights)

Because the pricing and booking rules live in services that take a session and plain arguments, they can be exercised directly. StayHub's suite runs a hundred tests in under three seconds, and most of them never construct a request. The ones that do — and there is a class of bug only they can find — are lesson 14.

Everything else that lives beside the app

Three directories sit outside app/ and are easy to place badly.

alembic/ owns the schema. Migrations are not application code and do not import services. The one file worth configuring carefully is alembic/env.py, which needs to see every model for autogenerate to work — import the declarative Base and the models package, and set target_metadata from it. The non-negotiable rule: never edit a migration that has been applied anywhere. Write a new one. An edited migration means two databases with the same version and different schemas, and nothing will tell you.

tests/ mirrors the layers, not the files. StayHub's suite splits by what is being tested rather than by module:

tests/
├── conftest.py                shared fixtures — the rollback session
├── test_pricing.py            a pure function, no database
├── test_cancellation_policy.py a pure function, no database
├── test_booking_service.py    service rules, real database
├── test_security.py           JWT and hashing
├── test_api_admin.py          through HTTP — pagination, authorization
├── test_api_uploads.py        through HTTP — every upload guard
└── test_api_middleware.py     through HTTP — things only the stack can show

The split is informative in itself: the first two need nothing, the middle three need a session, and the last three need the whole application. That gradient is a direct consequence of the layers, and it is why most of the suite is fast.

scripts/ is for things run by hand. Seeding, benchmarks, one-off migrations of data rather than schema. They import the app, so they live outside it.

Small conventions that save time

Absolute imports, always. from app.services.booking_service import BookingService, never from ..services import. Relative imports break when a file moves and read differently depending on where you are, and the saving is a few characters.

Keep __init__.py empty. The temptation is to re-export everything for shorter imports. The cost is that importing anything imports everything, which is how a circular import appears between two modules that do not reference each other. StayHub's package files are empty and its import graph stays a tree.

Name the layer in the filename. booking_service.py, booking_repository.py — not booking.py in four directories. Editors open files by name, and four tabs called booking.py is a small tax paid all day.

Group a subsystem by feature, not by layer. search/ holds its client, its index mapping, its indexer and its queries together, because they change together and are used as a unit. Layers are the default; a cohesive subsystem is the exception worth making.

Where the search subsystem lives, and why it is different

search/ breaks the layer rule deliberately, and the exception is instructive.

app/search/
├── client.py     the Elasticsearch connection, and whether it is reachable
├── index.py      the index mapping
├── indexer.py    the Postgres -> Elasticsearch sink
└── queries.py    building a query, and turning hits into DTOs

Those four files are a client, a schema, a writer and a reader — one of each layer, grouped by feature rather than by kind. That is the right call because they change together: adding a filterable field touches the mapping, the indexer and the query in one commit, and splitting them across four top-level directories would make one change a tour of the codebase.

The rule that emerges: layers by default, feature packages for subsystems that are used as a unit. A subsystem qualifies when it has its own vocabulary, its own failure modes, and a boundary you could plausibly replace — swapping Elasticsearch for something else should touch one directory.

What keeps it honest is that the boundary is real. Nothing outside search/ imports the Elasticsearch client, and the sink is called from the service layer rather than from routes:

    from app.search import indexer

    indexer.index_property(prop)  # removes it from the index — SUSPENDED is not visible

Note the local import. That one is a legitimate use rather than a smell: it keeps a route module from importing the search stack at module load, which matters because the search client tries to connect on import.

When not to do any of this

A structure has a cost, paid in indirection every time you read the code. Six layers for six endpoints is worse than one file, not better.

A reasonable progression:

SizeStructure
a handful of endpointsone main.py. Genuinely fine.
~10–20split routes into routers/, add schemas.py, models.py, config.py
20+, or a real domainthe full layout above
several teamsseparate services, and this becomes each one's internal shape

Two signals that you have reached the next step, both more reliable than a file-length rule. The same logic appears in two endpoints — extract a service. You cannot test a rule without spinning up a client — the rule is in the wrong layer.

What you should not do is build the full tree on day one for an application that might have eight endpoints forever. Structure is a response to pressure, and inventing the pressure is how a small project acquires a large project's overhead with none of its problems.

Next: dependency injection — the mechanism that connects these layers, and the reason a route signature can declare its own security rules.