Всем привет!
Меня зовут Илья Чубко, я — технический архитектор в направлении, которое занимается внедрением CRM-системы на low-code платформе BPMSoft для автоматизации и управления бизнес-процессами крупных и средних компаний в единой цифровой среде вендора «БПМСофт».

В статье «Как с помощью Angular доработать CRM-систему: наш опыт с BPMSoft» мы уже описывали подход к разработке визуальных компонентов и их интеграции в CRM-систему.
А что, если таких компонентов становится всё больше, они зависят друг от друга и используются как строительные блоки в архитектуре платформы и создаваемых на ней решений?

В этой статье я поделюсь опытом, как мы в К2Тех построили единую экосистему Angular-компонентов — с общей инфраструктурой, управлением зависимостями и автоматической сборкой на базе монорепозитория Nx.

Почему монорепозиторий?

При создании большого количества Angular-компонентов обычно начинают с отдельных репозиториев. Это удобно на первых этапах разработки, но по мере роста системы возникают типичные проблемы: несогласованность версий библиотек, дублирование кода и конфигураций, усложнение сборки.
Монорепозиторий решает эти задачи, объединяя всё в единую архитектуру и обеспечивая общие правила для разработки и сборки приложений.

Ниже — краткое сравнение классического подхода и монорепозитория:

Критерий

Несколько отдельных репозиториев

Монорепозиторий (Nx)

Структура проекта

Каждый проект в отдельном Git-репозитории

Все приложения и библиотеки хранятся в едином workspace

Управление зависимостями

Разные версии библиотек, дублирование пакетов

Единый package.json, согласованные версии

Повторное использование кода

Ограничено, общий код приходится копировать

Любая библиотека доступна другим проектам внутри workspace

Сборка и тестирование

Отдельные пайплайны, дублирование конфигураций

Общие правила сборки и тестов для всех пакетов

CI/CD

Каждая сборка запускается отдельно

nx affected пересобирает только изменённое

Кэширование и производительность

Нет кэширования между проектами

Встроенное кэширование сборок и тестов

Контроль архитектуры

Нет связей между проектами

Визуализация зависимостей, контроль импортов

Правила оформления и анализа кода (eslint, prettier)

Настройки копируются вручную

Общие конфигурации линтера и форматтера

Обновление зависимостей

Нужно обновлять в каждом проекте отдельно

Обновление выполняется один раз для всех

Масштабирование

Трудно поддерживать и развивать

Оптимально для десятков и сотен библиотек

Порог входа

Низкий, легко начать

Требует единовременной настройки, затем проще сопровождать

Установка и подготовка Nx

Если Nx ещё не установлен, добавим CLI глобально через npm

npm install -g nx

Создаем workspace командой npm или nx:

npx create-nx-workspace@latest my-workspace --preset=angular-standalone
nx create nx-template --preset=angular-standalone

Выбираем стиль, движок тестирования и даже ai agent. Можно создать workspace без интерактива следующей командой:

npx create-nx-workspace@latest nx-template --preset=angular-standalone --bundler=esbuild --style=scss --unitTestRunner=vitest --e2eTestRunner=none --ssr=false --nxCloud=skip --aiAgents=gemini

Структура директорий

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

apps/ ├─ tmp-app1/ ├─ tmp-app2/ libs/ ├─ core/ └─ shared/ ├─ tmp-lib1/ ├─ tmp-lib2/ └─ tmp-lib3/

Создание приложения

Приложения (apps) — это готовые проекты, которые собираются и разворачиваются как самостоятельные продукты.

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

npx nx g @nx/angular:app /apps/tmp-app --prefix=tmp --bundler=esbuild --ssr=false

Пример минимальной структуры приложения внутри директории apps:

apps/ └─ tmp-app/ ├─ config/ ├─ public/ └─ src/ ├─ components/ │ └─ app.ts ├─ index.html └─ styles.scss └─ project.json

Чтобы запустить приложение, необходимо выполнить команду:

npx nx run <application>:<target>:<environment>
npx nx run tmp-app1:serve:development

После вызова serve запустится первоначальный Hello World на http://localhost:4200

Создание библиотеки

