Чистая Архитектура для веб-приложений

Хочу поделиться с вами подходом который я уже много лет использую в разработке приложений, в том числе и веб-приложений. Многим разработчикам настольных, серверных и мобильных приложений этот подход хорошо знаком, т.к. является фундаментальным при построении таких приложений, однако в вебе он представлен очень скудно, хотя желающие использовать такой подход однозначно есть. Кроме того на таком подходе написан редактор VS Code.

Чистая Архитектура

В результате применения этого подхода вы отвяжетесь от конкретного фреймворка. Сможете легко переключать библиотеку представления внутри вашего приложения, например React, Preact, Vue, Mithril без переписывания бизнес логики, а в большинстве случаев даже вьюхи. Если у вас есть приложение на Angular 1, вы без проблем сможете перевести его на Angular 2+, React, Svelte, WebComponents или даже свою библиотеку представления. Если у вас есть приложение на Angular 2+, но нету специалистов для него, то вы без проблем сможете перевести приложение на более популярную библиотеку без переписывания бизнес логики. А в итоге вообще забыть про проблему миграции с фремворка на фреймворк. Что же это за магия такая?

Что такое Чистая Архитектура


Для того что бы понять это, лучше всего прочитать книгу Мартина Роберта «Чистая Архитектура» (Robert C.Martin «Clean Architecture»). Краткая выдержка из которого приведена в статье по ссылке.

Основные идеи заложенные в архитектуру:

  1. Независимость от фреймворка. Архитектура не зависит от существования какой-либо библиотеки. Это позволяет использовать фреймворк в качестве инструмента, вместо того, чтобы втискивать свою систему в рамки его ограничений.
  2. Тестируемость. Бизнес-правила могут быть протестированы без пользовательского интерфейса, базы данных, веб-сервера или любого другого внешнего компонента.
  3. Независимоcть от UI. Пользовательский интерфейс можно легко изменить, не изменяя остальную систему. Например, веб-интерфейс может быть заменен на консольный, без изменения бизнес-правил.
  4. Независимоcть от базы данных. Вы можете поменять Oracle или SQL Server на MongoDB, BigTable, CouchDB или что-то еще. Ваши бизнес-правила не связаны с базой данных.
  5. Независимость от какого-либо внешнего сервиса. По факту ваши бизнес правила просто ничего не знают о внешнем мире.


Идеи описанные в этой книге уже много лет являются основой для построения сложных приложений в самых разных сферах.

Достигается такая гибкость за счет разделения приложения на слои Service, Repository, Model. Я же добавил к Чистой Архитектуре подход MVC и получил следующие слои:

  • View — выводит данные клиенту, фактически визуализирует состояние логики клиенту.
  • Controller — отвечает за взаимодействие с пользователем посредством IO (ввод-вывод).
  • Service — отвечает за бизнес логику и ее переиспользование между компонентами.
  • Repository — отвечает за получение данных из внешних источников, такие как база данных, api, локальное хранилище и пр.
  • Models — отвечает за перенос данных между слоями и системами, а также за логику обработки этих данных.


Назначение каждого слоя рассмотрим ниже.

Кому подойдет Чистая Архитектура


Веб разработка прошла большой путь развития, начиная от простого скриптования на jquery до разработки больших SPA приложений. И сейчас веб приложения стали настолько большими что количество бизнес логики стало сопоставимо или даже превосходить серверные, настольные и мобильные приложения.

Разработчикам которые пишут сложные и большие приложения, а также переносят бизнес логику с сервера на веб приложения для экономии на стоимости серверов, Чистая Архитектура поможет организовать код и отмасштабироваться без проблем до огромных масштабов.

В тоже время если ваша задача просто верстка и анимация лендингов, то Чистую Архитектуру просто некуда вставить. Если ваша бизнес логика находиться на бекенде и ваша задача получить данные, отобразить клиенту и обработать клик по кнопке, то вы не прочувствуете гибкости Чистой Архитектуры, но она может стать отличным плацдармом для взрывного роста приложения.

Где уже применяется?


Чистая архитектура не привязана к какому то конкретному фреймворку, платформе или языку программирования. Десятилетия ее используют для написания настольных приложений. Его эталонную реализацию можно найти во фреймворках для серверных приложений Asp.Net Core, Java Spring и NestJS. Так же она очень популярна при написании iOs и Android приложений. Но в веб разработке он предстал в крайне не удачном виде во фреймворках Angular.

Так как я сам не только Typescript, но и C# разработчик, то для примера возьму эталонную реализацию этой архитектуры для Asp.Net Core.

Вот упрощенный пример приложения:

