Skip to main content
FastAPIArchitecturePythonBackend

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.

FastAPI AI Kit Team··3 min read

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_user without 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.

Read the docs
No subscriptions · One-time payment · Lifetime updates