🧩 От событий к коду: как я проектирую волейбольный агрегатор через Event Storming и DDD

Разбор архитектуры Telegram-сервиса для волейбольных игр: от сценариев пользователей до транзакций и агрегатов в коде.

⚙ Контекст

Я создаю Telegram-сервис для волейбольного сообщества города. Игроки ищут ближайшие игры, записываются, получают напоминания и отмечают оплату. Организаторы управляют наборами и видят статусы участников.

На этапе MVP в системе только: — Telegram-бот — сервер с бизнес-логикой — база данных

Чтобы не превратить MVP в хаос эндпоинтов, я пошёл по пути Event Storming + Domain-Driven Design (DDD).

🧠 Зачем Event Storming

Event Storming помогает не «рисовать базы», а думать событиями: «Игра создана», «Игрок записался», «Игра началась», «Оплата отмечена».

Так проще увидеть реальные процессы, а не только таблицы.

🔶 Шаг 1. Event Storming

Цель: описать систему глазами игроков, организаторов и системы напоминаний.

Основные контексты:

  1. Users — регистрация, роли (UserRegistered, OrganizerRegistered)
  2. Games — управление играми (GameCreated, GameClosed, GameStarted)
  3. Registrations — записи и лист ожидания (PlayerJoined, PlayerWaitlisted, RegistrationCanceled)
  4. Payments — отметки оплат (PaymentWindowOpened, PaymentMarked)
  5. Notifications — напоминания (ReminderScheduled, ReminderSent)

Жизненный цикл событий: GameCreated → PlayerJoined → WaitlistedPromoted → GameStarted → PaymentMarked

Бизнес-правила (policies):
• OnGameCreated → ScheduleReminders (24ч, 2ч)
• OnRegistrationCanceled → PromoteWaitlisted
• OnGameStarted → OpenPaymentWindow
• OnCapacityReached → BlockNewJoins

🧩 Шаг 2. Domain Model Design

Когда поведение стало понятным, я описал доменную модель с агрегатами, инвариантами и use-cases.

🧱 Агрегаты и инварианты

Game (корневой агрегат): — контролирует вместимость, статусы, время начала и окно оплаты — инварианты:
• capacity > confirmedCount
• startsAt > now()
• status ∈ {open, closed, finished, canceled}

Registration: — описывает участие игрока — инварианты: уникальность (gameId, userId), допустимые переходы, оплата только после старта

⚡ Основные use-cases

joinGame() — записывает игрока, проверяет лимит и уникальность

leaveGame() — отменяет участие и продвигает первого из листа ожидания. markPayment() — работает только после начала игры (now ≥ startsAt).

🧭 Работа с транзакциями

Чтобы исключить гонки:

  • уровень изоляции: Serializable
  • advisory-lock на Postgres:

Для идемпотентности — ключ событий: evt:<type>:<gameId>:<userId>:<bucket>

🧱 Структура данных (Prisma)

Game: id, organizerId, startsAt, capacity, status, registrations[]

Registration: id, gameId, userId, status (confirmed / waitlisted / canceled), paymentStatus (unpaid / paid)

🔔 Notifications и политики

  • OnGameCreated — планирование напоминаний (T-24 / T-2)
  • OnGameStarted — открытие окна оплаты
  • OnRegistrationCanceled — продвижение в списке ожидания

⚠ Основные риски

Гонки на последнем месте: транзакции + advisory lock Повторные клики: идемпотентность Ошибки Telegram: retry + логирование Несинхронное время: всё в UTC

📦 Архитектура MVP

Технологии: — Node.js + TypeScript — Fastify (API) — Prisma ORM + PostgreSQL — Redis + BullMQ (уведомления) — Sentry (ошибки)

Слои:

  1. Bot (Telegraf)
  2. Application (use-cases)
  3. Domain (агрегаты, события)
  4. Infrastructure (Prisma, Redis, Jobs)

🚀 Что дальше

  1. Реализовать BullMQ-очереди для напоминаний
  2. Добавить Outbox-механику событий
  3. Покрыть use-cases интеграционными тестами
  4. Провести пилот с реальными организаторами

🧭 Вывод

Event Storming позволил увидеть систему глазами игроков, а Domain-Driven Design превратил это понимание в устойчивую архитектуру.

Когда система растёт из событий, а не из таблиц — она естественно масштабируется и остаётся управляемой.

📎 Подробные схемы Event Storming и Domain Model есть в репозитории:

Начать дискуссию