Библиотеки (libs) — это наборы модулей, компонентов и сервисов, предназначенные для совместного использования между приложениями.

Каждая библиотека изолирована, имеет собственный project.json и может использоваться в других проектах внутри workspace. Можно создать отдельные группировки, например, core и shared, по своему усмотрению. Библиотеки лежат в основе масштабируемой архитектуры.

Создадим первую UI-библиотеку с префиксом tmp и поместим её в libs/shared

npx nx g @nx/angular:lib libs/shared/tmp-lib1 --buildable --prefix=tmp --inline-style --skip-tests --unit-test-runner=none

Пример рекомендуемой структуры библиотеки:

tmp-lib1/ ├─ src/ │ ├─ fonts/ │ └─ lib/ │ ├─ components/ │ │ ├─ index.ts │ │ ├─ tmp-lib1-handler.ts │ │ └─ tmp-lib1-wrapper.ts │ ├─ directives/ │ │ ├─ index.ts │ │ └─ example.directive.ts │ ├─ models/ │ │ ├─ index.ts │ │ └─ example.model.ts │ ├─ ngrx/ │ │ ├─ index.ts │ │ └─ example.store.ts │ ├─ pipes/ │ │ ├─ index.ts │ │ └─ example.pipe.ts │ └─ tokens/ │ ├─ index.ts │ └─ example.token.ts │ └─ index.ts ├─ ng-package.json ├─ package.json ├─ project.json └─ tsconfig.json

В корне index.ts содержит все блоки, которые мы будем экспортировать

export * from "./lib/directives";
export * from "./lib/components";
export * from "./lib/pipes";
export * from "./lib/models";
export * from "./lib/tokens";
export * from "./lib/ngrx";

А внутри каждого блока, например, components содержится список схем в index.ts:

export * from './tmp-lib1-handler';
export * from './tmp-lib1-wrapper';

А сами компоненты можно создавать в виде одного ts файла для компактности.
Например, файл tmp-lib1-wrapper.ts выглядит следующим образом:

import { Component } from '@angular/core';

@Component({
  selector: 'tmp-lib1-wrapper',
  imports: [],
  template: `<p>Hello I'm tmp-lib1</p>`,
  styles: ``,
})
export class TmpLib1WrapperComponent {}

Использование библиотек в приложениях

После создания библиотек их можно подключать в любые приложения внутри workspace.
Nx автоматически подхватывает структуру проектов и позволяет использовать библиотеки без дополнительных настроек.
Подключение библиотеки выполняется аналогично добавлению любого компонента

import { Component } from '@angular/core';
import { TmpLib1WrapperComponent } from '@nx-template/tmp-lib1';

@Component({
  imports: [TmpLib1WrapperComponent],
  selector: 'tmp-root',
  template: `
    <tmp-lib1-wrapper/>
  `,
  styles: ``,
})
export class App {}

Обратите внимание, что импорт делается не через абсолютную или относительную ссылку, а через контекст workspace. В нашем случае это @nx-template

import { TmpLib1WrapperComponent } from '@nx-template/tmp-lib1';

Чтобы отобразить зависимости компонентов внутри приложения и связи между проектами, необходимо выполнить следующую команду:

nx graph

Пример графа изображён на следующем рисунке:

Интеграция и применение в сторонних системах

Предположим, что ваше приложение должно по-разному обрабатывать аутентификацию, пути к сервисам или получать данные — в зависимости от того, где оно запущено: как самостоятельное приложение или как Custom Element в CRM-системе. Вместо того, чтобы загромождать код условными операторами, мы можем использовать DI для «внедрения» нужной реализации сервиса для каждого конкретного случая.

Внедрение зависимостей (DI) — это основной шаблон проектирования в Angular, который позволяет компонентам, сервисам и другим частям вашего приложения получать свои зависимости извне, вместо того, чтобы создавать их самостоятельно.

Подготовка

Для разделения логики скорректируем структуру проекта и определим разные конфигурации:

tmp-app/ └─ config/ ├─ app/ │ ├─ app.config.ts │ ├─ main.ts │ └─ tsconfig.app.json └─ ce/ ├─ app.config.ce.ts ├─ main.ce.ts └─ tsconfig.ce.json

