Skip to content

Architecture Patterns

Architecture Patterns

CAP теорема [💡20/100]

CAP теорема (Consistency, Availability, Partition Tolerance) описує обмеження у розподілених системах. Вона стверджує, що неможливо одночасно забезпечити всі три властивості

  • Consistency (узгодженість): Всі вузли системи бачать однакові дані в один і той самий момент часу. Будь-який запит до системи завжди повертає найновіше значення даних.
  • Availability (доступність): Кожен запит отримує відповідь, незалежно від стану будь-якого окремого вузла. Система гарантує, що всі запити завершуються успішно.
  • Partition Tolerance (стійкість до розділення): Система продовжує функціонувати, незважаючи на будь-які розділення мережі, які можуть розділити вузли системи на частини, що не можуть спілкуватися один з одним.

CAP теорема є основою для розуміння компромісів у проектуванні розподілених систем і допомагає приймати рішення щодо вибору архітектури залежно від вимог до системи.

Основні положення CAP теореми

  • Розподілена система не може одночасно забезпечити консистентність, доступність і толерантність до розділень.
  • У випадку виникнення розділення в мережі, система повинна обирати між підтримкою консистентності і доступності.

Вибір між властивостями

  • CA (Consistent and Available): Система забезпечує консистентність і доступність, але може зупинитися у випадку розділення (напр., традиційні реляційні бази даних).
  • CP (Consistent and Partition-tolerant): Система забезпечує консистентність і толерантність до розділень, але може бути недоступною під час розділення (напр., системи, що використовують алгоритми консенсусу).
  • AP (Available and Partition-tolerant): Система забезпечує доступність і толерантність до розділень, але може повернути непослідовні дані під час розділення (напр., розподілені NoSQL бази даних).

У реальних умовах розподілених систем вибір зазвичай здійснюється між узгодженістю та доступністю, оскільки розділення мережі є неминучим явищем. Приклад: у базах даних типу NoSQL, таких як MongoDB чи Cassandra, дизайн систем часто надає перевагу доступності і стійкості до розділення, жертвуючи частковою узгодженістю.

Links

PACELC теорема

Summary

PACELC розширює CAP, явно описуючи trade-off у "звичайному" режимі (без partition). Запропоновано Daniel Abadi у 2010 і формалізовано у 2012 у статті "Consistency Tradeoffs in Modern Distributed Database System Design" (IEEE Computer). Формулювання: If Partition (P): Available vs Consistent (AC) - як у CAP. Else (E): Latency vs Consistency (LC).

Призначення розширення

CAP описує вибір тільки в момент partition: A vs C. Але реальні розподілені системи у "звичайному" режимі вже роблять інший вибір - між latency і consistency. Strongly consistent system з replicated state мусить чекати підтвердження від кворуму реплік перед commit, що додає latency. Eventually consistent system може повернути відповідь з найближчої репліки за мікросекунди, ризикуючи прочитати stale data.

CAP цей вибір не показує. PACELC робить його явним.

Класифікація систем

Система PACELC Поведінка
PostgreSQL (sync replication) PC/EC Consistent у partition і в normal mode (на користь latency)
MySQL (semisync replication) PC/EC або PA/EC залежно від конфігурації
Cassandra PA/EL Available при partition; latency-first у normal mode (tunable consistency)
DynamoDB PA/EL (за замовч.) Eventually consistent reads; опціонально strong reads
MongoDB PA/EC (за замовч.) Available при partition; consistent у normal mode завдяки primary-replica
Spanner PC/EC Strongly consistent через TrueTime + Paxos; платить latency для globality
ScyllaDB / Riak PA/EL Як Cassandra-сімейство

Класифікація залежить від конфігурації: Cassandra з consistency_level=ALL поводиться ближче до CP. Тому PACELC-літера - це default або поширений профіль, а не жорстка категорія.

Практичне застосування

Питати не "ця база - AP чи CP?", а:

  1. Що система робить при partition - відмовляє в записах (PC) чи приймає потенційно неузгоджені (PA)?
  2. Що вона робить у normal mode - чекає кворум реплік (EC) чи відповідає з найближчої (EL)?

Дві пари незалежні: можна мати PC/EL (відмова при partition, але швидкі читання з реплік у normal mode) або PA/EC (приймає писання при partition, але у normal mode чекає replication).

Links

Consistency boundary

Summary

Consistency boundary - архітектурна межа, всередині якої дані мусять залишатися узгодженими (strongly consistent). За межами boundary допускається eventual consistency. Розпізнання правильної межі - один з ключових архітектурних виборів у розподілених системах. Boundary часто збігається з DDD Aggregate'ом для модифікацій і з логічною бізнес-операцією для read/snapshot.

Призначення

У монолітній RDBMS-системі вся база - один великий consistency boundary за замовчуванням (через ACID). У розподіленій системі це нереалістично: між сервісами, між БД і MQ, між регіонами - сильна консистентність коштує latency, доступності або обох разом (див. PACELC).

Тому архітектор явно вирішує, які саме дані повинні бути strongly consistent. Усе інше - eventually consistent.

Приклад: банківський переказ

Усередині consistency boundary (sync) Поза boundary (eventually consistent)
accounts.balance recommendation engine
ledger_entries analytics dashboard
transactions.status search index
idempotency_key email notification
audit_trail metrics, traces

Уся ліва колонка змінюється як один логічний акт (одна транзакція в одній БД, або координація через Outbox + Saga). Права колонка оновлюється асинхронно: користувач може побачити новий баланс одразу, але в search-index запис з'явиться через секунду - це прийнятно.

Зв'язок з DDD Aggregate

Eric Evans / Vaughn Vernon: Aggregate - кластер пов'язаних об'єктів, який трактується як одна одиниця змін; має корінь (Aggregate Root). Одне з ключових правил DDD - "одна транзакція = одна aggregate" (модифікація однієї aggregate за раз).

Aggregate boundary і consistency boundary часто збігаються:

  • Усередині aggregate'у: strong consistency через ACID-транзакцію.
  • Між aggregate'ами або bounded contexts: eventual consistency через events, Saga, Outbox.

Деталі - у ddd.md розділі Aggregate.

Зв'язок з Saga

Коли логічна операція виходить за межі одного boundary (наприклад, замовлення торкається Inventory, Payment, Shipping aggregates), пряма ACID-транзакція неможлива. Координація - через Saga (див. system_design.md розділ "Data Consistency"): локальна транзакція в кожному boundary плюс компенсуючі транзакції на випадок збою.

Критерії вибору boundary

