Processing services are the layer where a higher order of business logic is implemented. They may combine (or orchestrate) two primitive-level functions from their corresponding foundation service to introduce newer functionality. They may also call one primitive function and change the outcome with a little bit of added business logic. Sometimes, processing services are there as a pass-through to introduce balance to the overall architecture.
Processing services are optional, depending on your business need - in a simple CRUD operations API, processing services and all the other categories of services beyond that point will cease to exist as there is no need for a higher order of business logic at that point.
Here's an example of what a Processing service function would look like:
public ValueTask<Student> UpsertStudentAsync(Student student) =>
TryCatch(async () =>
{
ValidateStudentOnUpsert(student);
ValueTask<IQueryable<Student>> allStudents =
await this.studentService.RetrieveAllStudentsAsync();
bool studentExists = allStudents.Any(retrievedStudent =>
retrievedStudent.Id == student.Id);
return studentExists switch {
false => await this.studentService.RegisterStudentAsync(student),
_ => await this.studentService.ModifyStudentAsync(student.Id)
};
});
Processing services make Foundation services nothing but a layer of validation on top of the existing primitive operations. This means that Processing services functions are beyond primitive and only deal with local models, as we will discuss in the upcoming sections.
Processing services live between foundation services and the rest of the application when used. They may not call Entities or Business brokers. Still, they may call Utility brokers such as logging brokers, time brokers, and any other brokers that offer supporting functionality and are not specific to any particular business logic. Here's a visual of where processing services are located on the map of our architecture:
On the right side of a Processing service lies all the non-local models and functionality, whether through the brokers or the models, and the foundation service is trying to map them into local models. On the left side of Processing services are pure local functionality, models, and architecture. Starting from the Processing services, there should be no trace or track of any native or non-local models in the system.
Processing services, in general, are combiners of multiple primitive-level functions to produce a higher-order business logic. But they have many more characteristics than just that; let's talk about those here.
The language used in processing services defines the level of complexity and the capabilities it offers. Usually, processing services combine two or more primitive operations from the foundation layer to create a new value.
The Processing services language changes from primitive operations such as AddStudentAsync
or RemoveStudentAsync
to EnsureStudentExistsAsync
or UpsertStudentAsync
. They usually offer more advanced business logic operations to support higher-order functionality.
Here are some examples of the most common combinations a processing service may offer:
Processing Operation | Primitive Functions |
---|---|
EnsureStudentExistsAsync | RetrieveAllStudentsAsync + AddStudentAsync |
UpsertStudentAsync | RetrieveStudentByIdAsync + AddStudentAsync + ModifyStudentAsync |
VerifyStudentExistsAsync | RetrieveAllStudentsAsync |
TryRemoveStudentAsync | RetrieveStudentByIdAsync + RemoveStudentByIdAsync |
As you can see, the combination of primitive functions processing services might also include adding an additional layer of logic on top of the existing primitive operation. For instance, VerifyStudentExistsAsync
takes advantage of the RetrieveAllStudentsAsync
primitive function and then adds a boolean logic to verify the returned student by an Id from a query actually exists or not before returning a boolean
.
Processing services may borrow some of the terminology a foundation service uses. For instance, in a pass-through scenario, a processing service could be as simple as AddStudentAsync
. We will discuss the architecture-balancing scenarios later in this chapter.
Unlike Foundation services, Processing services are required to have the identifier Processing
in their names. For instance, we say StudentProcessingService
.
More importantly, Processing services must include the name of the entity that is supported by their corresponding Foundation service.
For instance, if a Processing service depends on a TeacherService
, then the Processing service name must be TeacherProcessingService
.
Processing services can only have two types of dependencies: a corresponding Foundation service or a Utility broker. That's simply because Processing services are nothing but an extra higher-order level of business logic orchestrated by combined primitive operations on the Foundation level.
Processing services can also use Utility brokers such as TimeBroker
or LoggingBroker
to support their reporting aspect, but they shall never interact with an Entity or Business broker.
Processing services can interact with only one Foundation service. In fact, without a foundation service, there can never be a Processing layer. And just like we mentioned above about the language and naming, Processing services take on the exact same entity name as their Foundation dependency.
For instance, a processing service that handles higher-order business logic for students will communicate with nothing but its foundation layer, which would be StudentService
. That means that processing services will have one and only one service as a dependency in its construction or initiation as follows:
public class StudentProcessingService
{
private readonly IStudentService studentService;
public StudentProcessingService(IStudentService studentService) =>
this.studentService = studentService;
}
However, processing services may require dependencies on multiple utility brokers such as DateTimeBroker
or LoggingBroker
... etc.
Unlike the Foundation layer services, Processing services only validate what they need from their input. For instance, if a Processing service is required to validate that a student entity exists, and its input model just happens to be an entire Student
entity, it will only validate that the entity is not null
and that the Id
of that entity is valid. The rest of the entity is out of the Processing service's concern.
Processing services delegate full validations to the layer of services that are concerned with that, which is the Foundation layer. Here's an example:
public ValueTask<Student> UpsertStudentAsync(Student student) =>
TryCatch(async () =>
{
ValidateStudentOnUpsert(student);
ValueTask<IQueryable<Student>> allStudents =
await this.studentService.RetrieveAllStudentsAsync();
bool isStudentExists = allStudents.Any(retrievedStudent =>
retrievedStudent.Id == student.Id);
return isStudentExists switch {
false => await this.studentService.AddStudentAsync(student),
_ => await this.studentService.ModifyStudentAsync(student.Id)
};
});
Processing services are also not very concerned about outgoing validations except for what they will use within the same routine. Some exceptions may be neccesary as discussed in later sections. For instance, if a Processing service is retrieving a model and will use this model to be passed to another primitive-level function on the Foundation layer, the Processing service will be required to validate that the retrieved model is valid, depending on which attributes of the model it uses. However, processing services will delegate the outgoing validation to the foundation layer for Pass-through scenarios.
Processing service's main responsibility is to provide higher-order business logic. This happens along with the regular signature mapping and various use-only validations, which we will discuss in detail in this section.
Higher-order business logic are functions that are above primitive. For instance, AddStudentAsync
function is a primitive function that does one thing and one thing only. But higher-order logic is when we try to provide a function that changes the outcome of a single primitive function like VerifyStudentExistsAsync
, which returns a boolean value instead of the entire object of the Student
, or a combination of multiple primitive functions such as EnsureStudentExistsAsync
which is a function that will only add a given Student
model if and only if the object mentioned above doesn't already exist in storage. Here are some examples:
The shifter pattern in a higher-order business logic is when the outcome of a particular primitive function is changed from one value to another. Ideally, a primitive type such as a bool
or int
is not a completely different type as that would violate the purity principle.
For instance, in a shifter pattern, we want to verify whether a student exists. We want only some objects, but we want to know whether they exist in a particular system. Now, this is a case where we only need to interact with one and only one foundation service, and we are shifting the value of the outcome to something else, which should fit perfectly in the realm of the processing services. Here's an example:
public ValueTask<bool> VerifyStudentExistsAsync(Guid studentId) =>
TryCatch(async () =>
{
ValidateStudentId(studentId);
ValueTask<IQueryable<Student>> allStudents =
await this.studentService.RetrieveAllStudentsAsync();
ValidateStudents(allStudents);
return allStudents.Any(student => student.Id == studentId);
});
In the snippet above, we provided higher-order business logic by returning a boolean value indicating whether a particular student with a given Id
exists in the system. There are cases where your orchestration layer of services isn't really concerned with all the details of a particular entity but just knows whether it exists or not as part of an upper business logic or what we call orchestration.
Here's another popular example of a processing services shifting pattern:
public ValueTask<int> RetrieveStudentsCountAsync() =>
TryCatch(async () =>
{
ValueTask<IQueryable<Student>> allStudents =
await this.studentService.RetrieveAllStudentsAsync();
ValidateStudents(allStudents);
return allStudents.Count();
});
In the example above, we provided a function to retrieve the count of all students in a given system. The system's designers determine whether to interpret a null
value retrieved for all students as an exception case that was not expected to happen or return a 0
, depending on how they manage the outcome.
In our case, we validate the outgoing data as much as the incoming data, especially if it will be used within the processing function to ensure further failures do not occur for upstream services.
Combining multiple primitive functions from the foundation layer to achieve a higher-order business logic is one of the main responsibilities of a processing service. As we mentioned before, some of the most popular examples are for ensuring a particular student model exists as follows:
public async ValueTask<Student> EnsureStudentExistsAsync(Student student) =>
TryCatch(async () =>
{
ValidateStudent(student);
ValueTask<IQueryable<Student>> allStudents =
await this.studentService.RetrieveAllStudentsAsync();
Student maybeStudent = allStudents.FirstOrDefault(retrievedStudent =>
retrievedStudent.Id == student.Id);
return maybeStudent switch
{
{} => maybeStudent,
_ => await this.studentService.AddStudentAsync(student)
};
});
In the code snippet above, we combined RetrieveAllStudentsAsync
with AddStudentAsync
to achieve a higher-order business logic operation. The EnsureStudenExistsAsync
operation needs to verify something or an entity exists first before trying to persist it. The terminology around these higher-order business logic routines is very important. Its importance lies mainly in controlling the expectations of the outcome and the inner functionality. However, it also ensures that engineers do not require fewer cognitive resources to understand the underlying capabilities of a particular routine.
The conventional language used in all of these services also ensures that redundant capability will not be created mistakenly. For instance, an engineering team without any form of standard might create TryAddStudentAsync
while already having an existing functionality such as EnsureStudentExistsAsync
, which does the same thing. With the limitation of the size of capabilities a particular service may have, the convention here ensured redundant work should never occur on any occasion.
There are so many different combinations that can produce higher-order business logic. For instance, we may need to implement functionality that ensures a student is removed. We use EnsureStudentRemovedByIdAsync
to combine a RetrieveByIdAsync
and a RemoveByIdAsync
in the same routine. It all depends on what level of capabilities an upstream service may need to implement such a functionality.
Although processing services operate fully on local models and local contracts, they are still required to map foundation-level services' models to their own local models. For instance, if a foundation service throws StudentValidationException
then processing services will map that exception to StudentProcessingDependencyValidationException
. Let's talk about mapping in this section.
In general, processing services are required to map any incoming or outgoing objects with a specific model of its own. But that rule only sometimes applies to non-exception models. For instance, if a StudentProcessingService
is operating based on a Student
model, and there's no need for a special model for this service, then the processing service may be permitted to use the exact same model from the foundation layer.
When processing services handle exceptions from the foundation layer, it is important to understand that exceptions in our Standard are more expressive in their naming conventions and role than any other model. Exceptions here define the what, where, and why every single time they are thrown.
For instance, an exception called StudentProcessingServiceException
indicates the entity of the exception, which is the Student
entity. Then, it indicates the location of the exception, which is the StudentProcessingService
. Lastly, it indicates the reason for that exception, which is ServiceException
, indicating an internal error to the service that is not a validation or a dependency of nature that happened.
Just like the foundation layer, processing services will do the following mapping to occurring exceptions from its dependencies:
Exception | Wrap Inner Exception With | Wrap With | Log Level |
---|---|---|---|
StudentDependencyValidationException | Any inner exception | StudentProcessingDependencyValidationException | Error |
StudentValidationException | Any inner exception | StudentProcessingDependencyValidationException | Error |
StudentDependencyException | - | StudentProcessingDependencyException | Error |
StudentServiceException | _ | StudentProcessingDependencyException | Error |
Exception | _ | StudentProcessingServiceException | Error |
[*] Processing services in Action (Part 1)
[*] Processing services in Action (Part 2)