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.
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 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.
2. Concept Mapping
Section titled “2. Concept Mapping”| FastAPI | Oridecon |
|---|---|
FastAPI() | create_app() in src/<app>/app.py — oridecon 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 | oridecon-tasks with provider registration (or direct injection of a task service) |
APIRouter | Module + Controller class with prefix |
pydantic.BaseModel | Dataclasses + oridecon.contracts.domain value objects (Pydantic is still usable for request shapes) |
SQLAlchemy / async session | oridecon-sql with DatabaseProviderProtocol |
httpx.AsyncClient | oridecon-http with HTTPClientProtocol |
pytest + TestClient | oridecon-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) | oridecon 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 Oridecon, 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"}# Orideconfrom 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.
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}# Oridecon — logic in a servicefrom oridecon.contracts.data.sql.database import DatabaseProviderProtocolfrom oridecon.result import Result, Ok, Errfrom 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)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 oridecon.di.provider import Providerfrom 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
@singletonclass UserService: ...3.4 Wire the composition root
Section titled “3.4 Wire the composition root”Drop the controller under src/my_app/controllers/ (oridecon gen controller users). List WebModule in create_app() — controllers stay discovered, not listed by hand.
from oridecon import Application, OrideconConfigfrom 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()oridecon 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. Oridecon 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)# Oridecon — 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 | Oridecon |
|---|---|---|
| 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() |
Oridecon’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. Oridecon’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# Orideconfrom oridecon import Applicationfrom oridecon.web import WebModulefrom 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 == 200Service Tests with Fakes
Section titled “Service Tests with Fakes”FastAPI’s dependency_overrides is the testing hatch. Oridecon’s is protocol fakes — and container.override when you need it inside a booted app:
# FastAPIapp.dependency_overrides[get_db] = lambda: FakeDB()
# Oridecon — inject the fake directlyfrom 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()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. Oridecon 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
# Oridecon — 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 — Oridecon prefers the protocol so the controller does not care which backend you bound:
# ❌ Wrong — importing SQL implementation in a controllerfrom oridecon.sql import DatabaseProvider # Cross-extension import
# ✅ Correct — depend on the protocolfrom oridecon.contracts.data.sql.database import DatabaseProviderProtocolCross-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 ...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()
# Oridecon — 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,
oridecon 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