Skip to content

Latest commit

 

History

History
242 lines (153 loc) · 12 KB

2. Services.md

File metadata and controls

242 lines (153 loc) · 12 KB

2 Services

2.0 Introduction

Services, in general, are the containers of all the business logic in software—they are the core component of any system and the main component that makes one system different from another.

Our main goal with services is to keep them agnostic from specific technologies or external dependencies.

Any business layer is more compliant with The Standard if it can plug into other dependencies and exposure technologies with the least integration effort.

2.0.0 Services Operations

When we say business logic, we mainly refer to three main categories of operations: validation, processing, and integration.



Let's talk about these categories.

2.0.0.0 Validations

Validations ensure that incoming or outgoing data match a particular set of rules, such as structural, logical, or external validations, in that exact order of priority. We will discuss this in detail in the upcoming sections.

2.0.0.1 Processing

Processing mainly focuses on flow control, mapping, and computation to satisfy a business need—the processing operations distinguish one service from another and, in general, one piece of software from another.

2.0.0.2 Integration

Finally, the integration process focuses on retrieving or pushing data from or to any integrated system dependencies.

We will discuss these aspects in detail in the upcoming chapter. The main thing to understand about services is that their design is to be pluggable and configurable, allowing them to easily integrate with any technology from a dependency standpoint and easily plug into any exposure functionality from an API perspective.

2.0.1 Services Types

Services are classified into several types based on their disposition in any given architecture. They fall into three main categories: validators, orchestrators, and aggregators.



2.0.1.0 Validators

Validator services are mainly broker-neighboring services or foundation services.

These services' primary responsibility is to add a validation layer on top of the existing primitive operations, such as the CRUD operations, to ensure incoming and outgoing data is validated structurally, logically, and externally before sending the data in or out of the system.

2.0.1.1 Orchestrators

Orchestrator services are the core of the business logic layer. They can be processors, orchestrators, coordinators, or management services, depending on the type of their dependencies.

Orchestrator services mainly focus on combining multiple primitive operations or multiple high-order business logic operations to achieve an even higher goal.

Orchestrator services are: The decision-makers within any architecture. The owners of the flow control in any system. The main component that makes one application or software different from the other.

We intentionally design Orchestrator services to be longer-lived than any other type of service in the system.

2.0.1.2 Aggregators

The primary responsibility of the aggregator services is to tie the outcome of multiple processing, orchestration, coordination, or management services to expose one single API for any given API controller or UI component to interact with the rest of the system.

Aggregators are the gatekeepers of the business logic layer. They ensure the data exposure components (like API controllers) interact with only one point of contact to interact with the rest of the system.

Aggregators, in general, don't care about the order in which they call the operations that are attached to them. Still, it is sometimes necessary to execute a particular operation, such as creating a student record before assigning a library card.

In the following chapters, we will discuss each type of these services.

2.0.2 Overall Rules

Several rules govern the overall architecture and design of services in any system.

These rules ensure the system's overall readability, maintainability, and configurability - in that particular order.

2.0.2.0 Do or Delegate

Every service should either do or delegate the work, but not both.

For instance, a processing service should delegate the work of persisting data to a foundation service rather than try to do that work itself.

2.0.2.1 Two-Three (Florance Pattern)

For Orchestrator services, the dependencies of services (not brokers) should be limited to two or three, not one, four, or more.

Suppose an Orchestrator depends only on one service. In that case, it violates the definition of orchestration, which is the combination of multiple operations from different sources to achieve a higher order of business logic.

This pattern violates Florance Pattern


This pattern follows the symmetry of the Florance Pattern


The Florance pattern also ensures the balance and symmetry of the overall architecture.

For instance, you can't orchestrate between a foundation and a processing service. This causes an imbalance in your architecture and difficulty when trying to combine one unified statement with the language each service speaks based on its level and type.

The aggregators are the only types of services allowed to violate this rule, where the combination and the order of services or their calls don't have any real impact.

We will discuss the Florance pattern in detail in the upcoming sections of The Standard.

2.0.2.2 Single Exposure Point

API controllers, UI components, or any other form of system data exposure should have one single point of contact with the business logic layer.

For instance, an API endpoint that offers endpoints for persisting and retrieving student data should not have multiple integrations with multiple services but one service that provides all these features.

Sometimes, a single orchestration, coordination, or management service does not offer everything related to a particular entity. An aggregator service combines all these features into one service that is ready to be integrated with exposure technology.

