НейроКотΔ
НейроКотΔ
AI-powered tech digest
@neurokotd
Капсульный фреймворк: упаковка архитектуры в ДНК проектов

Капсульный фреймворк: упаковка архитектуры в ДНК проектов

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

Перейдено от теории к практике

Показано, как был создан собственный капсульный фреймворк для микросервисов, что закладывалось в его основу и как он стал ДНК проектов.

Серия «Разработка через капсулы»:

  • Часть I: Опыт, которого нельзя потерять: что такое капсула и зачем она нужна
  • Часть II: Капсульный фреймворк: как упаковали архитектуру в ДНК проектов
  • Часть III: Капсулы и AI-агенты: как передать опыт разработчика машине (скоро)

До фреймворка: как это было

Прежде чем рассказывать, что было построено, стоит честно описать, от чего уходили. У нескольких технически схожих проектов на микросервисах использовался TypeScript, все строились на похожих принципах, все общались через брокер сообщений. Но при ближайшем рассмотрении сходство заканчивалось на уровне технологий. Структура сервисов в каждом проекте своя. Один проект называл директорию controllers/, другой handlers/, третий methods/. В одном проекте сервисы общались через HTTP, в другом через NATS, в третьем — смешанно. DI-контейнер где-то был, где-то нет. Телеметрия в одном проекте подключена через middleware, в другом руками в каждом методе, в третьем отсутствовала вовсе.

Но между разными проектами — это полбеды. Куда болезненнее была неоднородность внутри одного проекта. Микросервисная архитектура располагает к тому, что каждый сервис развивает свой разработчик. И каждый разработчик — художник, который видит мир по-своему. Два сервиса в одном проекте, использующие одни и те же технологии и общающиеся друг с другом через NATS, могли быть структурно совершенно разными. Один сервис — с чёткими слоями, портами и адаптерами. Соседний — плоская структура, вся логика в одном файле, репозиторий и бизнес-логика перемешаны. Оба работали. Оба написаны профессионалами. Но вместе они образовывали систему, в которой нет никакой предсказуемости.

Когда разработчик уходил, его сервис превращался в чёрный ящик. Формально — это TypeScript и NATS, как у всех. Фактически — у него своя философия, свои соглашения, свой способ думать о коде. Новый разработчик заходил внутрь и обнаруживал чужой мир. Либо тратил неделю на погружение, либо начинал переписывать по своим правилам. Второе случалось чаще.

Каждый проект держался на экспертах. Перевести разработчика между проектами было болезненно — фактически онбординг приходилось проходить заново, причём иногда для каждого сервиса отдельно. Уход ключевого архитектора всегда был риском: часть решений и правил существовала только в его голове.

Поняли, что проблема не в людях и не в технологиях — проблема в отсутствии ядра. Каждый проект изобретал своё. Было решено создать капсулу — фреймворк, который станет общим ядром для всех проектов.

От принципов к фреймворку

Первым делом нужно было извлечь опыт из голов и формализовать его. Последовала серия созвонов, на которых был составлен список ключевых архитектурных принципов. Список отсортирован от самых крупных к более мелким:
  • Микросервисная архитектура.
  • Нужна капсула именно для распределённых систем, а для нас это в первую очередь микросервисы.
  • Архитектура, управляемая событиями (EDA).
  • По опыту, коммуникация через генерацию и обработку событий — наиболее гибкий подход. Нет жёсткой связи между сервисами, нет цепочек синхронных вызовов. Это накладывает дополнительную сложность, но гибкость для нас важнее.
  • Гексагональная архитектура сервисов.
  • Часто меняли технологии в рамках проекта, поэтому важно отделить бизнес-логику от периферии. В гексагональной архитектуре (она же «Порты и Адаптеры») в центре находится бизнес-логика, а всё необходимое скрыто за интерфейсами — портами. В момент выполнения бизнес-логика получает конкретную реализацию порта — адаптер.
  • Инверсия зависимостей через инъекцию.
  • Придерживаясь гексагональной архитектуры, нужно реализовать инверсию зависимостей. Выбрали инъекцию через DI-контейнер.
  • Логи и телеметрия.
  • Распределённые системы сложно отслеживать. Стандартом выбран OpenTelemetry — и важно, что трассировка охватывает не только синхронные вызовы, но и асинхронные события, чтобы вся цепочка обработки была видна.

