Я начал с гнева
Предпосылки без технических деталей
Бывает, что проекты рождаются не из бизнес-требований, а из личной боли. Моя боль пришла с неожиданной стороны — от классического театра.
Однажды вечером ко мне подошла жена и высказала всё, что она думает о системе бронирования нашего местного театра. Репертуар на два месяца вперёд, а билеты исчезают из продажи в первую же минуту после открытия. Либо кто-то программно скупает всё на корню, либо на сайте творятся какие-то технические чудеса, создающие искусственный ажиотаж.
Моя честь была задета, я в гневе. Во мне проснулся соревновательный дух. Вооружившись браузером и devtools, я за несколько минут выяснил, что API театрального сайта открыто и дружелюбно отвечает на запросы. cURL-запрос, брошенный в терминал, вернул живой JSON со списком спектаклей и наличием билетов. Гипотеза об ушлом конкуренте стала основной.
Но просто знать — мало. Захотелось действовать. Так родилась миссия: создать мониторинг, который будет проверять наличие билетов чаще, чем это делает мифический конкурент.
Первая цель была скромной: получить работающий прототип за 15 минут. Не до архитектурных изысков. Не до конфигов. Только быстрый и грязный код, который решает проблему «здесь и сейчас».
Это сработало! Через полчаса в консоли замигали первые заветные строки о ... том, что билетов пока нет. Однако, это был успех. Оставалось ждать, когда кто-то сдаст свой билет назад (или искусственный ажиотаж снова высунет голову). Также именно в этот момент проявилась обратная сторона любого работающего прототипа — он моментально перестаёт быть достаточным.
Захотелось уведомлений в телеграм. Потом — возможности фильтровать спектакли. Потом — чтобы сервис работал всегда, а не только когда открыт мой ноутбук. Каждое новое желание упиралось в ограничения первоначальной архитектуры. А она была? Можно я назову свой 10-строчный index.ts монолитиком?
Этот проект прошёл классический путь эволюции: от однофайлового скрипта-монолита через наивное разделение на модули до осознанного применения принципов чистой архитектуры. В этой статье я покажу все этапы этого пути, и главное — архитектурные решения, которые принимались на каждом из них и почему.
Я не буду просто показывать красивый готовый код. Я покажу, какая боль заставляла меня переходить на следующий этап, и как выбранная архитектура эту боль устраняла. Это практический пример того, как архитектура рождается из требований, а не наоборот. Архитектура - это не статическое состояние "раз и навсегда" и я это докажу.
Итерация 0: Ликвидный монолит (The Throw-Away Monolith)
Любой проект, рождённый из гипотезы, должен начинаться с её максимально быстрой проверки. В этот момент главный архитектурный принцип — «You aren’t gonna need it» (YAGNI). Любая попытка заглянуть в будущее и добавить «абстракцию на вырост» — это потраченное впустую время, если гипотеза не сработает.
Поэтому архитектура первой итерации была выбрана осознанно и однозначно — Ликвидный монолит (Throw-Away Monolith). Её единственная цель — быть максимально простой, быстрой в написании и, что важно, не жалкой для удаления. Это код-однодневка, который мы морально готовы выбросить сразу после получения ответа.
Весь код жил в index.ts
. В нём было всё: конфигурация (константы), логика выполнения HTTP-запроса, таймер и бизнес-логика парсинга ответа (да просто result.json()
). Это антипаттерн с точки зрения чистого кода, но оптимальный паттерн для этапа Proof of Concept (PoC). Скорость важнее чистоты! Запрос мог упасть, JSON мог не распарситься, планировщика не было.
// index.ts
async function main() {
setInterval(() => {
// получили
const response = await fetch(URL)
// вернули
const data = response.json()
// отфильтрвоали
const filteredData = data.filter(FILTER_CB).map(show => show.name)
console.log(filteredData)
}, 1000 * 60 * 5)
}
main().catch(err => {
console.error(err);
process.exit(1);
});
Структура проекта
├── index.ts
Гипотеза проверена за полчаса. Никаких бойлерплейтов, зависимостей, даже билда — достаточно было запустить node index.ts
. Нет git-репозитория. Это идеальное архитектурное решение на данном этапе разработки, когда неизвестно, будет ли разработка вообще. Похожие концепции уже описаны, например, Monolith first или Sacrificial Architecture.
Итерация 1: Модульный монолит (The Modular Monolith)
Наконец, пришло первое уведомление - билетов нет! А спустя два часа оказалось, что уведомление о новых билетах, естественно, пропущено. Но это победа! Гипотеза подтверждена! Спустя секунду после этого, прототип перестал быть достаточным. Вручную следить за консолью — невыносимо скучно и непрактично. Сервис теперь нужен не для эксперимента, а для реального использования, причем минимум двумя пользователями, значит нужно не просто их оповещать удобным способом, а еще хранить состояние, кэшировать запросы, хранить состояние подписок (на этот спектакль хочу, а на тот не хочу).
Случился закономерный переход к Модульному Монолиту. Это не чистая архитектура, а всего лишь первый шаг к порядку — разделение кода по функциональным модулям внутри одной кодовой базы. Это тот момент, когда index.ts начинает худеть, но появляются import'ы.
Рождение модулей: Separation of Concerns.
Отделяется логика получения и отправки данных с появлением telegram-notifier.ts
.
Дополнительно рождается afisha-fetcher.ts
.scheduler.ts
следит за инетрвальным выполнением функции мониторинга monitor.ts
.subscriptions.ts
отвечает за SQLite и служит репозиторием для работы с БД.
Бонусом ко всему добавляются утилитарные чистые функции.
Чтобы организовать красивые подписки в телеграм, нужно зарегистрировать несколько команд у бота. Команда /list
тянет зависимость afisha-fetcher
, чтобы показать спектакли, а также subscriptions
, чтобы показать "подписано: N человек". Команда /log
использует logger.ts
и так далее
Структура проекта
├── src
│ ├── monitor.ts # Основная проверка билетов и уведомления
│ ├── scheduler.ts # Запускает мониторинг каждые N времени (node-cron)
│ ├── afisha-fetcher/ # Модуль для кэширования и получения данных о спектаклях
│ ├── telegram-bot/ # tg instnce, notifier, commands
│ ├── subscriptions/ # SQLite instance + репозиторий
│ ├── utils # Константы и утилиты
│ │ ├── const.ts # Константы
│ │ ├── index.ts # Утилиты
│ │ └── logger.ts # Логирование и хранение логов
│ ├── index.ts # Точка входа в приложение
│ │
│ ├── eslint/prettier/package.json/data.db/etc.
Тестируемость
Теперь можно тестировать по модулям. Да, сложно, но буду честен, никаких тестов на этом этапе не было. Оставил это адептам TDD.
Конфигурируемость
Появились конфиги eslint, prettier, tsconfig, .env. А также git-репозиторий.
Теперь сервис является реальным инструментом. Разбит на модули (пусть пока с высокой связанностью и низкой когезией). Уведомления автоматизированы. Перезапуск приложения не удаляет данные.
Итерация 2: Чистая Архитектура
Модульный монолит позволяет быстро добавлять новые фичи и даже фиксить старые баги. Однако, всё это имеет эффект в виде накопления технического долга. Что примечательно, в начале прошлой главы я написал "первый шаг к порядку". Именно стремление к порядку породило хаос... В процессе доработки приложения всё чаще задаются вопросы "а какой здесь тип/интерфейс" или "а почему он импортируется отсюда, это ведь нелогично". Появляется реальная потребность в автоматизированном тестировании, а высокая связанность усложняет этот процесс. Пет-проект превращается из источника эндорфина в невозможность запомнить, где лежит тот или иной метод, класс, интерфейс да кто вообще это написал?!. Настало время всё организовать логично и очевидно. Как говорится, всё очень здорово, но пора переделывать.
Мне нужны были контроль и предсказуемость. Я хотел бы работать только над новой фичей без необходимости копаться во всем монолите, пусть и модульном. Я хочу контролировать каждый слой абстракции и заранее знать, где и как появится новый модуль. Лучшим решением было использовать DDD + Clean Architecture.
DDD даёт контроль над предметной областью. Устанавливает жесткие рамки в виде предварительного описания интерфейсов в domain и лишь потом к их реализации.
Clean Architecture даёт контроль над связанностью и когезией. Модули/слои не должны зависеть друг от друга. Должен соблюдаться принцип единой ответственности. Важно использовать паттерн DI для полной изоляции слоёв.
Прежде всего, важно организовать уровни предметной области.
├── src
│ ├── application # Прикладная логика (оркестрация сервисов)
│ │ ├── use-cases # Бизнес-сценарии приложения
│ ├── domain # Ядро системы (интерфейсы и абстракции)
│ ├── infrastructure # Внешние адаптеры и реализации
│ └── shared # Общие утилиты и константы
Теперь я обязан начинать с domain. Важно в первую очередь описать все интерфейсы и лишь потом приступать к имплементациям. Пара примеров для наглядности:
// Описывает событие
export interface Event {
id: number;
date: string;
name: string;
count: number; // количество билетов (реальное поле из api)
}
// Интерфейс для модуля subscriptions, будет использоваться в DI
export interface ISubscriptionRepository {
addUser(chatId: number, username: string): void;
subscribe(chatId: number, showId: string): void;
unsubscribe(chatId: number, showId: string): void;
getSubscriptions(chatId: number): string[];
getSubscribers(showId: string): string[];
getAllSubscribers(): string[];
getSubscribersCountByShow(): Map<string, number>;
}
Интерфейсы реализуются в уровне infrastructure. Отличный пример кода, в котором нет проблем:
export class AfishaFetcher implements IFetcher {
private data: ItemsWithTickets[] = [];
/**
* Запрашивает доступные билеты
* @param filterCallback - если нужно отфильтровать данные перед выдачей
*/
async fetchAvailableTickets(filterCallback: (e: ItemsWithTickets) => boolean): Promise<ItemsWithTickets[]> {
await this.fetch();
return this.data.filter(filterCallback);
}
// другие методы
}
Но в примере выше, как уже было сказано, нет проблем. Класс лишь получает данные и как-то их фильтрует.
Но что если существует такой модуль, который должен обратиться к другим? Например, команда телаграм-бота не просто позволяет общаться с ним, но и запускает процессы, нужные для полноценного ответа.
Тогда на помощь приходит паттерн use-case, реализующий бизнес-сценарии. Например:
this.bot.command('list', await listCommand(listShowsUseCase));
Если бот получает команду /list
, он должен запустить метод, реализующий логику коллбэка команды - listCommand
export const listCommand = async (listShowsUseCase: TListShowsUseCase) => {
return async (ctx: Context) => {
if (ctx.chat) {
// здесь должен быть try-catch блок для обработки исключений, это очень важно!
const useCaseResult = await listShowsUseCase.execute(ctx.chat.id);
await ctx.reply('Выберите спектакль для подписки/отписки:', Markup.inlineKeyboard(useCaseResult));
}
};
};
Как видно, обработчик команды инфраструктурного адаптера telegram-bot ничего не знает о том, как реализовать команду. Он просто получает какие-то данные из use-case и просто отдает их пользователю. Таким образом, весь слой остается изолированным и "глупым", работающим самостоятельно и выбрасывающим исключение, если определенный use-case дал сбой.
// application/use-cases/list-shows.use-case.ts
import { IFetcher, ISubscriptionRepository } from '../../domain/services';
import { TListShowsUseCase } from '../../domain/use-cases';
export class ListShowsUseCase implements TListShowsUseCase {
// DIP
constructor(
private readonly fetcher: IFetcher,
private readonly repo: ISubscriptionRepository
) {}
async execute(chatId: number): Promise<{ shows: string[]; buttons: ButtonData[] }> {
/**
* Скучная логика execute находится здесь.
* Но именно здесь используются другие инфраструктурные слои.
* А передаются они через абстракции из domain-уровня!
* /
}
}
Так достигается следующее направление зависимостей:
Domain ← Application ← Infrastructure
↑ ↑ ↑
└───→ Shared ←────────────┘
В результате получилась четкая выдержанная архитектура с жесткими правилами разработки без лишних абстракций и простым внедрением зависимостей. А точка входа реализует Composition Root паттерн:
// Composition Root pattern
async function main() {
const fetcher = new AfishaFetcher();
const repo = new SubscriptionRepository();
const useCaseFactory = new UseCaseFactory(fetcher, repo, logger);
const telegramBot = new BotLauncher(process.env.TELEGRAM_TOKEN!, logger, useCaseFactory);
telegramBot.init();
startScheduler(() => runMonitor(fetcher, telegramBot, logger, repo));
}
main().catch(err => {
console.error(err);
process.exit(1);
});
Итоговая структура проекта
├── src
│ ├── application # Прикладная логика (оркестрация сервисов)
│ │ ├── use-cases # Бизнес-сценарии приложения
│ │ │ ├── list-shows.use-case.ts # Получение списка спектаклей
│ │ │ ├── toggle-subscription.use-case.ts # Управление подписками
│ │ │ ├── show-subscribers-list.use-case.ts # Просмотр подписчиков
│ │ │ ├── telegram-log.use-case.ts # Работа с логами
│ │ │ └── use-case-factory.ts # Фабрика use cases
│ │ ├── monitor.ts # Основная проверка билетов и уведомления
│ │ └── scheduler.ts # Планировщик, запускающий мониторинг каждые N времени (node-cron)
│ ├── domain # Ядро системы (интерфейсы и абстракции)
│ │ ├── services # Интерфейсы сервисов
│ │ ├── repositories # Интерфейсы репозиториев
│ │ ├── use-cases # Интерфейсы бизнес-сценариев
│ │ └── types # Бизнес-сущности и DTO
│ ├── infrastructure # Внешние адаптеры и реализации
│ │ ├── afisha-fetcher # Модуль для получения данных о спектаклях
│ │ ├── logger # Логирование событий
│ │ ├── subscriptions # Хранение подписок (SQLite репозиторий)
│ │ └── telegram-bot # Telegram-бот и нотификатор
│ ├── shared # Общие утилиты и константы
│ │ ├── const.ts # Константы приложения
│ │ ├── lib # Утилиты (чистые функции)
│ │ └── types # Общие типы данных
│ └── index.ts # Composition Root (точка входа)
Бонусная итерация. Сборка и деплой
Чтобы всё крутилось само, я купил самый простой VPS и установил docker. Приватный репозиторий живет в github, который позволяет использовать собственный GitHub Container Registry. Во время push into main, запускается CI Action, который собирает образ и отправляет его в GHCR, делая приватным. На VPS крутится watchtower, который каждые 10 минут проверяет, не появилась ли новая версия образа и если да, то пуллит и разворачивает ее с помощью одного лишь docker-compose.yml
. То есть всё происходит без моего участия само.
Таким образом, в репозиторий были добавлены Dockerfile
+ docker-compose.yml
Какие итоги и что дальше?
Прежде всего, моей целью было показать, как сервис, требующий подтверждения гипотезы вырождается в пет-проект с развитой архитектурой. Кстати, архитектура — это не догма, а инструмент для решения конкретных проблем. Понимание этих проблем и умение выбирать адекватные инструменты — это и есть путь к правильной организации не только кодовой базы, но и всей инфраструктуры вокруг.
Что может случиться в будущем?
Простые вещи:
Замена SQLite на PostgreSQL
Добавление новых инфраструктурных слоёв
Все виды тестирования
Балансировка нагрузки
Внедрение метрик
И так далее...
Также слои могут стать настолько масштабными и самодостаточными, что придется выделять их в отдельный сервис, а это верный путь к смене архитектуры на... микросервисную например. И это будет новым этапом развития. Что только подтверждает мой тезис о том, что архитектура не является статичной, это некое изменчивое состояние, которое зависит от внешних факторов. А лучший код это не "чистый код", а тот, который решает задачу вовремя.
Билеты-то купил?
Да. На все спектакли. ?
supercat1337
Прочитал с интересом) Спасибо, что делитесь опытом.
Расскажите, а чем вас SQLite не устраивает? В ноде на данный момент времени он уже нативный. Скорости операций хватит под вашу задачу. Зачем тащить другую СУБД?
Балансировку через cluster будете делать?
Libiros Автор
Очень важные вопросы!
Я выбрал SQLite, потому что это было идеальным решением для меня именно в тот момент. Главный критерий - zero-config и мгновенный легкий старт. Здесь скорее не SQLite меня не устраивает, а я проектирую систему под более сложные и гибкие гипотетические сценарии.
Преимущества PostgreSQL - пареллелизм, свой инстанс, не привязанный к ноде. Дружелюбие к горизонтальному масштабированию системы.
Это очень обширный вопрос. Пока моим сервисом пользуются единицы, поэтому мне далеко до конкретного планирования.
Но если так случится, что он вырастет и потребует масштабирования и, как следствие, балансировки, то буду решать, как разбивать приложение на сервисы. И если несколько воркеров потребуют некоего "общего состояния", то да, кластеризация, очереди и вот это всё (включая, кстати и psql)