Processing Services (Higher-Order Business Logic)

2.2.0 Introduction

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 a newer functionality. They may also call one primitive function and change the outcome with a little bit of added business logic. And 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 () =>
{
    ValidateStudent(student);

    IQueryable<Student> allStudents =
        this.studentService.RetrieveAllStudents();

    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. Which means that Processing services functions are beyond primitive, and they only deal with local models as we will discuss in the upcoming sections.

2.2.1 On The Map

When used, Processing services live between foundation services and the rest of the application. they may not call Entity or Business brokers, but they may call Utility brokers such as logging brokers, time brokers and any other brokers that offer supporting functionality and 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 it's through the brokers, or the models that the foundation service is trying to map into local models. On the left side of Processing services is pure local functionality, models and architecture. Starting from the Processing services themselves, there should be no trace or track of any native or non-local models in the system.

2.2.2 Charachteristics

Processing services in general are combiners of multiple primitive-level functions to produce a higher-order business logic. but they have much more characteristics than just that, let's talk about those here.

2.2.2.0 Language

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.

2.2.2.0.0 Functions Language

At a glance, Processing services language change from primitive operations such as AddStudent or RemoveStudent to EnsureStudentExists or UpsertStudent. they usually offer a more advanced business-logic operations to support a higher order functionality. Here's some examples for the most common combinations a processing service may offer:

Processing OperationPrimitive Functions

EnsureStudentExistsAsync

RetrieveAllStudents + AddStudentAsync

UpsertStudentAsync

RetrieveStudentById + AddStudentAsync + ModifyStudentAsync

VerifyStudentExists

RetrieveAllStudents

TryRemoveStudentAsync

RetrieveStudentById + RemoveStudentByIdAsync

As you can see, the combination of primitive functions processing services do might also include adding an additional layer of logic on top of the existing primitive operation. For instance, VerifyStudentExists takes advantage of the RetrieveAllStudents primitive function, and then adds a boolean logic to verify the returned student by and Id from a query actually exists or not before returning a boolean.

2.2.2.0.1 Pass-Through

Processing services may borrow some of the terminology a foundation service may use. for instance, in a pass-through scenario, a processing service with 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.

2.2.2.0.2 Class-Level Language

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 is dependant on a TeacherService, then the Processing service name must be TeacherProcessingService.

2.2.2.1 Dependencies

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 it's reporting aspect. but it shall never interact with an Entity or Business broker.

2.2.2.2 One-Foundation

Processing services can interact with one and 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 for instance. 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.

2.2.2.3 Used-Data-Only Validations

Unlike the Foundation layer services, Processing services only validate what it needs from it's input. For instance, if a Processing service is required to validate a student entity exists, and it's 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 concern of the Processing service. Processing services delegate full validations to the layer of services that is concerned with that which is the Foundation layer. here's an example:

public ValueTask<Student> UpsertStudentAsync(Student student) =>
TryCatch(async () =>
{
    ValidateStudent(student);

    IQueryable<Student> allStudents =
        this.studentService.RetrieveAllStudents();

    bool isStudentExists = allStudents.Any(retrievedStudent =>
        retrievedStudent.Id == student.Id);

    return isStudentExsits switch {
        false => await this.studentService.RegisterStudentAsync(student),
        _ => await this.studentService.ModifyStudentAsync(student.Id)
    };
});

Processing services are also not very concerned about outgoing validations except for what it's going to use within the same routine. For instance, if a Processing service is retrieving a model, and it's going to 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. For Pass-through scenarios however, processing services will delegate the outgoing validation to the foundation layer.

2.2.3 Responsibilities

Processing services 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.

2.2.3.0 Higher-Order Logic

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 VerifyStudentExists 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 aforementioned object doesn't already exist in storage. here's some examples:

2.2.3.0.0 Shifters

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 not a completely different type as that would violate the purity principle. For instance, in a shifter pattern, we want to verify if a student exists or not. We don't really want the entire object, but just whether it exists in a particular system or not. Now, this seems like 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> VerifyStudentExists(Guid studentId) =>
TryCatch(async () =>
{
    IQueryable<Student> allStudents =
        this.studentService.RetrieveAllStudents();

    ValidateStudents(allStudents);

    return allStudents.Any(student => student.Id == studentId);
});

In the snippet above, we provided a higher order business logic, by returning a boolean value of whether a particular student with a given Id exists in the system or not. There are cases where your orchestration layer of services isn't really concerned with all the details of a particular entity but just knowing whether it exists or not as a part of an upper business logic or what we call orchestration.

Here's another popular example for processing services shifting pattern:

public int RetrieveStudentsCount() =>
TryCatch(() =>
{
    IQueryable<Student> allStudents =
        this.studentService.RetrieveAllStudents();

    ValidateStudents(allStudents);

    return allStudents.Count();
});

In the example above, we provided a function to retrieve the count of all students in a given system. It's up to the designers of the system to determine whether to interpret a null value retrieved for all students to be an exception case that was not expected to happen or return a 0; all depending on how they manage the outcome. In our case here we validate the outgoing data as much as the incoming, especially if it's going to be used within the processing function to ensure further failures do not occur for upstream services.

2.2.3.0.1 Combinations

The combination of 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 is for ensuring a particular student model exists as follows:

public async ValueTask<Student> EnsureStudentExistsAsync(Student student) =>
TryCatch(async () =>
{
    ValidateStudent(student);

    IQueryable<Student> allStudents =
        this.studentService.RetrieveAllStudents();

    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 RetrieveAll with AddAsync to achieve a higher-order business logic operation. The EnsureAsync operation which needs to verify something or 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. But it also ensures less cognitive resources from the engineers are required 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 exactly the same thing. The convention here with the limitation of the size of capabilities a particular service may have ensures redundant work shall never occur in any occassion. There are so many different instances of combinations that can produce a higher-order business logic, for instance we may need to implement a functionality that ensure a student is removed. We use EnsureStudentRemovedByIdAsync to combine a RetrieveById and a RemoveById in the same routine. It all depends on what level of capabilities an upstream service may need to implement such a functionality.

2.2.3.1 Signature Mapping

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 is throwing StudentValidationException then processing services will map that exception to StudentProcessingDependencyValidationException. Let's talk about mapping in this section.

2.2.3.1.0 Non-Exception Local Models

In general, processing services are required to map any incoming or outgoing objects with a specific model to its own. But that rule doesn't always apply 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.

2.2.3.1.1 Exception Models

When it comes to processing services handling exceptions from the foundation layer, it is important to understand that exceptions in our Standard are more expressive in their naming conventions and their role more than any other model. Exceptions here define the what, where and why every single time they are thrown. For instance, an exception that's 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. And 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:

ExceptionWrap Inner Exception WithWrap WithLog 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)

[*] Processing services in Action (Part 3)

[*] Processing services in Action (Part 4)

Last updated