Пример приложения на Asp.Net Core
    /**
     * View
     */

    @model WebApplication1.Controllers.Profile

    

Добро пожаловать @Model.FirstName

/** * Controller */ public class IndexController : Controller { private static int _counter = 0; private readonly IUserProfileService _userProfileService; public IndexController(IUserProfileService userProfileService) { _userProfileService = userProfileService; } public async Task Index() { var profile = await this._userProfileService.GetProfile(_counter); return View("Index", profile); } public async Task AddCounter() { _counter += 1; var profile = await this._userProfileService.GetProfile(_counter); return View("Index", profile); } } /** * Service */ public interface IUserProfileService { Task GetProfile(long id); } public class UserProfileService : IUserProfileService { private readonly IUserProfileService _userProfileService; public UserProfileService(IUserProfileService userProfileService) { this._userProfileService = userProfileService; } public async Task GetProfile(long id) { return await this._userProfileService.GetProfile(id); } } /** * Repository */ public interface IUserProfileRepository { Task GetProfile(long id); } public class UserProfileRepository : IUserProfileRepository { private readonly DBContext _dbContext; public UserProfileRepository(DBContext dbContext) { this._dbContext = dbContext; } public async Task GetProfile(long id) { return await this._dbContext .Set() .FirstOrDefaultAsync((entity) => entity.Id.Equals(id)); } } /** * Model */ public class Profile { public long Id { get; set; } public string FirstName { get; set; } public string Birthdate { get; set; } }


Если вы не понимаете что в нем написано ничего страшного, дальше мы разберем его по частям каждый фрагмент.

Пример приведен для Asp.Net Core приложения, но для Java Spring, WinForms, Android, React архитектура и код будут такие же, меняется только язык и работа с вьюхой (если она есть).

Применение в веб-приложениях


Единственный фреймворк который пытался использовать Чистую архитектуру был Angular. Но получилось это просто ужасно, что в 1, что в 2+.

И причин для этого много:

  1. Angular монолитный фреймворк. И это его основная проблема. Если тебе в нем что то не нравится, ты вынужден давиться этим ежедневно, и ничего с этим не поделать. Мало того что в нем масса проблемных мест, так это еще и противоречит идеологии чистой архитектуры.
  2. Ужасная адаптация патерна DI. Его просто перенели как есть, без учета особенностей веб приложений и игнорирую модульную систему импортов современного Javascript.
  3. Ужасный движок представлений. Он очень примитивный и сильно уступает простоте JSX. Данные не типизируются на этапе написания кода, а на этапе компиляции научился отлавливать ошибки только в версии 6, а до этого только в рантайме. А прокинуть в компонент два шаблона и получить контент прокинутого и прокидывающего контроллера из разряда фантастики.
  4. Старый бандлер. В то время как бандлер Rollup позволял собирать ES2015 и делать 2 бандла для старых и новых браузеров уже 4 года, то сборщик angular научился это делать только в версии 9.
  5. И еще много проблем. В целом до ангуляр современные технологии докатываются с задержкой лет в 5 относительно React.

Но что же другие фреймворки? React, Vue, Preact, Mithril и прочие являются исключительно библиотеками представления и не предоставляют никакой архитектуры…, а архитектура у нас уже есть… осталось собрать всё в единое целое!

Начинаем создавать приложение


Рассматривать Чистую Архитектуру будем на примере выдуманного приложения, максимально приближенного к реальному веб-приложению. Это кабинет в страховой компании, который отображает профиль пользователя, страховые случаи, предлагаемые тарифы страхования и инструменты для работы с этими данными.
Прототип приложения

В примере будет реализована лишь малая часть функционала, но по ней можно понять где и как располагать остальной функционал. Начинать создавать приложение начнем со слоя Controller, а View слой подключим в самом конце. А по ходу создания рассмотрим каждый слой детальнее.

Паттерн Controller


Controller — отвечает за взаимодействие пользователя с приложением. Это может быть клик по кнопке на веб странице, настольном приложении, мобильном приложении, или ввод команды в консоли линукса, или сетевой запрос, или любое другое IO событие приходящее в приложение.

Самый простой контроллер в чистой архитектуре выглядит следующим образом:

export class SimpleController { // extends React.Component

    public todos: string[] = []; // состояние контроллера

    public addTodo(todo: string): void { // вызывает событие от пользователя
        this.todos.push(todo);
    }

    public removeTodo(index: number): void { // вызывает событие от пользователя
        this.todos.splice(index, 1);
    }

    // public render(): JSX.Element {...} // view injection

}


