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_CHECKING removes 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 ImportError or 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 core module, 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')"