A Feature-Module Layout for a FastAPI Application That Keeps Growing
One giant routers.py means nobody can add a feature without touching code they do not own.
The problem
A FastAPI project that starts small tends to start like this:
# app/main.py — after six months
from fastapi import FastAPI
from app.routers import users, items, orders, invoices, notifications, reports
app = FastAPI()
app.include_router(users.router)
app.include_router(items.router)
app.include_router(orders.router)
app.include_router(invoices.router)
app.include_router(notifications.router)
app.include_router(reports.router)
That part is fine. The problem is what sits behind it: a single app/routers.py, or a
app/routers/ package where every file imports from a single shared app/schemas.py and
a single shared app/crud.py, because that is where the first feature put its Pydantic
models and its database calls, and every feature after it followed the same pattern out of
consistency.
Six features in, app/schemas.py is eight hundred lines, half the imports at the top of
app/crud.py belong to features that have nothing to do with each other, and a merge
conflict between two people adding unrelated endpoints is routine — not because the logic
overlaps, but because both people’s changes land in the same three files. Nobody can tell,
from the diff alone, whether a change to crud.py affects invoices, orders, or both,
without reading the whole file.
This is not a hypothetical scaling problem that shows up at some large number of endpoints. It shows up as soon as two people work on the codebase at once, because the unit of change (one feature) and the unit of the file (everything) have stopped matching.
Working through it
The unit of ownership should be a directory, not a layer
The instinct that produces routers.py / schemas.py / crud.py is organising by
technical layer: all the routers together, all the schemas together. That groups code by
what kind of thing it is, not by what it is for. A feature-module layout inverts this:
group by feature first, and let each feature keep its own router, schema and service code
together, with the technical layer as a filename inside that directory rather than a
directory of its own at the top level.
app/
main.py
core/
config.py
database.py
features/
items/
__init__.py
router.py
schemas.py
service.py
users/
__init__.py
router.py
schemas.py
service.py
Adding a third feature means adding a third directory under features/, touching nothing
inside items/ or users/, and touching main.py exactly once, to register the new
router. That is the whole cost of a new feature to the rest of the codebase — one import
line, one include_router call.
What actually lives at the top level
Not everything belongs inside a feature. core/ is deliberately small and holds things
that no single feature owns: the database engine and session factory, application
settings, and anything genuinely cross-cutting like an authentication dependency used by
every feature. The test for whether something belongs in core/ rather than in a feature
is whether more than one feature needs it and no single feature would make sense as its
owner — a shared get_db dependency qualifies; a UserSchema used by the orders feature
to embed a user in a response does not, because it belongs to the users feature and
orders should import it from there.
Avoiding the circular import this naturally invites
Once features can depend on each other — orders needing something from users — a
straightforward mutual dependency becomes possible if two features both try to embed
each other’s models. The fix is directional discipline, decided ahead of time: a feature
may import from another feature’s schemas.py for read-only composition, but never
imports another feature’s service.py, and no feature imports from main.py. If two
features genuinely need to call into each other’s business logic, that logic has outgrown
being “one feature’s” and belongs in core/ or a new shared module, not in either
feature’s own service file, which would otherwise become another shared file three
unrelated features quietly depend on.
Wiring a feature’s router with its own prefix and tags
Each feature’s router.py declares its own APIRouter, complete with the prefix and
OpenAPI tag that show up in the generated documentation — main.py never needs to know
these details, only that the router exists:
# app/features/items/router.py
from fastapi import APIRouter
router = APIRouter(prefix="/items", tags=["items"])
This is what makes main.py’s job stay exactly one line per feature no matter how a
feature’s internal routes grow — adding a new endpoint to items never touches main.py
at all.
The solution
A complete, runnable two-feature FastAPI project.
app/
main.py
core/
__init__.py
database.py
features/
__init__.py
items/
__init__.py
router.py
schemas.py
service.py
users/
__init__.py
router.py
schemas.py
service.py
tests/
test_items.py
requirements.txt
# app/core/database.py
"""In-memory storage standing in for a real database in this example."""
items_db: dict[int, dict] = {}
users_db: dict[int, dict] = {}
# app/features/items/schemas.py
from pydantic import BaseModel
class ItemCreate(BaseModel):
name: str
price: float
class Item(ItemCreate):
id: int
# app/features/items/service.py
from app.core.database import items_db
from app.features.items.schemas import Item, ItemCreate
_next_id = 1
def create_item(payload: ItemCreate) -> Item:
global _next_id
item = Item(id=_next_id, **payload.model_dump())
items_db[item.id] = item.model_dump()
_next_id += 1
return item
def get_item(item_id: int) -> Item | None:
data = items_db.get(item_id)
return Item(**data) if data else None
# app/features/items/router.py
from fastapi import APIRouter, HTTPException
from app.features.items.schemas import Item, ItemCreate
from app.features.items import service
router = APIRouter(prefix="/items", tags=["items"])
@router.post("", response_model=Item, status_code=201)
def create_item(payload: ItemCreate) -> Item:
return service.create_item(payload)
@router.get("/{item_id}", response_model=Item)
def read_item(item_id: int) -> Item:
item = service.get_item(item_id)
if item is None:
raise HTTPException(status_code=404, detail="Item not found")
return item
# app/features/users/schemas.py
from pydantic import BaseModel
class UserCreate(BaseModel):
email: str
class User(UserCreate):
id: int
# app/features/users/service.py
from app.core.database import users_db
from app.features.users.schemas import User, UserCreate
_next_id = 1
def create_user(payload: UserCreate) -> User:
global _next_id
user = User(id=_next_id, **payload.model_dump())
users_db[user.id] = user.model_dump()
_next_id += 1
return user
# app/features/users/router.py
from fastapi import APIRouter
from app.features.users.schemas import User, UserCreate
from app.features.users import service
router = APIRouter(prefix="/users", tags=["users"])
@router.post("", response_model=User, status_code=201)
def create_user(payload: UserCreate) -> User:
return service.create_user(payload)
# app/main.py
from fastapi import FastAPI
from app.features.items.router import router as items_router
from app.features.users.router import router as users_router
app = FastAPI(title="Feature-module example")
app.include_router(items_router)
app.include_router(users_router)
# requirements.txt
fastapi==0.115.0
uvicorn[standard]==0.30.6
pydantic==2.9.2
httpx==0.27.2
pytest==8.3.3
# tests/test_items.py
"""Proves that the items feature works end to end, and that nothing in it
had to change when the users feature was added."""
from fastapi.testclient import TestClient
from app.main import app
client = TestClient(app)
def test_create_and_read_item():
created = client.post("/items", json={"name": "widget", "price": 9.99})
assert created.status_code == 201
item_id = created.json()["id"]
fetched = client.get(f"/items/{item_id}")
assert fetched.status_code == 200
assert fetched.json()["name"] == "widget"
def test_missing_item_returns_404():
response = client.get("/items/999")
assert response.status_code == 404
Verifying it
python -m venv .venv && source .venv/bin/activate
pip install -r requirements.txt
pytest tests/ -v
uvicorn app.main:app --reload
Correct output from pytest:
tests/test_items.py::test_create_and_read_item PASSED
tests/test_items.py::test_missing_item_returns_404 PASSED
http://127.0.0.1:8000/docs shows two tag groups, items and users, each carrying only
its own routes — the OpenAPI grouping falls directly out of the tags=["items"] set once,
in items/router.py, and needed no further configuration anywhere else.
Conclusion
Group by feature, not by technical layer, once more than one person touches the
codebase. A shared schemas.py or crud.py is an implicit merge point between features
that otherwise have nothing to do with each other; a directory per feature removes that
merge point structurally.
The cost of a new feature should be additive, not edited-in. If adding a feature means
one new directory and one new line in main.py, the architecture is doing its job; if it
means finding the right spot in a shared file and hoping nobody else edited nearby lines
in the meantime, it is not.
Directional import rules need deciding before the first cross-feature dependency, not after the first circular import. “Schemas may cross, services may not” is one workable rule; the specific rule matters less than picking one before two features need to talk to each other, at which point every choice looks equally reasonable and someone has to adjudicate it under time pressure.