What is an aggregation service?
An aggregation service is generally a RESTful web service that aggregates across multiple micro-services. It has a broader definition as described in Enterprise Integration Patterns but I am going to focus on using it in a micro-service context. When to use? Imagine you work at an e-commerce company that is revamping the products section of its website. The requirements state the product page needs bits of catalog, inventory, and pricing data. The data is spread across 3 domain bounded micro-services. This is when the aggregation service is useful.
Breaking down the three layer cake
The three layer cake can be broken down into controllers, services, and repositories.
A core goal is to support changes to the external layers without changing the business logic of the application. The controller should fully encapsulate the externally exposed api while the application’s external downstream apis are encapsulated in repositories. The service layer Always only uses internal data types. The service should never be exposed to the type of the HTTP response body or the types of the external data sources. Versions of your API WILL CHANGE over time, along with the services that your application consumes. If your application structure doesn’t support changing the layers independently, the code will become a mess over time. The example code uses Java and Spring, but the concepts apply to other languages and frameworks. There will be a future post on how to use Spring to handle cross-cutting concerns such as authentication, bean validation, configuration, error handling, and logging.
package structure
└── dev
└── rambling
└── threelayercake
├── controllers
├── services
├── model
├── repositories
└── util
Sequence Diagram
Lets take a dive into in each layer…
Controllers
Responsibilities
- expose operations on a resource
- validate inbound request
- transform to an internal model if needed
- delegate to work to the business logic layer
- form response to calling client
structure
│ ├── product
│ │ ├── ProductController.java
│ │ ├── ProductControllerRequestValidator.java
│ │ ├── ProductRequestTransformer.java
│ │ ├── ProductResponseTransformer.java
│ │ └── model
│ │ ├── ProductRequest.java
│ │ ├── ProductResponseV1.java
│ │ └── ProductResponseV2.java
example
/**
* Retrieve product information by upc
* @deprecated
* <p> Use /v2/products/{upc} instead
*/
@GetMapping("/v1/productByUpc")
public ResponseEntity<ProductResponseV1> nonRestfulProducts(@RequestBody ProductRequest productRequest) {
productControllerRequestValidator.validateUpc(productRequest);
ProductRequestContext productRequestContext = productRequestTransfomer.transform(productRequest);
Product product = productService.findByUpc(productRequestContext);
return ResponseEntity.ok(product);
}
@GetMapping("/v2/products/{upc}")
public ResponseEntity<ProductResponseV2> product(@PathVariable("upc") String upc,
@RequestParam("requestedFields") String[] requestedFields,
@RequestParam("sellingLocationIds") String[] sellingLocationIds) {
ProductRequestContext productRequest = productRequestTransfomer.transform(upc, requestedFields, sellingLocationIds);
productControllerRequestValidator.validateUpc(productRequest);
return productService.findByUpc(productRequest)
.map(ProductResponseTransformer::v2)
.ifPresentOrElse(ResponseEntity::ok, ResponseEntity::notFound);
}
@ExceptionHandler(RequestValidationException.class)
public ResponseEntity<?> handleException(RequestValidationException e) {
return ResponseEntity.badRequest().body(new AppError(e));
}
@ExceptionHandler(AppException.class)
public ResponseEntity<?> handleException(AppException e) {
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(new AppError(e));
}
The inclusion of multiple versions in the example was not an accident. It is essential to think about how the application structure will handle change over time because it will change.
The original author was still learning about REST and how to structure an api and eventually saw a better way.
This better way required breaking api changes, and the api consumers needed a gradual migration path.
The use of ProductRequestContext productRequest = productRequestTransfomer.transform(upc, requestedFields, sellingLocationIds)
allows the consumers to choose which format they need and the developers only need to support one model.
Originally, the v1 ProductRequest
object was passed into the service.
Once the need for v2 came, the broken encapsulation was refactored to ensure changes to one layer do not affect the others.
Services
Responsibilities
- Coordinate between repositories.
- Fetch the appropriate data based on the request context and feature flags.
- Delegate to a facade when multiple repositories make up a single domain
- Invoke a creator/transform to get the target object after the required data is retrieved
structure
├── services
│ └── product
│ ├── InventoryFacade.java
│ ├── ProductTransformer.java
│ └── ProductService.java
├── model
│ ├── product
│ │ ├── Product.java
│ │ ├── ProductRequestContext.java
│ │ ├── ProductCatalogData.java
│ │ ├── ProductPricingData.java
│ │ └── ProductInventoryData.java
example product service
@Autowired
public ProductService(final CatalogRepository catalogRepository,
final PricingRepository pricingRepository,
final InventoryFacade inventoryFacade,
final ProductTransformer productTransformer) {
this.catalogRepository = catalogRepository;
this.pricingRepository = pricingRepository;
this.inventoryFacade = inventoryFacade;
this.productTransformer = productTransformer;
}
public Optional<Product> findByUpc(final ProductRequestContext requestContext) {
ProductCatalogData productCatalogData = catalogRepository.fetchProductInfo(requestContext.getUpc());
ProductPricingData productPricingData = pricingRepository.fetchPricing(requestContext.getUpc());
ProductInventoryData productInventoryData = inventoryFacade.determineInventory(requestContext.getUpc());
return productTransformer.transform(requestContext, productCatalogData, productPricingData, productInventoryData);
}
The ProductService
does the coordination across the domains. It delegates to the correct repository and uses a transformer
to combine the relevant data. This structure should be easy to test and understand the required inputs for a Product.
A critical point is that the service layer only uses internal data types.
example inventory facade
@Autowired
public InventoryFacade(final InventoryRepository inventoryRepository,
final LegacyInventoryRepository legacyInventoryRepository) {
this.inventoryRepository = inventoryRepository;
this.legacyInventoryRepository = legacyInventoryRepository;
}
public ProductInventoryData determineInventory(String upc){
try {
return inventoryRepository.fetchProductInfo(upc);
} catch (RepositoryException e){
return legacyInventoryRepository.fetchInventory(upc);
}
}
The Facade concept is introduced here as a way to abstract multiple repositories, of the same domain, and keep the complexity of the primary service low and easily testable. The Facade pattern is not new and more information can be found here.
Repositories
Responsibilities
- convert to external model
- invoke external http api/jdbc/grpc/queue/etc
- convert back to internal model
structure
│ ├── catalog
│ │ ├── CatalogAuthInterceptor.java
│ │ ├── CatalogConfiguration.java
│ │ ├── CatalogRepository.java
│ │ ├── CatalogTransformer.java
│ │ └── model
│ │ └── CatalogResponse.java
example repository
@Autowired
public CatalogRepository(final CatalogConfiguration catalogConfiguration,
final RestTemplate catalogRestTemplate,
final CatalogTransformer catalogTransformer) {
this.catalogConfiguration = catalogConfiguration;
this.catalogResttemplate = catalogRestTemplate;
this.catalogTransformer = catalogTransformer;
}
public ProductCatalogData fetchProductInfo(String upc){
CatalogResponse catalogResponse = catalogRestTemplate.getForEntity(
catalogConfiguration.getUrl, CatalogResponse.class, upc
).getBody();
return catalogTransformer.transform(catalogResponse);
}
catalogTransformer.transform(catalogResponse)
is a very important line. This is where the external model is converted
into an internal model. This transformation helps protect your application from external changes. Now the Catalog service
owner can change its response format, and the only change required is to update the transformer. The same principles
apply to the formation of the outbound request. Consider a situation where you need to update a catalog item, the update
would require an HTTP POST where the body contained the update. The structure of the request body is subject to change over time.
The responsibility of building that request body belongs to the repository layer and can/should be delegated to a transformer.
The ProductService
must not build the catalog update request body. If the request format ever changes,
which it will, that change will span multiple layers of the application and breaks the intended encapsulation.
Conclusion
Use the Three Layer Cake architecture when aggregating over multiple domains. If multiple repositories make up a single domain, then a facade service may be in order. Most importantly, this architecture optimizes for change.