§ 2 — The Standard
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.
2.0.3 Versioning
File versioning governs how changes to models and services are managed and communicated in the codebase.
2.0.3.0 Model Versioning
When a model’s structure is changed (including adding, removing, or modifying properties), a new version of the model must be created. The version number must be incremented, and both the model file and its path must reflect the new version. This preserves backward compatibility and allows consumers to upgrade at their discretion.
New model versions must be created alongside the previous version and organised in a versioned subfolder.
Example:
Existing (V0):
Models/Foundations/Students/Student.cs
New extended model (V1) - nested under the previous version:
Models/Foundations/Students/V1/StudentV1.cs
2.0.3.1 Service Versioning
The same principle applies to services. When a service’s contract changes due to a model update, increment the model version in the service filename and folder path:
Services/Foundations/Students/V1/StudentV1Service.cs
Exceptions related to the new model version should follow the same nesting convention and reside under the corresponding versioned model path:
Models/Foundations/Students/V1/Exceptions/
2.0.3.2 Service Behavior Versioning
The filename of the service always reflects the model version and not the behavior version:
Services/Foundations/Students/V1/StudentV1Service.cs
Every time there is a behavior change, increment the method behavior version in the method name by creating a new method:
We will follow this convention for all behaviour changes:
[Operation] + [OperationBehaviorVersion] + [Resource] + [ResourceContractVersion] + [Optional QueryDescriptor] + [AsyncSuffix]
e.g.
Retrieve + V1 + Student + V1 + ById + Async
(V0 is implied and does not need to be explicitly stated in [OperationBehaviorVersion] OR [ResourceContractVersion])
Example of method behavior versioning for a model version at V0:
Services/Foundations/Students/StudentService.cs
ValueTask<Student> RetrieveStudentByIdAsync(Guid studentId)
ValueTask<Student> RetrieveV1StudentByIdAsync(Guid studentId)
ValueTask<Student> RetrieveV2StudentByIdAsync(Guid studentId)
Example of method behavior versioning for a model version greater than V0:
Services/Foundations/Students/V1/StudentV1Service.cs
ValueTask<StudentV1> RetrieveStudentV1ByIdAsync(Guid studentId)
ValueTask<StudentV1> RetrieveV1StudentV1ByIdAsync(Guid studentId)
ValueTask<StudentV1> RetrieveV2StudentV1ByIdAsync(Guid studentId)
2.0.3.3 Model Upgrade and Behavior Version Reset
Whenever the model version is incremented, the service filename and folder path must reflect the new model version, and the behavior version resets to V0:
Services/Foundations/Students/V2/StudentV2Service.cs
The service method behavior version resets to V0 whenever a new model version is introduced. Any further behavior changes for that model version will result in an increment of the method behavior version (V1, V2, etc.), while the model version remains constant.
Example:
Before model upgrade (model V1 with multiple behavior versions):
internal interface IStudentV1Service
{
ValueTask<StudentV1> RetrieveStudentV1ByIdAsync(Guid studentId)
ValueTask<StudentV1> RetrieveV1StudentV1ByIdAsync(Guid studentId)
ValueTask<StudentV1> RetrieveV2StudentV1ByIdAsync(Guid studentId)
}
After model upgrade (model V2 with only the latest behavior version):
internal interface IStudentV2Service
{
ValueTask<StudentV2> RetrieveStudentV2ByIdAsync(Guid studentId);
}
The new V2 model service only exposes the latest behavior under the implied V0 naming convention. The V2 in this instance refers to the model version since it is appended to the resource. The behavior version resets on a model upgrade and since the model is now at the implied V0 we do not have to indicate it after the operation.
2.0.3.4 Naming Conventions
The naming convention for versioned files
-
A versioned model is named
{Entity}V{n}.cs -
A versioned service named after the model version
{Entity}V{n}Service.cs- Any behaviour versioning is reflected in the method name, immediately after the operation.
When the behaviour version is omitted, it is implied to be V0. The version token immediately
after the operation belongs to the behaviour, while the version token appended to the resource
name belongs to the model — the two are tracked independently:
- V0 (implied) ->
ValueTask<EventAddressV1> AddEventAddressV1Async(EventAddressV1 eventAddress); - V1 ->
ValueTask<EventAddressV1> AddV1EventAddressV1Async(EventAddressV1 eventAddress); - V2 ->
ValueTask<EventAddressV1> AddV2EventAddressV1Async(EventAddressV1 eventAddress);
- V0 (implied) ->
- Any behaviour versioning is reflected in the method name, immediately after the operation.
When the behaviour version is omitted, it is implied to be V0. The version token immediately
after the operation belongs to the behaviour, while the version token appended to the resource
name belongs to the model — the two are tracked independently:
-
When the model version changes, the service filename increments to reflect the new model version, and the behavior version resets to V0. Following the formula from
2.0.3.2, the implied V0 behaviour version is omitted:- Before (model V1):
{Entity}V1Service.cs - After (model V2):
{Entity}V2Service.cs - The method naming resets to the implied V0 convention:
- V0 (implied, reset) ->
ValueTask<EventAddressV2> AddEventAddressV2Async(EventAddressV2 eventAddress);
- V0 (implied, reset) ->
- Before (model V1):
This ensures that the model version, behaviour version, and file structure are all independently readable and unambiguous at every level.
2.0.4 Class Visibility and Exposure Control
Classes must be declared with the lowest possible visibility required for their intended use. By default, types should be internal unless there is a clear and deliberate need to expose them outside the assembly.
This principle reduces the exposure surface area of a library, protects internal implementation details, and prevents unintended coupling by consumers.
A smaller public surface:
- Improves maintainability by allowing internal refactoring without breaking consumers
- Enforces clear architectural boundaries
- Limits misuse of internal logic that was never designed for external consumption
2.0.4.0 Exception Visibility Rules
Exception types follow a stricter, intent-driven visibility model:
-
Localization Exceptions —
publicThese represent well-defined, domain-specific failures that are meaningful to consumers. They are expected to propagate beyond the library boundary and must therefore be accessible. -
Categorization Exceptions —
internalThese are used strictly for internal classification and logging. They must never escape the library boundary and should be stripped or translated at each layer. Keeping them internal ensures they cannot leak into external contracts. -
Exposure Exceptions —
publicThese represent failures that are intentionally exposed to consumers (e.g., validation or dependency failures). They form part of the contract and must be accessible outside the library.
Example
Public localization exceptions - meaningful to consumers
public class NotFoundStudentException : Xeption
{
public NotFoundStudentException(string message)
: base(message)
{ }
}
public class InvalidStudentException : Xeption
{
public InvalidStudentException(string message)
: base(message)
{ }
}
public class FailedStudentServiceException : Xeption
{
public FailedStudentServiceException(string message, Exception innerException)
: base(message, innerException)
{ }
}
Internal categorization exceptions - used only within the library
internal class StudentValidationException : Xeption
{
public StudentValidationException(string message, Xeption innerException)
: base(message, innerException)
{ }
}
internal class StudentDependencyValidationException : Xeption
{
public StudentDependencyValidationException(string message, Xeption innerException)
: base(message, innerException)
{ }
}
internal class StudentDependencyException : Xeption
{
public StudentDependencyException(string message, Xeption innerException)
: base(message, innerException)
{ }
}
internal class StudentServiceException : Xeption
{
public StudentServiceException(string message, Xeption innerException)
: base(message, innerException)
{ }
}
internal class StudentService : IStudentService
{
// Internal logic hidden from consumers
}
Public contract for student operations
public interface IStudentClient
{
ValueTask<Student> GetStudentByIdAsync(Guid studentId);
}
Internal implementation of the public contract, with all dependencies and logic hidden
internal class StudentClient : IStudentClient
{
private readonly IStudentService studentService;
public StudentClient(IStudentService studentService) =>
this.studentService = studentService;
public ValueTask<Student> GetStudentByIdAsync(Guid studentId) =>
this.studentService.RetrieveStudentByIdAsync(studentId);
}
Public university client exposing only the public contract, with all internal details hidden
public interface IUniversityClient
{
IStudentClient Students { get; }
}
public class UniversityClient : IUniversityClient
{
public UniversityClient(UniversityClientConfigurations configurations)
{
IServiceProvider serviceProvider = RegisterServices(configurations);
InitializeClients(serviceProvider);
}
public IStudentClient Students { get; private set; }
private void InitializeClients(IServiceProvider serviceProvider)
{
this.Students = serviceProvider.GetRequiredService<IStudentClient>();
}
private static IServiceProvider RegisterServices(
UniversityClientConfigurations configurations)
{
var services = new ServiceCollection()
// Internal dependencies
.AddTransient<IStorageBroker, StorageBroker>()
.AddTransient<IDateTimeBroker, DateTimeBroker>()
.AddTransient<IStudentService, StudentService>()
// Internal client mapped to public interface
.AddTransient<IStudentClient, StudentClient>()
// Configurations
.AddSingleton(configurations);
return services.BuildServiceProvider();
}
}
Key Takeaways The consumer only interacts with UniversityClient and interfaces StudentClient and all supporting services are hidden (internal) Dependency injection is fully encapsulated within the library The library retains full control over implementation while exposing a stable contract
Guiding Principle
If a type is not explicitly part of the public contract, it must remain internal.
Visibility is not just an access modifier—it is a design decision that defines the boundary between what a library promises and what it is free to change.
This chapter lives on GitHub, where it is written in the open. Read the source or suggest a change.