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

Що таке 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 або користувацького інтерфейсу. Центральний шар (ядро) містить найважливішу бізнес-логіку. Цибулева структура акцентує увагу на суворій спрямованості залежностей — залежності рухаються лише з зовнішніх шарів до внутрішніх.

Моноліт

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

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

Моноліт підходить для невеликих команд і проєктів із передбачуваними обсягами роботи. Його також використовують для MVP (мінімально життєздатного продукту), де швидкість розробки важливіша за складні архітектурні рішення.

Моноліт часто є першим кроком у розробці, але з часом додатки можуть бути розділені на мікросервіси для підвищення гнучкості та масштабованості.

Переваги моноліту

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

Недоліки моноліту

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

Links

Microservices - Мікросервіси

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

Кожен сервіс є незалежним, має свою кодову базу, базу даних (або доступ до окремих таблиць) і виконує одне конкретне завдання. Взаємодія між сервісами зазвичай відбувається через легкі протоколи, такі як HTTP (REST, GraphQL) або брокери повідомлень (RabbitMQ, Kafka).

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

Переваги мікросервісів

  • Зручність масштабування: кожен сервіс можна масштабувати незалежно від інших.
  • Можливість використовувати різні технології для різних сервісів залежно від їхніх потреб.
  • Полегшення підтримки: зміни в одному сервісі не впливають на інші (за умови чітких контрактів).
  • Зменшення ризику: збій в одному сервісі не зупиняє роботу всієї системи.

Недоліки мікросервісів

  • Ускладнена інфраструктура: потрібен надійний механізм для оркестрації сервісів, моніторингу та логування.
  • Накладні витрати на комунікацію: виклики між сервісами додають латентність і вимагають захисту від помилок.
  • Складнощі в забезпеченні узгодженості даних між сервісами.

Моноліт vs Мікросервіси

Summary

Моноліт підходить для простих рішень і швидкого старту, тоді як мікросервіси необхідні для великих, складних проєктів, які вимагають масштабованості та модульності.

Вибір між монолітною архітектурою та мікросервісами залежить від масштабів проєкту, команди та бізнес-вимог. Кожен підхід має свої сильні сторони, які слід враховувати залежно від конкретної ситуації.

Коли обрати Моноліт

  • Проєкт невеликий, із чітко визначеною функціональністю та обмеженими масштабами.
  • Команда розробників мала, і всі члени працюють над одним кодом.
  • Потрібно швидко створити MVP (мінімально життєздатний продукт).
  • Інфраструктурні ресурси обмежені, немає необхідності в складній оркестрації.
  • Немає значних вимог до незалежного масштабування різних компонентів.
  • Основний фокус на простоті розробки, підтримки та розгортання.

Коли обрати Мікросервіси

  • Проєкт великий і передбачає постійне зростання як за функціональністю, так і за навантаженням.
  • Є чітка потреба в масштабуванні окремих частин системи, наприклад, модуля оплати чи пошуку.
  • У проєкті працює велика команда, яка може бути розділена на кілька груп, кожна з яких відповідає за окремий сервіс.
  • Проєкт потребує високої гнучкості у виборі технологій для різних частин системи.
  • Необхідно забезпечити високу надійність: збої в одному сервісі не повинні впливати на роботу інших.
  • Бізнес-вимоги швидко змінюються, і потрібна модульність для швидкого впровадження нових функцій.

Transactional Outbox Pattern

Summary

Паттерн Transactional Outbox - паттерн, який використовується у розподілених системах, щоб гарантувати узгодженість між змінами даних та відправкою подій. Додає два компоненти: таблицю outbox (Outbox Table) та relay-процес (Relay Process).

Transactional Outbox - це підхід, де події або повідомлення спочатку записуються в локальну транзакційну таблицю БД сервісу (outbox), разом із бізнес-даними. Після коміту транзакції окремий процес/воркер безпечно читає outbox і публікує події у чергу або брокер (Kafka, RabbitMQ тощо). Після успішної відправки запис позначається як відправлений або видаляється.

Таблиця outbox — це додаткова таблиця у базі даних, яка використовується для тимчасового зберігання подій, які потрібно опублікувати. Ця таблиця перебуває у тій базі, як і основні дані сервісу, і заповнюється у межах тієї ж транзакції. Об'єднуючи обидва записи в одну транзакцію, ми гарантуємо, що або виконуються обидві операції, або обидві будуть скасовані - це забезпечується ACID-властивістю транзакцій (атомарність, узгодженість, ізоляція, стійкість).

Процес ретрансляції — це фоновий процес (його називають обробником outbox або диспетчером подій). Він періодично опитує таблицю outbox щодо невідправлених подій і публікує їх у систему обміну повідомленнями (наприклад, RabbitMQ, AWS EventBridge, Azure Service Bus, Kafka тощо). Після успішного відправлення процес або позначає запис як доставлений, або видаляє його. Для того, щоб при роботі кількох екземплярів процесу ретрансляції забезпечити що кожна подія береться до обробки лише одним процесом та процеси ретрансляції не блокують один одного та не заважають без необхідності, потрібно додати блокування на рівні рядків у запити вибірки. Це дозволяє кільком ретрансляторам працювати паралельно і безпечно, не вибираючи одні й ті ж рядки. Щоб цього досягти, PostgreSQL пропонує акуратне рішення з використанням FOR UPDATE SKIP LOCKED:

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

Паттерн outbox дозволяє рознести логіку збереження події та її обробки.

Переваги

  • Надійність: Дозволяє уникнути проблеми "dual write", коли треба одночасно оновити БД і надіслати подію в брокер, але один з двох записів може провалитися. Подія не губиться, бо зберігається в одній транзакції з бізнес-даними.
  • Простота: Дає сильні гарантії узгодженості без складних двофазних транзакцій.
  • Проста діагностика: Можна бачити нерозіслані події.
  • Забезпечує at least once доставку подій.
  • Спрощує відновлення після збоїв: всі "забуті" події залишаються в таблиці.
  • Дозволяє масштабувати публікацію подій незалежно від основного сервісу.

Недоліки

  • Потрібно окремо масштабувати outbox-воркери.
  • Можлива затримка між записом події та її фактичною доставкою.
  • Потрібен cleanup старих подій.
  • at least once доставка означає, що споживачі повинні бути ідемпотентними.

Типовий pipeline

  • Основний сервіс робить бізнес-операцію → у тій же транзакції додає запис у таблицю outbox. Наприклад, у сервісі користувачів, коли створюється новий користувач, програма виконує дві операції в одній транзакції:
  • Додає новий запис користувача до таблиці users.
  • Додає відповідну подію UserCreated до таблиці outbox.
  • Воркери періодично читають таблицю, ретраять, публікують у брокер, логують помилки.
  • Після успіху роблять cleanup або архівують outbox.

Типова архітектура на прикладі обробки замовлення

  • Orders Service — містить основну бізнес-логіку з обробки замовлення.
  • Orders DB — реляційна СУБД. Має таблиці: orders, outbox.
  • Outbox Relay/worker — компонент, який читає таблицю outbox і записує в брокер, позначаючи записи як доставлені.
  • Message Broker — наш middleware з чергами/топіками для передачі подій.
  • Consumers — Billing/Inventory та інші з ідемпотентною обробкою.

Приклад таблиці outbox

Назва стовпця Тип Опис
id INT / UUID Унікальний ідентифікатор події в outbox
event_type STRING Тип події (наприклад, UserCreated)
payload JSON / TEXT Серіалізоване тіло події
created_at TIMESTAMP Час збереження події
sent_at TIMESTAMP NULLABLE Час відправлення події (може бути NULL)

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

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

Links