An Aggregation service's primary responsibility is to expose one single point of contact between the core business logic layer and any exposure layers. It ensures that multiple services of any variation share the same contract to be aggregated and exposed to one exposer component through one logical layer.
Aggregation services do not hold any business logic in themselves. They are simply a knot that ties together multiple services of any number. They can have any layer of services as dependencies, and it mainly exposes the call to these services accordingly. Here is a code example of an aggregation service:
public async ValueTask ProcessStudentAsync(Student student)
{
await this.studentRegistrationCoordinationService.RegisterStudentAsync(student);
await this.studentRecordsCoordinationService.AddStudentRecordAsync(student);
...
...
await this.anyOtherStudentRelatedCoordinationService.DoSomethingWithStudentAsync(student);
}
As the snippet shows above, an Aggregation service may have any number of calls in any order without limitation. There may be occasions where you may or may not need to return a value to your exposure layers depending on the overall flow and architecture, which we will discuss shortly in this chapter. More importantly, aggregation services should be distinct from orchestration services or variants.
Aggregation services always sit on the other end of a core business logic layer. They are the last point of contact between the exposure layers and logic layers. Here is a diagram visualization of where an Aggregation service can be found in the architecture:
Let's discuss the characteristics of Aggregation services.
Aggregation services mainly exist when multiple services share the same contract or primitive types of the same contract, requiring a single exposure point. They mainly exist in hyper-complex applications where multiple services (usually orchestration or higher but can be lower) require one single point of contact through exposure layers. Let's discuss the main characteristics of Aggregation services in detail.
Unlike any other service, Aggregation services can have any number of dependencies as long as these services have the same variation. For instance, an Aggregation service cannot aggregate between an Orchestration service and a Coordination service. It's a partial Florance-like pattern where services must have the same variation but are not necessarily limited by the number.
The dependencies for Aggregation services are not limited because the service doesn't perform any level of business logic between these services. It doesn't care what these services do or require. It only focuses on exposing these services regardless of what they were called before or after.
Here's what an Aggregation service test would look like:
[Fact]
private async Task ShouldProcessStudentAsync()
{
// given
Student randomStudent = CreatedRandomStudent();
Student inputStudent = randomStudent;
// when
await this.studentAggregationService.ProcessStudentAsync(inputStudent);
// then
this.studentRegistrationCoordinationServiceMock.Verify(service =>
service.RegisterStudentAsync(student),
Times.Once);
this.studentRecordsCoordinationServiceMock.Verify(service =>
service.AddStudentRecordAsync(student),
Times.Once);
...
...
this.anyOtherStudentRelatedCoordinationServiceMock.Verify(service =>
service.DoSomethingWithStudentAsync(student),
Times.Once);
this.studentRegistrationCoordinationServiceMock.VerifyNoOtherCalls();
this.studentRecordsCoordinationServiceMock.VerifyNoOtherCalls();
...
...
this.anyOtherStudentRelatedCoordinationServiceMock.VerifyNoOtherCalls();
}
As you can see above, we only verify and test for the aggregation aspect of calling these services. No return type is required in this scenario, but there might be one in the pass-through scenarios, which we will discuss shortly.
An implementation of the above test would be as follows:
public async ValueTask ProcessStudentAsync(Student student)
{
await this.studentRegistrationCoordinationService.AddStudentAsync(student);
await this.studentRecordsCoordinationService.AddStudentRecordAsync(student);
...
...
await this.anyOtherStudentRelatedCoordinationService.DoSomethingWithStudentAsync(student);
}
By definition, Aggregation services are naturally required to call several dependencies with no limitation. The order of calling these dependencies is also not a concern or a responsibility for Aggregation services because the call-order verification is considered a core business logic, which falls outside the responsibilities of an Aggregation service. That includes both the natural order of verification and the enforced order of verification, as we explained in section 2.3.3.0.1 in the previous chapter.
It violates The Standard to use simple techniques like a mock sequence to test an Aggregation service. It is also a violation to verify reliance on the return value of one service call to initiate a call to the next. These responsibilities are more likely to fall on the next lower layer of an Aggregation service for any orchestration-like service.
Aggregation services are still required to validate whether or not the incoming data is structurally valid at a higher level. For instance, an Aggregation service that takes a Student
object as an input parameter will only validate if the student
is null
. But that's where it all stops.
There may be an occasion where a dependency requires a property of an input parameter to be passed in, in which case it is also permitted to validate that property value structurally. For instance, if a downstream dependency requires a student name to be passed in. An Aggregation service will still be required to validate if the Name
is null
, empty, or just whitespace.
Aggregation services are not required to implement aggregation by performing multiple calls from one method. They can also aggregate by offering pass-through methods for multiple services. For instance, assume we have studentCoordinationService
, studentRecordsService
, and anyOtherStudentRelatedCoordinationService
where each service is independent in terms of business flow. So, an aggregation here is only at the level of exposure, not necessarily the execution level.
Here's a code example:
public partial class StudentAggregationService
{
...
public async ValueTask<Student> AddStudentAsync(Student student)
{
...
return await this.studentCoordinationService.RegisterStudentAsync(student);
}
public async ValueTask<Student> AddStudentRecordAsync(Student student)
{
...
return await this.studentRecordsCoordinationService.AddStudentRecordAsync(student);
}
...
...
public async ValueTask<Student> DoSomethingWithStudentAsync(Student student)
{
...
return await this.anyOtherStudentRelatedCoordinationService.DoSomethingWithStudentAsync(student);
}
}
As you can see above, each service uses the Aggregation service as a pass-through. There's no need for an aggregated routines call in this scenario, but it would still be a very valid scenario for Aggregation services.
It is essential to mention here that Aggregation services are optional. Unlike foundation services, Aggregation services may or may not exist in architecture. Aggregation services are there to solve a problem with abstraction. This problem may or may not exist based on whether the architecture requires a single exposure point at the border of the core business logic layer. This single responsibility of Aggregation services makes it much simpler to implement its task and perform its function efficiently. Aggregation services being optional is more likely than any other lower-level services, even in the most complex of applications out there.
If an aggregation service has to make two different calls from the same dependency amongst other calls, it is recommended that it aggregate for every dependency routine. But that's only from a clean-code perspective, and it doesn't necessarily impact the architecture or the result.
Here's an example:
public async ValueTask ProcessStudentAsync(Student student)
{
await this.studentCoordinationService.AddStudentAsync(student);
await ProcessStudentRecordAsync(student);
}
private async ValueTask ProcessStudentRecordAsync(Student student)
{
await this.studentRecordCoordinationService.AddStudentRecordAsync(student);
await this.studentRecordCoordinationService.NotifyStudentRecordAdminsAsync(student);
}
As previously mentioned, this organizational action doesn't warrant any change in testing or results.
An Aggregation service's most important rule/characteristic is that its dependencies (unlike orchestration services) must share the same contract. The input parameter for a public routine in any Aggregation service must be the same for all its dependencies. There may be occasions where a dependency may require a student id instead of the entire student, which is permitted with caution as long as the partial contract isn't a return type of another call within the same routine.
An Aggregation service's primary responsibility is to offer a single point of contact between exposer components and the rest of the core business logic. But in essence, abstraction is the actual value Aggregation services offer to ensure any business component is pluggable into any system regardless of exposure style.
Let's talk about these responsibilities in detail.
An aggregation service successfully assumes responsibility when its clients or consumers have no idea what lies beyond the lines of its implementation. For example, an aggregation service could combine ten different services and expose a single routine in a fire-and-forget scenario.
But even in pass-through scenarios, Aggregation services abstract away any identification of the underlying dependency from exposers at all costs. It only sometimes happens, especially in terms of localized exceptions. Still, it is close enough to make the integration seem as if it is with one single service that's offering all the options natively.
Aggregation services resemble orchestration-like services when mapping and aggregating exceptions from downstream dependencies. For instance, if studentCoordinationService
is throwing, StudentCoordinationValidationException
an Aggregation service would map that into StudentAggregationDependencyValidationException
. This falls back into the concept of exception unwrapping and then wrapping of localized exceptions, which we discussed in detail in section 2.3.3.0.2 of this Standard.