Skip to main content

ADR-004: Controller Architecture and Dependency Graph

Status

Accepted

Context

Initially, Tasteful implemented routes directly within flavor classes. This approach seemed logical - a flavor would contain its configuration, services, repositories, and routes all in one place:
# Original approach - routes directly in flavor
class UserFlavor(BaseFlavor):
    def __init__(self):
        super().__init__()

    @Get("/users/{user_id}")
    async def get_user(self, user_id: int):
        return await self.user_service.get_user(user_id)
However, this approach created several problems:
  1. Dependency Injection Limitations: Routes couldn’t be properly integrated into the dependency injection lifecycle
  2. Service Instance Naming: Variable names for services were auto-generated by regex minimization, making it hard for developers to identify and use them correctly.
  3. Service Coupling: Routes were tightly coupled to the flavor’s specific service instances
  4. Testing Complexity: Difficult to mock dependencies for individual route testing
  5. Flexibility Issues: Routes couldn’t easily use different services or configurations without interfering with the flavor’s core components
We needed an architecture that would:
  • Decouple routes from flavor lifecycle management
  • Enable proper dependency injection for routes
  • Allow routes to use different services or configurations independently
  • Maintain clear separation between flavor composition and route logic

Decision

We adopted decoupled controller architecture where routes are separated from flavor classes and managed through dedicated controller classes with proper dependency injection.

The New Architecture

Controllers: Handle routes and HTTP logic with injected dependencies
from tasteful.base_flavor import BaseController

class UserController(BaseController):
    def __init__(self, user_service: UserService, auth_service: AuthService):
        super().__init__(prefix="/users", tags=["users"])
        self.user_service = user_service
        self.auth_service = auth_service
    
    @Get("/{user_id}")
    async def get_user(self, user_id: int, request: Request):
        # Route logic with injected services
        user = request.state.user
        return await self.user_service.get_user(user_id, requesting_user=user)
    
    @Post("/")
    async def create_user(self, user_data: UserCreate, request: Request):
        user = request.state.user
        return await self.user_service.create_user(user_data, created_by=user)
Flavors: Compose and configure the complete dependency graph
class UserFlavor(BaseFlavor):
    def __init__(self):
        super().__init__(
            controller=UserController,
            services=[UserService, EmailService],
            repositories=[UserRepository],
            config=UserConfig
        )

Rationale

Why Decouple Controllers from Flavors?

The original approach of embedding routes directly in flavors created several architectural problems: Problem 1: Dependency Injection Lifecycle Issues
# Original problematic approach
class UserFlavor(BaseFlavor):
    def __init__(self):
        self.user_service = UserService()
    
    @Get("/users/{user_id}")
    async def get_user(self, user_id: int):
        # Route tied to flavor's specific service instance
        return await self.user_service.get_user(user_id)
Problem 2: Inflexible Service Usage Routes couldn’t use different configurations or alternative service implementations without modifying the entire flavor. Solution: Controller Decoupling
# New approach - controllers with dependency injection
class UserController(BaseController):
    def __init__(self, user_service: UserService, auth_service: AuthService):
        super().__init__(prefix="/users", tags=["users"])
        self.user_service = user_service  # Explicitly declared variable name with injected dependency
        self.auth_service = auth_service  # Explicitly declared variable name that can use different implementation
    
    @Get("/{user_id}")
    async def get_user(self, user_id: int, request: Request):
        # Route uses injected services, not flavor's fixed instances
        return await self.user_service.get_user(user_id)

Why Flavor as Pure Composition?

Flavors now serve a single, clear purpose: composing the complete dependency graph
class UserFlavor(BaseFlavor):
    """
    A flavor is purely a composition of:
    - Controller (handles routes)
    - Services (business logic)
    - Repositories (data access)
    - Config (configuration)
    """
    def __init__(self):
        super().__init__(
            controller=UserController,
            services=[UserService, EmailService, NotificationService],
            repositories=[UserRepository, AuditRepository],
            config=UserConfig
        )

Why Personal Container per Flavor?

Each flavor gets its own dependency injection scope:
  1. Isolation: Dependencies don’t interfere between flavors
  2. Flexibility: Each flavor can configure its dependencies independently
  3. Testing: Easy to override dependencies for specific flavors
  4. Modularity: Flavors are truly self-contained modules
# Each flavor manages its own dependency graph
class OrderFlavor(BaseFlavor):
    def __init__(self):
        super().__init__(
            controller=OrderController,
            services=[OrderService, PaymentService, InventoryService],
            repositories=[OrderRepository, PaymentRepository],
            config=OrderConfig
        )

# Container automatically wires dependencies within flavor scope
def _register_flavor_dependencies(self, flavor: BaseFlavor) -> None:
    # Controller gets its dependencies injected from flavor's services
    controller_deps = self._resolve_controller_dependencies(
        flavor.controller, 
        available_services=flavor.services,
        available_configs=flavor.config
    )

Consequences

Positive

  • Proper Separation of Concerns: Controllers handle HTTP logic, flavors handle composition
  • Dependency Injection Integration: Routes can participate fully in the DI lifecycle
  • Flexibility: Controllers can use different service implementations without affecting flavor composition
  • Testability: Easy to mock dependencies and test controllers in isolation
  • Modularity: Flavors are truly self-contained with their own dependency scopes
  • Maintainability: Changes to routes don’t interfere with flavor’s core components

Negative

  • Additional Abstraction Layer: More classes to understand (Controller + Flavor)
  • Learning Curve: Developers need to understand the controller/flavor separation
  • Container Complexity: Each flavor manages its own dependency graph

Trade-offs

  • Flexibility vs. Simplicity: More flexible dependency management but requires understanding two concepts (controllers and flavors)
  • Decoupling vs. Directness: Better separation of concerns but less direct than having routes in flavors
  • Testability vs. Setup: Much better testability but requires more initial setup

Migration Benefits

  • Backward Compatibility: Existing flavors can be gradually migrated
  • Incremental Adoption: Teams can adopt controller pattern flavor by flavor
  • Clear Upgrade Path: Simple transformation from flavor routes to controller classes

References

I