As software systems grow, the Pure Handlers pattern begins to show its limitations. Multiple operations on the same entity lead to duplicated validation logic, repetitive data access code, and scattered business rules. This is where we evolve to the second generation of our architectural journey: Controllers.
Controllers represent a natural evolution from handlers by grouping related operations into cohesive units. Instead of having numerous individual handlers, we now organize functionality around business entities or features. This transition maintains the operational clarity of handlers while reducing duplication and improving maintainability.
Continuing with our e-commerce example, we'll see how controllers bring order to the proliferation of handlers while managing growing complexity.
Architectural Pattern Overview
Structure and Components
The controller pattern introduces a more organized structure:
Key components include:
Controllers: Classes that group related operations around a specific entity or feature
Actions: Individual methods within controllers that handle specific operations
Data Access: Direct database interactions, still integrated into the controller
Shared State: Common configuration and dependencies managed at the controller level
In our e-commerce example, instead of individual handlers, we now have controllers like:
ProductController
: Managing product-related operationsCartController
: Handling shopping cart functionalityOrderController
: Processing order operationsCustomerController
: Managing customer information
Responsibilities and Relationships
In this pattern:
Controllers manage a collection of related operations
Each action (method) within a controller focuses on a specific task
Controllers manage shared resources and dependencies
Common validation and authorization logic can be centralized
Data access remains directly integrated within the controller
Change Frequency Characteristics
Controllers exhibit different change patterns compared to pure handlers:
Grouped Changes: Related operations often change together
Shared Component Effects: Changes to shared logic affect multiple actions
Reduced Duplication: Common code is centralized, reducing update frequency
Coordinated Updates: Controller methods are typically updated in groups
Stratification Analysis
Layer Organization
Controllers introduce slightly more stratification than pure handlers:
We now see:
Presentation Layer: UI or API endpoints that call controller actions
Controller Layer: Groups of related operations with shared dependencies
Data Access: Still integrated within controllers but potentially with some reusable components
While still relatively flat, this organization provides better separation between request handling and presentation concerns.
Also, we start to see the emergence of an internal stratification, at the controller level, which also starts to switch focus, from a transaction script style, that pure handlers use, to a more orchestration style at the controller level.
Change Isolation
Controllers improve change isolation in several ways:
Related operations are grouped, localizing changes within domain boundaries
Shared functionality is centralized, reducing the scope of common changes
Cross-cutting concerns can be managed at the controller level
Each controller can evolve independently based on domain needs
Common Variations
Several variations exist within the controller pattern:
Resource Controllers: Organized around REST resource operations (GET, POST, PUT, DELETE)
Feature Controllers: Organized around specific features or use cases
Transaction Controllers: Explicitly managing transaction boundaries
Session Controllers: Maintaining state across multiple operations
Technology-Agnostic Example
Let's examine our e-commerce ProductController
implementation.
This controller would:
Maintain shared database connections and configurations
Provide methods for retrieving, searching, and managing products
Centralize common validation and authorization logic
Handle transaction management for product operations
Complexity Management
Where Complexity Lives
In the controller pattern, complexity is managed through:
Grouping: Related operations are organized together, making relationships clearer
Sharing: Common logic is centralized rather than duplicated
Boundaries: Each controller establishes a clear domain boundary
Cohesion: Operations that change together stay together
How the Pattern Distributes Complexity
This pattern redistributes complexity by:
Moving common logic from individual handlers to the controller level
Centralizing resource management and configuration
Establishing clearer boundaries between functional areas
Improving context sharing between related operations
Scaling Characteristics
Controllers scale better than pure handlers in several ways:
By Feature: New features can be added as new controllers
By Team: Teams can own specific controllers
By Domain: Controllers align naturally with bounded contexts
By Use Case: Related operations are grouped for better understanding
However, they still face some scaling challenges:
Controllers can grow too large as more functionality is added
Mixed responsibilities can emerge within a controller
Cross-controller concerns remain difficult to manage
Business logic and data access remain tightly coupled
The Three Dimensions Analysis
Coordination
Coordination in controllers is:
Localized: Each controller manages coordination for its domain area
Method-based: Individual actions handle their own workflow
Transaction-scoped: Controllers often manage transaction boundaries
Shared-state: State can be shared across actions within a controller
In our e-commerce example, the CartController
would coordinate all cart-related operations, from adding products to calculating totals, sharing validation and business rules across actions.
Communication
Communication patterns in controllers are:
Direct method calls: Between controller actions and data access
Parameter passing: For transferring state between components
Shared connections: Database connections are often reused
Synchronous flows: Operations typically execute in a synchronous sequence
For example, the OrderController
would communicate directly with the database for order processing, using shared connection management and transaction handling.
Consistency
Consistency in controllers is managed through:
Controller-scoped transactions: Ensuring operations within an action are atomic
Shared validation: Applying consistent rules across related operations
Centralized error handling: Managing exceptions and failures consistently
State management: Tracking and validating state changes within the domain
Our e-commerce CustomerController
would ensure consistency by applying the same validation rules to all customer operations and managing customer-related transactions.
Dimensional Friction Points
The main friction points in controllers include:
Coordination/Communication Blending: The lines between workflow management and data access are still blurred
Consistency Boundaries: Transaction management becomes more complex as operations grow
Cross-Controller Coordination: Managing operations that span multiple controllers remains challenging
Testing Strategy
Testing Approach for Controllers
The testing approach for controllers resembles a diamond rather than a pyramid:
Controller Tests: Test whole controller actions (medium)
Integration Tests: Focus on controller interactions with databases (many)
Unit Tests: Limited due to lack of separation of concerns (few)
End-to-End Tests: Verifying system behavior through UI/API (few)
Test Focus by Layer
Testing focuses on:
Action Testing: Verifying each controller action's behavior
Integration Coverage: Ensuring database interactions work correctly
Transaction Behavior: Confirming proper transaction management
Error Handling: Testing failure scenarios and edge cases
Test Isolation Strategies
Common isolation approaches include:
Controller Instantiation: Testing controllers with test dependencies
Transaction Rollbacks: For database integration tests
Shared Test Fixtures: For common test data setup
Test Database: Separate from production for integration testing
Common Testing Challenges
Challenges with testing controllers include:
Large Test Setup: Controllers often require significant test setup
Mixed Concerns: Business logic mixed with data access complicates testing
Stateful Components: Controllers with state are harder to test in isolation
Transaction Testing: Verifying proper transaction boundaries
When to Evolve
Signs Controllers Are No Longer Sufficient
It's time to consider evolving beyond Controllers when:
Controllers Become Too Large: Individual controllers handle too many responsibilities
Business Logic Complexity Increases: Simple CRUD operations evolve into complex business rules
Cross-Controller Logic Emerges: The same business logic appears in multiple controllers
Testing Becomes Cumbersome: Testing controllers requires extensive setup and mock configuration
Team Size Grows: Multiple developers need to work on the same controller
Triggers for Moving to Services
Specific triggers that indicate it's time to move to Services include:
Business rules that transcend simple data operations
Logic that needs to be reused across multiple controllers
Domain operations that could benefit from being expressed as pure functions
Growing complexity in controller actions that mixes business and data concerns
The need for more sophisticated unit testing of business rules
Transition Strategies
To evolve gracefully from Controllers to Services:
Start by identifying complex business logic within controllers
Extract this logic into service classes with focused responsibilities
Keep the controllers as orchestrators calling these services
Refactor data access code to repositories as appropriate
Gradually move transaction management to the controller layer
NFR Analysis
Non-Functional Requirement Alignment
Simplicity: ⭐⭐⭐⭐ (Still straightforward but adding more structure)
Maintainability: ⭐⭐⭐⭐ (Improved through grouping and reduced duplication)
Testability: ⭐⭐⭐ (Better but still limited by mixed concerns)
Scalability: ⭐⭐⭐ (Better organized but still faces complexity scaling issues)
Performance: ⭐⭐⭐⭐ (Direct access paths with minimal overhead)
Extensibility: ⭐⭐⭐ (Better organization improves extension capabilities)
Strengths and Weaknesses
Strengths:
Reduces duplication across related operations
Creates clear boundaries between functional areas
Improves cohesion of related functionality
Simplifies resource sharing and configuration
Weaknesses:
Controllers can grow too large over time
Business logic remains mixed with data access
Cross-controller concerns are difficult to manage
Testing remains challenging due to mixed responsibilities
Optimization Opportunities
Even within the Controller pattern, several optimizations are possible:
Extract shared validation into reusable components
Implement controller middleware for cross-cutting concerns
Consider basic repository patterns for common data access
Use controller inheritance or composition for shared functionality
Always look at ways to deconstruct / break bigger controllers in smaller ones
Architect's Alert 🚨
Controllers can easily become bloated "god objects" that try to do too much. While they help organize related operations, they don't solve the fundamental problem of separating business logic from data access. Watch for controllers that exceed 5-7 public methods or mix multiple domain concerns—these are signs that further evolution is needed.
Remember that Controllers work best for:
Organizing related CRUD operations
Managing shared resources for a specific domain area
Establishing clear feature boundaries
Systems with moderate business logic complexity
They're less suitable for:
Complex business rules that need extensive testing
Logic that needs to be shared across multiple controllers
Systems undergoing rapid domain evolution
Large teams working on the same functional area
Conclusion and Next Steps
Controllers represent a significant step forward in our architectural evolution by bringing order to the chaos of individual handlers. They establish clear boundaries around domain concepts, reduce duplication, and provide a foundation for further refinement.
As our e-commerce system continues to grow, we'll find that business logic becomes increasingly sophisticated and deserves its own home. In our next article, we'll explore how Services emerge to address this need, providing a dedicated layer for business rules and domain logic.
The journey from Controllers to Services marks an important transition—from simply organizing operations to truly separating concerns across architectural layers.
Questions for Reflection
In your current systems, what controllers have grown too large or taken on too many responsibilities?
How do you currently manage business logic that needs to be shared across multiple controllers?
What strategies have you used to test controllers effectively despite their mixed concerns?
At what point did you recognize the need to evolve beyond controllers in your architectural journey?