Hello, Habr! I bring to your attention a translation of the article Implementing SOLID and the onion architecture in Node.js with TypeScript and InversifyJS by Remo H. Jansen
In this article, we will look at the architecture known as onion. Layered architecture - an approach to building application architecture that adheres to the principles of SOLID . It was created under the influence of DDD and some principles of functional programming, and also actively applies the principle of dependency injection.
Background
This section describes some of the software development approaches and principles needed to understand a layered architecture.
Principle of division of responsibility
Liability refers to various aspects of a software’s functionality. For example, “business logic” and the interface through which it is used are different responsibilities.
Separation of responsibility allows isolating the code that implements each responsibility. For example, changing the interface should not require changing the code of business logic, and so on.
SOLID Principles
SOLID is an acronym for the following five principles:

Principle of sole responsibility
A class should have only one responsibility. (Translator's note: a more precise wording, in my opinion, is: “A class must have one and only one reason for changes”)
The most effective way to break an application is to create a divine class.
The divine class is a class that knows and does too much. This approach is a good example of an anti-pattern.
. , , , . , , .
TypeScript . email, .
class Person {
public name : string;
public surname : string;
public email : string;
constructor(name : string, surname : string, email : string){
this.surname = surname;
this.name = name;
if(this.validateEmail(email)) {
this.email = email;
}
else {
throw new Error("Invalid email!");
}
}
validateEmail(email : string) {
var re = /^([\w-]+(?:\.[\w-]+)*)@((?:[\w-]+\.)*\w[\w-]{0,66})\.([a-z]{2,6}(?:\.[a-z]{2})?)$/i;
return re.test(email);
}
greet() {
alert("Hi!");
}
}
email Email
:
class Email {
public email : string;
constructor(email : string){
if(this.validateEmail(email)) {
this.email = email;
}
else {
throw new Error("Invalid email!");
}
}
validateEmail(email : string) {
var re = /^([\w-]+(?:\.[\w-]+)*)@((?:[\w-]+\.)*\w[\w-]{0,66})\.([a-z]{2,6}(?:\.[a-z]{2})?)$/i;
return re.test(email);
}
}
class Person {
public name : string;
public surname : string;
public email : Email;
constructor(name : string, surname : string, email : Email){
this.email = email;
this.name = name;
this.surname = surname;
}
greet() {
alert("Hi!");
}
}
, , , /.
/
.
, /:
class Rectangle {
public width: number;
public height: number;
}
class Circle {
public radius: number;
}
function getArea(shapes: (Rectangle|Circle)[]) {
return shapes.reduce(
(previous, current) => {
if (current instanceof Rectangle) {
return current.width * current.height;
} else if (current instanceof Circle) {
return current.radius * current.radius * Math.PI;
} else {
throw new Error("Unknown shape!")
}
},
0
);
}
( ). , . , ( ), , getArea
, , .
, , :
interface Shape {
area(): number;
}
class Rectangle implements Shape {
public width: number;
public height: number;
public area() {
return this.width * this.height;
}
}
class Circle implements Shape {
public radius: number;
public area() {
return this.radius * this.radius * Math.PI;
}
}
function getArea(shapes: Shape[]) {
return shapes.reduce(
(previous, current) => previous + current.area(),
0
);
}
( ) ( ).
.
, , . :
function getArea(shapes: Shape[]) {
return shapes.reduce(
(previous, current) => previous + current.area(),
0
);
}
Shape
, . , getArea
, Shape
. , TypeScript, (, Shape
area
, . , , .
, , , .
.
, : Rectangle
Circle
. , , . Shape
:
interface Shape {
area(): number;
serialize(): string;
}
class Rectangle implements Shape {
public width: number;
public height: number;
public area() {
return this.width * this.height;
}
public serialize() {
return JSON.stringify(this);
}
}
class Circle implements Shape {
public radius: number;
public area() {
return this.radius * this.radius * Math.PI;
}
public serialize() {
return JSON.stringify(this);
}
}
, :
function getArea(shapes: Shape[]) {
return shapes.reduce(
(previous, current) => previous + current.area(),
0
);
}
, , , :
return rectangle.serialize();
, serialize Shape
. -, — . .
, - , , , :
interface RectangleInterface {
width: number;
height: number;
}
interface CircleInterface {
radius: number;
}
interface Shape {
area(): number;
}
interface Serializable {
serialize(): string;
}
, .
class Rectangle implements RectangleInterface, Shape {
public width: number;
public height: number;
public area() {
return this.width * this.height;
}
}
class Circle implements CircleInterface, Shape {
public radius: number;
public area() {
return this.radius * this.radius * Math.PI;
}
}
function getArea(shapes: Shape[]) {
return shapes.reduce(
(previous, current) => previous + current.area(),
0
);
}
, .
class RectangleDTO implements RectangleInterface, Serializable {
public width: number;
public height: number;
public serialize() {
return JSON.stringify(this);
}
}
class CircleDTO implements CircleInterface, Serializable {
public radius: number;
public serialize() {
return JSON.stringify(this);
}
}
, ( ) ( , ).
, RectangleDTO
Rectangle
(DRY). , . , , . , .
DRY, DRY SOLID. , DRY , SOLID " " .
. - .
. , .
, SOLID D. , , SOLID. , SOLID . , , , :
SOLID . , , . , JavaScript ES5 ES6, SOLID . , TypeScript .
-- MVC
: , .

. , . , Product , SQL Server.
, . , , , . , .
. . , , , .
, . MVC, ; . , , .
MVC ( , ) . , . . — . - — . , . , , -.
MVC , , . , , -. MVC — .
-
MVC , - . , . -, . - , . , , -.
- . -, , - . - . - :
. , . , , . .

. , , , , .
-. , , (Data Mapper) .
. , catalog , . , , SQL- Sharepoint (CAML). .
( ):

— . , , , , , .
DDD, "". :
, , - . . ( ) - . , , ( SQL) ( HTTP).
, , , . -. (, ) (, ) , , - .
: (), (). , — HTTP , , . AircraftController
AircraftRepository
:
import { inject } from "inversify";
import { response, controller, httpGet } from "inversify-express-utils";
import * as express from "express";
import { AircraftRepository } from "@domain/interfaces";
import { Aircraft } from "@domain/entities/aircraft";
import { TYPE } from "@domain/types";
@controller("/api/v1/aircraft")
export class AircraftController {
@inject(TYPE.AircraftRepository) private readonly _aircraftRepository: AircraftRepository;
@httpGet("/")
public async get(@response() res: express.Response) {
try {
return await this._aircraftRepository.readAll();
} catch (e) {
res.status(500).send({ error: "Internal server error" });
}
}
}
AircraftController
HTTP AircraftRepository
. AircraftRepository
HTTP. :

. "comp" (composition) , AircraftRepository
AircraftController
. "ref" (reference) , AircraftController
Aircraft
.
AircraftRepository
, , AircraftController
AircraftRepository
, :

, () ( ). , , .

AircraftRepository
. , , - . "" InversifyJS
. InversifyJS
, @inject
, . , :
@inject(TYPE.AircraftRepository) private readonly _aircraftRepository: AircraftRepository;
, InversifyJS
:
container.bind<AircraftRepository>(TYPE.AircraftRepository).to(AircraftRepositoryImpl);
AircratRepository
Repository<T>
, .
import { Aircraft } from "@domain/entities/aircraft";
export interface Repository<T> {
readAll(): Promise<T[]>;
readOneById(id: string): Promise<T>;
}
export interface AircraftRepository extends Repository<Aircraft> {
}
:

Repository<T>
AircraftRepository
:
Repository<T>
Gene- ricRepositoryImpl<D, E>
AircraftRepository
AircraftRepositoryImpl
.
Repository<T>
import { injectable, unmanaged } from "inversify";
import { Repository } from "@domain/interfaces";
import { EntityDataMapper } from "@dal/interfaces";
import { Repository as TypeOrmRepository } from "typeorm";
@injectable()
export class GenericRepositoryImpl<TDomainEntity, TDalEntity> implements Repository<TDomainEntity> {
private readonly _repository: TypeOrmRepository<TDalEntity>;
private readonly _dataMapper: EntityDataMapper<TDomainEntity, TDalEntity>;
public constructor(
@unmanaged() repository: TypeOrmRepository<TDalEntity>,
@unmanaged() dataMapper: EntityDataMapper<TDomainEntity, TDalEntity>
) {
this._repository = repository;
this._dataMapper = dataMapper;
}
public async readAll() {
const entities = await this._repository.readAll();
return entities.map((e) => this._dataMapper.toDomain(e));
}
public async readOneById(id: string) {
const entity = await this._repository.readOne({ id });
return this._dataMapper.toDomain(entity);
}
}
, EntityDataMapper
TypeOrmRepository
. .
, EntityDataMapper
:
export interface EntityDataMapper<Domain, Entity> {
toDomain(entity: Entity): Domain;
toDalEntity(domain: Domain): Entity;
}
EntityDataMapper
:
import { toDateOrNull, toLocalDateOrNull } from "@lib/universal/utils/date_utils";
import { Aircraft } from "@domain/entities/aircraft";
import { AircraftEntity } from "@dal/entities/aircraft";
import { EntityDataMapper } from "@dal/interfaces";
export class AircraftDataMapper implements EntityDataMapper<Aircraft, AircraftEntity> {
public toDomain(entity: AircraftEntity): Aircraft {
}
public toDalEntity(mortgage: Aircraft): AircraftEntity {
}
}
EntityDataMapper
, TypeOrmRepository
. :

, , AircraftRepository
:
import { inject, injectable } from "inversify";
import { Repository as TypeOrmRepository } from "typeorm";
import { AircraftRepository } from "@domain/interfaces";
import { Aircraft } from "@domain/entities/aircraft";
import { GenericRepositoryImpl } from "@dal/generic_repository";
import { AircraftEntity } from "@dal/entities/aircraft";
import { AircraftDataMapper } from "@dal/data_mappers/aircraft";
import { TYPE } from "@dal/types";
@injectable()
export class AircraftRepositoryImpl
extends GenericRepositoryImpl<Aircraft, AircraftEntity>
implements AircraftRepository {
public constructor(
@inject(TYPE.TypeOrmRepositoryOfAircraftEntity) repository: TypeOrmRepository<AircraftEntity>
) {
super(repository, new AircraftDataMapper())
}
}
, , :

: (, ) (, ).
, — — .

This architectural approach has worked for me in large enterprise projects over the past ten years. Also, I finished dividing the huge layered monolith into microservices that follow the same architecture. I like to say that in the case of microservices on layered architecture, we have a "bag of bulbs".
I hope you enjoyed this article! Please share your thoughts in the comments or directly to the author @RemoHJansen .
A bonus for those who have read to the end is a repository with a working code example.