Питання, які допомагають окреслити boundary:

  1. Який інваріант не можна порушити навіть на мілісекунду? (наприклад, баланс не може стати від'ємним).
  2. Які дані змінюються разом як один логічний акт?
  3. Які дані можна оновити з затримкою, не порушуючи бізнес?
  4. Які помилки можна компенсувати (Saga), а які - тільки запобігти (single-aggregate transaction)?
  5. Який обсяг даних реалістично умістити в одну транзакцію без contention?

Зменшення boundary - менше contention, краща доступність, але більше eventual consistency у системі. Збільшення - простіша модель, але повільніше і менш доступне.

Links

Що таке low coupling and high cohesion [💡13/100]

Low Coupling (Слабке зв'язування)

Слабке зв'язування означає, що модулі або компоненти програми мають мінімальні залежності один від одного. Це означає, що зміна в одному модулі не повинна призводити до широкомасштабних змін у інших модулях. Модулі мають бути незалежними і можуть взаємодіяти через чітко визначені інтерфейси, що спрощує розуміння та підтримку коду.

Переваги слабкого зв'язування

  • Зменшення ризику впливу змін на інші частини коду.
  • Зручність в розробці та розумінні окремих модулів.
  • Більша можливість перевикористання коду.

High Cohesion (високе зчеплення)

Високе зчеплення означає, що в межах одного модуля або компонента повинні знаходитися лише функції та властивості, пов'язані з однією чіткою відповідальністю або функціональністю. Це означає, що кожен модуль повинен виконувати лише одну конкретну задачу, і його функції повинні бути пов'язані логічно та тісно.

Переваги високого зчеплення

  • Покращення зрозумілості та підтримки коду за рахунок чіткої відповідальності модулів.
  • Зниження взаємозалежності функцій у межах модуля, що полегшує розробку та тестування.

Види зв'язності. Зв'язність проявляється не лише на рівні коду:

  • Технічна - модуль прямо залежить від структур чи API іншого (спільна таблиця, формат повідомлення).
  • Темпоральна - один компонент мусить відпрацювати, поки інший його очікує (синхронний виклик у ланцюзі).
  • Deployment-зв'язність - компоненти не можна випустити окремо: реліз одного вимагає релізу іншого.
  • Командна (people). Дві команди змушені координувати зміни й релізи, бо їхні модулі переплетені.

Слабке зв'язування - мета на кожному з цих рівнів, а не лише на рівні коду.

Inversion of Control (IoC): бібліотека проти фреймворку

Summary

Inversion of Control - принцип, за яким керування потоком виконання передається від твого коду зовнішньому каркасу. Це головна відмінність бібліотеки від фреймворку: бібліотеку ти викликаєш і керуєш потоком сам; фреймворк викликає твій код - потоком керує він ("don't call us, we'll call you" - голлівудський принцип). Dependency Injection - один зі способів реалізувати IoC.

Бібліотека проти фреймворку

  • Бібліотека. Набір функцій/класів, які ти викликаєш у потрібний момент і в потрібному порядку, а результат обробляєш сам (requests.get(...) - ти розбираєш відповідь). Контроль у твоїх руках.
  • Фреймворк. Каркас, у який ти вписуєш свій код (обробники, моделі), а він сам вирішує, коли і як його викликати. У FastAPI ти пишеш async def handler(): ... під @app.get("/"), але сам її не викликаєш - це робить фреймворк, коли надійде відповідний запит. Без фреймворку довелося б вручну створити корутину, покласти в event loop, дістати результат і повернути клієнту.

Як фреймворк дізнається про твій код (registry). Декоратор @app.get("/") не запускає функцію, а реєструє її у внутрішньому реєстрі обробників (registry pattern). Далі роутер за вхідним запитом знаходить у реєстрі потрібний обробник і викликає його. Так керування й інвертується: твої функції - це колбеки, які викликає каркас. Dependency Injection - окремий випадок IoC: створення залежностей виноситься назовні.

Dependency Injection - DI - Впровадження залежності [💡26/100]

Dependency Injection (DI) - шаблон проектування, який полягає в передачі залежностей об'єктам під час їх створення. Тобто ми передаємо залежність, а не створюємо її в класі. Залежність - це інший клас, який потрібен нам в нашому поточному класі (наприклад, клас для доступу в БД).

Це робить код більш гнучким, оскільки об'єкти не прив'язані до конкретних реалізацій залежностей, і їх можна легко замінити або модифікувати.

class DatabaseConnection:
    def __init__(self, db_url):
        self.db_url = db_url

    def connect(self):  # Logic to establish a database connection    
        pass

class UserRepository:
    def __init__(self, db_connection):
        self.db_connection = db_connection

    def get_user(self, user_id):  # Logic to retrieve user from the database using db_connection      
        pass

db_connection = DatabaseConnection("mysql://username:password@localhost/db_name")
user_repository = UserRepository(db_connection)

У цьому прикладі db_connection є залежністю для UserRepository. Dependency Injection дозволяє замінити конкретну реалізацію DatabaseConnection, не змінюючи логіку UserRepository.

Три форми впровадження.

Залежність передають у клас чи функцію одним із трьох способів:

  • Через параметр функції/методу - передати залежність як ще один аргумент.
  • Через параметр конструктора - найпоширеніший варіант: залежність зберігають у __init__.
  • Через атрибут екземпляра - присвоїти залежність після створення об'єкта (застосовують рідко, зокрема при двофазній ініціалізації).
# 1. Function/method parameter
def clear_users(cursor):
    cursor.execute("TRUNCATE users;")

# 3. Instance attribute, assigned after construction
dao = UsersDAO()
dao.cursor = connection.cursor()

Підміна залежності в тестах. Передану ззовні залежність у тесті підмінюють фейковою, тож перевірка логіки UserRepository не торкається реальної БД чи мережі (за умови, що get_user делегує виклик у self.db_connection, а не створює з'єднання сам):

class FakeConnection:                       # in-memory stub instead of a real DB
    def get_user(self, user_id):
        return {"id": user_id, "name": "Alice"}

repo = UserRepository(FakeConnection())     # inject the fake via the constructor
assert repo.get_user(1) == {"id": 1, "name": "Alice"}   # no DB, no network

Без DI залежність створювалася б усередині UserRepository, і підмінити її в тесті без патчингу було б неможливо.

Поширені хибні уявлення:

  • DI - це щось зі світу Java, у моїй мові його немає. Хибно: достатньо мати можливість передати посилання на функцію чи об'єкт. Може застосовуватись і до Python, і до JavaScript, C, Go.
  • DI потрібен лише великим enterprise-застосункам. Радше критичний для великих. Малі застосунки без автотестів можуть існувати без нього, але з ускладненням потреба зростає.
  • Для DI потрібен спеціальний фреймворк/IoC-контейнер. Не потрібен: DI - можливість самої мови. Контейнери лише спрощують побудову графа залежностей; необережне їх використання навпаки може позбавити DI.

DI (механізм передачі залежності) не слід плутати з принципом інверсії залежностей (DIP) - вони служать різним цілям. DIP описаний у computer_science/solid.md.

DI контейнер

Dependency Injection Container - це інструмент, який автоматизує процес введення залежностей у об'єкти програми. Він відповідає за створення та впорядкування об'єктів та їх залежностей, і надає можливість легко вносити зміни в конфігурацію додатка, замінюючи одні реалізації на інші. Тобто він містить список інтерфейсів та їх реалізацій.

Hexagonal vs onion архітектура [💡19/100]

Hexagonal та onion архітектури є популярними підходами до побудови програмних систем, які спрямовані на підвищення гнучкості, зручності тестування та ізоляції бізнес-логіки від зовнішніх залежностей. Обидва підходи мають спільну мету, але різні акценти. Hexagonal архітектура більше зосереджується на вхідних і вихідних портах, тоді як Onion архітектура організована у вигляді шарів навколо ядра. Hexagonal архітектура використовує порти та адаптери для взаємодії з зовнішніми компонентами. Onion архітектура будується на кількох шарах, кожен з яких має свою відповідальність.

Hexagonal (порт-адаптерна архітектура)

Головна мета — відокремити ядро програми від зовнішніх компонентів, таких як база даних, користувацький інтерфейс або інші сервіси. Орієнтується на інтеграцію з зовнішнім світом через порти (інтерфейси) і адаптери. Ядро додатку спілкується з зовнішніми компонентами через порти, які є інтерфейсами. Адаптери реалізують ці інтерфейси і забезпечують зв'язок з реальними компонентами. Основна ідея: програма працює через чітко визначені точки входу та виходу. Це дозволяє легко замінювати зовнішні компоненти, такі як бази даних або веб-інтерфейси, без змін у бізнес-логіці. Використовує метафору шестикутника для ілюстрації, де кожна сторона представляє різні адаптери (наприклад, REST API, CLI, база даних).

Onion (цибулева архітектура)

Цибулева архітектура також спрямована на відокремлення ядра програми від інфраструктурних компонентів. Вона організована у вигляді шарів, які оточують ядро. Фокусується на ізоляції доменної логіки від залежностей через шарову організацію.
У центрі знаходиться доменна модель, яка не знає нічого про зовнішній світ. Інші шари оточують її і взаємодіють один з одним через інтерфейси. Шари відповідають за доступ до бази даних, API або користувацького інтерфейсу. Центральний шар (ядро) містить найважливішу бізнес-логіку. Цибулева структура акцентує увагу на суворій спрямованості залежностей — залежності рухаються лише з зовнішніх шарів до внутрішніх.

Primary проти secondary (driving/driven). Кокберн (автор патерну, 2005) розрізняє два боки шестикутника за тим, хто ініціює взаємодію. Primary (driving) адаптери - лівий бік - керують застосунком (UI, REST-контролер, CLI, тест, batch-скрипт): вони викликають порти застосунку. Secondary (driven) адаптери - правий бік - застосунок керує ними (БД, черга, поштовий сервіс, інший API): порт оголошує сам застосунок, а адаптер його реалізує (тут і працює інверсія залежностей). Початкова теза Кокберна - симетрія "всередині проти зовні", а не ліво-право: бізнес-логіку не змішують зі взаємодією із зовнішнім світом із жодного боку. Практична вигода - тестованість: на місце реальної БД підставляють in-memory mock-адаптер того самого порту, а автоматичні тести стають таким самим primary-адаптером, як і користувач, тож застосунок ганяють без UI і без БД.

Порт проєктують під потреби ядра, не під API інструмента. Порт (інтерфейс) належить бізнес-логіці, а адаптер - зовнішньому світу. Критично, щоб інтерфейс порту виражав потребу застосунку (OrderRepository.next_identity(), notify(customer)), а не дзеркалив API конкретного драйвера БД чи поштового сервісу. Інакше абстракція протікає: зміна інструмента тягне зміну порту, і ядро знову залежить від деталі.

Package by component проти package by layer. Поділ на шари (hexagonal/onion) - це дрібнозерниста сегрегація коду. Грубозерниста, не менш важлива, - поділ за субдоменами / bounded context'ами (package by feature/component за Робертом Мартіном і Саймоном Брауном), а не за технічними шарами (controllers/, services/, repositories/). Організація за компонентами тримає разом усе, що стосується однієї бізнес-можливості (вища зчепленість за бізнес-ознакою), і робить структуру проєкту промовистою щодо домену, а не технології.

Links

CQRS [💡26/100]

Summary

CQRS (Command Query Responsibility Segregation) - розділення моделей запису (commands) і читання (queries) у системі. Команди змінюють стан, запити лише читають; обидві сторони можуть мати окремі моделі даних, окремі сховища і масштабуватися незалежно.

CQS проти CQRS. Це різні рівні одного принципу, які часто плутають. CQS (Command-Query Separation, Бертран Меєр) - правило рівня методу: метод є або командою (змінює стан, нічого корисного не повертає), або запитом (повертає дані без побічних ефектів), але не одночасно. CQRS піднімає цю ідею на рівень архітектури: окремі моделі (а часто й сховища) для запису і читання. CQS не вимагає окремих сховищ; це дешевий дисциплінарний прийом усередині однієї моделі. Можна застосовувати CQS, не вдаючись до CQRS.

Принцип роботи

Замість єдиного коду й сховища, що обслуговує і запис, і читання, система ділиться на дві сторони:

  • Command side - приймає команди (CreateOrder, CancelPayment), валідує бізнес-правила, змінює стан у write-сховищі. Оптимізована під нормалізовану модель, транзакції, цілісність.
  • Query side - обслуговує читання за окремою read-моделлю, оптимізованою під конкретні запити (денормалізовані view, пошукові індекси, агрегати). Read-сховище може бути зовсім іншим: ClickHouse для аналітики, Elasticsearch для пошуку, Redis для кешу.

Між сторонами потрібен механізм синхронізації: команди публікують події (через Transactional Outbox або CDC), read-side оновлює свою модель.

Мотивація розділення

  • Різні патерни доступу. Запис - одиничні цілісні операції; читання - агрегати, повнотекстовий пошук, аналітика. Одна модель добре обслуговує лише одне з двох.
  • Незалежне масштабування. Read-side зазвичай має на порядки більше трафіку; його масштабують репліками читання або окремим сховищем, не зачіпаючи write-side.
  • Оптимізація під запити. Read-модель денормалізована саме під запити користувачів - без JOIN'ів через десятки таблиць.

Реалізації

  • Найпростіший варіант: read-репліки тієї ж БД. Master приймає INSERT/UPDATE, slaves обслуговують SELECT. CQRS лише на рівні маршрутизації запитів, без окремих моделей.
  • Окрема read-модель у тому ж сховищі: materialized view, що періодично оновлюються командними подіями. Простіше за окрему БД, але обмежено можливостями materialized view.
  • Окреме read-сховище: аналітичні запити йдуть у ClickHouse, наповнений через CDC або WAL-стрім з операційної Postgres. Пошук - в Elasticsearch, наповнений Outbox'ом. Це повноцінний CQRS.

Eventual consistency

Read-модель оновлюється асинхронно і відстає від write-моделі на час реплікації / процесингу подій. Це означає, що користувач, який щойно виконав команду, може не побачити свого результату у наступному GET-запиті.

Способи пом'якшити:

  • Read-your-writes для UI: після успішної команди UI оновлює локальний стан без додаткового GET'а.
  • Версіонування ресурсів: клієнт надсилає очікувану версію, query side чекає її появи або повертає stale-мітку.
  • Sticky read для першого запиту після команди: маршрутизація на write-side або read-репліку з мінімальним лагом.

Дві проблеми загострюються, коли read-сторона - окреме сховище з асинхронною проєкцією:

  • Дублювання даних через відсутність read-your-writes. За асинхронної проєкції користувач після команди не бачить результату у read-моделі, припускає, що дані не збереглися, і повторює введення - утворюється дублікат. Проявляється лише під навантаженням: на dev-машині лагу між сторонами немає, тож цей дефект не виявляють у тестуванні. Пом'якшують ті самі прийоми, що й вище.
  • Відставання проєкції під навантаженням. Read-модель оптимізована під читання, але оновлювати її доводиться з частотою надходження команд. На піку проєкція не встигає, і read-side відстає на хвилини - стає непридатним саме тоді, коли навантаження максимальне.

Зв'язок з Event Sourcing

CQRS часто йде разом з Event Sourcing (стан - похідна від послідовності подій), але це не є обов'язковою умовою. CQRS можна побудувати поверх звичайної реляційної БД, не зберігаючи історії подій як джерела істини. Event Sourcing без CQRS теж зустрічається, проте рідше.

Обмеження застосування

  • Простий CRUD-сервіс без специфічних аналітичних запитів. Розділення лише додасть синхронізаційного коду. Накладати на CRUD саме eventually-consistent варіант особливо шкідливо - породжує описане дублювання даних.
  • Команди й читання працюють з тими ж даними у тих самих структурах. Read-модель не дає виграшу.

CQRS - інвестиція у складність; виправдовується лише там, де патерни запису й читання справді розходяться. Це архітектурний стиль, не універсальний best practice: інструмент, який застосовують там, де доречно, а не за замовчуванням.

Links

Event Sourcing

Summary

Event Sourcing - архітектурний патерн, у якому поточний стан системи - похідна функція від послідовності всіх подій, що з ним сталися. Замість зберігати "balance = 100" зберігається "deposited 70, deposited 50, withdrew 20". Поточний стан реконструюється шляхом послідовного застосування подій (replay). Канонічний партнер для CQRS і event-driven архітектур.

Принцип роботи

Замість state-mutating операцій (UPDATE accounts SET balance = 100 WHERE id = 1) застосунок генерує домен-події і записує їх у append-only журнал (event store). Поточний стан обчислюється на ходу або кешується у read-моделях (snapshots).

# Traditional state model
accounts table:
  id=1, balance=100

# Event-sourced model
events table (append-only):
  id=1, event=AccountOpened,      payload={initial: 0}
  id=2, event=MoneyDeposited,     payload={amount: 70}
  id=3, event=MoneyDeposited,     payload={amount: 50}
  id=4, event=MoneyWithdrawn,     payload={amount: 20}

# Current balance = sum of events:
#   0 + 70 + 50 - 20 = 100

Переваги

  • Повний аудит "безкоштовно". Журнал подій - це і є audit log. Можна відповісти на "як саме баланс дійшов до цього значення" point-in-time.
  • Time travel. Реконструкція стану на будь-який момент: програти події до timestamp T.
  • Природне джерело для CQRS read-моделей. Кожна read-модель - окремий projection: підписується на події і будує свою денормалізовану таблицю (Postgres для UI, Elasticsearch для пошуку, ClickHouse для аналітики).
  • Event-driven інтеграція. Інші bounded contexts підписуються на ту ж стрічку - природна основа для Saga, Outbox, eventual consistency між сервісами.

Недоліки і пастки

  • Складність моделювання. Думати у термінах подій (UserAddressChanged, OrderShipped) важче за думати у термінах таблиць. Помилки в дизайні складніше виправити - події імутабельні, replay усієї історії - дорогий.
  • Schema versioning. Подія, записана 3 роки тому, мусить вміти десеріалізуватися сучасним кодом. Стратегії: upcasters (трансформація старих подій у нові формати), schema registry (Avro з compatibility rules).
  • Snapshots для performance. Replay тисячі подій на кожний запит - неприйнятна латентність. Канонічний шлях: snapshot щоразу N подій, поточний стан = snapshot + події після snapshot'у.
  • Eventually consistent read-моделі. Read-модель оновлюється з певною затримкою після події. Користувач може побачити "застарілий" стан - додавати UX-індикатори ("обробляємо...") або робити hybrid (синхронне оновлення критичної read-моделі).
  • Видалення даних (GDPR). Append-only журнал суперечить праву на забуття: подію не можна просто видалити, не зламавши replay. Практичні підходи - crypto-shredding (персональні дані шифрують окремим ключем на суб'єкта; видалення ключа робить payload нечитним) або redaction-події, що затирають вміст, зберігаючи послідовність журналу.

Event Sourcing і CQRS - часто разом, але незалежні

  • CQRS без ES: розділення read/write над тією самою БД (Postgres), read-side - матеріалізовані view або окрема денормалізована таблиця, оновлювана тригерами або CDC.
  • ES без CQRS: журнал подій - джерело істини, але читання теж із нього (через aggregations) - підходить для звітності, рідше для OLTP UI.
  • CQRS + ES: журнал подій → проектори → багато read-моделей під різні use cases. Канонічна повна форма.

Реалізації

  • EventStoreDB - спеціалізована БД для event sourcing.
  • Apache Kafka як event log - найпоширеніший варіант у мікросервісах; тривале зберігання у партиціях, replay через seek-to-beginning.
  • PostgreSQL append-only events table - простий старт; добре працює до ~мільйонів подій, потім потрібен Kafka або спеціалізована БД.
  • AxonFramework (Java/Kotlin), Marten (.NET) - opinionated framework'и для ES+CQRS на існуючих БД.

Коли застосовувати

  • Домени з жорсткими вимогами до аудиту (фінанси, медицина, регуляторні бізнеси).
  • Складні domain workflows, де "хто-що-коли" важливіше за "поточний стан".
  • Системи з кількома різними read-патернами (UI, analytics, search) - ES + CQRS природно дає кожному свою проекцію.

Коли НЕ застосовувати

  • Простий CRUD без аудиту - оверкіл, накладні витрати на event modeling не виправдані.
  • Команда без досвіду ES - помилки моделювання тут дорогі і важко виправляються.

Links

Event-Driven: нотифікація проти передачі стану

Summary

Два підходи до вмісту події в event-driven системі (за Фаулером): event notification - подія несе лише факт і ідентифікатор (order_id), споживач за потреби дотягує деталі з джерела; event-carried state transfer - подія несе повний стан, споживачеві не треба нікуди звертатися. Перше - менші повідомлення й завжди свіжі дані ціною додаткових запитів і зв'язності з джерелом; друге - автономність споживача й нижча латентність ціною великих повідомлень і ризику застарілих копій.

Event notification. Продюсер публікує лише факт ("замовлення створено") з мінімумом даних - зазвичай id. Споживач, якщо йому потрібні подробиці, робить зворотний запит до сервіса-джерела. Плюс: маленькі повідомлення, дані актуальні на момент читання. Мінус: додатковий round-trip і зв'язність - споживач залежить від доступності джерела, а сплеск подій породжує сплеск зворотних запитів.

Event-carried state transfer. Подія несе весь потрібний стан, тож споживач обробляє її автономно, без звернень до джерела. Плюс: нижча латентність, споживач переживає недоступність джерела. Мінус: більші повідомлення (трафік і сховище) і ризик працювати із застарілим знімком, якщо стан змінився після публікації.

Вибір - компроміс: нотифікація, коли деталі потрібні рідко або мусять бути свіжими; передача стану, коли важлива автономність споживача й низька латентність. Решта переваг EDA (decoupling, відмовостійкість, незалежне масштабування) спільні для обох. Надійну доставку самих подій забезпечує патерн Transactional Outbox, а ідемпотентність споживача - Inbox Pattern; пор. також Domain Events (внутрішні) проти integration events (між контекстами).

Links

Transactional Outbox Pattern [💡13/100]

Summary

Transactional Outbox - патерн розподілених систем, який забезпечує узгодженість між змінами даних у базі та відправкою подій у брокер повідомлень. Складається з двох компонентів: таблиці outbox у тій самій базі даних і окремого relay-процесу. Подія записується у таблицю outbox тією самою транзакцією, що й основні зміни даних, чим забезпечується атомарність між цими двома записами у БД. Relay-процес читає outbox і публікує події у брокер. Сама доставка у брокер залишається at-least-once (звідки виникає вимога ідемпотентності у консьюмера), проте гарантовано пов'язана з комітом у базі: подія потрапить у брокер лише після успішного завершення транзакції.

Проблема, яку вирішує

Сервіс, що обробляє вхідне замовлення, виконує коміт змін у власній базі даних і потім має повідомити інший сервіс через брокер повідомлень. Якщо публікація події у брокер виконується безпосередньо після коміту, між цими двома операціями можуть статися збій мережі, падіння процесу або відмова брокера. У такому випадку запис залишається у БД, а відповідна подія у брокер не потрапляє, через що порушується консистентність між сервісами.

Об'єднати публікацію в брокер з транзакцією БД неможливо, оскільки це дві різні системи. Розподілений двофазний коміт (2PC) між Postgres і Kafka або RabbitMQ технічно можливий, проте є дорогим і непрактичним рішенням.

Принцип роботи

Замість прямої публікації в брокер застосовується наступна схема.

  1. Код, що змінює дані, паралельно формує події, які потрібно надіслати.
  2. На етапі збереження у межах однієї транзакції виконується запис як в основні таблиці, так і в окрему таблицю outbox.
  3. Окремий процес-relay періодично опитує таблицю outbox і публікує події у брокер.
  4. Після отримання ACK від брокера relay позначає відповідний рядок як sent або видаляє його.

У разі падіння relay-процесу або тимчасової відмови брокера подія залишається у таблиці outbox і буде опублікована в наступному циклі. Якщо ж транзакція БД не була зафіксована, події у таблиці відсутні разом з основними змінами, оскільки виконувалися в межах однієї транзакції.

Слово transactional у назві патерна вказує саме на цю властивість: запис основних даних і запис рядка у outbox виконуються в межах однієї транзакції БД та фіксуються атомарно - або разом, або жоден з них.

Реалізація

Схема таблиці outbox:

CREATE TABLE outbox (
    id          BIGSERIAL PRIMARY KEY,
    aggregate   TEXT      NOT NULL,   -- 'order', 'invoice', ...
    event_type  TEXT      NOT NULL,   -- 'OrderPlaced'
    payload     JSONB     NOT NULL,
    created_at  TIMESTAMPTZ DEFAULT now(),
    sent_at     TIMESTAMPTZ                  -- NULL until published
);
CREATE INDEX ON outbox (sent_at) WHERE sent_at IS NULL;

Цю схему можна розширити додатковими полями, наприклад:

  • entity_id та entity_name - для відстеження об'єкта
  • retries, status або error_message - для відладки та розширеної обробки помилок
  • correlation_id - для трасування

У межах однієї транзакції БД (with session.begin()) одночасно виконуються запис в основні таблиці та запис рядка у таблицю outbox. Спосіб накопичення подій не має принципового значення: це може бути DDD-агрегат із методом pull_events(), сервісний шар, що повертає list[dict], або навіть інлайновий код у хендлері.

def place_order(session, order_id: int, items: list[dict]) -> None:
    with session.begin():                       # one DB transaction
        session.add(Order(id=order_id, items=items))
        session.add(OutboxRow(
            aggregate="order",
            event_type="OrderPlaced",
            payload={"order_id": order_id, "items": items},
        ))
    # commit -> row + outbox land in DB atomically

Relay

Relay реалізується як окремий процес або воркер. Існують готові рішення (наприклад, Debezium, що читає WAL через механізм логічної реплікації Postgres), проте власна реалізація relay-процесу не є складною задачею.

def relay_once(session, broker) -> None:
    rows = session.query(OutboxRow).filter_by(sent_at=None).limit(100).all()
    for row in rows:
        ack = broker.publish(row.payload)      # -> True / raises
        if ack:
            row.sent_at = now()                # or session.delete(row)
    session.commit()

Єдиний relay-процес є потенційним вузьким місцем у системі. Для запуску декількох relay-воркерів паралельно без ризику захоплення одного й того ж рядка кількома процесами у Postgres застосовується конструкція FOR UPDATE SKIP LOCKED:

SELECT * FROM outbox
WHERE sent_at IS NULL
ORDER BY created_at
LIMIT 100
FOR UPDATE SKIP LOCKED;
  • FOR UPDATE блокує вибрані рядки до завершення транзакції.
  • SKIP LOCKED пропускає рядки, які вже захопив інший воркер.

Завдяки цій конструкції декілька relay-воркерів працюють паралельно без блокувань і без дублювання відправок.

Нюанси

  • At-least-once-доставка. Relay може встигнути опублікувати подію, але не встигнути оновити поле sent_at; у такому випадку подія буде опублікована повторно. Консьюмер має бути ідемпотентним і виконувати дедуплікацію за event_id.
  • Збереження порядку. ORDER BY id сам по собі не гарантує строгий глобальний FIFO: FOR UPDATE SKIP LOCKED пропускає вже захоплені рядки, тож пізніший може бути опублікований раніше за заблокований попередник. Для строгого порядку потрібен або єдиний relay-процес, або шардинг за ключем (наприклад, entity_id) з рівно одним воркером на шард.
  • Очищення таблиці. Рядки зі статусом sent або видаляються, або зберігаються з TTL чи архівуванням, щоб запобігти безконтрольному зростанню таблиці.
  • Pull vs push. У простій реалізації relay використовує polling. Як оптимізацію можна застосувати механізм LISTEN/NOTIFY у Postgres, що дозволяє уникнути постійних запитів до таблиці.

Inbox Pattern

Summary

Inbox - дзеркальний патерн до Transactional Outbox, який застосовується на стороні консьюмера. Розділяє отримання повідомлення та його обробку на дві окремі фази: спочатку вхідне повідомлення атомарно зберігається у локальній таблиці inbox, а потім окремий процесор обробляє його в одній транзакції зі змінами в основних таблицях. Доставка залишається at-least-once, але обробка стає effectively-once за рахунок дедуплікації за event_id (строге exactly-once у розподілених системах недосяжне - див. Two Generals Problem).

Проблема, яку вирішує

Брокер забезпечує семантику at-least-once: одне й те саме повідомлення може бути доставлене кілька разів. Якщо консьюмер відправляє ack до обробки повідомлення, при падінні процесу посередині повідомлення може бути втрачене. Якщо ж ack відправляється після обробки, обробка може виконатися частково (зміни в БД виконані, але не зафіксовані комітом), а потім повторитися на retry. Потрібен механізм, який дозволяє атомарно прийняти повідомлення у персистентне сховище й відокремити момент прийняття від моменту обробки.

Принцип роботи

  1. Receiver читає повідомлення з брокера і атомарно записує його у локальну таблицю inbox (з обмеженням UNIQUE на полі event_id). Після успішного INSERT receiver відправляє ack у брокер.
  2. Processor (окремий воркер або cron-задача) вичитує повідомлення з таблиці inbox і обробляє його: виконує бізнес-логіку, записує зміни в основні таблиці та позначає рядок як processed. Усі ці операції виконуються в межах однієї транзакції.
CREATE TABLE inbox (
    event_id     UUID PRIMARY KEY,
    payload      JSONB,
    received_at  TIMESTAMPTZ DEFAULT now(),
    processed_at TIMESTAMPTZ
);

Переваги

  • ack у брокер відправляється лише після персистентного збереження повідомлення; при падінні процесора повідомлення не втрачається.
  • Обробка виконується в межах однієї транзакції разом зі змінами в основних таблицях, що в парі з дедуплікацією дає effectively-once-обробку.
  • Дублікати відсікаються обмеженням UNIQUE на етапі вставки в таблицю inbox.

Недоліки

  • Єдиний receiver є потенційним вузьким місцем. Масштабування виконується стандартними засобами брокера (consumer groups у Kafka, competing consumers у RabbitMQ); кожне повідомлення потрапляє лише до одного receiver-а, а UNIQUE на event_id лишається страховкою від повторної доставки, а не основним механізмом координації.
  • Введення проміжного зберігання додає затримку: повідомлення перебуває у таблиці inbox у проміжку між отриманням receiver-ом та обробкою processor-ом.
  • Якщо вимога effectively-once-обробки не є критичною, у багатьох випадках достатньо простішого варіанту - ідемпотентного консьюмера з UNIQUE-обмеженням (див. infrastructure/mq.md).

Зв'язок з Outbox

Поєднання Outbox і Inbox забезпечує end-to-end-надійність обміну повідомленнями: producer гарантує, що подія буде доставлена у брокер (через Outbox), а консьюмер гарантує, що вона не буде втрачена і буде застосована до стану рівно один раз (через Inbox + дедуплікацію). Це effectively-once на рівні бізнес-ефекту; сама доставка залишається at-least-once.

Saga [💡18/100]

Summary

Saga - спосіб реалізувати розподілену "транзакцію" як послідовність локальних транзакцій у різних сервісах. Глобальної атомарності немає: або виконуються всі кроки, або вже виконані відкочуються компенсуючими транзакціями. Ціна - eventual consistency замість миттєвої. Дві форми координації: хореографія (через події) і оркестрація (через центральний координатор).

Проблема, яку вирішує. У мікросервісах кожен сервіс має власну БД, тож одна ACID-транзакція на кілька сервісів неможлива. Спроба натягнути глобальну транзакцію веде до двофазного коміту (2PC, див. system_design.md): координатор блокує ресурси всіх учасників до завершення, а зависання чи мережевий збій одного зупиняє решту - падає доступність, повертається тісний зв'язок, координатор стає вузьким місцем і точкою відмови. Saga (Garcia-Molina та Salem, 1987) обмінює миттєву узгодженість на доступність і незалежність сервісів.

Принцип роботи. Бізнес-процес розбивають на кроки, кожен - локальна ACID-транзакція в одному сервісі. Saga не скасовує ACID локально: усередині кроку - звичайна транзакція БД. Якщо крок падає, виконуються компенсуючі дії для вже виконаних кроків у зворотному порядку. Гарантія - не атомарність, а eventual consistency: система прийде в узгоджений стан, але не миттєво.

Хореографія проти оркестрації.

  • Хореографія - сервіси реагують на події одне одного без центру. Сервіс A завершує локальну транзакцію і публікує подію; сервіс B підписаний на неї, робить свій крок і публікує наступну. Помилка -> подія-помилка, на яку учасники запускають компенсації. Плюс: слабка зв'язність, природно лягає на event-driven. Мінус: загальний стан саги ніде не видно цілісно - складно відлагоджувати, є ризик зациклення подій.
  • Оркестрація - центральний оркестратор знає весь сценарій, командує учасниками по черзі, а при збої запускає компенсації. Плюс: потік прозорий і керований, логіка компенсацій в одному місці. Мінус: ще один компонент, ризик перетворити його на God Object і точку відмови.

Орієнтир: для коротких прямих саг (2-4 кроки) при наявному EDA хореографія елегантна; для складних розгалужених процесів оркестрація передбачуваніша.

Компенсуючі транзакції. Компенсація - не ROLLBACK, а бізнес-операція, що семантично скасовує ефект: списали гроші -> повернути, зарезервували товар -> зняти резерв. Вимоги: ідемпотентність (повторна компенсація = одна компенсація, через унікальний ключ), надійність нарівні з прямими кроками і проєктування компенсації одразу разом із кроком. Деякі дії скасувати не можна (лист надіслано, товар відвантажено) - тоді або семантична компенсація (лист з вибаченням), або forward recovery, або ескалація на ручне втручання.

Типи кроків (за оригінальним описом):

  • Компенсована (compensatable) - має визначену компенсуючу транзакцію.
  • Поворотна (pivot) - точка незворотності: після її успіху сага зобов'язана дійти до кінця; її невдача відкочує все попереднє.
  • Повторювана (retriable) - крок після поворотної, який повторюють до успіху (компенсація не передбачена).

Forward-recovery Saga. Якщо всі кроки після поворотної зробити повторюваними, а компенсацій уникнути зовсім, сага лише "доводить дію до кінця" ретраями. Це спрощує реалізацію (не треба писати undo для кожного кроку - що у великій організації часто недосяжно) і відповідає інтересам користувача. Канонічна оркестрація такої саги: стан кроків і прогрес зберігають у БД; фоновий воркер добиває незавершені саги з exponential backoff; після N невдач - алерт розробникам. Щоб кілька реплік-воркерів не брали ту саму сагу, вибірку пачки записів роблять через SELECT ... FOR UPDATE SKIP LOCKED (без довгої транзакції), плюс окремий процес "відпускає" завислі саги (статус PROCESSING довше за таймаут -> назад у FAILED).

Готові движки замість саморобного runner'а. Усю інфраструктуру (персистентний стан кроків, ретраї з backoff, таймери, добивання незавершених саг, компенсації) дають durable-execution-движки - Temporal, Camunda/Zeebe, AWS Step Functions, Azure Durable Functions. Логіку оркестрації пишуть як звичайний код, а движок гарантує, що стан переживе рестарт процесу і виконання продовжиться з місця збою. Виправдані, коли саг багато або вони складні; для кількох простих саг саморобний воркер на БД дешевший за ще одну інфраструктурну залежність.

Обробка збою компенсації. Компенсація - звичайна розподілена операція, тож теж може впасти (мережа, недоступність сервіса, баг у логіці). Необмежені ретраї непридатні: при тривалій мережевій ізоляції їхня кількість чи сумарна тривалість виходять за розумні межі. Канонічна схема:

  1. Обмежені ретраї з exponential backoff + jitter на боці оркестратора/saga-runner'а (типово 3-7 спроб зі зростанням інтервалу).
  2. Durable-намір компенсації перед першим запуском (у БД або Transactional Outbox - нижче), щоб після рестарту процесу спробу можна було повторити.
  3. Після вичерпання спроб - запис у DLQ (dead-letter queue) зі статусом compensation_failed і повним контекстом (saga_id, крок, помилка, payload).
  4. Алерт оператору з метрикою: compensation_failed - індикатор бізнес-неконсистентності, а не просто рядок у лозі.
  5. Ручне або напівавтоматичне відновлення - оператор перезапускає компенсацію з DLQ, або cron-процес періодично перевіряє доступність сервіса й перепроводить компенсації.

Коли скасувати дію вже фізично неможливо - застосовують forward recovery. При ретраях компенсації обов'язкова ідемпотентність (див. infrastructure/mq.md) - інакше повторне списання чи скасування призведе до подвійного відкату.

Спостережуваність - обов'язкова. Без розподіленого трасування (OpenTelemetry), логів з кореляцією за saga_id і метрик стан розподіленої саги невидимий.

Links

Що таке ідемпотентність? [💡24/100]

Summary

Ідемпотентність - властивість операції, при якій повторний виклик з тими ж вхідними даними дає той самий результат, що і одноразовий виклик, без додаткових ефектів. "Можна викликати скільки завгодно разів - результат не змінюється".

Приклад:

DELETE /user/123
  • Перший виклик - видаляє користувача.
  • Повторний виклик - нічого не змінює, користувач уже видалений.
  • Обидва виклики повертають однаковий статус (наприклад, 204 No Content).

Області застосування

  • HTTP-методи: GET, PUT, DELETE - ідемпотентні за стандартом. POST - не ідемпотентний, бо створює новий ресурс при кожному виклику.
  • Фінансові операції - повторний запит на списання не повинен списати двічі.
  • Обробка повідомлень з черги - at-least-once доставка означає, що дублі можливі; обробник має бути ідемпотентним.
  • Retry-логіка - якщо запит безпечно повторити, retry на тимчасові помилки робиться без ризику.

Способи забезпечення

  • Idempotency key - клієнт передає Idempotency-Key: <uuid> у заголовку; сервер запам'ятовує результат першого виклику з цим ключем і повертає його ж на повторах.
  • Версіонування / умовні оновлення - UPDATE ... WHERE version = ? ігнорує застарілі апдейти.
  • Унікальні бізнес-ключі - INSERT ... ON CONFLICT DO NOTHING гарантує, що дубль не створить запис.
  • Перевірка стану перед дією - "якщо вже виконано - повернути попередній результат".

Distributed Lock

Summary

Розподілений лок (distributed lock) - механізм забезпечення ексклюзивного доступу до спільного ресурсу між процесами, що виконуються на різних машинах і не мають спільної пам'яті. Реалізується через зовнішнє сховище, яке виступає єдиним джерелом правди (Redis, ZooKeeper, etcd) і атомарно визначає, який саме процес отримав лок першим. Ключові аспекти реалізації - механізм lease (TTL), fencing token та коректне звільнення власного лока.

threading.Lock забезпечує синхронізацію лише в межах одного процесу, а multiprocessing.Lock - у межах однієї машини. Коли воркери розподілені у n процесах на m машинах, потрібен механізм, який забезпечує однакове бачення стану лока з будь-якого з них. Локальна пам'ять для цієї задачі непридатна; натомість необхідне зовнішнє сховище, яке за своєю природою забезпечує атомарність операцій.

Типові сценарії:

  • У певний момент часу даний aggregate_id повинен оброблятися лише одним воркером.
  • Cron-задача, розгорнута на декількох машинах, має фактично виконуватися лише на одному інстансі.
  • Leader election - вибір єдиного лідера серед однотипних інстансів.

Leader election як патерн. Вибір лідера потрібен, коли серед однотипних вузлів лише один має бути координатором (приймати запити й роздавати роботу ведомим або стежити за станом кластера). Найпростіша реалізація - через distributed lock: хто взяв lease-ключ, той лідер; lease поновлюють, а при його втраті (падіння лідера) інший вузол перехоплює лок. Спеціалізовані алгоритми (Bully, Raft, gossip) роблять це без зовнішнього сховища; на практиці частіше делегують готовому координатору (etcd / ZooKeeper / Consul), а сам алгоритм тримають як чорну скриньку. Класичне застосування - self-healing: оркестратор стежить за вузлами й перезапускає впалі, а кілька реплік оркестратора обирають лідера між собою, тож переживається й падіння самого оркестратора (модель Kubernetes control plane).

Базовий протокол

  1. Acquire - атомарно встановити ключ lock:<resource> зі значенням worker_id та часом життя TTL (lease). Якщо ключ установлено успішно, процес отримав лок; якщо ключ вже існує, процес очікує або відмовляється від операції.
  2. Critical section - виконати корисну роботу.
  3. Release - видалити ключ, виключно власний. Видалення чужого ключа призведе до зняття лока, який після завершення TTL вже міг бути отриманий іншим процесом.

Надійність та режими відмов

Lease (TTL). За відсутності TTL процес-власник, який зазнав збою, утримує лок безстроково. Механізм TTL вирішує проблему liveness: ключ автоматично видаляється після завершення заданого інтервалу. Проте такий підхід створює нову проблему: процес-власник може зупинитися (GC pause, stop-the-world), його TTL завершиться, лок отримає інший процес, а перший після відновлення продовжуватиме вважати, що утримує лок. Для вирішення цієї проблеми застосовуються fencing token-и.

Fencing token. При виконанні acquire процес отримує монотонно зростаючий номер - token. Усі операції над зовнішніми системами (запис у БД, виклик API тощо) включають цей token, а зовнішній ресурс приймає лише ті запити, token яких строго більший за останнє побачене значення (рівність недопустима - це пропустило б застарілий запит від попереднього власника). Якщо процес A зупинився, його TTL вичерпався, процес B отримав лок із більшим token-ом, а потім процес A відновив виконання і спробував виконати запис - БД відхилить запит від A, оскільки його token є застарілим. Без використання fencing token-ів розподілений лок забезпечує лише best-effort mutex: надійна робота гарантована лише за умови відсутності збоїв і стабільності мережі.

Redlock. Окремий інстанс Redis є точкою єдиного збою (single point of failure). Алгоритм Redlock надбудовується над декількома незалежними інстансами Redis: лок вважається отриманим, якщо більшість нод (3 з 5) підтвердили його захоплення в межах TTL. Цей підхід підвищує надійність, проте не вирішує проблеми fencing.

Реалізація

Найпоширенішим варіантом реалізації є Redis із застосуванням команди SET key value NX EX. Атомарність гарантується однопотоковим engine'ом Redis. Конкретні приклади коду та Lua-варіанти наведено у файлі infrastructure/database.md.

Links

Circuit Breaker

Summary

Circuit Breaker - resilience-патерн, який автоматично припиняє виклики до downstream-сервісу при виявленій деградації (high error rate, timeout). Захищає клієнта від витрачання ресурсів на безнадійні виклики і дає downstream-сервісу час відновитися. Аналогія з електричним запобіжником: при перевантаженні розриває коло, після відновлення - замикає назад. Канонічно описаний Майклом Найґардом у "Release It!" (2007).

Проблема, яку розв'язує

У звичайному сценарії клієнт викликає payments. Якщо payments недоступний (crashed, network partition, overload), кожен виклик блокує клієнтський потік до timeout'у (5-30s). 100 паралельних запитів витрачають 100 потоків × 30s × CPU/RAM на нічого. Клієнт стає теж недоступним - cascading failure. Без захисту збій одного сервісу обвалює всю систему.

Три стани

   [Closed]  ──── failures cross threshold ────▶  [Open]
       ▲                                            │
       │ success after probe                        │ wait timeout
       │                                            ▼
       └─────────  [Half-Open]  ◀───── probe request
  • Closed (нормальний стан): виклики проходять. Breaker рахує помилки (consecutive failures, error rate за window).
  • Open: при перевищенні порогу - breaker "розриває коло". Усі наступні виклики миттєво відхиляються з помилкою (CircuitBreakerOpen) без спроби досягти downstream. Економить ресурси клієнта і дає downstream час відновитися.
  • Half-Open: після reset_timeout (зазвичай 30-60s) breaker пропускає один пробний запит. Успіх → Closed (відновлено). Невдача → Open (нова пауза).

Конфігураційні параметри

  • failure_threshold - кількість consecutive failures або error rate за rolling window для переходу Closed → Open.
  • reset_timeout - як довго лишатися Open перед переходом у Half-Open.
  • success_threshold - скільки успіхів у Half-Open перед поверненням у Closed.
  • slow_call_duration - окремий тригер: повільні виклики (наприклад,

    2s) рахуються як failures, навіть якщо повертають успіх.

Реалізація

# Concept (pseudocode); production - use pybreaker / aiobreaker
class CircuitBreaker:
    def call(self, func, *args):
        if self.state == "Open":
            if time.time() - self.opened_at > self.reset_timeout:
                self.state = "Half-Open"
            else:
                raise CircuitBreakerOpen()
        try:
            result = func(*args)
            self._on_success()
            return result
        except Exception:
            self._on_failure()
            raise

Бібліотеки

  • Python: pybreaker, aiobreaker - sync/async.
  • Java: resilience4j (де-факто стандарт), Netflix Hystrix (deprecated, але історично канонічний).
  • .NET: Polly - комбіновані політики (retry + circuit breaker + bulkhead).
  • Service Mesh (Istio, Linkerd): circuit breaker як sidecar-policy - жодного коду в застосунку. Деталі - у microservices.md Service Mesh.

Пов'язані патерни

  • Retry: працює навпаки - повторює виклик. Circuit Breaker і Retry застосовуються разом, але обережно (див. наступну секцію).
  • Bulkhead: ізолює пули ресурсів (потоки, з'єднання) per-downstream, щоб збій одного не виснажив пул для інших.
  • Timeout: завжди окрім breaker'а - без timeout навіть Closed breaker не врятує від зависання на синхронному виклику.
  • Fallback: повертати кешовану/дефолтну відповідь замість помилки, коли breaker Open (graceful degradation).

Links

Retry pattern [💡12/100]

Summary

Retry - resilience-патерн, який повторює провалений виклик у надії на тимчасову природу збою (transient failure: network blip, rate limit, deadlock retry). Канонічна реалізація - exponential backoff + jitter. Безпечний лише на ідемпотентних операціях - інакше повтор створює дублі (списання грошей двічі, дві однакові записи).

Принцип роботи

При невдачі (HTTP 5xx, network timeout, retriable error code) клієнт чекає певний час і повторює запит. Між спробами - exponential backoff (1s, 2s, 4s, 8s...) щоб не задавити downstream під час відновлення. Плюс jitter (випадкове відхилення ±20%) щоб уникнути thundering herd, коли тисячі клієнтів синхронно повторюють у ту саму мілісекунду.

# Concept; production - use tenacity / backoff
async def retry(func, max_attempts=5, base_delay=1.0):
    for attempt in range(max_attempts):
        try:
            return await func()
        except RetriableError:
            if attempt == max_attempts - 1:
                raise
            delay = base_delay * (2 ** attempt)
            delay *= 0.8 + random.random() * 0.4  # jitter ±20%
            await asyncio.sleep(delay)

Що повторювати, що ні

  • Повторювати: HTTP 502/503/504, network timeouts, 40001 serialization failures у PostgreSQL, rate-limit 429, тимчасові DB deadlock'и.
  • НЕ повторювати: HTTP 400/401/403/404 - помилка коду/конфігу, повтор не допоможе (можливо нашкодить). HTTP 422 (validation) - те саме.
  • Обережно з 5xx: деякі 500-ки - детермінований баг у downstream; повтор лише множить навантаження.

Обов'язкова ідемпотентність на receiving side

Канонічний антипатерн: клієнт відправляє POST /payments/charge, сервер обробляє і списує гроші, але відповідь губиться у мережі. Клієнт повторює - сервер знову списує. Подвійне списання.

Захист: ідемпотентність - запит несе унікальний idempotency_key, сервер пам'ятає вже оброблені ключі і повертає закешовану відповідь без повторного side-effect'у. Деталі - у Ідемпотентність і Inbox Pattern.

Retry + Circuit Breaker

Обидва патерни застосовуються разом, але обережно:

  • Retry працює на рівні одного логічного виклику (5 спроб з backoff).
  • Circuit Breaker працює на рівні агрегату викликів (50 викликів з 100 провалились - відкрити коло).

Без breaker'а retry може посилити навантаження на падаючий сервіс (retry storm). Канонічний порядок: спочатку spend retry budget, якщо все одно failures - breaker відкривається і миттєво відхиляє наступні без retry'їв.

Бібліотеки

  • Python: tenacity - декоратор + flexible policy; backoff - простіший декоратор; urllib3.util.Retry для HTTP-клієнтів.
  • Java: resilience4j (Retry + CircuitBreaker в одному API), Spring @Retryable.
  • .NET: Polly - єдиний DSL для retry + breaker + timeout.
  • Service Mesh (Istio, Linkerd): retry-policy на рівні sidecar без коду.

Links

Rate Limiter [💡17/100]

Summary

Rate limiter обмежує кількість дій (запитів, повідомлень, операцій) на одиницю часу за ключем (user, IP, API token). Реалізується атомарно над спільним сховищем; типові алгоритми - token bucket, leaky bucket, fixed/sliding window.

Призначення

  • Захист від DDoS-атак і клієнтів, що надсилають запити до backend безперервним циклом (retry-storms, фронтенд без debounce).
  • Чесний розподіл ресурсів між клієнтами (per-API-key quotas, free vs paid tiers).
  • Запобігання вибуху витрат на платні зовнішні API.

Алгоритми

  • Token bucket - у бакеті накопичуються токени з постійною швидкістю; на запит "з'їдається" один. Допускає короткі burst-и до розміру бакета, загальна швидкість обмежена rate-ом наповнення.
  • Leaky bucket - запити стають у чергу, обробляються з постійною швидкістю; зайві дропаються або повертають 429.
  • Fixed window - лічильник за фіксований інтервал (1 хвилина від 00:00:00). Просте; вразливе на межі вікон - можна пробити ліміт ×2, поставивши запити край-у-край.
  • Sliding window - лічильник для зсувного інтервалу ("остання хвилина від now"). Чесніше, але дорожче за пам'яттю та обчисленнями.

Реалізація

Якщо лічильник тримати в пам'яті процесу, кожен інстанс пропустить ліміт незалежно - реальний ліміт виходить limit × n_instances. Тому стан кладуть у Redis / БД, де всі інстанси читають єдину точку правди.

Інкремент + перевірка + TTL мусять бути атомарні, інакше дві паралельні перевірки обидві бачать "ліміт не пробито" і обидві проходять. Реалізують через атомарні одно-командні операції (INCR + EXPIRE у Redis) або Lua-скрипти для складнішої логіки (sliding window).

Найпоширеніше - Redis з Lua-скриптом для sliding window. Конкретний код - див. infrastructure/database.md.

Готові імплементації

  • SlowAPI (github.com/laurentS/slowapi) - FastAPI/Starlette-сумісна обгортка над limits. Декоратор @limiter.limit("5/minute") на handler'і; backend storage - in-memory, Redis або Memcached. Підходить для базового per-IP / per-API-key ліміту.
  • limits - чистий Python-пакет з реалізаціями fixed/sliding window і moving window; основа SlowAPI.
  • Envoy ratelimit filter / API Gateway / Nginx limit_req - rate limit на рівні infrastructure перед application'ом; масштабує без коду в додатку.

Двошарова модель: infrastructure + application

У production rate limit зазвичай ставлять на обох рівнях, бо вони вирішують різні задачі:

  • Infrastructure-рівень (Nginx limit_req, Envoy, API Gateway, CDN) - глобальне обмеження за IP/токеном перед тим, як трафік досягне застосунку. Захищає від DDoS і retry-storm'ів: пакет із 21-го запиту за секунду відкидається ще на edge, не витрачаючи CPU/конект-пули application'а.
  • Application-рівень (SlowAPI, кастомний middleware, in-handler перевірка) - обмеження за бізнес-правилами, яких infrastructure не знає: per-tariff квоти ("free tier - 5 reports/min, paid - 100"), per-resource-cost ліміти ("не більше 3 одночасних відео на користувача"), per-feature throttling. Логіка потребує контексту користувача, плану, стану БД.

Infrastructure-шар без application-шару пропускає легітимні бізнес-порушення (користувач у free-tier лізе за межі плану); application-шар без infrastructure-шару витрачає ресурси на обробку атакувального трафіку.

Bulkhead (ізоляція ресурсів)

Summary

Bulkhead - resilience-патерн, що ізолює пули ресурсів (потоки, з'єднання, воркери) між незалежними навантаженнями, щоб збій або перевантаження одного не виснажили ресурси для решти. Назва - від водонепроникних перегородок у корпусі судна: пробоїна в одному відсіку не топить увесь корабель. Канонічно описаний Майклом Найґардом у "Release It!".

Проблема, яку розв'язує

Сервіс має спільний пул на всі downstream-виклики - наприклад, один пул з 50 потоків (чи з'єднань) на запити і до payments, і до recommendations. Якщо recommendations починає відповідати повільно, його виклики займають усі 50 потоків, і запити до payments теж не мають де виконатися - повільний некритичний сервіс кладе критичний. Це різновид cascading failure через вичерпання спільного пулу.

Принцип роботи

Кожній downstream-залежності (або класу запитів) виділяють окремий обмежений пул - потоків, з'єднань або семафор на кількість одночасних викликів. Коли пул залежності вичерпано, нові виклики саме до неї швидко відхиляються (або стають у коротку чергу), а пули інших залежностей лишаються недоторканими.

import asyncio

# Separate semaphore (bulkhead) per downstream: a slow service
# exhausts only its own limit, leaving the others untouched.
bulkheads = {
    "payments": asyncio.Semaphore(20),
    "recommendations": asyncio.Semaphore(10),
}

async def call(service: str, coro):
    async with bulkheads[service]:   # blocks only within this bulkhead
        return await coro

Нюанси

  • Найчастіше застосовують разом із Circuit Breaker і Timeout: bulkhead обмежує скільки одночасних викликів, timeout - як довго кожен, circuit breaker припиняє виклики до вже мертвого сервісу.
  • Ізоляція не безкоштовна: окремі пули гірше утилізують ресурси, ніж один спільний; розмір кожного відсіку підбирають під критичність і профіль навантаження залежності.
  • Реалізується пулом потоків/процесів, окремим connection pool на кожен сервіс або семафором в асинхронному коді. У resilience4j/Polly - вбудований Bulkhead; у service mesh - як політика sidecar'а.

Links

Cache Stampede (Dogpile effect)

Summary

Cache stampede (Dogpile effect) - ситуація, коли "гарячий" ключ кешу спливає і багато паралельних запитів одночасно промахуються повз кеш, після чого кожен іде перераховувати те саме значення у БД. Раптовий сплеск ідентичних запитів перевантажує джерело даних. Захист: single-flight (перераховує лише один), ймовірнісне раннє оновлення та stale-while-revalidate.

Принцип роботи

Кеш тримає значення під ключем з обмеженим TTL. Поки ключ присутній, запити обслуговуються з кешу і до БД не доходять. У момент спливу TTL ключ зникає; усі запити, що надходять до завершення першого перерахунку, дають cache miss і паралельно звертаються до джерела. Чим популярніший ключ, тим більший синхронний сплеск навантаження на БД - аж до каскадної деградації, коли повільні відповіді подовжують вікно, протягом якого накопичуються нові промахи.

Стратегії пом'якшення

  • Single-flight (lock на перерахунок). Першому промаху видається короткий замок; він перераховує значення і кладе у кеш, решта або чекають результату, або миттєво отримують застаріле. Реалізується атомарним SET key NX у Redis (див. Distributed Lock).
  • Probabilistic early expiration (XFetch). Кожен читач із малою, зростаючою до TTL імовірністю перераховує значення до спливу. Перерахунок виконує один випадковий запит на теплому кеші, тож синхронного промаху не виникає.
  • Stale-while-revalidate. Після спливу деякий час віддають застаріле значення, а оновлення запускають асинхронно у фоні. Запити не блокуються і не б'ють у БД одночасно.
  • Staggered TTL / jitter. До TTL додають випадкове відхилення, щоб масово записані ключі не спливали в одну мить - той самий прийом, що й jitter у Retry pattern.

Реалізація

# Single-flight via Redis lock: one request recomputes, others serve stale/retry
async def get_or_recompute(redis, key, ttl, recompute):
    value = await redis.get(key)
    if value is not None:
        return value
    lock = f"{key}:lock"
    if await redis.set(lock, "1", nx=True, ex=10):  # winner recomputes
        try:
            value = await recompute()
            await redis.set(key, value, ex=ttl)
            return value
        finally:
            await redis.delete(lock)
    await asyncio.sleep(0.05)                        # losers briefly back off
    return await redis.get(key) or await recompute()

Нюанси

  • Cache stampede - окремий випадок thundering herd: синхронний сплеск ідентичних дій. Відрізняється від retry storm, де сплеск дають повтори провалених викликів, а не сплив кешу.
  • Single-flight вводить точку серіалізації: якщо перерахунок довгий, очікувачі накопичуються. Комбінують зі stale-while-revalidate, щоб не блокувати читачів.

Links

Як зрозуміти, що застосунок зламався? [💡12/100]

Summary

Жодний один спосіб не дає повної картини. Зазвичай комбінують healthchecks (системна перевірка), метрики + алерти (поведінка), структуроване логування (деталі помилок), дашборди (огляд у реальному часі), synthetic monitoring (зовнішня перевірка).

1. Healthchecks

Спеціальні ендпоінти, в які періодично стукається оркестратор (Kubernetes, балансувальник):

  • /live - чи живий процес (відповідає = жоден loop не завис).
  • /ready - чи готовий приймати трафік (БД, черги, кеш доступні).
  • /health - узагальнена перевірка стану всіх залежностей.

Прості в реалізації, ідеально для Kubernetes liveness/readiness probes. Важлива різниця наслідків: провал livenessProbe перезапускає под, а провал readinessProbe лише виключає його з балансування (трафік не йде), не перезапускаючи. Тому повільний старт і прогрів залежностей вішають на readiness - інакше liveness рестартуватиме под у циклі ще до того, як він встигне піднятися.

2. Метрики + алерти

Автоматичні сповіщення на порушення SLI:

  • Високий відсоток 5xx (наприклад, >2% за 5 хв).
  • Зникнення трафіку (0 RPS за 10 хв при очікуваному рівні).
  • Затримки запитів (p95 > 2 сек).
  • Queue backlog зростає.
  • Memory / CPU usage наближається до ліміту.
  • Невдалі деплої.

Інструменти: Prometheus + Alertmanager, Grafana, Sentry (для помилок), PagerDuty/Opsgenie/Slack (нотифікація).

3. Структуроване логування

  • Трасування винятків з correlation_id для зв'язку запитів.
  • Виявлення частих помилок з агрегацією.
  • JSON-формат для парсинга в Logstash/Loki.

Інструменти: ELK (Elasticsearch + Logstash + Kibana), Loki + Grafana, Sentry.

4. Метрики й дашборди

Базові показники, які треба бачити постійно:

  • RPS (запитів за хвилину).
  • Частка помилок 4xx/5xx.
  • p50/p95/p99 латенсі.
  • Кількість активних задач/воркерів.
  • Стан черг (довжина, час обробки).
  • Ресурси: CPU, пам'ять, диск, мережа.

5. Synthetic monitoring (canary checks)

Зовнішній бот ходить за визначеним сценарієм (логін → пошук → оформлення замовлення) і звітує про результат. Ловить проблеми, які не видно зсередини - DNS, CDN, TLS.

Інструменти: Datadog Synthetic, Pingdom, Uptrends.

Multi-tenancy: моделі ізоляції даних

Summary

Multi-tenancy - це підхід, коли один екземпляр застосунку обслуговує кількох клієнтів (тенантів) з ізоляцією їхніх даних. Три класичні моделі - database-per-tenant, schema-per-tenant і shared schema з колонкою tenant_id - відрізняються рівнем ізоляції, експлуатаційною складністю і вартістю на тенанта.

Database-per-tenant

Окремий екземпляр БД (або принаймні окрема логічна база) на кожного тенанта. Ізоляція максимальна: дані фізично розділені, регуляторні вимоги на physical isolation (HIPAA для healthcare, PCI DSS для платіжних карт) або customer-managed keys (CMEK у GCP/AWS KMS) закриваються тривіально. Окремий connection pool, окремі бекапи, окремий моніторинг на кожного тенанта.

Експлуатаційна складність зростає лінійно з кількістю тенантів: міграцію треба проганяти на N інстансах, патчі - так само. Підходить для enterprise з десятками клієнтів, не для масового SaaS.

Schema-per-tenant

Один інстанс Postgres, окрема схема на кожного тенанта. Ізоляція через search_path або повне кваліфікування tenant_42.users. Запити прозоро спрямовуються у схему поточного тенанта.

Connection pool множиться на кількість схем (у Postgres search_path - per-session GUC, неможливо безпечно переключати на з'єднанні, що повертається в pool без явного скидання). Міграція проганяється N разів: міграція, яка на одній схемі займає 3 секунди, на 500 схемах виконуватиметься 25 хвилин з блокуванням деплою.

Shared schema + tenant_id

Усі тенанти зберігаються в одних таблицях, кожен рядок має колонку tenant_id. Запити фільтрують через WHERE tenant_id = :id. Один pool, одна міграція, один моніторинг.

Найдешевша і найгірша одночасно: ізоляція тримається на дисципліні розробників. Перший забутий WHERE у звіті чи фоновій задачі - cross-tenant data leak. На масштабі 100+ розробників і тисяч запитів імовірність такого промаху наближається до 1, а ціна одного - судовий позов про витік даних. Для 1000+ тенантів shared schema залишається практичним вибором, але виключно в комбінації з механічним захистом - PostgreSQL Row-Level Security (див. infrastructure/sql.md розділ "PostgreSQL Row-Level Security").

Порівняння

Модель Ізоляція Вартість на тенанта Сценарії
Database-per-tenant Максимальна Висока (інстанс + pool + бекапи) Регульовані ринки, < 100 клієнтів
Schema-per-tenant Висока Середня (схема + N міграцій) 100-500 клієнтів, помірні compliance-вимоги
Shared schema + RLS Логічна (на рівні БД) Низька (один pool) SaaS на 1000+ клієнтів

Гібридні підходи

Реальні системи часто комбінують моделі: shared schema для більшості клієнтів плюс окрема БД для одного-двох enterprise-клієнтів з вимогою фізичної ізоляції. Routing вирішується на рівні connection pool / DSN resolver.

Links

Defense in Depth для multi-tenant ізоляції

Summary

Defense in depth - архітектурний принцип, за яким критична інваріанта (для multi-tenant SaaS це ізоляція даних між тенантами) захищається кількома незалежними шарами. Жоден шар окремо не достатній, але обхід усіх одночасно вимагає одночасних помилок у різних компонентах.

Проблема, яку вирішує

У shared-schema multi-tenant системі (див. розділ "Multi-tenancy: моделі ізоляції даних") одна забута умова WHERE tenant_id = :id у звіті, фоновій задачі чи raw SQL-аналітиці призводить до витоку даних одного клієнта іншому. Покладатися виключно на дисципліну розробників - антипатерн: на масштабі 100+ розробників і тисяч запитів імовірність помилки наближається до 1.

Три шари захисту

  1. HTTP middleware / транспортний шар. Витягує ідентифікатор тенанта з автентифікаційного токена (JWT, сесійний cookie, Telegram auth-payload), валідує і кладе у contextvars.ContextVar (див. python/async.md розділ "ContextVar для request-scoped стану"). Завдання шару - відкинути запит без валідного tenant_id до досягнення бізнес-логіки.
  2. ORM / repository шар. Автоматично інжектить WHERE tenant_id = :id у кожен запит через декоратор @tenant_scoped або кастомний QueryBuilder. Repository в конструкторі вимагає tenant_id обов'язковим параметром; спроба викликати без нього - ValueError.
  3. PostgreSQL Row-Level Security. Останній рубіж: CREATE POLICY на кожній таблиці з tenant_id, який звіряє current_setting('app.tenant_id') з колонкою рядка. Деталі реалізації - у infrastructure/sql.md розділі "PostgreSQL Row-Level Security".

Необхідність кількох шарів одночасно

  • Middleware обходить будь-який код, що не йде через HTTP: cron-задачі, manage-команди, фонові воркери, міграції. Якщо такий код звертається до БД без явного встановлення tenant_id у контекст - middleware взагалі не виконається.
  • ORM обходить raw SQL для складної аналітики чи дебагу. Repository, який автоматично додає WHERE tenant_id, не контролює запит, написаний через session.execute(text("SELECT ...")).
  • RLS на рівні БД працює завжди для запитів через звичайні ролі, але вимагає правильно виставлений GUC (SET LOCAL app.tenant_id) перед запитом і не застосовується до TRUNCATE/REFERENCES (див. infrastructure/sql.md розділ "TRUNCATE vs DELETE").

Сукупний обхід вимагає одночасно: пропустити middleware-валідацію, написати raw SQL без WHERE і виконати його через роль з BYPASSRLS (або в адмін-сесії з вимкненим policy enforcement). Імовірність всіх трьох помилок одночасно - на порядки нижча за одну.

Застосовність поза multi-tenant

Той самий принцип переноситься на будь-яку інваріанту, ціна порушення якої несумісна з імовірнісним захистом: фінансові операції (input validation + service constraint + DB CHECK), авторизація (route guard + policy check + RLS), PII-захист (application-level encryption + column encryption + audit log).

Service Layer (Headless архітектура)

Summary

Service Layer - архітектурний шар між транспортом (HTTP-handler, бот-handler, CLI) і шаром доступу до даних. Містить бізнес-логіку у формі, не прив'язаній до конкретного transport'а: той самий OrderService.create() викликається з REST-endpoint, бот-команди, cron-задачі і unit-тесту без змін.

Проблема, яку вирішує

Типовий стартовий handler виглядає так:

@router.message(F.text == "/my_orders")
async def my_orders(message: Message, session: AsyncSession):
    result = await session.execute(
        select(Order).where(Order.user_id == message.from_user.id)
    )
    orders = result.scalars().all()
    text = "\n".join(f"{o.id}: {o.title}" for o in orders)
    await message.answer(text)

Проблеми: handler знає про БД, бізнес-логіка змішана з форматуванням, відсутня ізоляція по тенанту, той самий сценарій неможливо викликати з REST API без копіпасту. Unit-тест без mock'а Telegram написати неможливо.

Принцип роботи

Шари і напрямок залежностей:

handlers/      ← transport (aiogram, FastAPI, CLI)
services/      ← domain logic (transport-agnostic)
repositories/  ← data access (tenant-scoped)
database/      ← ORM models, raw queries

Handler виконує дві речі: парсить вхідні дані з transport-формату у domain-аргументи і форматує доменний результат у transport-відповідь. Між ними - єдиний виклик сервісу.

Service приймає всі необхідні параметри (включно з tenant_id, user_id, correlation_id) явно, через сигнатуру. Не звертається до request.state/ContextVar/current_user напряму - це робить service викликаним з будь-якого entrypoint без HTTP-контексту. Cross-cutting метадані (tracing, locale) - окремий випадок, де ContextVar виправданий (див. python/async.md розділ "ContextVar для request-scoped стану").

Repository ("шлюз" / "gateway") знає тільки про SQL і таблиці. Не імпортує сервіси, не виконує криптографію, не валідує бізнес-правила. Tenant-scoping застосовується тут (див. розділ "Defense in Depth для multi-tenant ізоляції").

Реалізація

# services/order_service.py - transport-agnostic
class OrderService:
    def __init__(self, order_repo: OrderRepository) -> None:
        self.order_repo = order_repo

    async def create_order(
        self,
        tenant_id: str,
        user_id: int,
        items: list[OrderItem],
        phone: str,
    ) -> Order:
        encrypted_phone = await asyncio.to_thread(encrypt_aes256, phone)
        return await self.order_repo.insert(
            tenant_id=tenant_id,
            user_id=user_id,
            items=items,
            encrypted_phone=encrypted_phone,
        )

# handlers/telegram.py - thin transport
@router.message(F.text.startswith("/order"))
async def handle_order(message: Message, service: OrderService) -> None:
    tenant_id = get_tenant_id()
    order = await service.create_order(
        tenant_id=tenant_id,
        user_id=message.from_user.id,
        items=parse_items(message.text),
        phone=message.contact.phone_number,
    )
    await message.answer(f"Order #{order.id} created.")

# handlers/api.py - same service, different transport
@router.post("/orders")
async def create_order(
    body: CreateOrderRequest,
    service: OrderService = Depends(get_order_service),
    tenant_id: str = Depends(get_tenant_id_from_jwt),
) -> OrderResponse:
    order = await service.create_order(
        tenant_id=tenant_id,
        user_id=body.user_id,
        items=body.items,
        phone=body.phone,
    )
    return OrderResponse.from_domain(order)

Enforcement

Розділення шарів живе, лише поки його перевіряють механічно. Прийнятні варіанти:

  • Лінтер на імпорти: import-linter (Python) або кастомне правило ruff, що забороняє імпорт repositories.* / database.* з модулів handlers.*. Конфіг виконується в pre-commit і CI.
  • Архітектурний тест: pytest, що ходить по AST модулів handlers/ і падає, якщо знаходить заборонений імпорт.

Без автоматичного enforcement правило поступово порушується: під дедлайн хтось імпортує repository напряму "тимчасово", далі копіюють цей патерн.

Організаційний enforcement - CODEOWNERS. Лінтер фіксує напрям залежностей, але не відповідає на питання "хто має право змінювати цей модуль". Це закриває CODEOWNERS - вбудована можливість GitHub, GitLab і Bitbucket. Це звичайний текстовий файл (за шляхом .github/CODEOWNERS, у корені репозиторію або в каталозі docs/), кожен рядок якого зіставляє glob-шаблон шляху з одним або кількома власниками - користувачем (@user) чи командою (@org/team):

# CODEOWNERS
/modules/billing/    @team-billing
/modules/catalog/    @team-catalog
*.py                 @backend-leads

Коли pull request змінює файли, що підпадають під шаблон, платформа автоматично додає відповідних власників як рев'юерів; у поєднанні з branch protection їхній апрув стає обов'язковою умовою мержу. Тож PR, що зачіпає чужий контекст, без перегляду власника не зливається. Ручний CODEOWNERS дрейфує від реальних меж - надійніше генерувати його з єдиного джерела істини (метадані контекстів), щоб список власників завжди відповідав фактичній структурі модулів. Лінтер ловить заборонений імпорт у CI, CODEOWNERS гарантує, що зміну межі побачить відповідальна команда - разом вони роблять архітектурну дисципліну дефолтом, а не домовленістю.

Канонічна перевірка шарування

Якщо service неможливо протестувати без mock'а transport-бібліотеки (aiogram, FastAPI, Flask, Click) - шарування порушено. Канонічний unit-тест сервісу використовує реальний repository з in-memory БД (SQLite) і не торкається жодного Message/Request/Context об'єкта.

Зв'язок з іншими патернами

  • Hexagonal/Onion - Service Layer є імплементацією application core у цих архітектурах; ports - сигнатури сервісів, adapters - handlers і repositories.
  • DDD (ddd.md) - Service Layer відповідає Application Services рівню; не плутати з Domain Services, які живуть у Domain шарі.

Feature Flags

Summary

Feature flag - runtime-перемикач, який ввімкнення/вимкнення функціональності робить операційним рішенням, а не релізом. На відміну від A/B-тесту (де розподіл випадковий і метрики порівнюються), feature flag - детермінований вмикач за критеріями: тенант, користувач, регіон, версія клієнта.

Проблема, яку вирішує

Без feature flag нова функціональність потрапляє до користувачів разом з деплоєм. Якщо щось ламається - rollback всього релізу. Якщо клієнт A просить обмежений доступ до feature X - починається if client_id == "A" у коді, згодом if client_id in ("A", "C") and region != "EU" тощо. На сотні умов код перетворюється на колекцію винятків, кожен реліз ламає двох клієнтів.

Антипатерн - кодувати клієнтську логіку через if. Вмикач має бути даними (рядок у БД, запис у Redis), а код - дивитися на стан вмикача.

Принцип роботи

Feature flag - функція is_enabled(flag_id, context) -> bool, де context містить ідентифікатор тенанта/користувача та інші атрибути. Реалізація читає конфігурацію з джерела істини (БД / Redis / spec-файл) і повертає рішення.

Source of truth тримається в одному місці. Поширені варіанти:

  • Таблиця feature_flags(tenant_id, flag_name, enabled) у Postgres з кешем у Redis. Просто, прозоро, дозволяє audit.
  • Окремий сервіс (LaunchDarkly, Unleash, Flagsmith) - корисний, коли flag застосовується не лише backend'ом, а й мобільним/веб-клієнтом.

Перевірка прапорця має бути швидкою (мікросекунди): кеш у пам'яті процесу з TTL 30-60 секунд або push-інвалідація через pub/sub.

Реалізація

class FeatureFlag(StrEnum):
    BOOKING_ENABLED = "booking_enabled"
    NEW_BILLING_FLOW = "new_billing_flow"

class FeatureFlagChecker:
    def __init__(self, repo: FeatureFlagRepo, cache: Cache) -> None:
        self.repo = repo
        self.cache = cache

    async def is_enabled(
        self, flag: FeatureFlag, tenant_id: str | None = None
    ) -> bool:
        key = f"ff:{tenant_id or 'global'}:{flag.value}"
        cached = await self.cache.get(key)
        if cached is not None:
            return cached == "1"
        value = await self.repo.is_enabled(flag, tenant_id)
        await self.cache.set(key, "1" if value else "0", ttl=60)
        return value

Типи прапорців

  • Release toggle - вмикає недописану функціональність у production без експозиції користувачам. Видаляється після релізу.
  • Operational toggle - вмикач для ресурсоємної функціональності (важкі звіти, експериментальні обчислення). Залишається довго.
  • Permission toggle - надає доступ окремим тенантам/користувачам (preview, early access, enterprise-tier feature). Залишається назавжди.
  • Experiment toggle - частина A/B-тесту. Розподіл випадковий, не операційний.

Змішування типів у одній таблиці прапорців ускладнює прибирання: release toggle, що "пропустили видалити", згодом сприймається як operational.

Відмінність від A/B-тестування

A/B-тест випадково розподіляє користувачів між варіантами і порівнює метрики. Feature flag детерміновано вмикає для заздалегідь визначених критеріїв. Експеримент може використовувати flag-інфраструктуру, але це окремий випадок - для повноцінних експериментів кращі спеціалізовані інструменти (Optimizely, GrowthBook).

Обмеження

  • Кожен flag - технічний борг. Код з if flag.is_enabled(...) ускладнює читання і тестування. Видалення release-прапорців після релізу - регулярна операція, інакше борг накопичується.
  • Перевірка прапорця в гарячому шляху має бути дешевою. Виклик до зовнішнього сервісу на кожен запит - неприйнятний; обов'язковий локальний кеш.

Links

Plugin-модулі через базовий клас

Summary

Plugin-модулі - підхід, при якому бізнес-функціональність ділиться на самодостатні модулі за єдиним контрактом (наприклад, абстрактний клас BaseModule). Реєстрація модуля у системі - вписування його у registry; per-tenant ввімкнення - комбінація з feature flags.

Проблема, яку вирішує

Multi-tenant SaaS обростає функціональністю, яку одні тенанти використовують, інші - ні. Без модульності розкидані if-перевірки тенантських прапорців з'являються через увесь код: if tenant.has_booking:, if tenant.has_shop:. Додавання нової бізнес-вертикалі вимагає правок у десятках місць.

Альтернатива - винести кожну вертикаль (booking, shop, billing, recruiting) у самодостатній модуль з єдиним інтерфейсом. Тоді нова вертикаль додається як один файл; вмикання per-tenant - один запис у feature flags.

Принцип роботи

Контракт описується абстрактним базовим класом (ABC) з обов'язковими методами/property. Реалізації успадковуються і виконують контракт. Центральний ModuleRegistry при старті застосунку імпортує всі реалізації (через importlib/pkgutil або явний список) і реєструє їх.

Модулі не імпортують одне одного. Якщо BookingModule потребує реагувати на подію з BillingModule - комунікація через event bus (Redis pub/sub, Kafka, in-process pub/sub). Це зберігає незалежність модулів і дозволяє вмикати їх вибірково.

Реалізація

from abc import ABC, abstractmethod

class BaseModule(ABC):
    """Contract for pluggable business modules.

    Subclasses declare a unique id, an optional feature flag and the entry
    points the host wires up at startup (routes, menu buttons, scheduled jobs).
    """

    feature_flag: FeatureFlag | None = None
    enabled_by_default: bool = False

    @property
    @abstractmethod
    def module_id(self) -> str:
        """Unique snake_case identifier, e.g. 'booking'."""

    @property
    @abstractmethod
    def display_name(self) -> str:
        """Human-readable name shown in UI."""

    @abstractmethod
    async def setup(self, container: Container) -> None:
        """Register routes, handlers, scheduled jobs into the host."""

    async def is_active(self, tenant_id: str) -> bool:
        if self.feature_flag is None:
            return self.enabled_by_default
        return await container.flag_checker.is_enabled(
            self.feature_flag, tenant_id
        )


class BookingModule(BaseModule):
    feature_flag = FeatureFlag.BOOKING_ENABLED

    @property
    def module_id(self) -> str:
        return "booking"

    @property
    def display_name(self) -> str:
        return "Booking"

    async def setup(self, container: Container) -> None:
        container.router.include_router(booking_router)
        container.scheduler.add_job(remind_upcoming_bookings, "cron", minute="*/5")


class ModuleRegistry:
    def __init__(self, modules: list[BaseModule]) -> None:
        self._modules = {m.module_id: m for m in modules}

    async def active_modules(self, tenant_id: str) -> list[BaseModule]:
        return [m for m in self._modules.values() if await m.is_active(tenant_id)]

Зв'язок з іншими патернами

  • Feature Flags - механіка per-tenant ввімкнення. Plugin-модуль поєднує реєстрацію з умовою активації.
  • Service Layer - модулі складаються з сервісів; модуль реєструє свої сервіси у DI-контейнері в setup().
  • Hexagonal/Onion - модуль закриває одну business capability за принципом vertical slice; внутрішньо може мати власне розшарування.

Обмеження

  • Контракт BaseModule має бути стабільним. Зміна сигнатури setup() - це breaking change для всіх модулів. Тому контракт тримають мінімальним.
  • Глобальний стан між модулями (спільний кеш, спільна БД) повертає неявний coupling. Якщо модулі ділять стан - або це спільна інфраструктура (SharedKernel в DDD-термінах), або один з модулів насправді є частиною іншого.

Скінченний автомат (Finite State Machine)

Summary

Скінченний автомат (FSM) моделює сутність зі строго визначеним набором станів і дозволеними переходами між ними. Замість розкиданих if/elif за полем status - явна таблиця переходів: з якого стану в який можна перейти і за якою подією. Класичні приклади: статус замовлення, платежу, заявки - будь-який життєвий цикл із правилами.

Принцип роботи

  • Стани й переходи. Скінченна множина станів (created → paid → shipped → delivered) і дозволені переходи. Спроба недозволеного переходу (наприклад, delivered → paid) відхиляється - інваріант життєвого циклу закодований у структурі переходів, а не в розкиданих перевірках.
  • Чому не if-и. Логіка переходів зосереджена в одному місці; додати стан = додати запис у таблицю переходів, не змінюючи тіла функцій (Open/Closed). Легше тестувати й візуалізувати як граф станів.
  • Реалізація в Python. Власний клас зі словником дозволених переходів або бібліотека transitions (декларативні states/transitions, що генерують методи-тригери). Стани зручно тримати в enum.Enum.

Реалізація

from enum import Enum

class State(Enum):
    CREATED = "created"
    PAID = "paid"
    SHIPPED = "shipped"
    DELIVERED = "delivered"
    CANCELLED = "cancelled"

# allowed transitions: (current state, event) -> next state
TRANSITIONS = {
    (State.CREATED, "pay"): State.PAID,
    (State.PAID, "ship"): State.SHIPPED,
    (State.SHIPPED, "deliver"): State.DELIVERED,
    (State.CREATED, "cancel"): State.CANCELLED,
    (State.PAID, "cancel"): State.CANCELLED,
}

def apply(state: State, event: str) -> State:
    try:
        return TRANSITIONS[(state, event)]
    except KeyError:
        raise ValueError(f"illegal transition: {state.value} -[{event}]->")

Зв'язок з DDD

Переходи стану - це інваріанти агрегату, тож їхнє місце - у методах кореня агрегату (order.pay(), order.cancel()), а не в сервісному шарі. Так стан не можна змінити в обхід правил. Споріднений патерн GoF - State, коли поведінку кожного стану виносять в окремий клас замість таблиці переходів.

Chain of Responsibility (ланцюг відповідальності)

Summary

Поведінковий патерн GoF: запит передається ланцюгом обробників, доки хтось його не опрацює. Кожна ланка або обробляє запит сама, або передає наступній. Відправник не знає, хто саме обробить - лише про початок ланцюга. Класичні приклади: middleware-конвеєр, валідація за етапами, послідовне заповнення форми (крок за кроком), ескалація обробки.

Принцип роботи

  • Кожен обробник має посилання на наступного й метод handle(request).
  • Обробник вирішує: опрацювати запит самому, передати далі, або і те, і те.
  • Додати, прибрати чи переставити ланку можна без зміни інших - ланцюг конфігурується ззовні (Open/Closed).
from abc import ABC, abstractmethod

class Handler(ABC):
    def __init__(self, nxt: "Handler | None" = None):
        self._next = nxt

    @abstractmethod
    def handle(self, request) -> str | None: ...

    def _forward(self, request):
        return self._next.handle(request) if self._next else None

class AuthHandler(Handler):
    def handle(self, request):
        if not request.get("user"):
            return "401 unauthorized"
        return self._forward(request)        # pass down the chain

class RateLimitHandler(Handler):
    def handle(self, request):
        if request.get("rate_exceeded"):
            return "429 too many requests"
        return self._forward(request)

chain = AuthHandler(RateLimitHandler())
chain.handle({"user": "alice"})

Де зустрічається

  • Middleware-конвеєри у вебфреймворках: кожен middleware або відповідає, або передає далі.
  • Послідовне заповнення форми (крок за кроком), де кожна ланка опрацьовує свій крок.
  • Обробка винятків як ескалація по рівнях відповідальності.

Споріднений зі скінченним автоматом (розділ вище) для лінійних сценаріїв, але CoR - про передачу запиту обробникам, а FSM - про переходи між станами.