Его задача получить от пользователя событие и запустить бизнес процессы. В идеальном случае Controller ничего не знает про View, и тогда его можно переиспользовать между платформами, например Web, React-Native или Electron.

А теперь давайте напишем контроллер для нашего приложения. Его задача получить профиль пользователя, имеющиеся тарифы и предложить наилучший тариф пользователю:

UserPageController. Контроллер с бизнес логикой
export class UserPageControlle {

    public userProfile: any = {};
    public insuranceCases: any[] = [];
    public tariffs: any[] = [];
    public bestTariff: any = {};

    constructor() {
        this.activate();
    }

    public activate(): void {
        this.requestUserProfile();
        this.requestTariffs();
    }

    public async requestUserProfile(): Promise { // получение данных
        try {
            const response = await fetch("./api/user-profile");
            this.userProfile = await response.json();
            this.findBestTariff();
        } catch (e) {
            console.error(e);
        }
    }

    public async requestTariffs(): Promise { // получение данных
        try {
            const response = await fetch("./api/tariffs");
            this.tariffs = await response.json();
            this.findBestTariff();
        } catch (e) {
            console.error(e);
        }
    }

    public findBestTariff(): void { // метод с бизнес логикой
        if (this.userProfile && this.tariffs) {
            this.bestTariff = this.tariffs.find((tarif: any) => {
                return tarif.ageFrom <= this.userProfile.age && this.userProfile.age < tarif.ageTo;
            });
        }
    }

    /**
     * ... множество других методов, запрос страховых случаев,
     * редактирование профиля, выбор тарифа и прочее
     */
}


У нас получился обычной контроллер без чистой архитектуры, если отнаследовать его от React.Component то получим рабочий компонент с логикой. Так пишут очень много разработчиков веб-приложений, но у такого подхода есть много существенных недостатков. Главный из которых невозможность переиспользования логики между компонентами. Ведь рекомендуемый тариф может выводиться не только в личном кабинете, но и на лендинге и множетсве других мест для привлечения клиента к услуге.

Для того что бы иметь возможность переиспользовать логику между компонентами ее необходимо вынести в специальный слой, который называется Service.

Паттерн Service


Service — отвечает за всю бизнес логику приложения. Если Controller’у понадобилось получить, обработать, отправить какие то данные — он делает это через Service. Если нескольким контроллерам понадобилась одна и та же логика, они работают с Service. Но сам слой Service ничего не должен знать о слое Controller и View и окружении в котором он работает.

Давайте вынесем логику из контроллера в сервис и внедрим сервис в контроллер:

UserPageController. Контроллер без бизнес логики
import { UserProfilService } from "./UserProfilService";
import { TariffService } from "./TariffService";

export class UserPageController {

    public userProfile: any = {};
    public insuranceCases: any[] = [];
    public tariffs: any[] = [];
    public bestTariff: any = {};

    // внедряем сервисы в контроллер
    private readonly userProfilService: UserProfilService = new UserProfilService();
    private readonly tarifService: TariffService = new TariffService();

    constructor() {
        this.activate();
    }

    public activate(): void {
        this.requestUserProfile();
        this.requestTariffs();
    }

    public async requestUserProfile(): Promise {
        try {
            // используем сервисы для получения данных
            this.userProfile = await this.userProfilService.getUserProfile();
            this.bestTariff = await this.tarifService.findBestTariff(this.userProfile);
        } catch (e) {
            console.error(e);
        }
    }

    public async requestTariffs(): Promise {
        try {
            // используем сервис для получения данных
            this.tariffs = await this.tarifService.getTariffs();
        } catch (e) {
            console.error(e);
        }
    }

    /**
     * ... множество других методов, запрос страховых случаев,
     * редактирование профиля, выбор тарифа и прочее
     */
}

UserProfilService. Сервис для работы с профилем пользователя
export class UserProfilService {
    public async getUserProfile(): Promise { // получение данных
        const response = await fetch("./api/user-profile");
        return await response.json();
    }
    
    /**
     * ... множество других методов для работы с профилем пользователя
     */
}

TariffService. Сервис для работы с тарифами
export class TariffService {
    // получение данных
    public async getTariffs(): Promise {
        const response = await fetch("./api/tariffs");
        return await response.json();
    }

    // метод с бизнес логикой
    public async findBestTariff(userProfile: any): Promise {
        const tariffs = await this.getTariffs();
        return tariffs.find((tarif: any) => {
            return tarif.ageFrom <= userProfile.age &&
                userProfile.age < tarif.ageTo;
        });
    }
    
    /**
     * ... множество других методов для работы с тарифами
     */
}


