Я начал с гнева
Предпосылки без технических деталей
Бывает, что проекты рождаются не из бизнес-требований, а из личной боли. Моя боль пришла с неожиданной стороны — от классического театра.
Однажды вечером ко мне подошла жена и высказала всё, что она думает о системе бронирования нашего местного театра. Репертуар на два месяца вперёд, а билеты исчезают из продажи в первую же минуту после открытия. Либо кто-то программно скупает всё на корню, либо на сайте творятся какие-то технические чудеса, создающие искусственный ажиотаж.
Моя честь была задета, я в гневе. Во мне проснулся соревновательный дух. Вооружившись браузером и 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
Добавление новых инфраструктурных слоёв
Все виды тестирования
Балансировка нагрузки
Внедрение метрик
И так далее...
Также слои могут стать настолько масштабными и самодостаточными, что придется выделять их в отдельный сервис, а это верный путь к смене архитектуры на... микросервисную например. И это будет новым этапом развития. Что только подтверждает мой тезис о том, что архитектура не является статичной, это некое изменчивое состояние, которое зависит от внешних факторов. А лучший код это не «чистый код», а тот, который решает задачу вовремя.
Билеты-то купил?
Да. На все спектакли. 😈