В main.ts описываем точку входа нашего приложения, например, для Сustom Element файл main.ce.ts будет выглядеть следующим образом:

import { createApplication } from "@angular/platform-browser";
import { createCustomElement } from "@angular/elements";
import { AppComponent } from "../../src/components/app";
import { appConfig } from "./app.config.ce";

createApplication(appConfig)
	.then((appRef) => {
		const injector = appRef.injector;
		const customElement = createCustomElement(AppComponent, { injector });
		customElements.define("tmp-ce", customElement);
	})
	.catch((err) => console.error(err));

Дальше нужно определить структуру, которая будет меняться. Для этого создадим модель данных в файле app-config.model.ts, где будем хранить имя приложения и путь к директории public:

export interface AppConfig {
	appName: string;
	publicPath: string;
}

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

import { InjectionToken } from "@angular/core";
import { AppConfig } from "../model/app-config.model";

export const APP_CONFIG = new InjectionToken<AppConfig>("APP_CONFIG", {
	providedIn: "root",
	factory: (): AppConfig => ({
		appName: "Angular",
		publicPath: "../public",
	}),
});

InjectionToken — это специальный объект в Angular, который используется как уникальный ключ для регистрации и получения зависимостей через DI.

После этого остаётся последний шаг: определить конфиг для Custom Element в файле app.config.ce.ts:

import { ApplicationConfig, provideBrowserGlobalErrorListeners, provideZonelessChangeDetection } from "@angular/core";
import { APP_CONFIG } from "../../src/tokens/app-config.token";

export const appConfig: ApplicationConfig = {
	providers: [
		provideBrowserGlobalErrorListeners(),
		provideZonelessChangeDetection(),
		{
			provide: APP_CONFIG,
			useValue: {
				appName: "BPMSoft Angular",
				publicPath: "../public",
			},
		},
	],
};

Использование

Для применения конфига в шаблоне достаточно внедрить токен через inject и добавить в html, например, с помощью интерполяции:

@Component({
	imports: [TmpLib1WrapperComponent, TmpLib2Component],
	selector: "tmp-root",
	template: `
		<h1>{{ appConfig.appName }}</h1>
	`
})
export class AppComponent {
	appConfig = inject<AppConfig>(APP_CONFIG);
}

Результат

В итоге приложение показывает заголовок Angular, а при использовании внутри CRM — BPMSoft Angular

Результаты

В статье я привёл упрощённые примеры, сфокусированные на ключевых принципах построения экосистемы: разделении ответственности, использовании библиотек, конфигурировании сборок и внедрении зависимостей. Этого достаточно, чтобы понять общие подходы и начать использовать монорепозиторий на практике.

В реальном проекте такие решения дополняются более широким набором инструментов и технологий:

  • CI/CD — автоматизация сборок и проверок через nx affected, что позволяет пересобирать только изменённые части workspace;

  • NgRx Signal Store — хранение состояния, работа через сигналы и вычисляемые значения;

  • Tailwind CSS — единая система стилей и токенов, ускоряющая работу с UI-компонентами;

  • Vite — быстрый dev-сервер и удобная сборка;

  • Vitest — современный тестовый раннер с высокой скоростью выполнения.

На практике экосистема получается значительно шире, чем базовые примеры.
Но фундамент остаётся тем же: единый monorepo-workspace, чёткая структура, переиспользуемые библиотеки и продуманная конфигурация.

Отдельное приложение можно создать, например, для описания и отображения элементов системы, по типу UI kit. Дополнительно в него можно включить:

  • выбор темы и цветовой схемы;

  • выбор размера шрифта;

  • переключение между светлой и тёмной темой.

Заключение

Монорепозиторий, в частности Nx, подходит не для всех проектов.
Если компонентов немного и зависимости между ними минимальны, такой подход будет избыточным: он требует общей инфраструктуры, единых правил и поддержки структуры workspace. В небольших проектах удобнее работать с отдельными репозиториями.

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

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

  • Исходный код примера проекта Nx - github

  • Исходный код примера пакета BPMSoft - github

Комментарии (0)