Теперь если нескольким контроллерам понадобиться получить профиль пользователя или тарифы, они смогу переиспользовать одну и туже логику из сервисов. В сервисах главное не забывать про SOLID принципы и что каждый сервис отвечает за свою зону ответственности. В данном случае один сервис отвечает за работу с профилем пользователя, а другой сервис за работу с тарифами.

Но что делать если источник данных поменяется, например fetch может смениться на websocket или grps или базу данных, а реальные данные понадобиться заменить тестовыми? И вообще зачем бизнес логике что то знать о источнике данных? Для решениях этих проблем существует слой Repository.

Паттерн Repository


Repository  — отвечает за общение с хранилищем данных. В качестве хранилища может выступать сервер, база данных, память, localstorage, sessionstorage или любое другое хранилище. Его задача абстрагировать слой Service от конкретной реализации хранилища.

Давайте вынесем сетевые запросы из сервисов в репозитории, контроллер при этом не меняем:

UserProfilService. Сервис для работы с профилем пользователя
import { UserProfilRepository } from "./UserProfilRepository";

export class UserProfilService {

    private readonly userProfilRepository: UserProfilRepository =
        new UserProfilRepository();

    public async getUserProfile(): Promise {
        return await this.userProfilRepository.getUserProfile();
    }
    
    /**
     * ... множество других методов для работы с профилем пользователя
     */
}

UserProfilRepository. Сервис для работы с хранилищем профилей пользователя
export class UserProfilRepository {
    public async getUserProfile(): Promise { // получение данных
        const response = await fetch("./api/user-profile");
        return await response.json();
    }
    
    /**
     * ... множество других методов для рабоыт с профилем пользователя
     */
}

TariffService. Сервис для работы с тарифами
import { TariffRepository } from "./TariffRepository";

export class TariffService {
    
    private readonly tarifRepository: TariffRepository = new TariffRepository();

    public async getTariffs(): Promise {
        return await this.tarifRepository.getTariffs();
    }

    // метод с бизнес логикой
    public async findBestTariff(userProfile: any): Promise {
        // запрашиваем у источника данных
        const tariffs = await this.tarifRepository.getTariffs();
        return tariffs.find((tarif: any) => {
            return tarif.ageFrom <= userProfile.age &&
                userProfile.age < tarif.ageTo;
        });
    }
    
    /**
     * ... множество других методов для работы с тарифами
     */
}

TariffRepository. Репозиторий для работы с хранилищем тарифов
export class TariffRepository {
    // получение данных
    public async getTariffs(): Promise {
        const response = await fetch("./api/tariffs");
        return await response.json();
    }

    /**
     * ... множество других методов для работы с хранилищем тарифов
     */
}


Теперь достаточно один раз написать запрос к данным и любой сервис сможет переиспользовать этот запрос. Позже мы рассмотрим пример как переопределить репозиторий не трогая код сервиса и внедрить моковый репозиторий на время тестирования.

В сервисе UserProfilService может показаться что он не нужен и контроллер может напрямую обратиться к репозиторию за данными, но это не так. В любой момент в бизнес слое могут появиться или измениться требования, может потребоваться дополнительный запрос или обогатить данные. Поэтому даже когда в слое сервиса нету логики цепочка Controller — Service — Repository должна сохраняться. Это вклад в ваше завтра.

Настало время разобраться что за заданные получает репозиторий, корректные ли они вообще. За это отвечает слой Models.

Модели: DTO, Entities, ViewModels


Models — отвечает за описание структур с которыми работает приложение. Такое описание очень помогает новым разработчикам проекта понять с чем работает приложение. Кроме того его очень удобно использовать для построения баз данных или проведений валидаций данных хранящихся в модели.

Модели в зависимости от типа использования делятся на разные паттерны:

  • Entities — отвечают за работу с базой данных и представляют из себя структуру повторяющую таблицу или документ в базе данных.
  • DTO (Data Transfer Object) — служат для переноса данных между разными слоями приложения.
  • ViewModel — содержат заранее подготовленную информацию необходимую для отображении в представлении.

Добавим в приложение модель профиля пользователя и другие модели, и сообщим остальным слоям что теперь мы работаем не с абстрактным объектом, а с вполне конкретным профилем:

UserPageController. Вместо типа any используются описанные модели
import { UserProfilService } from "./UserProfilService";
import { TariffService } from "./TariffService";
import { UserProfileDto } from "./UserProfileDto";
import { TariffDto } from "./TariffDto";
import { InsuranceCaseDto } from "./InsuranceCasesDto";

export class UserPageController {

