Skip to content

Architecture Patterns

Architecture Patterns

CAP теорема

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

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

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

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

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

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

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

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

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

Dependency Injection - DI - Впровадження залежності

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.

DI контейнер

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

Hexagonal vs onion архітектура

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

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

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

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

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

CQRS

Summary

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

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

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

  • 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-репліку з мінімальним лагом.

Зв'язок з Event Sourcing

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

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

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

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

Transactional Outbox Pattern

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.

Що таке ідемпотентність?

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 - вибір єдиного лідера серед однотипних інстансів.

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

  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

Rate Limiter

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'ом; масштабує без коду в додатку.

Як зрозуміти, що застосунок зламався?

Summary

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

1. Healthchecks

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

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

Прості в реалізації, ідеально для Kubernetes liveness/readiness probes.

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 напряму "тимчасово", далі копіюють цей патерн.

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

Якщо 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-термінах), або один з модулів насправді є частиною іншого.