FastAPI is a great way to write HTTP APIs on Starlette. QuadKit shares that lineage. Path operations, Pydantic request shapes, OpenAPI, and TestClient-style tests all have a direct home here.
What changes is the application around the routes: a container instead of Depends() on the handler, providers instead of ad-hoc startup hooks, and quadkit-contracts so SQL, cache, and LLM backends can move without rewriting callers.
You do not have to rewrite the whole app. Start with one new service or one new endpoint. This guide maps the concepts you already know, then walks through a small port.
1. What you keep, and what you gain
Section titled “1. What you keep, and what you gain”You keep the HTTP instincts. You gain a composition root.
Constructor injection. Depends() on the path operation becomes a typed constructor parameter. Same idea — declare what you need, get it resolved — one level up, so services and tests share it.
Contracts. Services depend on protocols from quadkit-contracts, not on a concrete SDK. Swap databases, caches, and LLM providers through configuration when you are ready — not because the framework forced you to on day one.
Providers. FastAPI’s startup and shutdown hooks still exist as a pattern. QuadKit names them: register(), boot(), shutdown(), ordered by ProviderPriority.
Install what you need. quadkit, quadkit-contracts, and quadkit-web are independent packages that share only contracts. The HTTP app you already know how to write is still the HTTP app.
2. Concept Mapping
Section titled “2. Concept Mapping”| FastAPI | QuadKit |
|---|---|
FastAPI() | create_app() in src/<app>/app.py — quadkit run |
@app.get("/") | @get("/") on a Controller |
@app.post("/") | @post("/") on a Controller |
app.add_middleware() | WebModule.configure(middleware=[...], discover=[...]) |
Depends() | Constructor injection with container resolution |
BackgroundTasks | A service resolved from the container (a task package is not published yet) |
APIRouter | Module + Controller class with prefix |
pydantic.BaseModel | Dataclasses + quadkit.contracts.domain value objects (Pydantic is still usable for request shapes) |
SQLAlchemy / async session | A repository service behind a protocol you define in your app (persistence packages are not published yet) |
httpx.AsyncClient | Your own client behind a protocol, injected like any other service |
pytest + TestClient | quadkit-testing with WebTestBed or ContainerTestFixture |
@app.on_event("startup") | Provider.boot() |
@app.on_event("shutdown") | Provider.shutdown() |
app.include_router() | WebModule.configure(discover=["my_app.controllers", "my_app.modules"]) |
@app.exception_handler() | ResultResponseMapper + error middleware |
app.state | Container — register and resolve services |
uvicorn.run(app) | quadkit run (or any ASGI server against my_app.app:app) |
3. Step-by-Step Migration
Section titled “3. Step-by-Step Migration”The sections below walk through converting a FastAPI application to QuadKit, one layer at a time.
3.1 Start with a Controller
Section titled “3.1 Start with a Controller”A FastAPI route function becomes a Controller class method:
# FastAPIfrom fastapi import FastAPI
app = FastAPI()
@app.get("/users/{user_id}")async def get_user(user_id: str): return {"id": user_id, "name": "Ada"}# QuadKitfrom quadkit.web import Controller, get
class UserController(Controller): prefix = "/users"
@get("/{user_id}") async def get_user(self, user_id: str) -> dict: return {"id": user_id, "name": "Ada"}The controller’s prefix replaces the repeated path segment. Route parameters map the same way — Starlette-style {param} syntax.
3.2 Move Business Logic to a Service
Section titled “3.2 Move Business Logic to a Service”Extract what the endpoint does into a service class. Dependencies are constructor-injected:
# FastAPI — logic in the route@app.get("/users/{user_id}")async def get_user(user_id: str, db: Session = Depends(get_db)): row = await db.execute("SELECT * FROM users WHERE id = :id", {"id": user_id}) user = row.fetchone() if not user: raise HTTPException(404, "User not found") return {"id": user.id, "name": user.name}# QuadKit — logic in a servicefrom quadkit.contracts.data.sql.database import DatabaseProviderProtocolfrom quadkit.result import Result, Ok, Errfrom quadkit.contracts.exceptions.domain import NotFoundError
class UserService: def __init__(self, db: DatabaseProviderProtocol) -> None: self.db = db
async def find(self, user_id: str) -> Result[dict, NotFoundError]: row = await self.db.execute_query("SELECT * FROM users WHERE id = ?", [user_id]) if not row: return Err(NotFoundError(f"User {user_id} not found")) return Ok({"id": row[0]["id"], "name": row[0]["name"]})The controller then delegates:
class UserController(Controller): prefix = "/users"
def __init__(self, users: UserService) -> None: self.users = users
@get("/{user_id}") async def get_user(self, user_id: str) -> Result[dict, NotFoundError]: return await self.users.find(user_id)3.3 Register the Service with a Provider
Section titled “3.3 Register the Service with a Provider”The service needs to be registered so the container can inject it:
from quadkit.di.provider import Providerfrom quadkit.contracts.core.di import ContainerRegistrarProtocol
class UserServiceProvider(Provider): name = "user_service" async def register(self, container: ContainerRegistrarProtocol) -> None: container.singleton(UserService, UserService)Or use the @singleton decorator for auto-registration:
from quadkit import singleton
@singletonclass UserService: ...3.4 Wire the composition root
Section titled “3.4 Wire the composition root”Drop the controller under src/my_app/controllers/ (quadkit gen controller users). List WebModule in create_app() — controllers stay discovered, not listed by hand.
from quadkit import Application, QuadKitConfigfrom quadkit.web import WebModule
def create_app(config: QuadKitConfig | None = None) -> Application: application = Application(name="my-api", config=config) application.add_modules( [ WebModule.configure( discover=["my_app.controllers", "my_app.modules"], ), ] ) return application
app = create_app()quadkit runWebModule / WebProvider has PRESENTATION priority and boots last — infrastructure (database, cache, auth) is ready by the time routes are mounted. Do not list controllers in app.py; discovery is the contract. See Common mistakes.
4. Dependency Injection Deep Dive
Section titled “4. Dependency Injection Deep Dive”FastAPI’s Depends() resolves a dependency at the path operation. QuadKit does the same work on the constructor, so the service is equally easy to call from a route, a task, or a test.
Constructor Injection
Section titled “Constructor Injection”# FastAPI — Depends() at the function level@app.get("/orders")async def list_orders( repo: OrderRepository = Depends(get_order_repo), user: User = Depends(get_current_user),): return await repo.find_by_user(user.id)# QuadKit — constructor injection at the class levelclass OrderController(Controller): prefix = "/orders"
def __init__( self, repo: OrderRepository, current_user: User, ) -> None: self.repo = repo self.user = current_user
@get("/") async def list_orders(self) -> list[dict]: return await self.repo.find_by_user(self.user.id)The container resolves OrderRepository and User from their type hints — the same type-driven idea as Depends(), moved to the class.
Container Resolution
Section titled “Container Resolution”You can resolve dependencies manually when needed — typically in Provider.boot():
async def boot(self, container: BootContainerProtocol) -> None: db = await container.resolve(DatabaseProviderProtocol) await db.connect()Scoped vs Singleton
Section titled “Scoped vs Singleton”| Scope | FastAPI | QuadKit |
|---|---|---|
| Singleton | @lru_cache or manual | @singleton or container.singleton() |
| Request-scoped | Depends() with yield | @scoped or container.scoped() |
| Transient | Default Depends() | @transient or container.transient() |
QuadKit’s scoped container is particularly useful for per-request units of work:
@scopedclass UnitOfWork: def __init__(self, db: DatabaseProviderProtocol) -> None: self._db = db
async def begin(self) -> None: await self._db.begin_transaction()
async def commit(self) -> None: await self._db.commit_transaction()
async def rollback(self) -> None: await self._db.rollback_transaction()5. Testing
Section titled “5. Testing”FastAPI tests with TestClient are the right instinct. QuadKit’s WebTestBed is that client against a booted Application. Services can also be constructed directly with fakes.
Controller Tests
Section titled “Controller Tests”# FastAPIfrom fastapi.testclient import TestClient
def test_get_user(): client = TestClient(app) response = client.get("/users/1") assert response.status_code == 200# QuadKitfrom quadkit import Applicationfrom quadkit.web import WebModulefrom quadkit.testing import WebTestBed
async def test_get_user(): async with Application.boot( name="test", modules=[WebModule.stub()], ) as app: client = WebTestBed(app) response = await client.get("/users/1") assert response.status_code == 200Service Tests with Fakes
Section titled “Service Tests with Fakes”FastAPI’s dependency_overrides is the testing hatch. QuadKit’s is protocol fakes — and container.override when you need it inside a booted app:
# FastAPIapp.dependency_overrides[get_db] = lambda: FakeDB()
# QuadKit — inject the fake directlyfrom quadkit.testing import FakeCache
async def test_order_service(): cache = FakeCache() service = OrderService(cache=cache) result = await service.place("order-1") assert result.is_ok()Override in the Container
Section titled “Override in the Container”When you need to replace one dependency in a booted application:
container = Container(testing_mode=True)container.override(UserRepository, FakeUserRepository())6. Common Pitfalls
Section titled “6. Common Pitfalls”Forgetting to Register Providers
Section titled “Forgetting to Register Providers”A service decorated with @singleton is only auto-registered when Application.discover_providers() scans its package. If you add a new service and the container can’t resolve it, check that either:
- A provider in
src/<app>/di/(or a moduleprovider.py) registered it - Its package is included in
discover_providers("my_app.di") - You registered it via
container.singleton()inregister()
Trying to Resolve Before Boot
Section titled “Trying to Resolve Before Boot”The container is open for registration only during the register() phase. Resolution during registration raises an error — the container hasn’t frozen yet. Do resolution in boot():
# ❌ Wrong — resolution during registrationasync def register(self, container): db = await container.resolve(DatabaseProviderProtocol) # Fails
# ✅ Correct — register only bindingsasync def register(self, container): container.singleton(DatabaseProviderProtocol, MyDatabase)
# ✅ Correct — resolve in bootasync def boot(self, container): db = await container.resolve(DatabaseProviderProtocol) await db.connect()Using Exceptions for Domain Errors
Section titled “Using Exceptions for Domain Errors”FastAPI’s HTTPException is the HTTP-shaped expected failure. QuadKit keeps that mapping at the edge and uses Result in the domain so the same service works behind a queue or a CLI:
# FastAPIif not user: raise HTTPException(status_code=404, detail="User not found")return user
# QuadKit — return Resultif not user: return Err(NotFoundError(f"User {user_id} not found"))return Ok(user)Domain errors go through Result. Infrastructure errors (connection loss, timeout) are raised as exceptions — the ResultResponseMapper converts Ok/Err to the appropriate HTTP status, so controllers stay clean.
Direct Imports Across Extension Packages
Section titled “Direct Imports Across Extension Packages”If your routes import SQLAlchemy (or any client) today, that still works — QuadKit prefers the protocol so the controller does not care which backend you bound:
# ❌ Wrong — one extension importing another's implementationfrom quadkit.cli import CLIRunnerProtocol # Cross-extension import
# ✅ Correct — depend on the protocol from the foundation layerfrom quadkit.contracts.data import DatabaseProviderProtocolCross-extension communication goes through contracts in quadkit-contracts, never through direct imports. See the Architecture doc for details.
Expecting Starlette’s Request Object Everywhere
Section titled “Expecting Starlette’s Request Object Everywhere”FastAPI’s Request is still there when you need the ASGI scope. Most controllers don’t: route parameters, body, and query params are extracted automatically. Inject Request from quadkit.web when you want it:
from quadkit.web import Request
class UserController(Controller): @get("/users/{user_id}") async def get(self, user_id: str, request: Request) -> dict: client_ip = request.client.host ...Using app.state for Shared State
Section titled “Using app.state for Shared State”If you stash shared objects on app.state today, the container is the equivalent:
# FastAPIapp.state.db = Database()
# QuadKit — register in the containercontainer.singleton(DatabaseProviderProtocol, MyDatabase)
# Then inject wherever neededclass MyService: def __init__(self, db: DatabaseProviderProtocol) -> None: self.db = dbNext Steps
Section titled “Next Steps”- Your First App — scaffold,
quadkit run, add a route - Coming from FastAPI — the same map as a narrative
- Application Lifecycle — the composition root and boot sequence
- Dependency Injection — scopes, decorators, and manual resolution
- Providers — the 2-phase lifecycle and boot ordering
- Testing — fakes, test beds, and protocol compliance suites
- Ecosystem — every extension package and what it does