    /**
     * Используем модель для типа и пустую модель для первой отрисовки
     * как заглушку до тех пока данные из сервиса не придут.
     */
    public userProfile: UserProfileDto = new UserProfileDto();
    public insuranceCases: InsuranceCaseDto[] = [];
    public tariffs: TariffDto[] = [];
    public bestTariff: TariffDto | void = void 0;

    private readonly userProfilService: UserProfilService = new UserProfilService();
    private readonly tarifService: TariffService = new TariffService();

    constructor() {
        this.activate();
    }

    public activate(): void {
        this.requestUserProfile();
        this.requestTariffs();
    }

    public async requestUserProfile(): Promise {
        try {
            this.userProfile = await this.userProfilService.getUserProfile();
            this.bestTariff = await this.tarifService.findBestTariff(this.userProfile);
        } catch (e) {
            console.error(e);
        }
    }

    public async requestTariffs(): Promise {
        try {
            this.tariffs = await this.tarifService.getTariffs();
        } catch (e) {
            console.error(e);
        }
    }

    /**
     * ... множество других методов, запрос страховых случаев,
     * редактирование профиля, выбор тарифа и прочее
     */
}

UserProfilService. Вместо any указываем возвращаемую модель
import { UserProfilRepository } from "./UserProfilRepository";
import { UserProfileDto } from "./UserProfileDto";

export class UserProfilService {

    private readonly userProfilRepository: UserProfilRepository =
        new UserProfilRepository();

    public async getUserProfile(): Promise { // возвращаем модель
        return await this.userProfilRepository.getUserProfile();
    }
    
    /**
     * ... множество других методов для работы с профилем пользователя
     */
}

TariffService. Вместо any указываем возвращаемую модель
import { TariffRepository } from "./TariffRepository";
import { TariffDto } from "./TariffDto";
import { UserProfileDto } from "./UserProfileDto";

export class TariffService {
    
    private readonly tarifRepository: TariffRepository = new TariffRepository();

    public async getTariffs(): Promise { // возвращаем модель
        return await this.tarifRepository.requestTariffs();
    }

    // возвращаем модель
    public async findBestTariff(userProfile: UserProfileDto): Promise {
        const tariffs = await this.tarifRepository.requestTariffs();
        return tariffs.find((tarif: TariffDto) => {
            // было userProfile.age стало userProfile.getAge()
            const age = userProfile.getAge();
            return age &&
                tarif.ageFrom <= age &&
                age < tarif.ageTo;
        });
    }
    
    /**
     * ... множество других методов для работы с тарифами
     */
}

UserProfilRepository. Вместо any указываем возвращаемую модель
import { UserProfileDto } from "./UserProfileDto";

export class UserProfilRepository {
    public async getUserProfile(): Promise { // возвращаем модель
        const response = await fetch("./api/user-profile");
        return await response.json();
    }
    
    /**
     * ... множество других методов для рабоыт с профилем пользователя
     */
}

TariffRepository. Вместо any указываем возвращаемую модель
import { TariffDto } from "./TariffDto";

export class TariffRepository {
    public async requestTariffs(): Promise { // возвращаем модель
        const response = await fetch("./api/tariffs");
        return await response.json();
    }

    /**
     * ... множество других методов для работы с хранилищем тарифов
     */
}

UserProfileDto. Модель с описанием данных с которыми мы работаем
export class UserProfileDto { // <-- модель с описанием данных и логикой
    public firstName: string | null = null;
    public lastName: string | null = null;
    public birthdate: Date | null = null;

    public getAge(): number | null {
        if (this.birthdate) {
            const ageDifMs = Date.now() - this.birthdate.getTime();
            const ageDate = new Date(ageDifMs);
            return Math.abs(ageDate.getUTCFullYear() - 1970);
        }
        return null;
    }

    public getFullname(): string | null {
        return [
            this.firstName ?? "",
            this.lastName ?? ""
        ]
            .join(" ")
            .trim() || null;
    }

}

TariffDto. Модель с описанием данных с которыми мы работаем
export class TariffDto {
    public ageFrom: number = 0;
    public ageTo: number = 0;
    public price: number = 0;
}


Теперь в каком бы слое приложения мы не находились мы точно знаем с какими данными мы работаем. Так же благодаря описанию модели мы нашли ошибку в нашем сервисе. В логике сервиса использовалось свойство userProfile.age, которого на самом деле нет, но есть дата рождения. А для высчитывания возраста необходимо вызвать метод модели userProfile.getAge ().

