Microservice-Driven Architecture
What is a Microservice?
Microservice-driven architecture, or simply microservices, is an architectural style that structures an application as a collection of loosely coupled services.
Each of these services is designed to execute a specific process or business capability, making them easy to understand, develop, and maintain.
For instance:
FileService
- This service is responsible for handling all file-related operations, such as file creation, reading, updating, and deletion.ConfigurationService
- This service is tasked with managing all the configuration settings of an application.LoggerService
- It captures and stores logs, which are records of events or transactions taking place within the application, typically used for debugging and monitoring purposes.ProcessService
- The ProcessService is involved with the execution and management of application processes.LifecycleService
- It manages the various stages an application or component goes through from initialization to termination.WindowService
- It handles operations such as opening, closing, resizing, and moving windows.
Microservices and Dependency Injection (DI)
MDA can perfectly work with the idea of Dependency Injection (DI) to achieve a much less coupled system from an overall perspective. In order to understand the concepts in depth, I will show some real applications of what we can benefit from those two concepts.
For instance, consider the following class:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
/**
* @class The main class of the application. It handles the core business of the
* application.
*/
export class ApplicationInstance extends Disposable implements INotaInstance {
constructor(
@IInstantiationService private readonly mainInstantiationService: IInstantiationService,
@IEnvironmentService private readonly environmentService: IMainEnvironmentService,
@IMainLifecycleService private readonly lifecycleService: IMainLifecycleService,
@ILogService private readonly logService: ILogService,
@IFileService private readonly fileService: IFileService,
@IMainStatusService private readonly statusService: IMainStatusService,
) {
super();
// ...
}
// ...
}
- The
ApplicationInstance
depends on all sorts of microservices in abstraction (in our context, depends on interfaces).ApplicationInstance
is not responsible for creating those microservices (less coupled systems). - Those prefixes in front of every field like
@ILogService
are known as decorators. These are language-specific techniques used to achieve DI in TypeScript. We will talk about this technique later.
Microservices Depends on Microservices
Similarly, microservices are not special classes, they can also depend on other microservices as well. As an example, the following MainWindowService
also depends on all sorts of different microservices:
1
2
3
4
5
6
7
8
9
10
11
class MainWindowService implements IMainWindowService {
constructor(
@IInstantiationService private readonly instantiationService: IInstantiationService,
@ILogService private readonly logService: ILogService,
@IFileService private readonly fileService: IFileService,
@IMainLifeCycleService private readonly lifeCycleService: IMainLifeCycleService,
@IEnvironmentService private readonly mainEnvironmentService: IMainEnvironmentService,
) {
// ...
}
}
From a framework perspective, I can construct MainWindowService
as follows:
- Regardless of the specific microservice instance passed through the framework, as long as that instance implements the corresponding interface, the system will function seamlessly.
- For instance, as long as
FileService
,TestFileService
, andNullFileService
have all implemented the interfaceIFileService
, the system will operate without issues.
- For instance, as long as
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
// normal case
const mainWindowService = new MainWindowService(
new InstantiationService(),
new LogService(),
new FileService(),
new MainLifeCycleService(),
new MainEnvironmentService(),
);
// unit test case
const mainWindowService = new MainWindowService(
new TestInstantiationService(),
new TestLogService(),
new TestFileService(),
new TestMainLifeCycleService(),
new TestMainEnvironmentService(),
);
// null case
const mainWindowService = new MainWindowService(
new NullInstantiationService(),
new NullLogService(),
new NullFileService(),
new NullMainLifeCycleService(),
new NullMainEnvironmentService(),
);
Godfather of All Microservices
Let’s address some potential questions that might arise so far:
- What happens when I’ve instantiated a
FileService
somewhere in my program, and another microservice, that depends on aFileService
, needs to be constructed? From where does the new microservice obtain the previously constructed FileService? - Does one have to manually construct all microservices, or is there a more efficient way to automate the construction process?
- Moreover, how do those decorators work, which we’ve previously seen and which are placed before each constructor parameter?
All these questions find their answers in a sophisticated solution, a class called
InstantiationService
.InstantiationService
lies at the heart of the microservice ecosystem. It’s responsible for managing the lifecycle of various services in the system. - The primary task of
InstantiationService
is to create instances of services, injecting any dependencies as needed. InstantiationService
is a concrete solution to achieve the Dependency Injection (DI) principle.
To illustrate how InstantiationService
operates, consider the following API example:
1
2
3
4
5
6
7
8
// initialization (acting as DI)
const instantiationService = new InstantiationService();
// register a dependency (microservice) into DI (we will talk about it later)
instantiationService.register(IFileService, new FileService());
// create the service by its corresponding dependency tree (how it works?)
const mainWindowService = instantiationService.createInstance(MainWindowService);
- The above example provokes some thoughts:
- First, what is a dependency tree of a microservice?
- Second, how do we determine the dependency tree of each microservice?
- Third, how do we create a microservice by its corresponding dependency tree? Don’t worry, I will explain these concepts to you step by step.
1. What Is a Dependency Tree?
A dependency tree represents a hierarchical relationship between different modules (or classes). Recall the previous example, ApplicationInstance
, its dependency tree would look like the following:
1
2
3
4
5
6
7
8
9
10
11
12
ApplicationInstance
├─ IInstantiationService
├─ IEnvironmentService
├─ IMainLifecycleService
├─ ILogService
├─ IFileService
└─ IMainStatusService
├─ IInstantiationService
├─ ILogService
├─ IFileService
├─ IMainLifecycleService
└─ IEnvironmentService
2. How Do We Determine a Dependency Tree?
Following the earlier example, where MainWindowService
depends on IFileService
. We can draw a few conclusions about IFileService
:
IFileService
serves as an abstraction.IFileService
as an interface, is a syntax sugar from TypeScript, which only exists on compile-time.IFileService
can be implemented as concrete classes, such asFileService
,TestFileService
orNullTestService
, and so forth.
- Since the interface is just a syntax sugar, it will be removed once compiled. We need a runtime solution to identify the existence of
IFileService
. Otherwise, theInstantiationService
will not be able to establish the connection betweenIFileService
andFileService
/TestFileService
/NullFileService
, which is essential for constructing aMainWindowService
based onIFileService
.- Luckily we can do that by using decorators. Let me introduce
createDecorator
decorator.
2.1 Runtime Identifier for Every Microservice - createDecorator
Decorator
VSCode provide a useful function, createDecorator
, which creates a unique decorator that can be served as an identifier for each microservice.
Sidenote: In TypeScript, a decorator is essentially a function.
- The decorator created by
createDecorator
, acts like an identifier, which can be stored in DI to make a connection between the microservice and the concrete class implementation as follows:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// fileService.ts
const IFileService = createDecorator('file-service'); // note: a variable in TypeScript can have the same name as an interface.
export interface IFileService {
// ...
}
export class FileService implements IFileService {
// ...
}
// playground.ts
const instantiationService = new InstantiationService();
// DI registration (⭐)
instantiationService.register(IFileService, new FileService()); // we've seen this line of code before
The decorator performs two functionalities in our cases:
- First, since the decorator is a variable, thus it exists in run-time, it can be used in our DI system (
InstantiationService
). As previously mentioned, It establishes a connection between the abstraction concept (IFileService
) and a concrete implementation (our case isFileService
), as we’ve just done in the above example.- At line 16, the DI system now recognizes an abstraction (
IFileService
), and a way to construct its corresponding class (FileService
).
- At line 16, the DI system now recognizes an abstraction (
- Second, this decorator also helps us to construct a dependency tree at runtime, which leads us to the next sub-topic.
Variables and interfaces can have the same names. In this case, we have a variable and an interface both named
IFileService
.
Sidenote: Decorators will be immediately executed once the JavaScript script is loaded.
2.2 Build a Dependency Tree at Runtime Using Decorator
Let’s see what can this decorator be used for:
1
2
3
4
5
6
7
8
9
10
11
class MainWindowService implements IMainWindowService {
constructor(
@IInstantiationService private readonly instantiationService: IInstantiationService,
@ILogService private readonly logService: ILogService,
@IFileService private readonly fileService: IFileService,
@IMainLifecycleService private readonly lifecycleService: IMainLifecycleService,
@IEnvironmentService private readonly mainEnvironmentService: IMainEnvironmentService,
) {
// ...
}
}
A decorator, as the name tells, is used to add something extra to a class parameter. It does one key job:
- It marks the decorated class (in this case,
MainWindowService
), which depends on the parameter (e.g.IFileService
), at runtime.
You do not need to worry about the black magic behind the decorators created by createDecorator. It works and works elgantly, I can promise you.
To give you a better illustration of what decorators do, let us consider the following class:
1 2 3 class TestService { constructor() {} }Currently, the class does not depend on anything. We can use an imagined function
getDependencyTreeFor
:
1 2 3 const dependencies = getDependencyTreeFor(TestService); console.log(dependencies); // []Now, we let
TestService
depends on two other services:
1 2 3 4 5 6 class TestService{ constructor( @IService1 private readonly service1: IService1, @IService2 private readonly service2: IService2, ) {} }If we print out its dependency tree again, we will see different things:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 const dependencies = getDependencyTreeFor(TestService); console.log(dependencies); /** [ { id: [Function: serviceIdentifier] { _: undefined, toString: [Function (anonymous)] }, index: 1, optional: false }, { id: [Function: serviceIdentifier] { _: undefined, toString: [Function (anonymous)] }, index: 0, optional: false } ] */In essence, the decorator’s job is to create and store the above dependency tree at runtime.
Continuing our example, the complete dependency tree of MainWindowService
will look like the following:
1
2
3
4
5
6
MainWindowService
└─ IInstantiationService
└─ ILogService
└─ IFileService
└─ IMainLifecycleService
└─ IMainEnvironmentService
Furthermore, the dependency tree can be more than just one level. The following dependency tree is also valid:
1
2
3
4
5
6
7
MainWindowService
└─ IInstantiationService
└─ ILogService
└─ IFileService
└─ IMainLifecycleService
└─ ILogService
└─ IMainEnvironmentService
However, the DI system will throw a runtime error when encounters a cyclic dependency. That is, encountering A depends on B, B depends on C, and C also depends on A.
3. How Do We Create a Microservice?
Now, we already covered a way to create a dependency tree for our classes. We finally reach a stage to discuss how we create our microservices.
3.1 Register a Microservice
For every dependency tree, its tree leaf (the dependency that depends on nothing) cannot be constructed automatically. It means the leaf must be provided by ourselves. We call this the registration process.
It is quite simple, recall our previous example:
1
2
3
4
5
6
7
8
// initialization (acting as DI)
const instantiationService = new InstantiationService();
// `FileService` does not depend on anything
// register a dependency (microservice) into DI (we just covered)
instantiationService.register(IFileService, new FileService());
// register other dependencies...
Of course, the other tree nodes other than just leafs can also be registered optionally.
3.2 Assembly Factory - InstantiationService
Here comes the final step of constructing a microservice: assembling. This is done inside the method createInstance
:
1
2
// construct the service (by its corresponding dependency tree)
const mainWindowService = instantiationService.createInstance(MainWindowService);
When the InstantiationService
(the DI System) is constructing a microservice, it will look for its dependency tree, iterate each dependency, and try to perform the following two operations to that dependency:
- If the dependency already exists, meaning it has been constructed before, then the process is done and it continues to the next dependency.
- If the dependency has not been constructed yet, the DI system recursively constructs this dependency.
After all the dependencies have been constructed, the InstantiationService
uses them to construct the final target, the original microservice that was requested to be built.
Lazy Loading
Recall the second step when assembling in InstantiationService
, where it checks ‘if the dependency has not been constructed yet’.
One might wonder how a microservice can be registered into the DI system without actually constructing one. For that, VSCode introduce a utility tool named SyncDescriptor
:
1
2
3
4
5
6
7
8
9
10
11
12
export class SyncDescriptor<T> {
readonly ctor: any;
readonly staticArguments: any[];
readonly supportsDelayedInstantiation: boolean;
constructor(ctor: new (...args: any[]) => T, staticArguments: any[] = [], supportsDelayedInstantiation: boolean = false) {
this.ctor = ctor;
this.staticArguments = staticArguments;
this.supportsDelayedInstantiation = supportsDelayedInstantiation;
}
}
With the help of SyncDescriptor
, we can achieve lazy loading when registering a microservice:
1
2
3
4
5
// initialization (acting as DI)
const instantiationService = new InstantiationService();
// register a dependency into DI using lazy loading
instantiationService.register(IFileService, new SyncDescriptor(FileService));
- When registering a microservice using
SyncDescriptor
, known as lazy loading technique, it ensures that the microservice is only constructed when it is actually needed.- This strategy can significantly improve the efficiency and scalability of an application.