Skip to content
Packages Examples Agents Blog Get started

Migrating from FastAPI

FastAPI is a great way to write HTTP APIs on Starlette. Oridecon 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 oridecon-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 oridecon-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. Oridecon names them: register(), boot(), shutdown(), ordered by ProviderPriority.

Install what you need. oridecon-web, oridecon-sql, oridecon-cache are independent packages that share only contracts. The HTTP app you already know how to write is still the HTTP app.


FastAPIOridecon
FastAPI()create_app() in src/<app>/app.pyoridecon 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
BackgroundTasksoridecon-tasks with provider registration (or direct injection of a task service)
APIRouterModule + Controller class with prefix
pydantic.BaseModelDataclasses + oridecon.contracts.domain value objects (Pydantic is still usable for request shapes)
SQLAlchemy / async sessionoridecon-sql with DatabaseProviderProtocol
httpx.AsyncClientoridecon-http with HTTPClientProtocol
pytest + TestClientoridecon-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)oridecon run (or any ASGI server against my_app.app:app)

The sections below walk through converting a FastAPI application to Oridecon, 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"}
# Oridecon
from oridecon.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}
# Oridecon — logic in a service
from oridecon.contracts.data.sql.database import DatabaseProviderProtocol
from oridecon.result import Result, Ok, Err
from oridecon.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 oridecon.di.provider import Provider
from oridecon.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 oridecon import singleton
@singleton
class UserService:
...

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

src/my_app/app.py
from oridecon import Application, OrideconConfig
from oridecon.web import WebModule
def create_app(config: OrideconConfig | 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
oridecon 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. Oridecon 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)
# Oridecon — 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()
ScopeFastAPIOridecon
Singleton@lru_cache or manual@singleton or container.singleton()
Request-scopedDepends() with yield@scoped or container.scoped()
TransientDefault Depends()@transient or container.transient()

Oridecon’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. Oridecon’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
# Oridecon
from oridecon import Application
from oridecon.web import WebModule
from oridecon.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. Oridecon’s is protocol fakes — and container.override when you need it inside a booted app:

# FastAPI
app.dependency_overrides[get_db] = lambda: FakeDB()
# Oridecon — inject the fake directly
from oridecon.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. Oridecon 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
# Oridecon — 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 — Oridecon prefers the protocol so the controller does not care which backend you bound:

# ❌ Wrong — importing SQL implementation in a controller
from oridecon.sql import DatabaseProvider # Cross-extension import
# ✅ Correct — depend on the protocol
from oridecon.contracts.data.sql.database import DatabaseProviderProtocol

Cross-extension communication goes through contracts in oridecon-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 oridecon.web when you want it:

from oridecon.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()
# Oridecon — register in the container
container.singleton(DatabaseProviderProtocol, MyDatabase)
# Then inject wherever needed
class MyService:
def __init__(self, db: DatabaseProviderProtocol) -> None:
self.db = db