Но есть одна проблема. Если мы попытаемся воспользоваться методами из модели что предоставил текущий репозиторий, то получим исключение. Все дело в том что методы response.json () и JSON.parse () возвращает не нашу модель, а объект JSON, который никак не связан с нашей моделью. Убедиться в этом можно если исполнить команду userProfile instanceof UserProfileDto, получится ложное утверждение. Для того что бы преобразовать данные полученные из внешнего источника к описанной модели существует процесс Десериализации данных.

Десериализация данных


Deserialization — процесс восстановления необходимой структуры из последовательности байт. Если в данных будет информация не указанная в моделях, она будет проигнорирована. Если в данных будет информация противоречащая описании модели — произойдет ошибка десериализации.

И самое интересно тут то, что при проектировании ES2015 и добавлении ключевого слово class забыли добавить десериализацию… То что во всех языках присутствует из коробки, в ES2015 просто забыли…

Для решения этой проблемы мною была написана библиотека для десериализации TS-Serializable, статью о которой можно прочитать по этой ссылке. Цель которой вернуть потерянный функционал.

Добавляем поддержку десериализации в модели и саму десериализацию в репозиторий:

TariffRepository. Добавляем процесс десериализации
import { UserProfileDto } from "./UserProfileDto";

export class UserProfilRepository {
    public async getUserProfile(): Promise {
        const response = await fetch("./api/user-profile");
        const object = await response.json();
        return new UserProfileDto().fromJSON(object); // добавляем десериализацию
    }
    
    /**
     * ... множество других методов для рабоыт с профилем пользователя
     */
}

TariffRepository. Добавляем процесс десериализации
import { TariffDto } from "./TariffDto";

export class TariffRepository {
    public async requestTariffs(): Promise { // возвращаем модель
        const response = await fetch("./api/tariffs");
        const objects: object[] = await response.json();
        return objects.map((object: object) => {
            return new TariffDto().fromJSON(object); // добавляем десериализацию
        });
    }

    /**
     * ... множество других методов для работы с хранилищем тарифов
     */
}

ProfileDto. Добавляем поддержку десериализации
import { Serializable, jsonProperty } from "ts-serializable";

export class UserProfileDto extends Serializable { // <-- наследуемся от базового класса

    @jsonProperty(String, null) // <-- вспомогательный декоратор
    public firstName: string | null = null;

    @jsonProperty(String, null) // <-- вспомогательный декоратор
    public lastName: string | null = null;

    @jsonProperty(Date, null) // <-- вспомогательный декоратор
    public birthdate: Date | null = null;

    public getAge(): number | null {
        if (this.birthdate) {
            const ageDifMs = Date.now() - this.birthdate.getTime();
            const ageDate = new Date(ageDifMs);
            return Math.abs(ageDate.getUTCFullYear() - 1970);
        }
        return null;
    }

    public getFullname(): string | null {
        return [
            this.firstName ?? "",
            this.lastName ?? ""
        ]
            .join(" ")
            .trim() || null;
    }

}

TariffDto. Добавляем поддержку десериализации
import { Serializable, jsonProperty } from "ts-serializable";

export class TariffDto extends Serializable { // <-- наследуемся от базового класса

    @jsonProperty(Number, null) // <-- вспомогательный декоратор
    public ageFrom: number = 0;

    @jsonProperty(Number, null) // <-- вспомогательный декоратор
    public ageTo: number = 0;

    @jsonProperty(Number, null) // <-- вспомогательный декоратор
    public price: number = 0;

}


Теперь во всех слоях приложения можно быть абсолютно уверенным в том что мы работаем именно с теми моделями которые ожидаем. В представлении, контроллере и других слоях можно вызывать методы описанной модели.

Для чего Serializable и jsonProperty?

Для работы библиотеки необходимо наследоваться от базового класса Serializable — это необходимо для корректного расширения возможностей базового объекта Ecmascript и последующей с ним работы в Typescript. Также необходимо использовать вспомогательный декоратор jsonProperty для описания тех типов которые мы ожидаем от данных, это связано с тем что у Typescript очень не развитая рефлексия и он генерирует не корректную информацию для uniontypes. Но возможно при развитии рефлексии или трансформеров от них можно будет отказаться.


Теперь у нас есть почти готовое приложение. Настало время протестировать логику написанную в слоях Controller, Service и Models. Для этого нам необходимо в слое Repository вместо реального запроса на сервер вернуть специально подготовленные тестовые данные. Но как же подменить Repository не трогая того кода который пойдет в продакшен. Для этого существует паттерн Dependency Injection.

Dependency Injection — внедряем зависимости


Dependency Injection — внедряет зависимости в слои Contoller, Service, Repository и дает возможность переопределить эти зависимости за пределами этих слоев.

