Трёхслойная архитектура (она же чистая) предполагает разделение кода приложения на «слои» с чётко разграниченными обязанностями.
Трёхслойная архитектура напоминает луковичную, но немного отличается в деталях. Для маленьких приложений эти отличия несущественны, и мы можем считать эти понятия синонимами. Но для лучшего понимания разницы советуем прочесть статью «Onion Architecture» 🧅
Слои приложения
Секция статьи "Слои приложения"Трёхслойная архитектура подразумевает разделение кода на 3 слоя:
- домен;
- прикладной слой;
- слой адаптеров и портов.
На схемах слои обычно вкладывают друг в друга:
Рассмотрим каждый слой в отдельности и определим, по каким признакам код относят к одному из слоёв.
Доменный слой
Секция статьи "Доменный слой"В доменном слое находятся код и данные из предметной области приложения. Код доменного слоя — это самое важное, что отличает одно приложение от другого. Иногда доменный слой (или просто домен) ещё называют бизнес-логикой.
Например, в интернет-магазине в домен могут попасть сущности пользователя, товара, корзины и заказа. Код для создания нового пользователя или заказа, функции подсчёта итоговой цены корзины и скидки на товары, функция для добавления и удаления товара из корзины — это всё домен.
А вот код для общения с базой данных или для отрисовки интерфейса, например, к домену не относится. Он не попадает в предметную область приложения, он скорее её «обслуживает», чтобы преобразованные доменом данные можно было сохранить в БД или чтобы их увидели пользователи.
На схеме домен расположен в центре, потому что именно он определяет, как будет организован остальной код приложения.
Домен определяет:
- В каком виде ему необходимо передать данные из внешнего мира.
- С помощью каких методов и функций к нему можно обращаться.
- В каком виде он предоставит результат.
В JavaScript данные домена, как правило, представляются в виде структур: объектов, массивов, встроенных примитивов.
const user = { name: "Alex", email: "say-hi@alex.com",};
const user = { name: "Alex", email: "say-hi@alex.com", };
Схематичное описание структуры данных называется типом. В типах указывают, какие поля и значения должна содержать структура. Типы данных домена называются доменными типами.
Удобнее всего для описания типов данных использовать TypeScript. С его помощью можно описать схему данных для какой-то сущности так:
type User = { name: string; email: string;};
type User = { name: string; email: string; };
Код выше можно прочитать так: «Данные типа User
обязаны содержать 2 поля: name
и email
. Значения обоих полей должны быть строками».
JavaScript же для описания типов может предложить разве что классы.
class User { constructor(name, email) { this.name = name; this.email = email; }}
class User { constructor(name, email) { this.name = name; this.email = email; } }
Прикладной слой
Секция статьи "Прикладной слой"Вокруг домена находится прикладной слой. Он содержит код сценариев и юзкейсов приложения.
Обычно это обработчики команд, которые выполняют пользовательский сценарий. Ещё слой содержит интерфейсы портов и адаптеров.
В случае с нашим магазином в прикладном слое было бы описано, как проходит процесс покупки и оплаты, какие заказы может видеть пользователь, что может видеть продавец или администратор магазина. А также здесь находились бы интерфейсы модулей, которые получают данные от внешних сервисов (но не сами эти модули).
Как правило, код прикладного слоя по большей части состоит из:
- получения нужных данных;
- вызова доменного кода, чтобы преобразовать данные в соответствии со сценарием;
- сохранения или отправки результата.
Прикладной слой реализует функциональную архитектуру, где получением и отправкой данных занимаются функции с сайд-эффектами, а преобразованием данных занимаются чистые функции.
Разделение на слои побуждает оставлять в домене функции, которые занимаются только преобразованием данных. Они ничего не хранят, ничего сами не запрашивают — они зависят лишь от аргументов. Из-за этого их проще тестировать и изменять.
Прикладной слой может выполнять роль «нечистого» контекста, который заботится о том, чтобы передать доменному слою данные в нужном ему виде и обработать результат.
import { source } from "ports/input";import { target } from "ports/output";import { updateProduct } from "domain/product";function renameProductHandler(command) { // Получаем продукт из внешнего сервиса через порт (сайд-эффект): const product = source.getProductById(command.productId); // Вызываем доменную функцию обновления (чистая функция): const updated = updateProduct(product, { name }); // Сохраняем продукт во внешнем сервисе через порт (сайд-эффект): target.saveProduct(updated);}
import { source } from "ports/input"; import { target } from "ports/output"; import { updateProduct } from "domain/product"; function renameProductHandler(command) { // Получаем продукт из внешнего сервиса через порт (сайд-эффект): const product = source.getProductById(command.productId); // Вызываем доменную функцию обновления (чистая функция): const updated = updateProduct(product, { name }); // Сохраняем продукт во внешнем сервисе через порт (сайд-эффект): target.saveProduct(updated); }
Важно отметить, что прикладной слой никогда не вызывает внешние сервисы напрямую. Также он не содержит кода портов и адаптеров, а только их интерфейсы — то есть прикладной слой диктует спецификации, контракты, по которым с нашим приложением должны работать внешние сервисы.
Реализация этих интерфейсов находится в наружном слое — слое портов и адаптеров.
Слой портов и адаптеров
Секция статьи "Слой портов и адаптеров"Этот слой содержит код для связи приложения с внешним миром.
Порт — это спецификация, как сторонний сервис может общаться с нашим приложением, или как наше приложение хочет, чтобы с ним общались сторонние сервисы.
Например, мы можем заявить, как нам хочется общаться с хранилищем данных. Пусть нашему приложению будет удобно, чтобы хранилище предоставляло метод для сохранения товара saveProduct
и метод для получения товара по его ID getProductById
.
interface ProductsStorage { saveProduct(product: Product): void; getProductById(id: UniqueId): Product;}
interface ProductsStorage { saveProduct(product: Product): void; getProductById(id: UniqueId): Product; }
Тогда внешний сервис, который будет реализовывать хранилище данных, будет обязан содержать эти два метода. Если у внешнего сервиса API не соответствует нашим пожеланиям, мы напишем адаптер.
Адаптер — переходник, который делает несовместимый интерфейс внешнего сервиса совместимым с тем, который требует наше приложение. Например, если API отдаёт данные в виде snake
, а мы хотим camelCase
, заниматься переводом будут адаптеры.
// Адаптер к сервису получения данных (браузерному fetch):function fetchUser(id) { return fetch(`/users/${id}`);}// Адаптер данных пользователя, приводит названия полей в нужный вид:function fromResponse(serverUser) { const { Name, Email } = serverUser; return { name: Name, email: Email };}
// Адаптер к сервису получения данных (браузерному fetch): function fetchUser(id) { return fetch(`/users/${id}`); } // Адаптер данных пользователя, приводит названия полей в нужный вид: function fromResponse(serverUser) { const { Name, Email } = serverUser; return { name: Name, email: Email }; }
Во фронтенде входной порт — чаще всего интерфейс пользователя. Обработка пользовательских событий, перерисовка экрана — это всё задачи как раз для слоя адаптеров и портов. Выходными портами можно назвать те интерфейсы, которые общаются, например, с бэкенд-сервером.
Если мы пишем какой-то консольный сервис или сервер API, то входными портами в этих случаях будут соответственно консоль и эндпоинты API.
Кроме слоёв в концепции трёхслойной архитектуры ещё можно встретить понятия инфраструктуры и «общего ядра» (shared kernel).
Под инфраструктурой понимают код, который соединяет приложение с базой данных, внешними сервисами типа отправки SMS, рассылки писем и т. п. Во фронтенде чаще всего инфраструктура — это бэкенд-сервер и сторонние API типа систем оплаты.
Shared Kernel — это код, который доступен любому модулю, но зависимость от которого не повышает их зацепление. В фронтенде к shared kernel можно отнести, например, глобально доступные объекты типа window
. В каких-то случаях к нему относят и сам язык программирования.
Иногда shared kernel и слои могут переходить друг в друга — это зависит от глубины проектирования. Если код, например, запускается и в браузере, и на сервере, то от глобальных объектов зависеть напрямую уже нельзя, и надо использовать адаптеры.
Плюсы разделения на слои
Секция статьи "Плюсы разделения на слои"Выделение домена, прикладного слоя и адаптеров с портами позволяет нам:
- Разделить зоны ответственности между модулями по их предназначению.
- Организовать код так, чтобы модули было проще заменять и тестировать.
- Собирать и доставлять приложение не только целиком, но отдельными фичами.
- Абстрагировать код и переиспользовать модули в разных проектах.
В целом, степень проработки и проектирования зависит от требований к приложению. Одно дело, когда у нас небольшой интернет-магазин, который работает только в браузере, и другое — когда у нас React-приложение, которое должно работать в браузере, рендериться на сервере и иметь общий код с проектом на React Native.
Спроектированное под разные окружения приложение проще изменять и поддерживать, но реализация будет дороже.
Взаимодействие слоёв
Секция статьи "Взаимодействие слоёв"Трёхслойная архитектура не только определяет сами слои, но и регламентирует их взаимодействие. Например, она строго определяет направление зависимостей.
Направление зависимостей
Секция статьи "Направление зависимостей"В трёхслойной архитектуре все зависимости направлены к домену. Это значит, что внешние слои могут зависеть от внутренних, но не наоборот.
В классических реализациях домен не зависит ни от чего — это «голые» спецификации бизнес-логики, их данные и код для их обработки.
Прикладной слой зависит только от домена. Он может использовать код и сущности из доменного слоя и свои сущности и данные.
Внешний слой может зависеть от чего угодно.
Такое направление зависимостей заставляет нас программировать «под домен» и держать код каждого из слоёв чистым. Мы как бы напоминаем себе, что спецификацию поведения диктует наше приложение, а внешние сервисы мы адаптируем под его нужды.
Может показаться странным и неудобным, что прикладной слой включает в себя интерфейсы портов и адаптеров, но это противоречие решается с помощью внедрения зависимостей.
К сожалению, в JavaScript нет нативного способа реализовать внедрение зависимостей, но есть библиотеки типа inversify или inject-js.
Управляемые и управляющие адаптеры
Секция статьи "Управляемые и управляющие адаптеры"На наших схемах выделено две «зоны»: зелёная слева и красная справа. Они подсказывают, в какую сторону направлено действие адаптеров и портов, попадающих в эту зону.
Слева — управляющие адаптеры (driving adapters). Это те адаптеры, которые принимают сигналы от пользователей и говорят нашему приложению, что надо сделать.
К управляющим адаптерам можно отнести адаптеры над пользовательским интерфейсом, UI. Например, фреймворк для обработки пользовательских событий и отрисовки интерфейса вполне можно включить в эту категорию.
Справа — управляемые адаптеры (driven adapters). Эти адаптеры получают сигналы от нашего приложения, оно говорит им, что надо сделать. Управляемые адаптеры реализуют интерфейсы портов прикладного слоя, становятся обёрткой над инфраструктурой — внешними сервисами, — адаптируя их под нужды приложения.
Уменьшаем издержки
Секция статьи "Уменьшаем издержки"Архитектура — это в первую очередь инструмент, а при выборе инструмента следует изучить не только их пользу, но и издержки, которые ему присущи.
Пользу и выгоды трёхслойной архитектуры мы уже рассмотрели, поглядим теперь на издержки:
- Трёхслойная архитектура требует больше времени на проектирование и реализацию.
- Она повышает порог входа в проект.
- Она может увеличить количество кода, который браузеру придётся парсить.
Рассмотрим способы, как мы можем эти издержки уменьшить.
Начинаем с домена
Секция статьи "Начинаем с домена"На деле далеко не каждому проекту нужна точная и полная реализация трёхслойной архитектуры. Зачастую мы можем ограничиться выделением доменного слоя, а остальные слои выделять по мере необходимости.
Выделить именно домен стоит, потому что это самая важная часть приложения, ядро. В коде доменного слоя находятся самые важные правила преобразования данных, которыми пользуется весь остальной код.
Чем чище, понятнее и проще домен, тем легче разобраться, что в приложении в принципе происходит. Выделенный домен проще тестировать, а значит, вероятность написать код без ошибок выше. Он также напомнит о направлении зависимостей в проекте и поможет сохранить кодовую базу более чистой.
Удовлетворяем нужды приложения
Секция статьи "Удовлетворяем нужды приложения"При написании адаптеров стоит помнить, что мы не «адаптируем наше приложение ко внешнему миру», а наоборот — адаптируем внешний мир под нужды нашего приложения.
Если внешний сервис предоставляет неудобное API, нам стоит подумать, как написать адаптер, который сделает интерфейс удобным.
Адаптер должен позволить нам заменить внешний сервис на другой без изменения кода прикладного слоя. Поэтому сперва нам следует спроектировать интерфейс для порта или адаптера, и только потом писать реализацию, «клей» между интерфейсом и внешним миром.
Пример приложения
Секция статьи "Пример приложения"В качестве примера приложения можно привести генератор Canvas-картинок деревьев. Это приложение рисует на Canvas изображения с фракталами, похожими на деревья.
- В доменном слое у него находятся модули, отвечающие за построение фрактала и за работу с 2D-геометрией.
- В прикладном слое — модуль, который «переводит» фрактал в команды для рисования на Canvas.
- В наружном слое — адаптеры для работы с DOM и Canvas.
В деталях устройство проекта можно изучить на Гитхабе.