Migrating from Vanilla FastAPI¶
This guide maps FastAPI patterns to their pico-fastapi equivalents. It covers route definitions, dependency injection, middleware, testing, and the application entry point.
At a glance¶
| Concept | Vanilla FastAPI | pico-fastapi |
|---|---|---|
| Routes | @app.get("/path") on functions | @get("/path") on class methods |
| Dependencies | Depends() in function parameters | Constructor injection via __init__ |
| State | app.state or global variables | Scoped components (singleton, request, session) |
| Middleware | app.add_middleware(...) | FastApiConfigurer with priority |
| Testing | Override Depends() | Container overrides |
| App creation | app = FastAPI() | app = container.get(FastAPI) |
1. Routes: functions to controller methods¶
Before¶
from fastapi import FastAPI
app = FastAPI()
@app.get("/api/users")
async def list_users():
return [{"id": 1, "name": "Alice"}]
@app.get("/api/users/{user_id}")
async def get_user(user_id: int):
return {"id": user_id, "name": "Alice"}
@app.post("/api/users")
async def create_user(data: UserCreate):
return {"id": 2, **data.model_dump()}
After¶
from pico_fastapi import controller, get, post
@controller(prefix="/api/users", tags=["Users"])
class UserController:
def __init__(self, service: UserService):
self.service = service
@get("/")
async def list_users(self):
return self.service.get_all()
@get("/{user_id}")
async def get_user(self, user_id: int):
return self.service.get(user_id)
@post("/")
async def create_user(self, data: UserCreate):
return self.service.create(data)
What changed:
- Functions become methods on a class decorated with
@controller @app.get(...)becomes@get(...)(imported frompico_fastapi)- Path parameters, query parameters, and request bodies work exactly the same — pico-fastapi forwards all FastAPI route kwargs
- The controller groups related routes under a shared
prefix
What stayed the same:
- Path parameter syntax (
{user_id}) - Pydantic request body parsing (
data: UserCreate) - Query parameters in the method signature
- All
response_model,status_code,summary, etc. kwargs
2. Dependencies: Depends() to constructor injection¶
Before¶
from fastapi import Depends, FastAPI
app = FastAPI()
def get_db():
db = Database()
try:
yield db
finally:
db.close()
def get_user_service(db: Database = Depends(get_db)):
return UserService(db)
@app.get("/api/users")
async def list_users(service: UserService = Depends(get_user_service)):
return service.get_all()
After¶
from pico_ioc import component
from pico_fastapi import controller, get
@component(scope="singleton")
class Database:
# container manages lifecycle
pass
@component
class UserService:
def __init__(self, db: Database): # injected by the container
self.db = db
def get_all(self):
return self.db.query_all()
@controller(prefix="/api/users")
class UserController:
def __init__(self, service: UserService): # injected by the container
self.service = service
@get("/")
async def list_users(self):
return self.service.get_all()
What changed:
- No
Depends()chains — dependencies are declared in__init__ @componentregisters the class with the container- Lifecycle (scope, cleanup) is managed by the container, not by generator functions with
yield
When to still use Depends():
Depends() is still useful for request-specific values that come from the HTTP request itself and don't belong in the container:
from fastapi import Depends, Header
def get_current_user(authorization: str = Header(...)):
return decode_token(authorization)
@controller(prefix="/api/profile")
class ProfileController:
def __init__(self, service: ProfileService): # container injection
self.service = service
@get("/")
async def get_profile(self, user=Depends(get_current_user)): # request-level
return self.service.get(user.id)
Rule of thumb:
- Services, repos, config -> constructor injection (
__init__) - Current user, headers, cookies ->
Depends()in the route method
3. Middleware: app.add_middleware() to configurers¶
Before¶
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
app = FastAPI()
app.add_middleware(
CORSMiddleware,
allow_origins=["http://localhost:3000"],
allow_methods=["*"],
)
After¶
from pico_ioc import component
from pico_fastapi import FastApiConfigurer
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
@component
class CORSConfigurer(FastApiConfigurer):
priority = -100 # outer — runs before scope middleware
def configure_app(self, app: FastAPI) -> None:
app.add_middleware(
CORSMiddleware,
allow_origins=["http://localhost:3000"],
allow_methods=["*"],
)
What changed:
- Middleware is added inside a
FastApiConfigurer, not at the module level - The configurer has a
prioritythat controls ordering relative to the scope middleware (see Configurers guide) - Configurers can inject dependencies (settings, services) via
__init__
Priority cheatsheet:
| Middleware | Suggested priority | Why |
|---|---|---|
| CORS | -100 | Must handle preflight before anything |
| Session | -50 | Session cookie needed before scopes |
| Auth (token parsing) | 10 | Can use request-scoped services |
| Rate limiting | 20 | After auth (to know who the user is) |
| Logging | -10 or 5 | Outer = raw; inner = with user context |
4. Error handlers¶
Before¶
@app.exception_handler(ValueError)
async def handle_value_error(request, exc):
return JSONResponse(status_code=400, content={"error": str(exc)})
After — option A: in a configurer¶
@component
class ErrorHandlerConfigurer(FastApiConfigurer):
priority = 0
def configure_app(self, app: FastAPI) -> None:
@app.exception_handler(ValueError)
async def handle_value_error(request, exc):
return JSONResponse(status_code=400, content={"error": str(exc)})
After — option B: directly on the app (in main.py)¶
If you only have one or two handlers, registering them on the app after container.get(FastAPI) is also fine:
app = container.get(FastAPI)
@app.exception_handler(ValueError)
async def handle_value_error(request, exc):
return JSONResponse(status_code=400, content={"error": str(exc)})
Both approaches work. Use configurers when the handler needs injected dependencies.
5. Application entry point¶
Before¶
from fastapi import FastAPI
app = FastAPI(title="My API", version="1.0.0")
# Routes registered by decorating functions with @app.get, etc.
After¶
# application.yaml
# fastapi:
# title: My API
# version: 1.0.0
from pico_boot import init
from pico_ioc import configuration, YamlTreeSource
from fastapi import FastAPI
config = configuration(YamlTreeSource("application.yaml"))
container = init(modules=["myapp"], config=config)
app = container.get(FastAPI)
What changed:
FastAPI()is created by the container's factory, not by you- Settings come from config files / env vars, not constructor arguments
- Controllers, services, and configurers are wired automatically
6. Testing¶
Before¶
from fastapi.testclient import TestClient
def get_mock_db():
return MockDatabase()
app.dependency_overrides[get_db] = get_mock_db
with TestClient(app) as client:
response = client.get("/api/users")
After¶
from pico_boot import init
from fastapi import FastAPI
from fastapi.testclient import TestClient
container = init(
modules=["myapp"],
overrides={Database: MockDatabase()},
)
app = container.get(FastAPI)
with TestClient(app) as client:
response = client.get("/api/users")
What changed:
- Override the class, not the factory function
- Overrides are passed to
init(), not patched on the app - Each
init()creates a fresh container — no shared state between tests
7. APIRouter to controller¶
If you use APIRouter to group routes:
Before¶
from fastapi import APIRouter
router = APIRouter(prefix="/api/items", tags=["Items"])
@router.get("/")
async def list_items():
...
@router.post("/")
async def create_item(data: ItemCreate):
...
# In main.py
app.include_router(router)
After¶
from pico_fastapi import controller, get, post
@controller(prefix="/api/items", tags=["Items"])
class ItemController:
def __init__(self, service: ItemService):
self.service = service
@get("/")
async def list_items(self):
...
@post("/")
async def create_item(self, data: ItemCreate):
...
# No include_router needed — the container registers routes automatically
What changed:
APIRouterbecomes@controller— same kwargs (prefix,tags,dependencies,responses)- No
app.include_router()— pico-fastapi discovers and registers controllers automatically - Routes get access to injected services via
self
Migration checklist¶
For each file in your existing FastAPI app:
- Replace
@app.get/post/put/delete/patchwith@get/@post/@put/@delete/@patchinside a@controllerclass - Move
Depends()chains for services to constructor injection - Keep
Depends()for request-specific data (current user, headers) - Replace
app.add_middleware()calls withFastApiConfigurerclasses - Replace
app = FastAPI()withapp = container.get(FastAPI) - Replace
app.dependency_overridesin tests with containeroverrides - Add
@componentto every service, repository, and config class - List all modules in
init(modules=[...])
See also¶
- Getting Started — full tutorial
- Configurers guide — middleware ordering
- Testing guide — testing patterns
- FAQ — common questions