Dependency Injection Pattern

Overview

All services use constructor-based dependency injection instead of module-level global singletons. Dependencies are created in main() and passed explicitly to consumers via constructors or factory function parameters.

Motivation

  • Testability: Tests create fresh instances with mock dependencies directly, no fragile patch() paths
  • Explicitness: Each component declares its dependencies in its constructor signature
  • Isolation: No shared mutable state between test cases

Pattern

gRPC Services (galaxy, players, physics, tick-engine)

Dependencies flow from main() through factory functions:

main() creates instances
  → create_server(port, deps...) passes to Servicer constructor
  → create_health_app(deps...) passes to health endpoint closures

Servicer Constructor Injection

class PhysicsServicer:
    def __init__(self, redis_state: RedisState, simulation: PhysicsSimulation):
        self._redis_state = redis_state
        self._simulation = simulation

Server Factory

def create_server(port: int, redis_state: RedisState, simulation: PhysicsSimulation) -> grpc.aio.Server:
    server = grpc.aio.server()
    servicer = PhysicsServicer(redis_state, simulation)
    physics_pb2_grpc.add_PhysicsServicer_to_server(servicer, server)
    server.add_insecure_port(f"[::]:{port}")
    return server

Health App Factory (Closure Pattern)

def create_health_app(redis_state: RedisState, simulation: PhysicsSimulation) -> Starlette:
    async def health_ready(request):
        if _shutting_down:
            return JSONResponse({"status": "shutting_down"}, status_code=503)
        if redis_state.connected and simulation.initialized:
            return JSONResponse({"status": "ready"})
        return JSONResponse({"status": "not_ready"}, status_code=503)

    routes = [Route("/health/ready", health_ready), ...]
    return Starlette(routes=routes)

The _shutting_down flag remains module-level in health.py with its set_shutting_down() mutator, since it is set by signal handlers in main() and read by health endpoint closures within the same module.

FastAPI Service (api-gateway)

Dependencies are stored on app.state during startup and accessed via FastAPI’s Depends() system:

Dependency Providers (deps.py)

from fastapi import Request
from .grpc_clients import GrpcClients
from .websocket_manager import WebSocketManager

def get_grpc_clients(request: Request) -> GrpcClients:
    return request.app.state.grpc_clients

def get_ws_manager(request: Request) -> WebSocketManager:
    return request.app.state.ws_manager

Route Handler Injection

@router.post("/api/game/status")
async def game_status(grpc: GrpcClients = Depends(get_grpc_clients)):
    response = await grpc.tick_engine.GetStatus(...)
    return {"status": "ok", ...}

Startup Wiring

@app.on_event("startup")
async def startup():
    app.state.grpc_clients = GrpcClients()
    await app.state.grpc_clients.connect()
    app.state.ws_manager = WebSocketManager(app.state.grpc_clients)
    await app.state.ws_manager.connect()

Encapsulation Rules

Internal attributes (prefixed with _) must not be accessed from outside their owning class. When external code needs access to an internal resource, the class must expose a public property or method.

Example: WebSocketManager.redis

Route handlers need access to the Redis connection managed by WebSocketManager. Rather than accessing ws_manager._redis directly (breaking encapsulation), WebSocketManager exposes a public redis read-only property:

class WebSocketManager:
    def __init__(self, grpc_clients: GrpcClients) -> None:
        self._redis: Optional[redis.Redis] = None
        ...

    @property
    def redis(self) -> Optional[redis.Redis]:
        """Public accessor for the Redis connection."""
        return self._redis

Route handlers use ws_manager.redis instead of ws_manager._redis. This preserves the ability to change the internal storage mechanism without breaking callers.

Service Dependency Maps

Service Component Constructor Dependencies
galaxy GalaxyServicer GalaxyService
players PlayersServicer PlayersService
players PlayersService Database
physics PhysicsServicer RedisState, PhysicsSimulation
physics PhysicsSimulation RedisState
tick-engine TickEngineServicer TickEngineState, TickLoop
tick-engine TickLoop TickEngineState
api-gateway WebSocketManager GrpcClients
api-gateway Route handlers via Depends(): GrpcClients, WebSocketManager, AdminAuth, LoginRateLimiter

Testing

With DI, tests construct components directly with mock dependencies:

def test_get_bodies():
    mock_service = MagicMock(spec=GalaxyService)
    mock_service.get_bodies.return_value = []
    servicer = GalaxyServicer(mock_service)
    response = servicer.GetBodies(request, context)
    assert response.bodies == []

No patch() needed for injected dependencies. patch() is still used for non-injected module-level imports (e.g., structlog.get_logger()).


Back to top

Galaxy — Kubernetes-based multiplayer space game

This site uses Just the Docs, a documentation theme for Jekyll.