Обратите внимание на несколько деталей. Каждое поле request и response — это полноценная JSON Schema с типами, валидацией и описаниями. Событие OrderCreated помечено флагом stream: true, что означает — оно будет храниться в JetStream и доступно для обработки в течение заданного времени (здесь messageTTL: 86400 — одни сутки).

После описания схемы запускался генератор. На выходе появлялись файлы, которые никогда не редактировались вручную:

// interfaces.ts — TypeScript-типы для всех методов и событий. Генерируется автоматически на основе JSON-схем, редактировать нельзя:
export interface CreateOrderRequest {
  credentials: { userid: string; spaceid: string; };
  data: { productid: string; quantity: number; };
}
export interface CreateOrderResponse {
  result: { orderid: string; };
}

export interface OrderCreatedEvent {
orderid: string; userid: string; spaceid: string; productid: string; quantity: number;
}

export type EmitterOrder = {
OrderCreated: (data: OrderCreatedEvent, uniqId?: string) => void;
};

export type EmitterOrderExternal = {
OrderCreated: EventStreamHandler;
};

index.ts
  • типизированный клиент сервиса для других сервисов. Любой сервис, который хочет вызвать метод OrderService, импортирует этот клиент и получает полную типизацию — без необходимости знать о деталях NATS.

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

Кодогенератор создаёт следующую структуру директорий — и она одинакова для всех сервисов в системе:


service/
├── domain/
│ ├── aggregates/ # Бизнес-сущности
│ └── ports/ # Интерфейсы репозиториев
├── infra/
│ ├── repositories/ # Реализации репозиториев
│ └── components/ # Инфраструктурные компоненты
├── methods/ # Классы методов (наследуют BaseMethod)
├── processing/ # Обработчики событий от других сервисов
├── service.schema.json # Схема — единственный источник истины
├── interfaces.ts # Автогенерация — не редактировать
├── index.ts # Клиент сервиса — автогенерация
├── service.ts # DI-контейнер и запуск
└── start.ts # Точка входа

Метод CreateOrder


Скелет метода


typescript
// Сгенерированный скелет — разработчик дописывает только handler
export class CreateOrder extends BaseMethod {
static settings = serviceSchema.methods.CreateOrder;

constructor() {
super();
}

async handler(request: CreateOrderRequest): Promise {
// TODO: implement
throw new Error('Not implemented');
}
}


Реализация метода


typescript
export class CreateOrder extends BaseMethod {
static settings = serviceSchema.methods.CreateOrder;

constructor(
@inject(TYPES.OrderRepo) private readonly repo: IOrderRepo
) {
super();
}

async handler({ credentials, data }: CreateOrderRequest): Promise {
const order = this.repo.create({
userid: credentials.userid,
spaceid: credentials.spaceid,
productid: data.productid,
quantity: data.quantity,
});

await this.repo.persist(order, credentials);

this.emitter.OrderCreated({
orderid: order.getId(),
userid: credentials.userid,
spaceid: credentials.spaceid,
productid: data.productid,
quantity: data.quantity,
});

return {
result: { orderid: order.getId() },
};
}
}


Публикация событий


Когда сервис хочет опубликовать событие, он вызывает типизированный эмиттер — this.emitter.OrderCreated(...). Фреймворк знает о событии из схемы и публикует его в нужный NATS-стрим.

Обработчики событий

Другой сервис (например, NotificationService) хочет реагировать на создание заказа. Для этого создаётся класс-обработчик в папке processing/:

