Я начал с гнева

Предпосылки без технических деталей

Бывает, что проекты рождаются не из бизнес-требований, а из личной боли. Моя боль пришла с неожиданной стороны — от классического театра.

Однажды вечером ко мне подошла жена и высказала всё, что она думает о системе бронирования нашего местного театра. Репертуар на два месяца вперёд, а билеты исчезают из продажи в первую же минуту после открытия. Либо кто-то программно скупает всё на корню, либо на сайте творятся какие-то технические чудеса, создающие искусственный ажиотаж.

Моя честь была задета, я в гневе. Во мне проснулся соревновательный дух. Вооружившись браузером и 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

  • Добавление новых инфраструктурных слоёв

  • Все виды тестирования

  • Балансировка нагрузки

  • Внедрение метрик

  • И так далее...

Также слои могут стать настолько масштабными и самодостаточными, что придется выделять их в отдельный сервис, а это верный путь к смене архитектуры на... микросервисную например. И это будет новым этапом развития. Что только подтверждает мой тезис о том, что архитектура не является статичной, это некое изменчивое состояние, которое зависит от внешних факторов. А лучший код это не "чистый код", а тот, который решает задачу вовремя.

Билеты-то купил?

Да. На все спектакли. ?

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


  1. supercat1337
    15.09.2025 11:06

    Прочитал с интересом) Спасибо, что делитесь опытом.

    Расскажите, а чем вас SQLite не устраивает? В ноде на данный момент времени он уже нативный. Скорости операций хватит под вашу задачу. Зачем тащить другую СУБД?

    Балансировку через cluster будете делать?


    1. Libiros Автор
      15.09.2025 11:06

      Очень важные вопросы!

      Я выбрал SQLite, потому что это было идеальным решением для меня именно в тот момент. Главный критерий - zero-config и мгновенный легкий старт. Здесь скорее не SQLite меня не устраивает, а я проектирую систему под более сложные и гибкие гипотетические сценарии.

      Преимущества PostgreSQL - пареллелизм, свой инстанс, не привязанный к ноде. Дружелюбие к горизонтальному масштабированию системы.

      Балансировку через cluster будете делать?

      Это очень обширный вопрос. Пока моим сервисом пользуются единицы, поэтому мне далеко до конкретного планирования.
      Но если так случится, что он вырастет и потребует масштабирования и, как следствие, балансировки, то буду решать, как разбивать приложение на сервисы. И если несколько воркеров потребуют некоего "общего состояния", то да, кластеризация, очереди и вот это всё (включая, кстати и psql)