2.0.2.3 Same or Primitives I/O Model

All services must maintain a single contract regarding their return and input types, except if they are primitives.

For instance, a service that provides operations for an entity type Student - should not return from any of its methods from any other entity type.

You may return an aggregation of the same entity, whether it's custom or native, such as List<Student> or AggregatedStudents models, or a primitive type like getting students count, or a boolean indicating whether a student exists or not but not any other non-primitive or non-aggregating contract.

A similar rule applies for input parameters - any service may receive an input parameter of the same contract, a virtual aggregation contract, or a primitive type but not any other contract.

This rule focuses the responsibility of a service on a single entity and all its related operations.

When a service returns a different contract, it violates its naming convention like a StudentOrchestrationService returning List<Teacher> - and it starts falling into the trap of being called by other services from entirely different data pipelines.

If primitive input parameters belong to a different entity model that is not necessarily a reference to the primary entity, it begs the question of orchestrating between two processing or foundation services to maintain a unified model without breaking the pure-contracting rule.

Suppose an orchestration service requires a combination of multiple different contracts. In that case, a new unified virtual model indicates the need for a new unique contract for the orchestration service, with mappings implemented underneath on the concrete level of that service to maintain compatibility and integration safety.

2.0.2.4 Every Service for Itself

Every service is responsible for validating its inputs and outputs. Do not rely on services upstream or downstream to validate your data.

This is a defensive programming mechanism to ensure that if implementations are swapped behind contracts, the responsibility of any given service is not affected if downstream or upstream services decide to pass on their validations for any reason.

Within any monolithic, microservice, or serverless architecture-based system, every service is designed to split off from the system at some point and become the last point of contact before integrating with some external resource broker.

For instance, in the following architecture, services map parts of an input Student model into a LibraryCard model. Here's a visual of the models:

Student
public class Student
{
    public Guid Id {get; set;}
    public string Name {get; set;}
}
LibraryCard
public class LibraryCard
{
    public Guid Id {get; set;}
    public Guid StudentId {get; set;}
}

Now, assume that our orchestrator service StudentOrchestrationService is ensuring every new student that gets registered will need to have a library card, so our logic may look as follows:

public async ValueTask<Student> RegisterStudentAsync(Student student)
{
    Student registeredStudent =
        await this.studentProcessingService.RegisterStudentAsync(student);

    await AssignStudentLibraryCardAsync(student);

    return registeredStudent;
}

private async ValueTask<LibraryCard> AssignStudentLibraryCardAsync(Student student)
{
    LibraryCard studentLibraryCard = MapToLibraryCard(student);

    return await this.libraryCardProcessingService.AddLibraryCardAsync(studentLibraryCard);
}

private LibraryCard MapToLibraryCard(Student student)
{
    return new LibraryCard
    {
        Id = Guid.NewGuid(),
        StudentId = student.Id
    };
}

As you can see above, a valid student id is required to map to a LibraryCard successfully. Since the mapping is the orchestrator's responsibility, we must ensure that the input student and its id are in good shape before proceeding with the orchestration process.

2.0.2.5 Flow Forward

Services cannot call services at the same level. For instance, Foundation Services cannot call other Foundation Services, and Orchestration Services cannot call other Orchestration Services from the same level. This principle is called a Flow-Forward - as the illustration shows:



2.0.2.5.0 For APIs

Due to fractality, The same rule applies to methods within these services. Public APIs cannot call public APIs. Here's an example:

public async ValueTask<Student> RetrieveStudentByIdAsync(Guid studentId)
{
    ...

    return await this.storageBroker.SelectStudentByIdAsync(studentId);
}

public async ValueTask<Student> ModifyStudentAsync(Student student)
{
    ...

    Student maybeStudent = 
        await this.storageBroker.SelectStudentByIdAsync(studentId);
    
    ...
    ...
}

In the Foundation Service example above, we cannot call RetriveStudentByIdAsync in a public method from another public method such as ModifyStudentAsync. You will see that both methods call the exact same method from a lower dependency, like a StorageBroker, fully independent of one another.

While this may seem redundant, the reason for this is that public APIs, contracts, or otherwise, are destined to be deprecated at some point in their lifetime. They may also be changed completely from an implementation standpoint. If a public API depended on another public API at the same level, the deprecation of one will cause a cascading effect on all others. That's a symptom of Chaotic design, which The Standard strongly prohibits.