Fixing FastAPI Dependency Injection Circular Imports
Key takeaways:
- The cycle is at import time: two modules import each other at the top level.
- The cleanest fix is to depend on a protocol and bind the implementation in the factory.
- Function-local imports break a cycle pragmatically when restructuring is overkill.
TYPE_CHECKINGremoves import-time cost for annotation-only imports.- All four fixes also tend to improve testability.
This is a focused troubleshooting guide for Dependency Injection Strategies, and it reinforces the wiring approach from Application Factory Patterns.
The Problem This Solves
A growing FastAPI app reaches a point where a router needs a service and the service needs something from the router's module, and the app stops importing. The error is cryptic because the real cause — an import-time cycle — is structural, not local. This guide gives four reliable fixes.
Prerequisites
- A FastAPI app failing at import with
ImportErroror partially-initialized modules.
Step-by-Step Fixes
1. Depend on a protocol, bind in the factory (preferred)
# app/orders/ports.py — no imports of concrete modules.
from typing import Protocol
class OrderRepo(Protocol):
async def get(self, order_id: int) -> dict | None: ...
# app/orders/router.py — depends on the interface, not the implementation.
from fastapi import APIRouter, Request
router = APIRouter()
def get_repo(request: Request) -> OrderRepo:
return request.app.state.order_repo # Bound in create_app, not imported.
# app/main.py — the factory binds the concrete class, breaking the cycle.
from app.orders.repo import SqlOrderRepo
def create_app():
app = FastAPI()
app.state.order_repo = SqlOrderRepo()
app.include_router(router)
return app
2. Function-local import
def get_service():
# Imported at call time, so there is no module-load cycle.
from app.services.billing import BillingService
return BillingService()
3. Type-only imports under TYPE_CHECKING
from typing import TYPE_CHECKING
if TYPE_CHECKING: # Not imported at runtime — no cycle.
from app.services.billing import BillingService
def charge(service: "BillingService") -> None:
...
Edge Cases and Gotchas
- Hidden cycles through
__init__.py. A package__init__that imports submodules can create a cycle indirectly; keep__init__thin. - Shared models. If two domains import each other for a shared type, move that type to a neutral
coremodule, per Structuring Large FastAPI Projects for Scale. - Function-local overuse. Many local imports signal a structural problem; prefer the protocol fix.
Verification
# A clean import proves the cycle is gone.
python -c "import app.main; print('import OK')"
Related Reading
- Up to the topic: Dependency Injection Strategies.
- Related guides: Application Factory Patterns and Structuring Large FastAPI Projects for Scale.