
В первой части мы рассмотрели теорию, лежащую в основе концепции луковой архитектуры. Теперь предлагаю рассмотреть практическое применение. Будет МНОГО кода.
Практика
Давайте попрактикуемся в создании системы из трёх основных слоёв и одного слоя, который будет хранить в себе настройки для остальных.
Слой 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)
Паттерны, которые используем в слоях

