Skip to content

Migrating from FastAPI

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.


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.


FastAPIQuadKit
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
BackgroundTasksA service resolved from the container (a task package is not published yet)
APIRouterModule + Controller class with prefix
pydantic.BaseModelDataclasses + quadkit.contracts.domain value objects (Pydantic is still usable for request shapes)
SQLAlchemy / async sessionA repository service behind a protocol you define in your app (persistence packages are not published yet)
httpx.AsyncClientYour own client behind a protocol, injected like any other service
pytest + TestClientquadkit-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.stateContainer — register and resolve services
uvicorn.run(app)quadkit run (or any ASGI server against my_app.app:app)

The sections below walk through converting a FastAPI application to QuadKit, one layer at a time.

A FastAPI route function becomes a Controller class method:

# FastAPI
from fastapi import FastAPI
app = FastAPI()
@app.get("/users/{user_id}")
async def get_user(user_id: str):
return {"id": user_id, "name": "Ada"}
# QuadKit
from 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.

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 service
from quadkit.contracts.data.sql.database import DatabaseProviderProtocol
from quadkit.result import Result, Ok, Err
from 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)

The service needs to be registered so the container can inject it:

from quadkit.di.provider import Provider
from 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
@singleton
class UserService:
...

Drop the controller under src/my_app/controllers/ (quadkit gen controller users). List WebModule in create_app() — controllers stay discovered, not listed by hand.

src/my_app/app.py
from quadkit import Application, QuadKitConfig
from 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()
Terminal window
quadkit run

WebModule / 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.


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.

# 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 level
class 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.

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()
ScopeFastAPIQuadKit
Singleton@lru_cache or manual@singleton or container.singleton()
Request-scopedDepends() with yield@scoped or container.scoped()
TransientDefault Depends()@transient or container.transient()

QuadKit’s scoped container is particularly useful for per-request units of work:

@scoped
class 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()

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.

# FastAPI
from fastapi.testclient import TestClient
def test_get_user():
client = TestClient(app)
response = client.get("/users/1")
assert response.status_code == 200
# QuadKit
from quadkit import Application
from quadkit.web import WebModule
from 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 == 200

FastAPI’s dependency_overrides is the testing hatch. QuadKit’s is protocol fakes — and container.override when you need it inside a booted app:

# FastAPI
app.dependency_overrides[get_db] = lambda: FakeDB()
# QuadKit — inject the fake directly
from 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()

When you need to replace one dependency in a booted application:

container = Container(testing_mode=True)
container.override(UserRepository, FakeUserRepository())

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 module provider.py) registered it
  • Its package is included in discover_providers("my_app.di")
  • You registered it via container.singleton() in register()

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 registration
async def register(self, container):
db = await container.resolve(DatabaseProviderProtocol) # Fails
# ✅ Correct — register only bindings
async def register(self, container):
container.singleton(DatabaseProviderProtocol, MyDatabase)
# ✅ Correct — resolve in boot
async def boot(self, container):
db = await container.resolve(DatabaseProviderProtocol)
await db.connect()

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:

# FastAPI
if not user:
raise HTTPException(status_code=404, detail="User not found")
return user
# QuadKit — return Result
if 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.

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 implementation
from quadkit.cli import CLIRunnerProtocol # Cross-extension import
# ✅ Correct — depend on the protocol from the foundation layer
from quadkit.contracts.data import DatabaseProviderProtocol

Cross-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
...

If you stash shared objects on app.state today, the container is the equivalent:

# FastAPI
app.state.db = Database()
# QuadKit — register in the container
container.singleton(DatabaseProviderProtocol, MyDatabase)
# Then inject wherever needed
class MyService:
def __init__(self, db: DatabaseProviderProtocol) -> None:
self.db = db