typescript
export class OrderProcessing {
constructor(
@inject(TYPES.NotificationRepo) private readonly repo: INotificationRepo
) {}

private async onOrderCreated(event: {
data: OrderCreatedEvent;
ack: () => void;
nak: (ms: number) => void;
}) {
try {
await this.repo.sendOrderConfirmation(event.data);
event.ack(); // подтверждаем обработку
} catch (error) {
event.nak(5000); // повтор через 5 секунд
}
}

public start(orderClient: OrderClient) {
const listener = orderClient.getListener('NotificationService', { deliver: 'new' });
listener.on('OrderCreated', this.onOrderCreated.bind(this));
}
}


Механизм ack/nak


Механизм ack/nak — это гарантия доставки JetStream. Если обработчик упал, не вызвав ack, брокер повторит доставку через указанное время. Это встроено в фреймворк и работает автоматически.

Точка сборки

service.ts

Точка сборки. Здесь настраивается DI-контейнер и запускается сервис:

typescript
export async function main(broker?: NatsConnection) {
const brokerConnection = broker || (await connect({
servers: configs.application.natsHost,
maxReconnectAttempts: -1,
}));

// Настраиваем DI
container.bind(TYPES.OrderRepo, DependencyType.ADAPTER, OrderRepo, {
init: true, // репозиторий установит соединение с БД при init()
});

// Запускаем сервис
const service = new Service({
name: serviceSchema.name,
brokerConnection,
methods: [CreateOrder, GetOrder, ListOrders, CancelOrder],
events: serviceSchema.events,
gracefulShutdown: {
additional: await container.initDependencies(), // инициализируем и сразу передаём для graceful shutdown
},
});
}


Вызов container.initDependencies() — обязательный шаг, если хотя бы один адаптер зарегистрирован с опцией init: true. Фреймворк вызывает метод init() у таких адаптеров в нужном порядке и возвращает массив инициализированных объектов — его удобно сразу передать в gracefulShutdown.additional, чтобы при остановке сервиса соединения закрылись корректно.

Массив methods — это все классы методов сервиса. Фреймворк сам подписывает каждый метод на соответствующую NATS-тему, исходя из поля action в схеме. Добавить новый метод — значит написать его класс и добавить в этот массив. Всё.

Ключевые возможности фреймворка

Пройдя по циклу создания сервиса, можно увидеть, как каждый принцип воплощается в конкретные возможности.

Request/Reply через NATS

Каждый метод сервиса — это NATS Request/Reply. Клиентский код вызывает метод как обычную асинхронную функцию:

javascript
const orderClient = serviceInstance.buildService(OrderClient);
const result = await orderClient.CreateOrder({
credentials: { userid, spaceid },
data: { product_id: 'prod-42', quantity: 3 },
});

Под капотом это NATS-запрос с ожиданием ответа, таймаутом и автоматической десериализацией. С точки зрения вызывающего кода — обычный вызов TypeScript-функции с полной типизацией.

Pub/Sub с гарантией доставки через JetStream

События с флагом stream: true публикуются в NATS JetStream. Это означает:

  • Сообщения хранятся на брокере в течение messageTTL.
  • Подписчики могут получать события с любой точки: с момента подписки (deliver: 'new'), с начала стрима (deliver: 'all') или с последнего необработанного (deliver: 'last').
  • Гарантия exactly-once обработки через ack/nak — даже если обработчик упал, событие будет доставлено повторно.

Без фреймворка настройка JetStream — это многострочный boilerplate. В капсуле всё это задаётся в схеме через streamOptions и работает «из коробки».

Параметр deliver при подписке — важная деталь, которая определяет поведение при запуске и перезапуске сервиса:

  • deliver: 'new' — обрабатывать только события, появившиеся после старта подписки. Подходит для большинства случаев: реакция на новые факты.
  • deliver: 'all' — начать с самого первого события в стриме. Подходит для event sourcing: восстановление состояния при старте или при развёртывании нового сервиса, которому нужно «догнать» историю.

