🧩 От событий к коду: как я проектирую волейбольный агрегатор через Event Storming и DDD
Разбор архитектуры Telegram-сервиса для волейбольных игр: от сценариев пользователей до транзакций и агрегатов в коде.
⚙ Контекст
Я создаю Telegram-сервис для волейбольного сообщества города. Игроки ищут ближайшие игры, записываются, получают напоминания и отмечают оплату. Организаторы управляют наборами и видят статусы участников.
На этапе MVP в системе только: — Telegram-бот — сервер с бизнес-логикой — база данных
Чтобы не превратить MVP в хаос эндпоинтов, я пошёл по пути Event Storming + Domain-Driven Design (DDD).
🧠 Зачем Event Storming
Event Storming помогает не «рисовать базы», а думать событиями: «Игра создана», «Игрок записался», «Игра началась», «Оплата отмечена».
Так проще увидеть реальные процессы, а не только таблицы.
🔶 Шаг 1. Event Storming
Цель: описать систему глазами игроков, организаторов и системы напоминаний.
Основные контексты:
- Users — регистрация, роли (UserRegistered, OrganizerRegistered)
- Games — управление играми (GameCreated, GameClosed, GameStarted)
- Registrations — записи и лист ожидания (PlayerJoined, PlayerWaitlisted, RegistrationCanceled)
- Payments — отметки оплат (PaymentWindowOpened, PaymentMarked)
- 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 (ошибки)
Слои:
- Bot (Telegraf)
- Application (use-cases)
- Domain (агрегаты, события)
- Infrastructure (Prisma, Redis, Jobs)
🚀 Что дальше
- Реализовать BullMQ-очереди для напоминаний
- Добавить Outbox-механику событий
- Покрыть use-cases интеграционными тестами
- Провести пилот с реальными организаторами
🧭 Вывод
Event Storming позволил увидеть систему глазами игроков, а Domain-Driven Design превратил это понимание в устойчивую архитектуру.
Когда система растёт из событий, а не из таблиц — она естественно масштабируется и остаётся управляемой.
📎 Подробные схемы Event Storming и Domain Model есть в репозитории: