Handbook frontend. Луковая архитектура. Часть 2

В первой части мы рассмотрели теорию, лежащую в основе концепции луковой архитектуры. Теперь предлагаю рассмотреть практическое применение. Будет МНОГО кода.
Практика
Давайте попрактикуемся в создании системы из трёх основных слоёв и одного слоя, который будет хранить в себе настройки для остальных.
-
Слой ui может зависеть только от core (через интерфейсы) и bootstrap.
-
Слой core не может зависеть ни от кого, кроме bootstrap.
-
Слой data может зависеть только от core (через интерфейсы), внешних API и bootstrap.
-
Слой bootstrap может зависеть от всех слоёв для их настройки.
Структура луковой архитектуры:
-
modules — список модулей с луковой архитектурой (core, data, ui, bootstrap);
-
pages — список страниц, где будут отображаться контейнеры (smart‑компоненты Angular).

Если в процессе разработки у вас получилось так, что одного слоя просто нет или он является пустым, то нужно оставлять папку пустой, добавляя в неё файл.gitignore, чтобы другие разработчики в вашей команде понимали, по каким «рельсам» и что вносить в будущем.
Структура всегда должна быть сохранена.
Описание структуры модуля
library-list/
|-bootstrap
|-constants
|-core (папка опциональная)
|-constants.ts
|-index.ts
|-data (папка опциональная)
|-constants.ts
|-index.ts
|-ui (папка опциональная)
|-constants.ts
|-index.ts
|-configurations
|-layers
|-core
|-configure-core-providers.interface.ts
|-configure-core.provider.ts
|-data
|-configure-data-providers.interface.ts
|-configure-data.provider.ts
|-ui
|-configure-ui-providers.interface.ts
|-configure-ui.provider.ts
|-configure-layers.ts
|-configure-providers.ts
|-index.ts
|-index.ts
|-library-list-module.interface.ts
|-library-list.module.ts
|-core
|-book
|-enums
|-book-status.enum.ts
|-interfaces
|-book.interface.ts
|-tokens
|-library
|-interfaces
|-library-paginate.interface.ts
|-library-repository.interface.ts
|-library-state.interface.ts
|-library-store.interface.ts
|-library.interface.ts
|-tokens
|-ticket
|-interfaces
|-history-reading-book.interface.ts
|-readers-ticket.interface.ts
|-ticket-validity-period.interface.ts
|-tokens
|-*.token.ts
|-library-facade.interface.ts
|-library.facade.ts
|-data
|-book
|-dto
|-book-change-request.interface.ts
|-book-response.interface.ts
|-gql
|-create-book.graphql.ts
|-update-book.graphql.ts
|-filter-book.graphql.ts
|-library
|-interfaces
|-library-mapper.interface.ts
|-mappers
|-library-mapper.service.ts
|-gql
|-create-library.graphql.ts
|-update-library.graphql.ts
|-filter-library.graphql.ts
|-library.store.ts
|-library.repository.ts
| -translate
| -translate-resolver.ts
| -translate-config.interface.ts
| -translate-config.token.ts
|-ui
|-containers
|-library-list
|-interfaces (папка опциональна)
|-<название-папки-с-названием-сущности>
|-...
|-mappers (папка опциональна)
|-<название-папки-с-названием-сущности>
|-...
|-services (папка опциональна)
|-<название-папки-с-названием-сущности>
|-...
|-library-list.component.html
|-library-list.component.scss
|-library-list.component.spec.ts
|-library-list.component.ts
|-components
|-<название-компонента>
|-<название-компонента>.component.html
|-<название-компонента>.component.scss
|-<название-компонента>.component.spec.ts
|-<название-компонента>.component.ts
Слой bootstrap
Слой bootstrap отвечает за настройку и конфигурацию системы. Сюда выносят всё, что связано с инициализацией, подключением зависимостей и хранением настроек.
Когда все настройки собраны в одном месте, ими проще управлять. Это даёт единую точку входа для конфигурации: не нужно искать параметры по разным файлам и слоям, достаточно зайти в bootstrap и увидеть полную картину.
Такой подход упрощает поддержку, уменьшает вероятность ошибок при настройке и делает процесс конфигурирования предсказуемым.

В bootstrap можно и нужно хранить:
-
конфигурации для сборки и запуска модулей;
-
«магические» числа, строки, стандартные значения;
-
настройки подключения к внешним сервисам;
-
DI‑контейнеры и внедрение зависимостей.
В bootstrap НЕ должно быть других:
-
объявлений enum для других слоёв;
-
типов и интерфейсов для переиспользования в других слоях;
-
реализаций функций и классов для других слоёв.
В папке bootstrap/configurations/constants находятся константы, которые нужны для слоёв core, data, ui
Основная идея заключается в разделении констант по логическим слоям. Например, если вы используете в слое core какую‑то константу (скажем, в паттерне «фасад»), которая лежит в data, то что‑то пошло не так.
Такое разделение констант также помогает понять, что должно относиться к конкретному слою, и корректнее спроектировать структуру и взаимодействие слоёв, не нарушая чистую архитектуру.
|-bootstrap
|-constants
|-core (папка опциональная)
|-constants.ts
|-index.ts
|-data (папка опциональная)
|-constants.ts
|-index.ts
|-ui (папка опциональная)
|-constants.ts
|-index.ts
|-...
Файл constants/ui/constants.ts:
export const UI_MESSAGES = {
changeSubmit: 'Вы уверены, что хотите сохранить данные',
deleteSubmit: 'Вы уверены, что хотите удалить запись',
};
export const CURRENT_BOOK_DEFAULT = {
book: {},
user: {},
};
export const SEARCH_DEFAULT = '';
Файл constants/core/constants.ts:
export const CORE_MESSAGES = {
successfullyChange: 'Запись успешно изменена',
successfullyDelete: 'Запись успешно удалена',
error: 'Произошла ошибка: ',
methodError: 'Method not implemented.'
};
Файл constants/data/constants.ts:
import { ILibraryState } from '../../../core/library';
export const LIBRARY_STATE_DEFAULT: ILibraryState = {
list: [],
searchBook: '',
pagination: {
currentPage: 1,
selectedSize: 5,
pageSizes: [5, 10, 20, 50]
},
loading: true,
sortColumn: {
type: 'asc | desc',
name: ''
},
message: '',
};
export const REQUEST_PARAMS = {
paginate: 'paginate',
author: 'author'
};
export const DATA_MESSAGES = {
methodError: 'Method not implemented.'
};
В папке bootstrap/configurations/layers находятся конфигурация для слоёв core, data и ui
Папка layers это точка сборки архитектурных слоев приложения. Здесь каждый слой (core, data, ui) предоставляет свой контракт как набор возможностей, которые он поддерживает. А конкретная реализация этих возможностей определяется внешними провайдерами.
Таким образом достигается:
-
гибкость: можно легко подменить реализацию (например, для тестов);
-
явность: чётко видно, какие настройки доступны для каждого слоя;
-
независимость: слои не знают друг о друге, их конфигурация изолирована.
|-bootstrap
|-...
|-configurations
|-layers
|-core
|-configure-core-providers.interface.ts
|-configure-core.provider.ts
|-data
|-configure-data-providers.interface.ts
|-configure-data.provider.ts
|-ui
|-configure-ui-providers.interface.ts
|-configure-ui.provider.ts
|-configure-layers.ts
|-configure-providers.ts
|-index.ts
Папка layers/ui
Файл layers/ui/configure‑ui‑providers.interface.ts:
import { Type } from '@angular/core';
import { Observable } from 'rxjs';
/**
* Конфигурация провайдеров для ui слоя
* Используется для настройки зависимостей и передачи данных между слоями
*/
export interface IUiProvidersConfiguration {
/**
* Фабрика для получения идентификатора книги
*
* Предоставляет способ передачи bookId из внешнего контекста (например, из роута или родительского компонента)
* в ui слой модуля через Observable. Это позволяет реактивно отслеживать изменения bookId.
*/
bookIdFactory?: Type<Observable<number>>;
}
Файл layers/ui/configure‑ui.provider.ts:
import { EnvironmentProviders, Provider } from '@angular/core';
import { ActivatedRoute } from '@angular/router';
import { IUiProvidersConfiguration } from './configure-ui-providers.interface';
import { BOOK_ID, bookIdFactory } from '../../../../ui/containers/providers/book-id.providers';
/**
* Настраивает провайдеры для ui слоя модуля.
*
* Позволяет гибко конфигурировать зависимости, передаваемые в ui,
* с возможностью переопределения стандартных фабрик через configuration.
*
* @param configuration - опциональная конфигурация для переопределения провайдеров
* @returns массив провайдеров для регистрации в модуле
*/
export const configureUiProviders = (
configuration?: IUiProvidersConfiguration
): (Provider | EnvironmentProviders)[] => {
return [
{
provide: BOOK_ID,
deps: [ActivatedRoute],
useFactory: configuration?.bookIdFactory ?? bookIdFactory,
},
];
};
Папка layers/core
Файл layers/core/configure‑core‑providers.interface.ts:
import { Type } from '@angular/core';
import { ILibraryFacade } from '../../../../core/library-facade.interface';
/**
* Конфигурация провайдеров для core слоя
* Позволяет переопределять реализации фасада и других зависимостей ядра
*/
export interface ICoreProvidersConfiguration {
/**
* Фасад для работы с библиотекой
*
* Предоставляет возможность подставить альтернативную реализацию фасада,
* например, для тестирования, мокирования или расширения функциональности.
*/
libraryFacade?: Type<ILibraryFacade>;
}
Файл layers/core/configure‑core.provider.ts:
import { EnvironmentProviders, Provider } from '@angular/core';
import { ICoreProvidersConfiguration } from './configure-core-providers.interface';
import { LIBRARY_FACADE } from '../../../../core/library';
import { LibraryFacade } from '../../../../core/library.facade';
/**
* Настраивает провайдеры для core слоя модуля.
*
* Позволяет гибко конфигурировать зависимости ядра с возможностью переопределения
* реализации фасада через конфигурацию. Это даёт возможность подменять реализации
* для тестирования, мокирования или расширения функциональности.
*
* @param configuration - опциональная конфигурация для переопределения провайдеров
* @returns массив провайдеров для регистрации в модуле
*/
export const configureCoreProviders = (
configuration?: ICoreProvidersConfiguration
): (Provider | EnvironmentProviders)[] => {
return [
{
provide: LIBRARY_FACADE,
useClass: configuration?.libraryFacade ?? LibraryFacade,
},
];
};
Папка layers/data
Файл layers/data/configure‑data‑providers.interface.ts:
Код
import { Type } from '@angular/core';
import { ILibraryRepository, ILibraryStore } from '../../../../core/library';
import { ILibraryMapper } from '../../../../data/library/mappers/library-mapper.interface';
import { IApiUrl } from '../../../../data/api-url.interface';
/**
* Конфигурация провайдеров для data слоя
* Позволяет переопределять реализации репозитория, маппера, стора и URL для API
*/
export interface IDataProvidersConfiguration {
/**
* Репозиторий для работы с данными библиотеки
*
* Отвечает за выполнение запросов к API и получение данных.
* Позволяет подменить стандартную реализацию для тестирования,
* мокирования или добавления дополнительной логики.
*/
libraryRepository?: Type<ILibraryRepository>;
/**
* Маппер для преобразования данных между структурами
*
* Сопоставляет данные между внешним форматом (бэкенд/API) и внутренней доменной моделью.
* Может быть переопределён для поддержки разных версий API или структур данных.
*/
libraryMapper?: Type<ILibraryMapper>;
/**
* Хранилище состояния для библиотеки
*
* Централизованное хранилище данных модуля. Позволяет подменить реализацию
* для тестирования или использования альтернативных стратегий управления состоянием.
*/
libraryStore?: Type<ILibraryStore>;
/**
* URL для API запросов
*
* Конфигурация базового URL и эндпоинтов для работы с бэкендом.
* Позволяет переключаться между окружениями (dev, staging, prod)
* или подставлять разные адреса
*/
apiUrl?: IApiUrl;
}
Файл layers/data/configure‑data.provider.ts:
Код
import { EnvironmentProviders, Provider } from '@angular/core';
import { IDataProvidersConfiguration } from './configure-data-providers.interface';
import { LIBRARY_REPOSITORY, LIBRARY_STORE } from '../../../../core/library';
import { LibraryRepository } from '../../../../data/library/library.repository';
import { LibraryStore } from '../../../../data/library/library.store';
import { LibraryMapper } from '../../../../data/library/mappers/library-mapper.service';
import { LIBRARY_MAPPER } from '../../../../data/library/tokens/library-mapper-token';
import { API_URL } from '../../../../data/api-url';
/**
* Настраивает провайдеры для data слоя модуля.
*
* Регистрирует все зависимости, связанные с работой с данными:
* репозиторий для запросов к API, маппер для преобразования структур,
* стор для хранения состояния и URL для подключения к бэкенду.
*
* Позволяет переопределять любую реализацию через конфигурацию,
* что даёт гибкость для тестирования, мокирования или подмены реализаций.
*
* @param configuration - опциональная конфигурация для переопределения провайдеров
* @returns массив провайдеров для регистрации в модуле
*/
export const configureDataProviders = (
configuration?: IDataProvidersConfiguration
): (Provider | EnvironmentProviders)[] => {
return [
{
provide: LIBRARY_REPOSITORY,
useClass: configuration?.libraryRepository ?? LibraryRepository,
},
{
provide: LIBRARY_MAPPER,
useClass: configuration?.libraryMapper ?? LibraryMapper,
},
{
provide: LIBRARY_STORE,
useClass: configuration?.libraryStore ?? LibraryStore,
},
{
provide: API_URL,
useValue: configuration?.apiUrl ?? '',
},
];
};
Файл configure‑layers.ts
Код
import { EnvironmentProviders, Provider } from '@angular/core';
import { configureCoreProviders } from './core';
import { configureDataProviders } from './data';
import { configureUiProviders } from './ui';
import { ILayersProviders } from '../library-list-module.interface';
/**
* Конфигурирование слоёв луковой архитектуры модуля
*
* Основная точка входа для регистрации всех провайдеров модуля.
* Собирает вместе конфигурации каждого слоя (core, data, ui),
* позволяя гибко настраивать модуль через единый объект конфигурации.
*
* @param configuration - опциональная конфигурация для всех слоёв
* @param configuration.core - конфигурация для core слоя
* @param configuration.data - конфигурация для data слоя
* @param configuration.ui - конфигурация для ui слоя
* @returns массив провайдеров для регистрации в модуле
*
* @example
* // Базовое использование без конфигурации
* const providers = configureLayers();
*
* @example
* // Конфигурирование всех слоёв
* const providers = configureLayers({
* core: {
* libraryFacade: CustomLibraryFacade
* },
* data: {
* apiUrl: { baseUrl: 'https://api.example.com' }
* },
* ui: {
* bookIdFactory: () => this.route.params.pipe(map(p => p['id']))
* }
* });
*
* @example
* // Переопределение только одного слоя
* const providers = configureLayers({
* data: {
* libraryRepository: MockLibraryRepository
* }
* });
*/
export const configureLayers = (
configuration?: ILayersProviders
): (Provider | EnvironmentProviders)[] => [
...configureCoreProviders(configuration?.core),
...configureDataProviders(configuration?.data),
...configureUiProviders(configuration?.ui),
];
Файл configure‑providers.ts
import { EnvironmentProviders, Provider } from '@angular/core';
import { configureLayers } from './configure-layers';
import { IConfigurationsProviders } from '../library-list-module.interface';
/**
* Основная точка входа для конфигурации всех провайдеров модуля
*
* Собирает вместе:
* 1. Провайдеры слоёв архитектуры (core, data, ui) через configureLayers
* 2. Внешние провайдеры, переданные извне (например, для расширения функциональности)
*
* @param configuration - опциональная конфигурация
* @param configuration.layers - конфигурация для слоёв архитектуры
* @param configuration.externalProviders - дополнительные внешние провайдеры
* @returns массив провайдеров для регистрации в модуле
*/
export const configureProviders = (
configuration?: IConfigurationsProviders
): (Provider | EnvironmentProviders)[] => [
...configureLayers(configuration?.layers),
...configuration?.externalProviders ?? [],
];
Файл index.ts
export * from './core';
export * from './data';
export * from './ui';
export * from './configure-layers';
export * from './configure-providers';
В папке bootstrap, где находится сам модуль
Файл index.ts:
export * from './layers';
Файл library‑list‑module.interface.ts:
Код
import { EnvironmentProviders, Provider } from '@angular/core';
import {
ICoreProvidersConfiguration,
IDataProvidersConfiguration,
IUiProvidersConfiguration
} from './layers';
/**
* Конфигурация модуля LibraryList
* Используется при инициализации модуля через forRoot()
*
*/
export interface ILibraryListModuleConfig {
/**
* Конфигурация слоев модуля
* Позволяет переопределить стандартные реализации слоев:
*
* - data - конфигурация слоя данных (Store, Repository, Mapper и тд)
* - core - конфигурация ядра модуля (сервисы, facade и тд)
* - ui - конфигурация UI компонентов (сервисы, handlers, компоненты и тд)
* @example
* ```typescript
* layers: {
* data: {
* libraryStore: CustomLibraryStore, // Свой Store
* libraryRepository: CustomLibraryRepository, // Свой Repository
* apiUrl: { // URL для API
* list: 'v2/books/list',
* change: 'v2/books/update',
* delete: 'v2/books/remove'
* }
* }
* }
* ```
*/
layers?: ILayersProviders;
/**
* Внешние провайдеры для модуля
* Позволяет добавить дополнительные провайдеры, которые будут доступны в модуле
* @example
* ```typescript
* externalProviders: [
* provideHttpClient(), // HTTP клиент
* provideRouter(routes), // Роутинг
* provideAnimations(), // Анимации
* { provide: APP_CONFIG, useValue: config } // Кастомные провайдеры
* ]
*/
externalProviders?: (Provider | EnvironmentProviders)[],
}
export interface ILayersProviders {
/**
* Конфигурация слоя данных
* Store, Repository, Mapper для работы с данными
*/
data?: IDataProvidersConfiguration,
/**
* Конфигурация ядра модуля
* Основные сервисы, фасады и тд
*/
core?: ICoreProvidersConfiguration,
/**
* Конфигурация UI слоя
* Компоненты, директивы, пайпы для отображения
*/
ui?: IUiProvidersConfiguration
}
/**
* Тип для полной конфигурации модуля
* Объединяет все возможные конфигурации
*/
export interface IConfigurationsProviders extends ILibraryListModuleConfig {}
Файл library‑list.module.ts:
Код
import { NgModule, ModuleWithProviders } from '@angular/core';
import { CommonModule } from '@angular/common';
import { provideHttpClient, withInterceptorsFromDi } from '@angular/common/http';
import { FormsModule } from '@angular/forms';
import { LibraryListComponent } from '../../ui/containers/library-list.component';
import { configureProviders } from './layers';
import { ILibraryListModuleConfig } from './library-list-module.interface';
/**
* Модуль для работы со списком книг библиотеки.
*
* Предоставляет компонент LibraryListComponent и все необходимые зависимости
* для работы с данными по слоистой архитектуре.
* Поддерживает настройку через forRoot и forChild.
*/
@NgModule({
imports: [CommonModule, FormsModule],
declarations: [LibraryListComponent],
exports: [LibraryListComponent],
providers: [provideHttpClient(withInterceptorsFromDi())],
})
export class LibraryListModule {
/**
* Подключение модуля в feature / lazy-модуле.
*
* Поведение совпадает с forRoot: те же configureProviders(config).
* Отдельный метод нужен для читаемости и единого стиля Angular-модулей,
* а не потому что провайдеры здесь «не глобальные».
*/
public static forRoot(
config?: ILibraryListModuleConfig
): ModuleWithProviders<LibraryListModule> {
return {
ngModule: LibraryListModule,
providers: [configureProviders(config)],
};
}
/**
* Подключение модуля с конфигурацией для дочерних модулей
*
* Используется при ленивой загрузке или в фича-модулях.
* В отличие от forRoot, не регистрирует глобальные провайдеры повторно.
* Позволяет передать конфигурацию для конкретного контекста использования.
*/
public static forChild(
config?: ILibraryListModuleConfig
): ModuleWithProviders<LibraryListModule> {
return {
ngModule: LibraryListModule,
providers: [configureProviders(config)],
};
}
}
Слой ui

