All posts
Backend/6 min read/Jun 1, 2026

Structure Your FastAPI Backend by Feature, Not by Layer

The layered structure every FastAPI tutorial teaches looks clean in a screenshot. It falls apart the first time you add soft deletes and have to touch five directories for one logical change.

Cover image for Structure Your FastAPI Backend by Feature, Not by Layer

Series: Building Backarch — Engineering decisions from building backarch.com

Here is a thing most FastAPI tutorials don't tell you: the structure they show you — all models in models/, all routes in routers/, all services in services/ — is optimised for reading, not for changing.

It looks clean in a screenshot. It falls apart the first time you add soft deletes to one feature and have to edit five directories to do it.

The cost of layered architecture isn't obvious until your third feature

In a layered project, every feature is spread horizontally across your tree. Adding soft deletes to notes means touching models/notes.py, schemas/notes.py, repositories/notes.py, services/notes.py, and routers/notes.py — five files, five directories, one logical change.

The PR diff looks like a cross-cutting refactor when it's actually a single-feature enhancement. Code review is harder because the reviewer has to hold the whole feature in their head across five separate files. git blame on any one file tells you who edited the layer, not who owns the feature.

It turns out the problem isn't layering per se — it's the unit of organisation. Layers are great when the natural unit of change is "all models" or "all routes." For most product backends, the natural unit of change is "the notes feature."

Owning a feature means owning a directory

The alternative is to let each feature own its vertical slice: every file that belongs to a feature lives in that feature's folder.

app/features/
├── auth/
│   ├── models.py      # SQLAlchemy models
│   ├── repo.py        # database queries
│   ├── schemas.py     # Pydantic request/response models
│   ├── service.py     # business logic
│   └── router.py      # FastAPI routes
├── canvas/
│   └── (same five files)
├── notes/
│   └── (same five files)
├── pricing/
├── ai/
├── billing/
└── orgs/

Adding soft deletes to notes now touches exactly one directory. The PR diff is the notes feature. The reviewer sees the full change in one place. git blame on notes/service.py shows who owns notes.

One feature change touching 5 directories vs 1 directory
One feature change touching 5 directories vs 1 directory

We ran nine features through this structure in Backarch, and the consistency pays off in a way that isn't obvious upfront: future features never start from scratch. The scaffold is already there. When a new feature is ready to ship, one line activates it:

app.include_router(auth_router,    prefix="/v1")
app.include_router(canvas_router,  prefix="/v1")
app.include_router(notes_router,   prefix="/v1")
app.include_router(pricing_router, prefix="/v1")
# billing_router — not shipped yet

One line to activate. One line to defer.

Not everything belongs in a feature directory

Feature directories own domain logic. But some code has no domain identity. JWT verification isn't an auth concern — it's infrastructure used by every feature. S3 uploads don't belong to notes — they're a cross-cutting concern used by any feature that handles attachments.

That code lives in app/core/:

app/core/
├── dependencies.py   # get_current_user, require_plan
├── security.py       # JWT encode/decode, password hashing
├── exceptions.py     # UnauthorizedException, NotFoundException
├── s3.py             # presigned URL helpers
├── secrets.py        # secrets manager loader
├── logging.py        # structured logging configuration
└── router.py         # /v1/health

The one rule for core/: nothing inside it imports from app.features. If you find yourself importing a feature model from inside core/, the dependency is inverted and something belongs in a different place.

Services that operate across feature boundaries — cost estimation that touches both topology models and pricing data, health scoring that aggregates across multiple domains — live in app/services/. These are the cross-feature orchestrators. They import from features freely; features don't import from each other through them.

Cross-feature dependencies are explicit, not injected

When one feature needs another feature's data, the dependency is a plain Python import — no service locator, no dependency injection container:

# app/features/canvas/service.py
from app.features.pricing import service as pricing_service
 
async def get_diagram_with_cost(db: AsyncSession, diagram_id: UUID) -> DiagramResponse:
    diagram = await repo.get_by_id(db, diagram_id)
    cost = await pricing_service.estimate_cost(db, diagram.nodes)
    return DiagramResponse(**diagram.__dict__, estimated_cost=cost)

You can Cmd+Click to the definition. The dependency is visible in the import, not hidden in a constructor or a registry. When the pricing logic changes, you know exactly which service files import it.

When a feature module earns its own shared service

The pattern holds until a feature's service.py starts doing too much. A busy feature — one handling CRUD, versioning, complex scoring, and cross-feature orchestration — can grow to five hundred lines. Other features start importing from it. At that point, it's no longer a feature service; it's a shared library wearing a feature's clothes.

The move is to extract the shared logic into app/services/ and leave the feature's service.py as a thin coordinator:

# Before: canvas/service.py has grown to 500+ lines, imported by two other features
async def estimate_cost(db, nodes): ...          # used by the pricing feature
async def compute_health_score(db, diagram): ... # used by the scoring feature
async def create_diagram(db, user_id, data): ...
async def update_diagram(db, diagram_id, data): ...
# After extraction
 
# app/services/canvas_analysis.py — the promoted shared logic
async def estimate_cost(db, nodes): ...
async def compute_health_score(db, diagram): ...
 
# app/features/canvas/service.py — now a thin coordinator
from app.services import canvas_analysis
 
async def create_diagram(db, user_id, data): ...
async def update_diagram(db, diagram_id, data): ...
# delegates analysis to the shared service

The promotion signal is two-part: the file crosses five hundred lines, or two other features import from it. Not before. Premature extraction creates indirection without benefit.

The feature structure makes this refactor safe. Nothing reaches into canvas/ from outside except through the API or explicit service imports. Extracting the shared logic doesn't break any hidden dependency.


When we added soft-delete recovery to one feature in Backarch, the change touched exactly one directory. Every other feature had zero awareness of it. One focused PR, one clear reviewer, one obvious place to look when something breaks months later.

The question worth asking: which feature in your codebase takes the most directory-hopping to understand fully? The gap between that feature and the easiest one is exactly where your structure is working against you.

Next: Your CI and your deploy are racing each other on every push. Here's the one trigger change that ends the race.

PK
Piyush Kumar
CO-FOUNDER · BACKARCH

One engineering deep-dive,
every other week.

No fluff. Frontend, backend, infra, and AI — real post-mortems and walkthroughs from engineers shipping in production.