The series has built substantial functionality—full CRUD operations for posts and users (tutorial 6), async conversion for improved performance (tutorial 7). Before adding more features like forms and authentication, this chapter organizes the existing code. This polish-then-build approach mirrors common patterns in real-world development: build functionality, then organize before adding complexity.
The Problem: Growing main.py
The main.py file has become lengthy, containing imports, lifespan function, app setup, template routes, user routes, post routes, API routes, and exception handlers—all mixed together. While everything works, adding more features would make this file a nightmare to maintain.
The Solution: API Routers
Routers are FastAPI’s tool for organizing routes into modules, similar to Flask blueprints. The restructuring creates a routers directory, moves user routes into one file, moves post routes into another, and updates main.py to include them. Critically, when complete, everything works identically from the outside—same URLs, same functionality. Only the internal organization improves.
What is an API Router?
Instead of defining all routes on the main app object, routes are defined on a router and then included in the app.
Basic pattern. In a separate file, create a router:
router = APIRouter()Define routes using router.get() and router.post() instead of app.get() and app.post().
In the main file, connect it:
app.include_router(router)Benefits:
- Apply common prefixes to groups of routes
- Apply tags for organized documentation
- Keep related code together
Creating the Router Structure
Create a routers directory in the project root. Inside routers, create __init__.py:
# routers/__init__.py (empty file)This makes routers a proper Python package. While not strictly required in modern Python, it helps tools like linters and type checkers—still a best practice.
Create two router files:
routers/users.pyrouters/posts.py
Building the Users Router
Imports
A key benefit of splitting routes: each router only imports what it needs instead of everything in main.py:
from typing import Annotated
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select
import models
from database import get_db
from schemas import UserCreate, UserResponse, UserUpdateCritical addition: APIRouter from FastAPI, plus database dependencies, models, and only the schemas needed for users.
Creating the Router Instance
router = APIRouter()This is what routes will be decorated with instead of app.
Moving User Endpoints
Organizational decision: Only move API routes (paths starting with /api/users) to routers. Template routes (homepage, post page, user posts page) remain in main.py. This way main.py handles front-end views, and routers handle API routes.
Start with one route to see the changes.
Before (in main.py):
@app.post(
"/api/users",
response_model=UserResponse,
status_code=status.HTTP_201_CREATED
)
async def create_user(
user: UserCreate,
db: Annotated[AsyncSession, Depends(get_db)]
):
# ... function body ...After (in routers/users.py):
@router.post(
"",
response_model=UserResponse,
status_code=status.HTTP_201_CREATED
)
async def create_user(
user: UserCreate,
db: Annotated[AsyncSession, Depends(get_db)]
):
# ... function body ...Two key changes:
app.post→router.post— Decorate with the router instance instead of app"/api/users"→""— Path changed to an empty string
Understanding Relative Paths with Prefixes
Using an empty string might seem odd, but the path in the router is relative. When including this router, a prefix of /api/users will be specified. The router’s empty string path becomes that prefix.
Let’s see how prefixes work.
In main.py:
app.include_router(users.router, prefix="/api/users")In users.py:
@router.post("") # Becomes /api/usersFinal route: /api/users
If the path were "/" instead of "":
@router.post("/") # Becomes /api/users/Final route: /api/users/ (trailing slash)
Why avoid trailing slashes? They can cause 307 redirect issues. Clean paths without trailing slashes are preferred, and using empty strings ensures this.
Moving Remaining User Routes
Move all user API endpoints from main.py to routers/users.py:
@router.get("/{user_id}", response_model=UserResponse)
async def get_user(
user_id: int,
db: Annotated[AsyncSession, Depends(get_db)]
):
# ... function body ...
@router.get("/{user_id}/posts", response_model=list[PostResponse])
async def get_user_posts(
user_id: int,
db: Annotated[AsyncSession, Depends(get_db)]
):
# ... function body ...
@router.patch("/{user_id}", response_model=UserResponse)
async def update_user(
user_id: int,
user_data: UserUpdate,
db: Annotated[AsyncSession, Depends(get_db)]
):
# ... function body ...
@router.delete("/{user_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_user(
user_id: int,
db: Annotated[AsyncSession, Depends(get_db)]
):
# ... function body ...Pattern for each route:
- Decorator:
app.get→router.get,app.patch→router.patch, etc. - Path: Remove
/api/usersprefix, keep the rest (/{user_id},/{user_id}/posts) - Function body: Unchanged—all async/await logic, database operations, validation remain identical
Only the decorator and path change. Everything else (async/await, database logic, validation) stays the same.
Building the Posts Router
Create routers/posts.py with similar structure:
Imports
from typing import Annotated
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select
from sqlalchemy.orm import selectinload
import models
from database import get_db
from schemas import PostCreate, PostResponse, PostUpdatePost-specific schemas are imported, plus selectinload for eager loading relationships.
Router Instance
router = APIRouter()Moving Post Endpoints
All post API endpoints move to routers/posts.py:
@router.get("", response_model=list[PostResponse])
async def get_posts(db: Annotated[AsyncSession, Depends(get_db)]):
# ... function body ...
@router.post("", response_model=PostResponse, status_code=status.HTTP_201_CREATED)
async def create_post(
post: PostCreate,
db: Annotated[AsyncSession, Depends(get_db)]
):
# ... function body ...
@router.get("/{post_id}", response_model=PostResponse)
async def get_post(
post_id: int,
db: Annotated[AsyncSession, Depends(get_db)]
):
# ... function body ...
@router.put("/{post_id}", response_model=PostResponse)
async def update_post_full(
post_id: int,
post_data: PostCreate,
db: Annotated[AsyncSession, Depends(get_db)]
):
# ... function body ...
@router.patch("/{post_id}", response_model=PostResponse)
async def update_post_partial(
post_id: int,
post_data: PostUpdate,
db: Annotated[AsyncSession, Depends(get_db)]
):
# ... function body ...
@router.delete("/{post_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_post(
post_id: int,
db: Annotated[AsyncSession, Depends(get_db)]
):
# ... function body ...Same transformation: decorators use router instead of app, paths remove /api/posts prefix, function bodies remain unchanged.
Updating main.py
Removing Unused Imports
Schemas are no longer used in main.py—they’re in individual routers. Remove them:
# Remove these:
# from schemas import PostCreate, PostResponse, PostUpdate, UserCreate, UserResponse, UserUpdateMany other imports remain because template routes still use them.
Importing Routers
from routers import posts, usersThis imports the router modules from the routers package.
Including Routers
After the templates setup, include the routers:
app.include_router(users.router, prefix="/api/users", tags=["users"])
app.include_router(posts.router, prefix="/api/posts", tags=["posts"])Breaking down the parameters:
users.router— The router instance fromrouters/users.pyprefix="/api/users"— URL prefix added to all routes in this router. The router’s empty string becomes/api/users,/{user_id}becomes/api/users/{user_id}tags=["users"]— Organizes the/docspage by creating collapsible sections. All user endpoints appear under a “users” header, all post endpoints under a “posts” header. Without tags, they’d be in a flat list. Tags improve documentation organization significantly.
Avoiding Function Name Conflicts
FastAPI uses function names as route names by default. If a router function has the same name as a template route, it can conflict with url_for().
Example conflict:
# In main.py
@app.get("/", name="home")
def home(request: Request):
...
# In a router
@router.get("")
def home(): # Conflict!
...Solution: Use unique, descriptive function names. The current code already does this—all routes have clear, descriptive names (create_user, get_posts, update_post_full, etc.). Just avoid naming functions identically across different routers.
Verifying the Structure
After reorganization, main.py should contain:
- Imports (including routers)
- Lifespan function
- App creation
- Static and media mounts
- Templates setup
- Router inclusion (users and posts)
- Template routes (HTML views)
- Exception handlers
All API routes are now gone from main.py—they’re in their respective routers. The file is significantly less crowded.
Critical point: Despite these extensive changes, all URLs and responses are identical. Functionality is unchanged. Routes are simply registered on router objects instead of the app object. This is purely internal organization.
Testing the Reorganization
Start the server and verify everything works:
- Homepage: Loads correctly
- API routes:
/api/postsreturns posts - Documentation: Navigate to
/docs
The documentation now shows organized groups:
-
users (collapsible section)
- Create User
- Get User
- Get User Posts
- Update User
- Delete User
-
posts (collapsible section)
- Get Posts
- Create Post
- Get Post
- Update Post Full
- Update Post Partial
- Delete Post
This organization comes from the tags parameter.
Functional Testing
Create a user:
{
"username": "testuser",
"email": "testuser@example.com"
}Response includes user ID 3.
Create a post with that user:
{
"title": "Testing Routers",
"content": "Router content",
"user_id": 3
}Post is created successfully with full author information included. Check the front-end—the new post appears correctly.
Benefits of Router Organization
Before and After Comparison
Before: main.py contained everything—over 400+ lines
After:
main.py: Less than 150 linesrouters/users.py: ~150 linesrouters/posts.py: ~150 lines
Much better than having everything in one file.
Separation of Concerns
- Post logic lives in
posts.py - User logic lives in
users.py - App configuration and front-end live in
main.py
The front-end could be split off if the app grew larger, but it remains in main.py for this series since it’s not much code.
Easier Code Navigation
Looking for the create post endpoint? Instead of scrolling through main.py, you know it’s in posts.py. Finding code becomes trivial.
Scalable Structure
Need admin endpoints? Create routers/admin.py following the same pattern. Each router follows established conventions.
Team Benefits
Different developers can work on different routers with fewer merge conflicts.
Testing Benefits
Routers can be tested individually with mocked dependencies per route.
Alternative Organization Strategies
Routes were organized by resource (users, posts)—this is common. Other approaches:
- By version:
v1/users.pyv2/users.py
- By access level:
public.pyadmin.py
- By feature:
billing.pyanalytics.py
However organized, the pattern remains the same and helps structure code. This resource-based organization will be used throughout the series.
Summary
The application was reorganized using routers to separate concerns and improve maintainability. A routers package was created containing users.py and posts.py. API routes were moved to their respective routers with decorators changed from app to router and paths adjusted to be relative to prefixes. main.py now includes routers with app.include_router(), specifying prefixes (/api/users, /api/posts) and tags for documentation organization. Template routes remain in main.py for front-end views.
The file structure is now:
main.py(~150 lines) — app configuration, template routes, exception handlersrouters/users.py(~150 lines) — all user API endpointsrouters/posts.py(~150 lines) — all post API endpoints
External behavior is identical—same URLs, same responses. Only internal organization changed.
This is a fundamental pattern used in any real-world FastAPI project. Organized code makes adding features significantly easier.
The next chapter builds front-end forms with JavaScript using the Fetch API to interact with the clean API structure. Following that, authentication will be implemented so only users can edit their own posts on both the front-end and in the API. The organized code established here will make adding these features substantially easier.