Единственная обязанность слоя ui — отображение данных, полученных из core, и передача ему действий пользователя. Здесь может выполняться преобразование данных из формата core в формат, удобный для отображения.
Структура отображения:
|-ui
|-containers
|-library-list // Контейнерный компонент (Smart)
|-providers (папка опциональна)
|-<название-папки-с-названием-сущности>
|-interfaces (папка опциональна)
|-<название-папки-с-названием-сущности>
|-...
|-mappers (папка опциональна)
|-<название-папки-с-названием-сущности>
|-...
|-services (папка опциональна)
|-<название-папки-с-названием-сущности>
|-...
|-library-list.component.html
|-library-list.component.scss
|-library-list.component.spec.ts
|-library-list.component.ts
|-components // Dump компоненты (презентационные)
|-<название-компонента> // Каждый компонент в своей папке
|-<название-компонента>.component.html
|-<название-компонента>.component.scss
|-<название-компонента>.component.spec.ts
|-<название-компонента>.component.ts
Правила именования и организации файлов и папок:
-
Внутри containers папки именуют по названию контейнерного компонента (например, library‑list, book‑details, user‑profile). Каждый контейнер соответствует определённой странице или крупному функциональному блоку.
-
Внутри components папки именуют по названию презентационного компонента (например, book‑card, search‑input, pagination‑controls). Каждый компонент решает одну узкую задачу и не знает о данных за пределами своих входных параметров.
-
Опциональные папки внутри контейнера (interfaces, mappers, services) именуют по названию сущности, с которой они работают. Например, interfaces/book, mappers/book, services/book‑api. Это позволяет держать связанные сущности рядом и не разбрасывать их по разным углам проекта.
-
Имена должны отражать тематическую область: вместо абстрактных components/utils или containers/common используйте конкретные названия: book‑card, search‑form, filter‑panel. Так новый разработчик сразу поймё, что искать и где.
-
Сохраняется единообразие: структура повторяется от модуля к модулю. Это снижает когнитивную нагрузку, разработчик знает, что, например, маппер для книги всегда лежит в ui/containers/library‑list/mappers/book/, а не в случайном месте.
Файл library‑list.component.ts:
Код
import { ChangeDetectionStrategy, Component, computed, effect, Inject } from '@angular/core';
import { ILibraryFacade } from '../../core/library-facade.interface';
import { ILibrary, ILibraryCurrentBook, LIBRARY_FACADE } from '../../core/library';
import { CURRENT_BOOK_DEFAULT, UI_MESSAGES, SEARCH_DEFAULT } from '../../bootstrap/constants/ui';
@Component({
selector: 'app-library-list',
templateUrl: './library-list.component.html',
styleUrls: ['./library-list.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush
})
export class LibraryListComponent {
protected readonly vm = this.libraryFacade.vm;
protected list = computed(() => this.vm().list);
protected message = computed(() => this.vm().message);
protected currentBook: ILibraryCurrentBook = CURRENT_BOOK_DEFAULT;
protected search: string = SEARCH_DEFAULT;
protected stateForm: string | null = null;
constructor(
@Inject(LIBRARY_FACADE) private readonly libraryFacade: ILibraryFacade
) {
effect(() => this.showMessage());
effect(() => this.changeSearchParam());
effect(() => this.changeBook());
}
private showMessage(): void {
const message = this.message();
if (message === '') {
return;
}
alert(this.message());
}
private changeSearchParam(): void {
this.search = this.vm().searchBook;
}
private changeBook(): void {
this.stateForm = '';
this.currentBook = CURRENT_BOOK_DEFAULT;
}
protected onSearch(value: string): void {
this.libraryFacade.searchBook(value);
}
protected onChangePaginate(size: number): void {
this.libraryFacade.changePaginate(1, size);
}
protected onChange(item: ILibrary): void {
this.stateForm = 'change';
this.currentBook = structuredClone(item);
}
protected onDelete(item: ILibrary): void {
if (!confirm(UI_MESSAGES.deleteSubmit)) {
return;
}
this.libraryFacade.deleteBook(item.book.id);
}
protected onSubmit(): void {
if (!confirm(UI_MESSAGES.changeSubmit) || !this.currentBook.book.id) {
return;
}
this.libraryFacade.changeBook(structuredClone(this.currentBook) as ILibrary);
}
}
Файл library‑list.component.html:
Код
@let vmSignal = vm();
@if(vmSignal) {
<div>
Поиск по автору: <input type="text" [(ngModel)]="search" (change)="onSearch(search)">
</div>
<div style="display: inline-block;">
<button class="options" *ngFor="let pageSize of vmSignal.pagination.pageSizes" (click)="onChangePaginate(pageSize)">
{{pageSize}}
</button>
</div>
<div style="display: inline-block;">
<button>Добавить</button>
</div>
<table border="1">
<thead>
<th>№</th>
<th>Название книги</th>
<th>Автор</th>
<th>Дата выхода</th>
<th>Кто изменил запись</th>
<th>Действия</th>
</thead>
<tbody>
<tr *ngFor="let item of vmSignal.list">
<td>{{item.book.id}}</td>
<td>{{item.book.name}}</td>
<td>{{item.book.author}}</td>
<td>{{item.book.datePublication}}</td>
<td>{{item.user.surname}} {{item.user.name}}</td>
<td>
<button (click)="onChange(item)">
Изменить
</button>
<button (click)="onDelete(item)">
Удалить
</button>
</td>
</tr>
</tbody>
</table>
}
@if(vmSignal) {
@if (vmSignal.loading) {
<div>Загрузка...({{vmSignal.loading}})</div>
}
<div>{{vmSignal.searchBook}}</div>
<div>{{vmSignal.pagination.selectedSize}}</div>
}
@if(stateForm === 'change') {
<div>Форма для редактирования</div>
<div class="card-body">
<form #changeBook="ngForm" (submit)="onSubmit()">
<div class="form-group">
<input type="text" name="id" class="form-control" placeholder="" [(ngModel)]="currentBook.book.id" disabled>
</div>
<div class="form-group">
<input type="text" name="name" class="form-control" placeholder="" [(ngModel)]="currentBook.book.name" required>
</div>
<div class="form-group">
<input type="text" name="datePublication" class="form-control" placeholder="" [(ngModel)]="currentBook.book.datePublication" required>
</div>
<div class="form-group">
<input type="text" name="surname" class="form-control" placeholder="" [(ngModel)]="currentBook.user.surname" required>
</div>
<div class="form-group">
<input type="text" name="userName" class="form-control" placeholder="" [(ngModel)]="currentBook.user.name" required>
</div>
<button class="btn btn-primary" [disabled]="changeBook.invalid"> Изменить </button>
</form>
</div>
}
Слой сore

Этот слой — сердце бизнес‑логики модуля. Он занимает центральное место в архитектуре и служит связующим звеном между интерфейсом и данными. Его главная задача — оркестрация, он управляет потоком данных и делегирует работу со внешними источниками слою data через контракты (репозиторий, хранилище). Слой ui к core только обращается (через фасад), core от ui не зависит и сам не занимается ни отображением, ни хранением.
Ключевой принцип: изменения в ui или data не должны затрагивать core. Это значит, что вы можете переписать интерфейс или сменить способ хранения данных, и бизнес‑правила останутся нетронутыми. Именно здесь сосредоточена основная ценность приложения, его логика и алгоритмы.
Для этого в core используем три ключевых подхода:
-
Паттерн «Фасад» предоставляет простой публичный интерфейс для компонентов ui. Всё взаимодействие с core происходит через фасад, который скрывает внутреннюю сложность. Компоненты не знают о существовании хранилища, репозитория или других внутренних механизмов, они просто вызывают методы фасада.
-
Принцип CQRS (Command Query Responsibility Segregation) разделяет операции на команды (изменение данных) и запросы (чтение данных). Это позволяет оптимизировать каждую операцию отдельно и делает код более предсказуемым.
-
View Model (vm) — модель данных, подготовленная специально для отображения. Фасад предоставляет ui готовую модель, которую компоненты только показывают, не занимаясь преобразованиями. Это обеспечивает чёткое разделение между тем, как данные выглядят и как они обрабатываются.
Что можно хранить в core:
-
интерфейсы и типы, используемые в других слоях;
-
бизнес‑правила и алгоритмы;
-
логику работы с состоянием приложения;
-
контракты (интерфейсы) для репозиториев и хранилищ, слоя data.
Чего в core быть НЕ должно:
-
реализаций работы с API или базой данных;
-
Компонентов интерфейса и логики отображения;
-
специфичных для фронтенда деталей (роутинг, DOM‑манипуляции).
Благодаря такой организации слой core остаётся независимым от внешних факторов. Его можно тестировать изолированно, переиспользовать в разных проектах и модифицировать, не боясь сломать интерфейс или данные. Это и есть его главная ценность: он делает бизнес‑логику устойчивой и защищённой от изменений в смежных слоях.
Структура отображения:
|-core
|-book
|-enums
|-book-status.enum.ts
|-interfaces
|-book.interface.ts
|-tokens
|-*.token.ts
|-library
|-interfaces
|-library-paginate.interface.ts
|-library-repository.interface.ts
|-library-state.interface.ts
|-library-store.interface.ts
|-library.interface.ts
|-tokens
|-*.token.ts
|-<название-сущности>
|-interfaces
|-<название-сущности-1>.interface.ts
|-<название-сущности-2>.interface.ts
|-<название-сущности-3>.interface.ts
|-tokens
|-*.token.ts
|-library-facade.interface.ts
|-library.facade.ts
Файл library‑facade.ts:
Код
import { computed, effect, Inject, Injectable } from '@angular/core';
import { Observable, forkJoin, map, of, switchMap, first } from 'rxjs';
import { ILibraryFacade } from './library-facade.interface';
import {
ILibrary,
ILibraryRepository,
ILibraryStore,
LIBRARY_REPOSITORY,
LIBRARY_STORE
} from './library';
import { CORE_MESSAGES } from '../bootstrap/constants/core';
@Injectable()
export class LibraryFacade implements ILibraryFacade {
public readonly vm = this.libraryStore.vm;
private search = computed(() => this.vm().searchBook);
private selectedSize = computed(() => this.vm().pagination.selectedSize);
constructor(
@Inject(LIBRARY_STORE) private readonly libraryStore: ILibraryStore,
@Inject(LIBRARY_REPOSITORY) private readonly libraryRepository: ILibraryRepository
) {
effect(() => this.updateBookList());
}
private updateBookList(): void {
this.libraryRepository
.getBooks(this.selectedSize(), this.search())
.pipe(first())
.subscribe((books) => {
this.libraryStore.updateList(books);
});
}
public changeBook(book: Partial<ILibrary>): void {
this.libraryStore.updateStore({ loading: true });
this.libraryRepository.changeBook(book)
.pipe(first())
.subscribe({
next: () => {
this.libraryStore.updateBook(book);
this.libraryStore.updateStore({
loading: false,
message: CORE_MESSAGES.successfullyDelete
});
},
error: (error) => {
this.libraryStore.updateStore({
loading: false,
message: CORE_MESSAGES.error + `${ error.message }`
});
}
});
}
public deleteBook(id: number): void {
const selectedSize = this.libraryStore.storeValue.pagination.selectedSize;
const searchBook = this.libraryStore.storeValue.searchBook;
this.libraryRepository.deleteBook(id)
.pipe(
switchMap((data) => {
return forkJoin([
of(data),
this.libraryRepository.getBooks(selectedSize, searchBook)
]).pipe(
map((response) => {
return this.libraryStore.updateList(response[1]);
})
)
}),
first()
)
.subscribe({
next: () => {
this.libraryStore.updateStore({
message: CORE_MESSAGES.successfullyDelete
});
},
error: (error) => {
this.libraryStore.updateStore({
message: CORE_MESSAGES.error + `${ error.message }`
});
}
});
}
public changePaginate(currentPage: number, selectedSize: number): void {
this.libraryStore.updatePagination(currentPage, selectedSize);
}
public searchBook(value: string): void {
this.libraryStore.updateSearchBook(value);
}
}
Файл library‑facade.interface.ts:
Код
import { Observable } from 'rxjs';
import { ILibrary, ILibraryState } from './library';
import { Signal } from '@angular/core';
/**
* Фасад для работы с библиотекой
*
* Единая точка входа для ui слоя. Предоставляет методы для управления
* данными и состояние для отображения. Скрывает внутреннюю реализацию
* работы с хранилищем и репозиторием.
*/
export interface ILibraryFacade {
/**
* Содержит готовое состояние, которое компоненты только отображают.
* Использует Signal для реактивного обновления.
*/
readonly vm: Signal<ILibraryState>;
/**
* Альтернативный доступ к View Model через Observable
* Опционально. Нужен для интеграции с RxJS-кодом или обратной совместимости.
*/
readonly vm$?: Observable<ILibraryState>;
/**
* Изменение параметров пагинации
*
* @param currentPage - номер текущей страницы
* @param selectedSize - количество элементов на странице
*/
changePaginate(currentPage: number, selectedSize: number): void;
/**
* Поиск книги по строке
*
* @param value - поисковый запрос
*/
searchBook(value: string): void;
/**
* Удаление книги по идентификатору
*
* @param id - ID книги
*/
deleteBook(id: number): void;
/**
* Изменение данных книги
*
* Принимает частичный объект, так как при редактировании могут передаваться
* не все поля.
*
* @param book - обновлённые данные книги
*/
changeBook(book: Partial<ILibrary>): void;
}
Контракты и токены в слое core
В слое сore хранятся только контракты (интерфейсы) и токены для внедрения зависимостей. Сами реализации находятся в слое data.
Контракты (интерфейсы):
-
library‑repository.interface.ts описывает контракт для работы с данными. Определяет, какие методы должен реализовать репозиторий для получения, сохранения и удаления данных. Реализация репозитория находится в слое data.
-
library‑state.interface.ts описывает структуру состояния модуля. Определяет, какие данные хранятся и как они организованы. Используется как описание модели для хранилища.
-
library‑store.interface.ts описывает контракт для хранилища. Определяет методы управления состоянием: чтение, обновление, подписка на изменения. Реализация хранилища находится в слое data.
Токены:
-
library‑repository.token.ts — токен для внедрения реализации репозитория. Позволяет подменять реализацию через DI.
-
library‑store.token.ts — токен для внедрения реализации хранилища. Аналогично позволяет гибко управлять зависимостями.
-
library‑facade.token.ts — токен для внедрения фасада. Используется компонентами для получения экземпляра фасада через DI.
Зачем это нужно:
-
Разделение ответственности: слой core описывает, что должно быть сделано, но не знает, как. Реализация вынесена в слой data.
-
Гибкость: через DI можно подменить реализацию без изменения кода слоя core. Это упрощает тестирование (моки) и расширение функциональности.
-
Единая точка доступа: интерфейсы и токены лежат в слое core, и все слои ссылаются на них. Это исключает дублирование и путаницу.
-
Соблюдение архитектурных принципов: слой core не зависит от слоя data, слой data зависит от слоя core. Это делает слой core независимым и защищённым от изменений в реализации.
Файл library‑repository.interface.ts:
import { Observable } from 'rxjs';
import { ILibrary } from './library.interface';
export interface ILibraryRepository {
/**
* Получить список книг библиотеки
*/
getBooks(paginate: number, author: string): Observable<ILibrary[]>;
/**
* Обновить состояние по книге
*/
updateBook(): void;
/**
* Поиск информации по картотеке
*/
bookFileSearch(): void;
/**
* Изменение книги
*/
changeBook(book: Partial<ILibrary>): Observable<ILibrary[]>;
/**
* Удаление книги
*/
deleteBook(id: number): Observable<ILibrary[]>;
}
Файл library‑state.ts:
Код
import { IPagination } from './library-paginate.interface';
import { ILibrary } from './library.interface';
export interface ILibraryState {
/**
* Список информации по книгам
*/
list: ILibrary[];
/**
* Пагинация
*/
pagination: IPagination;
/**
* Поиск по названию книги
*/
searchBook: string;
/**
* Сортировка по колонке
*/
sortColumn: {
type: 'asc | desc',
name: string
};
/**
* Статус загрузки
*/
loading: boolean;
/**
* Сообщение
*/
message: string;
}
Файл library‑store.interface.ts:
Код
import { Observable } from 'rxjs';
import { Signal } from '@angular/core';
import { ILibraryState } from './library-state';
import { ILibrary } from './library.interface';
export interface ILibraryStore {
readonly vm: Signal<ILibraryState>;
readonly vm$: Observable<ILibraryState>;
storeValue: ILibraryState;
/**
* Обновление пагинации
* @param currentPage - какая страница выбрана
* @param selectedSize - размер подгружаемого списка
*/
updatePagination(currentPage: number, selectedSize: number): void;
/**
* Обновление состояния поиска книги
* @param search - какую книгу ищем
*/
updateSearchBook(search: string): void;
/**
* Обновление книги
* @param book - обновленная книга
*/
updateBook(book: Partial<ILibrary>): void;
/**
* Обновление списка книг
* @param list - обновленный список книг
*/
updateList(list: ILibrary[]): void;
/**
* Удаление книги
* @param id - идентификатор удаляемой книги
*/
deleteBook(id: number): void;
/**
* Обновление хранилища
* @param payload
*/
updateStore(payload: Partial<ILibraryState>): void;
}
Файл library‑repository.token.ts:
import { InjectionToken } from '@angular/core';
import { ILibraryRepository } from '../interfaces/library-repository.interface';
export const LIBRARY_REPOSITORY = new InjectionToken<ILibraryRepository> (
'[LIBRARY_REPOSITORY]: Хранилище, отвечающее за состояние библиотеки'
);
Файл library‑store.token.ts:
import { InjectionToken } from ‘@angular/core’;
import { ILibraryStore } from ‘…/interfaces/library-store.interface’;
export const LIBRARY_STORE = new InjectionToken (
‘[LIBRARY_STORE]: Store отвечающий за состояние библиотеки’
);
Слой Data

Он отвечает за хранение, получение и отправку данных. Это единственный слой в архитектуре, который знает о внешних источниках информации API, базах данных, локальном хранилище и других внешних системах.
Главная задача — обеспечить слой core данными и предоставить механизмы для их сохранения. Слой data реализует контракты (интерфейсы), объявленные в слое core, но при этом сам не знает, кто и как будет использовать эти данные. Это обеспечивает слабую связанность и возможность замены способа хранения без влияния на бизнес‑логику.
Ключевые принципы:
-
Data следует контрактам, определённым в слое core (репозиторий, хранилище).
-
Data не знает о существовании ui и ничего не знает об отображении данных.
-
Data преобразует данные из внешних форматов во внутренние структуры.
-
Data управляет состоянием модуля (через хранилище) и синхронизирует его с внешними источниками.
Что находится в data:
-
Репозиторий с реализацией методов для работы с данными (запросы к API, обработка ответов).
-
Хранилище с реализацией управления состоянием (хранение, обновление).
-
Мапперы с преобразованием структур данных между внешним форматом (бэкенд) и внутренней доменной моделью.
-
DTO — это объекты для передачи данных между слоями.
-
Сервисы для работы с API, HTTP‑клиенты.
Чего в слое data быть НЕ должно:
-
Бизнес‑логики и алгоритмов (это ответственность Core).
-
Компонентов интерфейса и логики отображения.
-
Зависимостей от реализаций слоя core (класс фасада и подобное). Слой data зависит только от контрактов (интерфейсов и токенов) слоя core и реализует их.
Cлой data реализует контракты слоя core и отвечает за работу с внешними источниками данных. Он не содержит бизнес‑логики и не знает об отображении, но предоставляет слою core всё необходимое для работы. Изменения в слое data не затрагивают core и ui, что делает систему гибкой и легко поддерживаемой.
Структура отображения:
|-data
|-book
|-dto
|-book-change-request.interface.ts
|-book-response.interface.ts
|-gql
|-create-book.graphql.ts
|-update-book.graphql.ts
|-filter-book.graphql.ts
|-library
|-interfaces
|-library-mapper.interface.ts
|-mappers
|-library-mapper.service.ts
|-gql
|-create-library.graphql.ts
|-update-library.graphql.ts
|-filter-library.graphql.ts
|-library.store.ts
|-library.repository.ts
|-translate
| -translate-resolver.ts
| -translate-config.interface.ts
| -translate-config.token.ts
Файл library‑store.ts:
Код
import { Injectable, signal } from '@angular/core';
import { toObservable } from '@angular/core/rxjs-interop';
import { ILibrary, ILibraryState, ILibraryStore } from '../../core/library';
import { LIBRARY_STATE_DEFAULT } from '../../bootstrap/constants/data';
@Injectable()
export class LibraryStore implements ILibraryStore {
private state = signal<ILibraryState>(LIBRARY_STATE_DEFAULT);
public readonly vm = this.state.asReadonly();
public readonly vm$ = toObservable(this.vm);
public get storeValue(): ILibraryState {
return this.vm();
}
/**
* Обновление состояния объекта пагинации
* @param currentPage - выбранная страница
* @param selectedSize - размер загружаемого списка
*/
public updatePagination(currentPage: number, selectedSize: number): void {
const pagination = {
...this.storeValue.pagination,
currentPage,
selectedSize
};
this.updateStore({
pagination,
loading: true
});
}
/**
* Обновление состояния свойства поиска книги
* @param search - какую информацию ищем
*/
public updateSearchBook(search: string): void {
this.updateStore({
searchBook: search,
loading: true
});
}
public updateList(list: ILibrary[]): void {
this.updateStore({
list,
loading: false
});
}
public updateBook(book: Partial<ILibrary>): void {
const newList = this.storeValue.list.map((item) => {
return item.book.id === book?.book?.id ? { ...item, ...book } : item;
});
this.updateStore({
list: newList,
loading: true
});
}
public deleteBook(id: number): void {
const newList = this.storeValue.list.filter((item) => {
return item.book.id !== id;
});
this.updateStore({
list: newList,
loading: true
});
}
/**
* Обновить внутренний кеш состояния и отправить его из хранилища
* @param payload - новое состояние
*/
public updateStore(payload: Partial<ILibraryState>): void {
this.state.update(state => ({
...state,
...payload
}));
}
}
Файл library.repository.ts:
Код
import { Inject, Injectable } from '@angular/core';
import { HttpClient, HttpErrorResponse, HttpParams } from '@angular/common/http';
import { Observable, catchError, map, throwError } from 'rxjs';
import { ILibrary, ILibraryRepository } from '../../core/library';
import { ILibraryMapper } from './mappers/library-mapper.interface';
import { IBookResponse } from '../book/dto/book-response.interface';
import { IBookRequest } from '../book/dto/book-change-request.interface';
import { API_URL } from '../api-url.token';
import { DATA_MESSAGES, REQUEST_PARAMS } from '../../bootstrap/constants/data';
import { IApiUrl } from '../api-url.interface';
import { LIBRARY_MAPPER } from './library-mapper.token';
@Injectable()
export class LibraryRepository implements ILibraryRepository {
constructor(
@Inject(API_URL) private readonly apiUrl: IApiUrl,
@Inject(LIBRARY_MAPPER) private readonly libraryMapper: ILibraryMapper,
private readonly httpClient: HttpClient
) {}
public updateBook(): void {
throw new Error(DATA_MESSAGES.methodError);
}
public bookFileSearch(): void {
throw new Error(DATA_MESSAGES.methodError);
}
public getBooks(paginate: number, author: string): Observable<ILibrary[]> {
const url: string = this.apiUrl['list'];
const requestParams: HttpParams = new HttpParams()
.set(REQUEST_PARAMS.paginate, paginate)
.set(REQUEST_PARAMS.author, author);
return this.httpClient.get<IBookResponse[]>(url, { params: requestParams })
.pipe(
map(response => {
return this.libraryMapper.mapFromLibrary(response);
})
);
}
public changeBook(book: Partial<ILibrary>): Observable<ILibrary[]> {
const url: string = this.apiUrl['change'];
const body: IBookRequest = this.libraryMapper.mapToBookChangeDto(book);
return this.httpClient.post<IBookResponse[]>(url, body)
.pipe(
map(response => {
return this.libraryMapper.mapFromLibrary(response);
}),
catchError(this.onError)
);
}
public deleteBook(id: number): Observable<ILibrary[]> {
const url: string = this.apiUrl['delete'];
const body = {
id: id
};
return this.httpClient.post<IBookResponse[]>(url, body)
.pipe(
map(response => {
return this.libraryMapper.mapFromLibrary(response);
}),
catchError(this.onError)
);
}
private onError(error: HttpErrorResponse): Observable<never> {
return throwError(() => error);
}
}
Файл library‑mapper.service.ts:
import { Injectable } from '@angular/core';
import { ILibraryMapper } from './library-mapper.interface';
import { IBookResponse } from '../../book/dto/book-response.interface';
import { ILibrary } from '../../../core/library';
import { IBookRequest } from '../../book/dto/book-change-request.interface';
@Injectable()
export class LibraryMapper implements ILibraryMapper {
public mapFromLibrary(response: IBookResponse[]): ILibrary[] {
return response as ILibrary[];
}
public mapToBookChangeDto(book: Partial<ILibrary>): IBookRequest {
return {
bookId: book?.book?.id ?? 0,
bookName: book?.book?.name ?? '',
bookAuthor: book?.book?.author ?? '',
bookDatePublication: book?.book?.datePublication ?? new Date()
};
}
}
Как слои работают вместе

-
UI →: пользователь нажимает на кнопку «Изменить книгу» и вызывается метод
onChangeBook($event)компонента Dump из сервисаLibraryListComponent. -
UI → Core: из компонента
Dumpотправляется событие выше с данными в компонент Smart и вызывается методchangeBook(book)из сервисаLibraryFacade, и передаёт данные из слоя ui в core. -
Core → Data: слой core вызывает метод
changeBook(book)у сервисаLibraryRepositoryдля сохранения данных на сервер, и передаёт управление слою data. -
Data(Маппинг): слой data пересопоставляет структуры данных, которые приняты на фронтенде, со структурой, которая принята на бэкенде, и возвращает новую структуру данных обратно в сервис
LibraryRepository. -
Data → Backend: сервис
LibraryRepositoryотправляет данные на сохранение бэкенду. -
Backend → Data: через какое‑то время получает обновленные данные по обновлённой книге.
-
Data (обратный маппинг): пересопоставляем пришедшую с бэкенда во фронтенд структуру данных с доменной моделью.
-
Data → Core: передаём информацию обратно со слоя data в core.
-
Core → Data (обновление в Store): по изменённой книге обновляем данные в хранилище.
-
Data → Core: обновлённая модель данных (vm) автоматически отправляется из слоя data в core.
-
Core → UI: обновлённая модель данных (vm) автоматически отправляется из слоя core в ui в компонент
Smart. -
UI(Smart → Dump): компонент
Smartв асинхронном режиме передаёт в компонентDumpновую модель данных (vm) для отрисовки.
Выводы
Главные преимущества луковой архитектуры — это простота и предсказуемость за счёт чёткого разделения на ядро и периферию. Будучи монолитной по своей природе, она лишена сложностей, присущих распределённым системам: бизнес‑логика в центре, адаптеры снаружи, всё понятно, удобно для понимания и относительно дёшево в обслуживании.
Что мы получили:
-
Слабая связанность слоёв: изменения во внешних адаптерах (data, ui) не тянут за собой переписывание ядра (core).
-
Высокая скорость внесения правок: правим нужный слой, не трогая остальные; ядро остаётся нетронутым при замене внешних технологий.
-
Лёгкое расширение функциональности: новую точечно добавляем в ядро или в другой слой.
-
Упрощается написание тестов и документации: каждый слой тестируем и описываем отдельно, ядро тестируем изолированно от внешних зависимостей.
-
Ускоренная проверка кода: понятно, к какому слою относится изменение, и кто за него отвечает.
-
Параллельная разработка: несколько разработчиков могут работать в одном модуле, не мешая друг другу, один пишет core, другой — data, третий — ui.
-
Чёткое разделение ответственности: ядро отвечает за бизнес‑правила, адаптеры — за взаимодействие с внешним миром.
-
Проблемы локализуются быстрее: сразу понятно, в каком слое искать причину: если сломалась логика — виноват core, если API — data, если вёрстка — ui.
-
Упрощается навигация по проекту: структура предсказуема и интуитивно понятна: все знают, что core не зависит от HTTP и БД.
-
Быстрая масштабируемость: добавляются новые модули, а не переписываются старые; внешние адаптеры можно заменять без правок в ядре.
-
Конфигурируемость: возможность настраивать модули при передаче между проектами через сборку (bootstrap).
-
Поддержка опциональных механик: плагины и другие слои подключаются без изменения ядра, достаточно реализовать нужный интерфейс.
Единственный недостаток — более высокий порог входа для новых разработчиков; чтобы эффективно работать, нужно понимать, как устроены слои, почему зависимости направлены внутрь и как адаптеры взаимодействуют с ядром через контракты. Но это плата за порядок и предсказуемость, которая окупается на длинной дистанции.
Полезная литература
Архитектура и принципы
-
Роберт Мартин. «Чистая архитектура»
-
Эрих Гамма, Ричард Хелм, Роберт Джонсон, Джон Влиссидес. “Паттерны объектно‑ориентированного проектирования” (GoF)
-
SOLID: https://ru.wikipedia.org/wiki/SOLID_(программирование) (подробный разбор — в книге «Чистая архитектура»)
-
Cohesion и Coupling: https://en.wikipedia.org/wiki/Cohesion_(computer_science), https://en.wikipedia.org/wiki/Coupling_(computer_programming)
Паттерны, которые используем в слоях
Автор: T1_IT