В программе слой Controller зависит от слоя Service, а он зависит от слоя Repository. В текущем виде слои сами вызывают свои зависимости через создание экземпляра. А для того что бы переопределить зависимость, слою необходимо из вне задать эту зависимость. Для этого есть множество способов, но самый популярным является передача зависимости как параметр в конструкторе.

Тогда создание программы со всеми зависимости будет выглядеть следующим образом:

var programm = new IndexPageController(new ProfileService(new ProfileRepository()));


Согласитесь — выглядит ужасно. Даже с учетом того что в программе всего две зависимости, это уже выглядит ужасно. Что уже говорить про программы в которых сотни и тысячи зависимостей.

Для решения проблемы понадобится специальный инструмент, а для этого его необходимо найти. Если обратиться к опыту других платформ, например Asp.Net Core то там регистрация зависимостей происходит на этапе инициализации программы и выглядит примерно следующим образом:

DI.register(IProfileService,ProfileService);


а далее фреймворк при создании контроллера уже сам создаст и внедрит эту зависимость.

Но тут есть три существенные проблемы:

  1. При транспиляции Typescript в Javascript от интерфейсов не остается и следа.
  2. Все что попало в классический DI остается в нем навсегда. Его очень сложно вычистить при рефакторинге. А в веб приложении необходимо экономить каждый байт.
  3. Почти все библиотеки представлений не используют DI и конструкторы контроллеров заняты параметрами.

В веб приложениях DI используется только в Angular 2+. В Angular 1 при регистрации зависимостей вместо интерфейса использовалась строка, в InversifyJS вместо интрерфейса используется Symbol. И все это реализовано настолько ужасно, что лучше уже много new как в первом примере этого раздела чем эти решения.

Для решения всех трех проблем был придуман собственный DI, а решение для него мне помог найти фреймворк Java Spring и его декоратор autowired. Описание принципа работы этого DI можно прочитать в статье по ссылке, а репозиторий GitHub.

Настало время применить получившийся DI в нашем приложении.

Соединяем все в единое целое


Для внедрения DI на все слои накинем декоратор reflection, который заставит typescript генерировать дополнительную метаинформацию о типах зависимостей. В контроллере где необходимо вызвать зависимости повесим декоратор autowired. А в том месте где программа инициализируется определим в каком окружении какая зависимость будет реализована.

Для репозитория UserProfilRepository создадим такой же репозиторий, но с тестовыми данными вместо реального запроса. В итоге получаем следующий код:

Main.ts. Место инициализации программы
import { override } from "first-di";
import { UserProfilRepository } from "./UserProfilRepository";
import { MockUserProfilRepository } from "./MockUserProfilRepository";

if (process.env.NODE_ENV === "test") {
    // для тестового окружение подменяем реальный запрос на тестовые данные
    override(UserProfilRepository, MockUserProfilRepository);
}

UserPageController. Внедряем зависимость через декоратор autowired
import { UserProfilService } from "./UserProfilService";
import { TariffService } from "./TariffService";
import { UserProfileDto } from "./UserProfileDto";
import { TariffDto } from "./TariffDto";
import { InsuranceCaseDto } from "./InsuranceCasesDto";
import { autowired } from "first-di";

export class UserPageController {

    public userProfile: UserProfileDto = new UserProfileDto();
    public insuranceCases: InsuranceCaseDto[] = [];
    public tariffs: TariffDto[] = [];
    public bestTariff: TariffDto | void = void 0;

    @autowired() // внедряет зависимость
    private readonly userProfilService!: UserProfilService;

    @autowired() // внедряет зависимость
    private readonly tarifService!: TariffService;

    constructor() {
        // конструктор для внедрения не используется, т.к. занят фреймворком
        this.activate();
    }

    public activate(): void {
        this.requestUserProfile();
        this.requestTariffs();
    }

    public async requestUserProfile(): Promise {
        try {
            this.userProfile = await this.userProfilService.getUserProfile();
            this.bestTariff = await this.tarifService.findBestTariff(this.userProfile);
        } catch (e) {
            console.error(e);
        }
    }

    public async requestTariffs(): Promise {
        try {
            this.tariffs = await this.tarifService.getTariffs();
        } catch (e) {
            console.error(e);
        }
    }

    /**
     * ... множество других методов, запрос страховых случаев,
     * редактирование профиля, выбор тарифа и прочее
     */
}

UserProfilService. Внедряем генерацию рефлексии и зависимости
import { UserProfilRepository } from "./UserProfilRepository";
import { UserProfileDto } from "./UserProfileDto";
import { reflection } from "first-di";

