How we structure a production FastAPI project
A practical guide to the routers, services, and repository pattern that makes FastAPI codebases easy to maintain at scale.
How we structure a production FastAPI project
FastAPI makes it dangerously easy to ship a working API fast. The problem is that "working fast" and "maintainable at scale" diverge the moment you add your second feature.
This post walks through the architecture we use in FastAPI AI Kit — the routers/services/repositories pattern — and explains why each layer exists, not just what it does.
The problem with flat FastAPI apps
A typical FastAPI tutorial looks like this:
@app.post("/users")
async def create_user(db: Session = Depends(get_db), body: UserCreate):
user = db.query(User).filter(User.email == body.email).first()
if user:
raise HTTPException(400, "Email already registered")
new_user = User(email=body.email, hashed_password=hash(body.password))
db.add(new_user)
db.commit()
return new_user
This works perfectly for a weekend project. But six months later, when you need to:
- Test
create_userwithout a real database - Reuse the user creation logic from a background task
- Add audit logging without touching every route
...you're in trouble. Business logic, data access, and HTTP concerns are tangled together.
The three-layer pattern
We split every domain into three layers:
1. Router (app/api/*.py)
Routers handle HTTP. They validate input, call services, and map results to responses. Nothing else.
# app/api/users.py
@router.post("/users", response_model=UserResponse, status_code=201)
async def create_user(
body: UserCreate,
user_service: UserService = Depends(get_user_service),
):
user = await user_service.register(body.email, body.password)
return UserResponse.from_orm(user)
This route handler has three responsibilities: accept a request, call a service, return a response. It has zero database awareness.
2. Service (app/services/*.py)
Services hold business logic. They orchestrate repositories, send emails, emit events. They don't know about HTTP or database sessions.
# app/services/auth_service.py
class AuthService:
def __init__(self, user_repo: UserRepository):
self.user_repo = user_repo
async def register(self, email: str, password: str) -> User:
if await self.user_repo.exists_by_email(email):
raise EmailAlreadyExists(email)
hashed = hash_password(password)
return await self.user_repo.create(email=email, hashed_password=hashed)
EmailAlreadyExists is a domain exception — the router catches it and converts it to an HTTP 409. The service never touches HTTPException.
3. Repository (app/repositories/*.py)
Repositories are the only layer that touches the database. They expose typed methods and hide SQLAlchemy behind a clean interface.
# app/repositories/user_repo.py
class UserRepository:
def __init__(self, session: AsyncSession):
self.session = session
async def exists_by_email(self, email: str) -> bool:
result = await self.session.execute(
select(User).where(User.email == email)
)
return result.scalar_one_or_none() is not None
async def create(self, *, email: str, hashed_password: str) -> User:
user = User(email=email, hashed_password=hashed_password)
self.session.add(user)
await self.session.flush()
return user
Testing AuthService now requires only a mock UserRepository — no database, no HTTP client. The test is fast and deterministic.
Dependency injection with FastAPI
FastAPI's Depends() system makes wiring these layers clean:
async def get_user_service(
session: AsyncSession = Depends(get_db),
) -> UserService:
repo = UserRepository(session)
return UserService(repo)
No service locator, no global state. Every dependency is explicit and overridable in tests.
When to break the rules
This pattern adds indirection. For a simple CRUD endpoint with no business logic, a thin router that calls the DB directly is fine. The layering pays off when:
- You need the same logic in multiple contexts (route + background task)
- You want to test business logic without I/O
- You're adding cross-cutting concerns (audit, rate limiting, events)
Takeaway
Start flat if you're prototyping. Refactor to services when you notice yourself duplicating business logic or struggling to test a route. FastAPI AI Kit ships with this structure pre-built — so you spend your time on features, not architecture.
Build your AI backend with FastAPI AI Kit.
Clone, configure, and ship — everything is already wired up.
