Routers and Layers
22 min · Intermediate
Need help? Ask a tutor on WhatsApp (opens in a new tab)One file with every route works for a demo, but not for a real app. A router groups the routes of one topic, and layers keep HTTP, rules and storage apart. Each piece becomes small enough to understand and to test.
A router groups related routes
APIRouter is a group of routes with a shared prefix and tags. It works like a small FastAPI app that you plug into the main app with include_router.
A real API has dozens of routes. Keeping them all in one file gets confusing. An APIRouter is a mini app that holds the routes of one topic, such as products or orders. The main app then plugs the router in with include_router.
from fastapi import APIRouter
router = APIRouter(prefix="/orders", tags=["orders"])
@router.get("/{order_id}")def read_order(order_id: int): return {"id": order_id}
# in main.pyapp.include_router(router)Example 1 · A router with a prefix
from fastapi import FastAPIfrom fastapi.testclient import TestClient
app = FastAPI()from fastapi import APIRouter
orders = APIRouter(prefix="/orders", tags=["orders"])customers = APIRouter(prefix="/customers", tags=["customers"])
@orders.get("/{order_id}")def read_order(order_id: int): return {"order_id": order_id}
@customers.get("")def list_customers(): return []
app.include_router(orders)app.include_router(customers)client = TestClient(app)print(client.get("/orders/5").json())print(client.get("/customers").json())print(sorted(app.openapi()["paths"])){'order_id': 5}
[]
['/customers', '/orders/{order_id}']What this code does
- Each router has its own prefix, so paths inside it stay short.
- The route
@customers.get("")answers at/customers.
Example 2 · Routers inside a versioned router
from fastapi import FastAPIfrom fastapi.testclient import TestClient
app = FastAPI()from fastapi import APIRouter
products = APIRouter(prefix="/products", tags=["products"])tickets = APIRouter(prefix="/tickets", tags=["tickets"])
@products.get("/")def list_products(): return ["pen"]
@tickets.get("/{ticket_id}")def read_ticket(ticket_id: int): return {"ticket": ticket_id}
api_v1 = APIRouter(prefix="/api/v1")api_v1.include_router(products)api_v1.include_router(tickets)app.include_router(api_v1)
client = TestClient(app)print(client.get("/api/v1/products/").json())print(client.get("/api/v1/tickets/3").json())print(client.get("/products/").status_code)['pen']
{'ticket': 3}
404What this code does
- A router can include other routers, so one prefix such as
/api/v1is written once. - The old short paths no longer exist outside the prefix.
The prefix is added to every path, so this route answers at /orders/{order_id}. The tag groups the routes on the docs page.
Three layers, three jobs
Service layer is the code that holds the business rules of the app, such as no duplicate names or a stock check. It has no HTTP code and does not know how data is stored.
A route function should only handle HTTP: read the request, call something, return an answer. The business rules go in a service. The code that talks to storage goes in a repository. Each layer knows only the layer below it.
# route -> service -> repository# HTTP rules storage# 422, 404 409, totals dict, SQLExample 3 · Route, service and repository
from fastapi import APIRouter, FastAPI, HTTPExceptionfrom fastapi.testclient import TestClientfrom pydantic import BaseModel
# repository: only storageclass ProductRepository: def __init__(self): self.rows = {} self.next_id = 1
def add(self, name, price): row = {"id": self.next_id, "name": name, "price": price} self.rows[self.next_id] = row self.next_id += 1 return row
def get(self, product_id): return self.rows.get(product_id)
def find_by_name(self, name): return next((r for r in self.rows.values() if r["name"] == name), None)
# service: business rules, no HTTPclass DuplicateProduct(Exception): pass
class ProductService: def __init__(self, repo): self.repo = repo
def create(self, name, price): if self.repo.find_by_name(name): raise DuplicateProduct(name) return self.repo.add(name, price)
# routes: only HTTPclass ProductIn(BaseModel): name: str price: float
service = ProductService(ProductRepository())router = APIRouter(prefix="/products", tags=["products"])
@router.post("", status_code=201)def create_product(body: ProductIn): try: return service.create(body.name, body.price) except DuplicateProduct: raise HTTPException(status_code=409, detail="Product already exists")
@router.get("/{product_id}")def read_product(product_id: int): row = service.repo.get(product_id) if row is None: raise HTTPException(status_code=404, detail="Product not found") return row
app = FastAPI()app.include_router(router)client = TestClient(app)
r = client.post("/products", json={"name": "Keyboard", "price": 49.5})print(r.status_code, r.json())r = client.post("/products", json={"name": "Keyboard", "price": 10})print(r.status_code, r.json())print(client.get("/products/1").json()["name"])print(client.get("/products/9").status_code)201 {'id': 1, 'name': 'Keyboard', 'price': 49.5}
409 {'detail': 'Product already exists'}
Keyboard
404What this code does
- The route turns
DuplicateProductinto a 409 answer. The service does not know about HTTP. - The repository stores rows in a dict. A database version would keep the same method names.
When each layer has one job, you can change the database without touching the routes, and test the rules without any HTTP.
A folder layout that grows well
Give each layer its own place and each topic its own file. Start small: a file per layer is fine. Split into folders when a file passes a few hundred lines. The shape below is a common start.
# a sample layout# app/# main.py# routers/products.py# services/product_service.py# repositories/product_repo.py# schemas/product.pyExample 4 · Print the folder layout of a project
import tempfilefrom pathlib import Path
files = [ "app/main.py", "app/routers/products.py", "app/services/product_service.py", "app/repositories/product_repo.py", "app/schemas/product.py", "tests/test_products.py",]
def show(folder, prefix=""): for item in sorted(folder.iterdir(), key=lambda p: (p.is_file(), p.name)): print(prefix + item.name + ("/" if item.is_dir() else "")) if item.is_dir(): show(item, prefix + " ")
with tempfile.TemporaryDirectory() as tmp: root = Path(tmp) for name in files: path = root / name path.parent.mkdir(parents=True, exist_ok=True) path.touch() show(root)app/
repositories/
product_repo.py
routers/
products.py
schemas/
product.py
services/
product_service.py
main.py
tests/
test_products.pyWhat this code does
- The program builds the empty files in a temporary folder and prints the tree.
- Folders come first, then files, so the layers are easy to see.
Schemas hold the Pydantic models. main.py only creates the app and includes the routers.
Test the rules without HTTP
Because the service has no HTTP code, a test can create it with a fake repository and call it as a plain Python object. Such tests are small and fast. Add a few TestClient tests on top to check the wiring.
repo = ProductRepository()service = ProductService(repo)service.create("Pen", 5)try: service.create("Pen", 6)except DuplicateProduct: print("duplicate")Example 5 · Test the service without HTTP
from fastapi import APIRouter, FastAPI, HTTPExceptionfrom fastapi.testclient import TestClientfrom pydantic import BaseModel
# repository: only storageclass ProductRepository: def __init__(self): self.rows = {} self.next_id = 1
def add(self, name, price): row = {"id": self.next_id, "name": name, "price": price} self.rows[self.next_id] = row self.next_id += 1 return row
def get(self, product_id): return self.rows.get(product_id)
def find_by_name(self, name): return next((r for r in self.rows.values() if r["name"] == name), None)
# service: business rules, no HTTPclass DuplicateProduct(Exception): pass
class ProductService: def __init__(self, repo): self.repo = repo
def create(self, name, price): if self.repo.find_by_name(name): raise DuplicateProduct(name) return self.repo.add(name, price)
# routes: only HTTPclass ProductIn(BaseModel): name: str price: float
service = ProductService(ProductRepository())router = APIRouter(prefix="/products", tags=["products"])
@router.post("", status_code=201)def create_product(body: ProductIn): try: return service.create(body.name, body.price) except DuplicateProduct: raise HTTPException(status_code=409, detail="Product already exists")
@router.get("/{product_id}")def read_product(product_id: int): row = service.repo.get(product_id) if row is None: raise HTTPException(status_code=404, detail="Product not found") return row
app = FastAPI()app.include_router(router)client = TestClient(app)
class FakeRepo: def __init__(self): self.added = []
def find_by_name(self, name): return None
def add(self, name, price): self.added.append((name, price)) return {"id": len(self.added), "name": name, "price": price}
fake = FakeRepo()service = ProductService(fake)print(service.create("Pen", 5))print(fake.added)
repo = ProductRepository()real = ProductService(repo)real.create("Pen", 5)try: real.create("Pen", 6)except DuplicateProduct as err: print("duplicate:", err){'id': 1, 'name': 'Pen', 'price': 5}
[('Pen', 5)]
duplicate: PenWhat this code does
- A fake repository records calls and needs no storage at all.
- The rule is tested directly: the second
PenraisesDuplicateProduct.
The rule "no duplicate names" is tested with no request, no status code and no JSON.
Next step
Check your understanding
12 quick questions