@reflection // заставляет typescript генерировать рефлексию
export class UserProfilService {

    private readonly userProfilRepository: UserProfilRepository;

    constructor(userProfilRepository: UserProfilRepository) {
        // внедряем зависимость через конструктор
        this.userProfilRepository = userProfilRepository;
    }

    public async getUserProfile(): Promise {
        return await this.userProfilRepository.getUserProfile();
    }

    /**
     * ... множество других методов для работы с профилем пользователя
     */
}

TariffService. Внедряем генерацию рефлексии и зависимости
import { TariffRepository } from "./TariffRepository";
import { TariffDto } from "./TariffDto";
import { UserProfileDto } from "./UserProfileDto";
import { reflection } from "first-di";

@reflection // заставляет typescript генерировать рефлексию
export class TariffService {

    private readonly tarifRepository: TariffRepository;

    constructor(tarifRepository: TariffRepository) {
        // внедряем зависимость через конструктор
        this.tarifRepository = tarifRepository;
    }

    public async getTariffs(): Promise {
        return await this.tarifRepository.requestTariffs();
    }

    public async findBestTariff(userProfile: UserProfileDto): Promise {
        const tariffs = await this.tarifRepository.requestTariffs();
        return tariffs.find((tarif: TariffDto) => {
            const age = userProfile.getAge();
            return age &&
                tarif.ageFrom <= age &&
                age < tarif.ageTo;
        });
    }

    /**
     * ... множество других методов для работы с тарифами
     */
}


UserProfilRepository. Внедряем генерацию рефлексии
import { UserProfileDto } from "./UserProfileDto";
import { reflection } from "first-di";

@reflection // заставляет typescript генерировать рефлексию
export class UserProfilRepository {
    public async getUserProfile(): Promise {
        const response = await fetch("./api/user-profile");
        const object = await response.json();
        return new UserProfileDto().fromJSON(object);
    }

    /**
     * ... множество других методов для работы с профилем пользователя
     */
}

MockUserProfilRepository. Новый репозиторий для тестирования
import { UserProfileDto } from "./UserProfileDto";
import { reflection } from "first-di";

@reflection // заставляет typescript генерировать рефлексию
export class MockUserProfilRepository { // репозиторий для тестов
    public async getUserProfile(): Promise {
        const profile = new UserProfileDto();
        profile.firstName = "Констанция";
        profile.lastName = "Константинопольская";
        profile.birthdate = new Date(Date.now() - 1.5e12);
        return Promise.resolve(profile); // возвращаем тестовые данные
    }

    /**
     * ... множество других методов для рабоыт с профилем пользователя
     */
}

TariffRepository. Внедряем генерацию рефлексии
import { TariffDto } from "./TariffDto";
import { reflection } from "first-di";

@reflection // заставляет typescript генерировать рефлексию
export class TariffRepository {
    public async requestTariffs(): Promise {
        const response = await fetch("./api/tariffs");
        const objects: object[] = await response.json();
        return objects.map((object: object) => {
            return new TariffDto().fromJSON(object);
        });
    }

    /**
     * ... множество других методов для работы с хранилищем тарифов
     */
}


Теперь в любом месте программы есть возможность поменять реализацию любой логики. В нашем примере вместо реального запроса профиля пользователя на сервер в тестовом окружении будут использованы тестовые данные.

В реальной жизни могут встречаться замены в любом месте, например можно поменять логику в каком либо сервисе, реализовывая в продакшене старый сервис, а в рефакторинге уже новый. Проводить A/B тесты с бизнес логикой, менять документо-ориентированную базу на реляционную, и вообще сменить сетевой запрос на вебсокеты. И все это без остановки разработки на переписывание решения.

Настало время увидеть результат работы программы. Для этого существует слой View.

Внедряем View


Слой View отвечает за представление данных которые содержатся в слое Controller пользователю. Я в примере буду использовать для этого React, но на его месте может быть любой другой, например Preact, Svelte, Vue, Mithril, WebComponent или любой другой.

Для этого просто отнаследуем наш контроллер от React.Component, и добавим ему метод render с отображением представления:

Main.ts. Запускает отрисовку React компонента
import { override } from "first-di";
import { UserProfilRepository } from "./UserProfilRepository";
import { MockUserProfilRepository } from "./MockUserProfilRepository";
import { UserPageController } from "./UserPageController";
import React from "react";
import { render } from "react-dom";

if (process.env.NODE_ENV === "test") {
    // для тестового окружение подменяем реальный запрос на тестовые данные
    override(UserProfilRepository, MockUserProfilRepository);
}

render(React.createElement(UserPage
    
            

© Habrahabr.ru