Батчевая обработка событий

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

javascript
const batchListener = orderClient.getListener('NotificationService:Batch', {
deliver: 'new',
batch: true,
maxPullRequestBatch: 50, // максимум событий в одном батче
maxPullRequestExpires: 3000, // ждать не более 3 секунд до отправки неполного батча
});

javascript
batchListener.on('OrderCreated', async (messages, meter) => {
meter.start();
for (const message of messages) {
try {
await this.repo.sendOrderConfirmation(message.data);
message.ack();
} catch {
message.nak(5000);
}
}
meter.end();
});

Эффективность при обработке событий


Это принципиально меняет производительность при обработке тысяч событий: вместо тысячи round-trip к базе данных можно сделать один батчевый INSERT.

Runtime-валидация по JSON Schema

Каждый метод может включать автоматическую валидацию входных и выходных данных:

json
"options": {
"runTimeValidation": {
"request": true,
"response": true
}
}

Фреймворк компилирует JSON Schema в валидатор при старте и проверяет данные до вызова handler. Если данные не соответствуют схеме — клиент получит структурированную ошибку валидации, а handler не будет вызван.

Кеширование методов

Для методов с дорогостоящими запросами можно включить кеш прямо в схеме:

json
"options": {
"cache": 5
}

Значение 5 означает кеширование на 5 секунд. Структура файла строго задокументирована — и это то, что нужно агенту: не угадывать формат, а работать по известному контракту.

Итого

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

Фреймворк — это не изолированная капсула. Он живёт внутри иерархии других капсул компании: техрадар определяет его технологический стек, а он сам определяет структуру всех проектов. Когда техрадар переводит технологию в статус «больше не используем» — это прямой сигнал к обновлению фреймворка, что автоматически затрагивает все проекты на нём.

Мы рассмотрели, как из набора принципов рождается конкретная капсула — со своей схемой, кодогенерацией, DI-контейнером, событийной архитектурой и архитектурным альбомом. Ни один из этих элементов не случаен: каждый появился из опыта предыдущих проектов и решает конкретную проблему, с которой мы сталкивались.

Это не универсальный инструмент. Другая команда, с другим контекстом и другим техрадаром, написала бы другой фреймворк. Именно поэтому мы называем его капсулой — это слепок нашего опыта, а не абстрактная методология.

И здесь важно сделать шаг назад и посмотреть шире. В этой части мы говорили о фреймворке для разработки — но методология капсул не ограничивается кодом. Капсула — это способ организовать любой повторяющийся процесс. Оценка проекта на входе, найм разработчика, онбординг нового члена команды — каждый из этих процессов тоже можно упаковать в капсулу. Зафиксировать опыт, который иначе живёт только в голове у конкретного человека, придать ему форму и сделать воспроизводимым.

При этом форма капсулы определяется природой самого процесса. Для разработки это фреймворк с кодогенерацией. Для оценки проекта — возможно, математическая модель с весовыми коэффициентами. Для онбординга — структурированный план с чеклистами и точками контроля. Форма разная, суть одна: опыт перестаёт зависеть от конкретного носителя.

Именно поэтому слово «капсула» точнее, чем «фреймворк» или «методология». Капсулы — это не про очередной технический инструмент. Это про то, как компания накапливает и передаёт опыт — в разработке, в управлении, в найме, в чём угодно.

Капсула = Экспертное знание + Минимализм + Готовый инструмент

Наш фреймворк — это одна из таких капсул. Экспертное знание: принципы распределённых систем, выработанные на нескольких проектах. Минимализм: только для распределенных систем на TypeScript, NATS и OpenTelemetry — ничего лишнего. Готовый инструмент: кодогенерация, DI-контейнер, архитектурный альбом — всё работает из коробки.

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