Generation Three: Services - Encapsulating Business Logic
As we grow our e-commerce platform, the controllers we've built start to feel the weight of increasing business complexity. The pricing logic now includes conditional discounts, tax calculations, and promotional bundles. Order processing encompasses inventory checks, user credit validation, and loyalty program integration. With each new feature, our controllers grow larger and more entangled.
This is where the third generation of our architecture emerges: Services.
Services represent a significant advancement in our architectural journey. They acknowledge that business logic deserves its own dedicated home, separate from the coordination and request handling concerns of controllers. While controllers focus on "what to do," services encapsulate "how to do it."
Consider our product catalog: what started as simple product retrieval now involves dynamic pricing, personalized recommendations, and inventory-aware availability status. Rather than embedding all this complexity in the ProductController, we can extract it into a dedicated ProductService, allowing each component to focus on what it does best.
Architectural Pattern Overview
Structure and Components
The service pattern introduces a more layered architecture where each component has clearer responsibilities.
Controllers serve as orchestrators that handle HTTP requests, input validation, and workflow coordination. They decide which services to call in what order, manage transaction boundaries, and translate between API requests and internal service calls. In our e-commerce platform, the CheckoutController doesn't implement pricing or inventory logic directly—it coordinates these activities by calling the appropriate services.
Services encapsulate business logic and domain rules in focused components. A PricingService knows all the rules about calculating product prices, including discounts, taxes, and promotions. It doesn't care about HTTP requests or database queries; it focuses solely on calculating the right price based on the inputs it receives.
Repositories may start to emerge in this generation as optional components that abstract data access. While some services still access data directly, others begin delegating this responsibility to specialized components. Repositories, are not new though, they could also be part of a solution with Gen 1 Pure handlers Gen 2 Controllers.
DTOs (Data Transfer Objects) and domain models formalize the interfaces between layers, creating clear contracts between components.
Our e-commerce platform now organizes its business logic into dedicated services—ProductService manages catalog functionality, CartService implements shopping cart business rules, OrderService handles order processing and validation, while PricingService concentrates on all aspects of product pricing.
Responsibilities and Relationships
In this architecture, controllers and services form a symbiotic relationship with clear boundaries. Controllers take responsibility for handling HTTP requests, orchestrating the overall workflow, and managing transaction boundaries. When a user places an order, the OrderController validates the input, starts a transaction, then coordinates the process by calling various services in sequence.
Services, on the other hand, implement the actual business logic, domain rules, and computations. The OrderService doesn't know about HTTP or transaction boundaries, but it understands everything about order validation, confirmation processes, and business rules. Controllers call services to perform these business operations, while services may call other services (with care …) to compose more complex functionality.
Data access patterns begin to evolve at this stage. In some cases, data access logic still resides within services, while in others, it begins to move into dedicated repositories. This transition often happens gradually as the team recognizes patterns in data access that could benefit from abstraction.
Change Frequency Characteristics
With services, we begin to see different parts of our system evolve at their natural pace. Business logic in services can evolve independently from request handling in controllers. When tax calculation rules change, we only need to update the PricingService, leaving controllers untouched.
Updates to business rules affect only the relevant services, creating focused changes with minimal ripple effects. If we change how shipping costs are calculated, that change remains isolated to the ShippingService.
The reusability of services means changes to shared business logic happen in one place. When the CartService calculates totals, it calls the PricingService—the same service used by the product catalog. If pricing logic changes, both features benefit from the update automatically.
Each component now evolves according to its own natural pace and requirements, rather than being forced to change in lockstep with other components.
Stratification Analysis
Layer Organization
With services, our architecture develops more meaningful stratification that better reflects the different concerns within our system.
The presentation layer consists of UI components or API endpoints that call controllers. These focus purely on user interaction and data presentation without embedding business rules or data access logic.
The controller layer takes responsibility for orchestration, workflow management, and transaction control. Controllers decide the sequence of operations, manage transaction boundaries, and coordinate the overall process, but delegate the actual business operations to services.
The service layer houses business logic, domain rules, and operations. This is where the rules of our e-commerce business live—how prices are calculated, how orders are processed, and how inventory is managed. Services don't concern themselves with HTTP requests or database connections; they focus on expressing business capabilities.
The data access layer begins to emerge, either within services or as dedicated repositories. This layer handles the mechanics of retrieving and storing data, isolating the rest of the system from the specifics of the database technology.
This stratification creates clearer boundaries between different types of functionality and allows each layer to focus on its core responsibilities without being distracted by concerns that belong elsewhere.
Dependency Flow
The dependency flow in our architecture becomes more structured and purposeful. Controllers depend on services, which may depend on repositories or direct data access mechanisms. This creates a more linear flow of dependencies compared to previous generations, where responsibilities were more tangled.
When a customer views their order history, the request flows from the UI to the OrderController, which calls the OrderService, which may use an OrderRepository to access the database. Each layer has a clear responsibility and depends only on the layer below it.
Transaction management typically resides in controllers, spanning multiple service calls, while domain logic lives within services, creating a clean separation of these essential concerns.
Change Isolation
The service pattern significantly improves change isolation compared to previous generations. Business logic changes now affect only specific services without requiring changes to controllers or UI components. When we change how discounts are calculated, the PricingService changes, but the ProductController remains untouched.
User interface changes primarily impact controllers and UI components without requiring modifications to business logic. Data access changes can start to be isolated to repositories or data access methods, protecting services from database evolution.
This improved isolation means that different types of changes affect different components, allowing teams to work more independently and reducing the risk of unintended consequences from changes.
Implementation Patterns
Clasic example of a service structure:
class ProductController {
private readonly productService;
// Controller actions that delegate to the service
GetProductDetails(id) {
// Handle request, validation, and orchestration
return this.productService.getProduct(id);
// or if you a prefer a more sophisticated version
var productResult = this.productService.getProduct(id);
return Contracts.V1.Product.FromDto(productResult)
}
}
class ProductService {
// Business logic methods
getProduct(id) { ... }
searchProducts(criteria) { ... }
calculateProductRating(productId) { ... }
}
Common Variations
Several variations exist within the service pattern:
Domain Services: Focus on business domain operations
Infrastructure Services: Manage technical concerns like email, logging, etc.
Stateless vs. Stateful Services: Depending on business needs
Application Services: Start grouping the business logic in use-cases and move the orchestration from the controller ( up next ).
Example
Let's examine our e-commerce OrderService
implementation:
This service would:
Implement order validation and business rules
Calculate order totals, taxes, and shipping
Apply discounts and promotions
Handle inventory verification
Create and update order status
Complexity Management
Where Complexity Lives
The service pattern doesn't eliminate complexity—it redistributes it to more appropriate locations. Business logic now lives separately from orchestration, giving each type of complexity its proper home. Consider our order placement process: the complexity of coordinating the overall workflow (getting cart details, processing payment, creating the order) lives in the controller, while the complexity of applying business rules (calculating totals, validating inventory, determining shipping options) resides in specialized services.
Each service focuses on specific business capabilities rather than trying to be all things to all use cases. The PricingService understands pricing logic deeply, while the InventoryService becomes the authority on stock management. This specialization allows each service to develop expertise in its domain without being burdened by unrelated concerns.
Complex operations can now be composed from simpler service calls. Creating an order involves multiple business operations, each handled by a specialized service. The OrderController orchestrates these calls but delegates the actual business operations to appropriate services.
Every service now has a clear, focused purpose—a single responsibility that guides its design and evolution. This focus helps prevent the "kitchen sink" services that plague many systems.
How the Pattern Distributes Complexity
By moving business logic out of controllers into specialized services, we create a cleaner separation between orchestration and business rules. The CartController no longer needs to understand discount calculations—it simply calls the PricingService when it needs a price. This separation allows each component to be simpler and more focused on its core responsibility.
Clear boundaries emerge between different types of functionality. Business rules live in services, workflow management in controllers, and data presentation in the UI. These boundaries reduce mental load by allowing developers to focus on one type of complexity at a time.
Business rules can now be expressed more purely, without being tangled with HTTP handling or database queries. The logic for calculating shipping costs can be expressed in a clean, focused method in the ShippingService, making it easier to understand, test, and modify.
Service composition enables complex operations to be built from simpler components. Adding a product to a cart might involve checking inventory, calculating price, applying user-specific discounts, and updating the cart—each handled by a specialized service called in sequence by the controller.
Scaling Characteristics
Services scale better than controllers in several dimensions. They align naturally with business domain concepts, allowing the system to grow along the natural boundaries of the business. The catalog team can focus on product-related services, while the order team concentrates on order processing services.
Team structure benefits from this alignment as teams can own specific services that match their business domain expertise. The shipping team owns the ShippingService, bringing together business and technical knowledge in a cohesive unit.
Complex business rules finally find a proper home, separated from orchestration concerns. As the business rules for tax calculation grow in complexity, they remain contained within the TaxService rather than bloating controllers.
Perhaps most importantly, services enable reuse across multiple controllers. The same PricingService can be used by the product catalog, shopping cart, and order processing, ensuring consistent application of pricing rules throughout the system.
Despite these improvements, some scaling challenges remain. Services can grow too large if not properly bounded, taking on too many responsibilities over time. Transaction management across multiple service calls requires careful coordination to maintain data consistency. Data access logic may still be embedded in services rather than being properly abstracted. And as services call other services, dependency graphs can become complex and difficult to understand.
The Three Dimensions Analysis
Coordination
Coordination in the service pattern is:
Controller-Centered: Controllers orchestrate the overall workflow
Transaction-Managed: Controllers typically manage transaction boundaries
Workflow-Oriented: Complex operations broken into service method calls
Compositional: Services may call other services to compose functionality
In our e-commerce example, the CheckoutController
would coordinate the overall checkout process by calling various services like CartService
, PaymentService
, and OrderService
within a transaction boundary.
Communication
Communication patterns in services are:
Method Invocation: Direct method calls between components
DTO-Based: Data transfer objects for cross-layer communication
Service-to-Service: Services communicating with each other
Predominantly Synchronous: Operations typically execute synchronously
The ProductCatalogService
might communicate with PricingService
to get current prices while assembling product details, passing data through well-defined DTOs.
Consistency
Consistency in services is managed through:
Controller-Managed Transactions: Controllers define transaction boundaries
Business Rule Enforcement: Services enforce domain invariants
Service Atomicity: Services implement operations that are atomic from a business perspective
Explicit State Management: State transitions are managed deliberately
Our e-commerce InventoryService
would enforce consistency rules like "can't sell products with zero inventory" while the controller ensures the entire operation happens within a transaction.
Dimensional Friction Points
The main friction points in the service pattern include:
Transaction Management: Balancing between controller-level transactions and service autonomy
Service Communication: Managing dependencies between services
Consistency Boundaries: Determining where transaction boundaries should lie
Data Access Coupling: Services may still be coupled to data access mechanisms
Testing Strategy
Testing Approach for Services
The testing approach for services returns to a more traditional pyramid:
Unit Tests: Testing service methods in isolation (many)
Integration Tests: Verifying service interactions with data sources (some)
Controller Tests: Testing the orchestration layer (some)
End-to-End Tests: Verifying system behavior through UI/API (few)
Test Focus by Layer
Testing focuses on:
Service Logic: Unit testing business rules and calculations
Controller Orchestration: Testing workflow and coordination
Component Integration: Verifying that services work together correctly
Data Access: Testing service interaction with databases
Test Isolation Strategies
Common isolation approaches include:
Service Mocking: Testing controllers with mocked services
Repository Mocking: Testing services with mocked data access
In-Memory Databases: For testing data access components
Test Doubles: For isolating service dependencies
Common Testing Challenges
Challenges with testing services include:
Service Dependencies: Managing dependencies between services in tests
Transaction Boundaries: Testing proper transaction management
Integration Complexity: Testing services that integrate with multiple components
State Management: Testing stateful services
When to Evolve
Signs Services Are No Longer Sufficient
As our e-commerce platform grows in sophistication and scale, several signs may indicate that our service-based architecture is reaching its limits.
Query and command operations often develop divergent needs. We notice that product catalog browsing requires high-performance read operations optimized for search and filtering, while product inventory updates need strong consistency and business rule enforcement. Trying to serve both needs with the same service creates uncomfortable compromises.
Services themselves may grow too large, taking on too many responsibilities. What started as a focused OrderService gradually expands to handle order creation, modification, cancellation, returns, exchanges, subscriptions, and more. This increasing scope makes the service harder to understand, modify, and test.
Client diversity presents another challenge. Our mobile app needs lightweight product summaries, while the admin dashboard requires detailed product information with inventory and pricing history. The web storefront needs product data with personalized recommendations. A single service returning one-size-fits-all responses becomes inefficient.
Performance optimization needs become more specialized. Read operations benefit from caching, denormalized data structures, and specialized query optimizations, while write operations require validation, consistency checks, and transaction management. These different requirements become difficult to reconcile within a single service.
Finally, domain complexity reaches a level where business rules deserve their own rich domain model. Order processing now involves sophisticated workflows, state transitions, and business rules that go beyond what simple service methods can cleanly express.
Triggers for Moving to CQRS with Application Services
Several specific triggers indicate it's time to consider CQRS (Command Query Responsibility Segregation) with Application Services.
When we notice read and write operations scaling differently—perhaps our product catalog handles millions of reads but only thousands of writes—forcing both through the same service creates unnecessary constraints. Similarly, performance bottlenecks in read operations that could be solved with specialized optimizations suggest a need for separation.
Reminder: CQRS does not need different databases, or different tables.
Complex domain rules that would benefit from explicit command handling provide another sign. When creating an order involves multiple validation steps, inventory checks, and business rule applications, an explicit command model can make this process clearer and more maintainable.
The need for specialized read models becomes apparent when different clients require substantially different views of the same underlying data. And when we start considering event-driven architectures for certain features, CQRS provides a natural foundation for this approach.
Transition Strategies
The transition from Services to CQRS need not be abrupt or disruptive. We can evolve our architecture gradually, starting with the areas that would benefit most from the separation.
We might begin by identifying services with clear read/write disparities and separating their operations internally. The ProductService could internally route read operations to optimized methods while keeping write operations focused on consistency and business rules.
Next, we can extract command handling into dedicated application services that focus specifically on processing business operations. These services implement explicit commands like "CreateOrder" or "AddProductToCart," with clear validation and business rule enforcement.
For read operations, we create specialized query handlers optimized for specific data access patterns. These can use denormalized data structures, caching, or other optimizations without being constrained by write-oriented concerns.
Controllers then evolve to use this command/query pattern explicitly, separating read and write operations at the API level. Finally, we introduce explicit command and query objects that formalize the inputs and outputs of our operations, creating a clearer contract between consumers and our application services.
NFR Analysis
Non-Functional Requirement Alignment
Simplicity: ⭐⭐⭐ (Added complexity but better organization)
Maintainability: ⭐⭐⭐⭐⭐ (Significant improvement through separation of concerns)
Testability: ⭐⭐⭐⭐ (Much better with isolated business logic)
Scalability: ⭐⭐⭐⭐ (Better component scalability and team scalability)
Performance: ⭐⭐⭐ (Some overhead from additional layers)
Extensibility: ⭐⭐⭐⭐ (Much better with clear extension points)
Strengths and Weaknesses
Strengths:
Clear separation of business logic from orchestration
Better reusability of business functionality
Improved testability of business rules
Clearer domain boundaries and responsibilities
Weaknesses:
Increased architectural complexity
Potential for over-engineering simple operations
Service dependencies can create coupling
Data access may still be mixed with business logic
Optimization Opportunities
Even within the Service pattern, several optimizations are possible:
Move data access to dedicated repositories
Implement service interfaces for better abstraction
Create domain models to represent business concepts
Consider simple caching strategies for frequently used data
Architect's Alert 🚨
Services offer great benefits, but they come with their own architectural pitfalls. Most commonly, services drift away from their focused purpose, gradually accumulating unrelated responsibilities until they become "kitchen sink" services that do too many things.
Watch carefully for services that mix business logic with data access concerns. When the OrderService contains both order validation logic and SQL queries, it's taking on too many responsibilities. Similarly, be wary of services that handle too many domain concepts—a CustomerService that also manages products, orders, and shipping has lost its focus.
Perhaps most insidious are the catch-all services that accumulate miscellaneous functionality. The infamous "UtilityService" often becomes a dumping ground for functionality that doesn't have a clear home, signaling a need for architectural refinement. But really now, no utility services, utilities belong in libs, usually in static classes, if they need state or to be instantiated, they are not utilities.
Services work best when they encapsulate cohesive business capabilities. They excel at implementing business rules and calculations that need to be consistent across multiple use cases. The PricingService ensures that products are priced consistently whether viewed in the catalog, cart, or order history.
They're ideal for centralizing validation and business logic that applies across multiple controllers or use cases. And they provide a natural home for domain-specific operations that express the core capabilities of your business.
However, services show limitations when read and write operations have very different scaling needs. They struggle with performance-critical read operations that need specialized optimizations like denormalized data structures or extensive caching. As domain models grow in complexity and richness, simple service methods may not provide adequate expression of business concepts and rules. And event-driven systems often benefit from more explicit command handling than the service pattern typically provides.
Conclusion and Next Steps
Services transform our architecture by giving business logic the dedicated home it deserves. No longer tangled with request handling or buried in controllers, business rules can now shine in focused, specialized components. This clarity improves every aspect of our system—making it more maintainable as changes affect smaller, more focused components; more testable as business logic can be verified independently; and more reusable as services can be called from multiple controllers.
Our e-commerce platform now has a clearer separation of concerns. The ProductController handles HTTP interactions but delegates the complex business of product management to the ProductService. This separation allows teams to develop expertise in specific business domains rather than spreading their attention across the entire system.
Yet as our platform continues to grow, new challenges emerge. We notice that product browsing and product management have fundamentally different needs. Browsing needs to be lightning-fast, optimized for search and filtering, and able to handle thousands of concurrent users. Product management, by contrast, requires careful validation, business rule enforcement, and transactional consistency.
Trying to serve both these needs with the same services creates uncomfortable compromises. This is where our architecture must evolve once more—towards CQRS (Command Query Responsibility Segregation) with Application Services. This next generation explicitly separates read and write operations, allowing each to be optimized for its specific requirements without constraining the other.
The journey from Services to CQRS represents more than just a technical evolution. It reflects a deeper understanding of our domain—recognizing that reading data and changing data are fundamentally different operations with different requirements, constraints, and optimization opportunities.
Questions for Reflection
Take a moment to consider your own systems through the lens of services:
How clearly are orchestration and business logic separated in your current architecture? Many systems have a muddy boundary, with business rules scattered between controllers and services without a clear organizing principle.
Which business rules in your domain are most in need of a dedicated home? Complex calculations, validation rules, and domain-specific operations often benefit most immediately from extraction into focused services.
How do you manage transactions that span multiple service calls? This remains one of the trickier aspects of the service pattern, requiring careful consideration of transaction boundaries and consistency requirements.
Have you experienced the testing benefits that services enable? Many teams find that extracting business logic into services dramatically improves their ability to write focused, reliable tests without excessive mocking or complex setup.