Реалізація простої бізнес-логіки
Реалізація простої бізнес-логіки (Implementing Simple Business Logic)
Вступ
У Главі 2 ми дізналися, що не всі піддомени однакові. Різні піддомени мають різні рівні стратегічної важливості та складності. В цій главі розпочнемо вивчення різних способів моделювання та реалізації бізнес-логіки.
Що ми вивчимо?
Почнемо з двох паттернів для відносно простої бізнес-логіки:
Transaction Script
Active Record
Транзакційний сценарій (Transaction Script)
Організує бізнес-логіку за процедурами, де кожна процедура обробляє один запит від користувача.
— Martin Fowler, Patterns of Enterprise Application Architecture
Концепція паттерна
Публічний інтерфейс системи можна розглядати як набір бізнес-транзакцій, доступних для виконання споживачами. Ці транзакції можуть:
- 📖 Читати інформацію з системи
- ✏️ Змінювати стан системи
- 🔄 Виконувати обидві операції
Паттерн Transaction Script вибудовує бізнес-логіку на основі процедур, де:
✅ Кожна процедура реалізує одну операцію
✅ Публічні операції використовуються як межі інкапсуляції
✅ Процедури можуть безпосередньо звертатися до БД або через тонкий шар абстракції
Реалізація
Кожна процедура реалізована у вигляді простого процедурного сценарію. Єдина непохитна вимога — транзакційна поведінка.
Приклад: Конвертація JSON → XML
public class ConvertFileJob
{
private IDatabase _db;
public void Execute()
{
_db.StartTransaction();
try
{
var job = _db.LoadNextJob();
var json = LoadFile(job.Source);
var xml = ConvertJsonToXml(json);
WriteFile(job.Destination, xml.ToString());
_db.MarkJobAsCompleted(job);
_db.Commit();
}
catch
{
_db.Rollback();
throw;
}
}
}
Чому (Why): Нам потрібно конвертувати файли JSON у XML як частина ETL-процесу. Операція має бути атомарною.
Що (What): Використовуємо Transaction Script для організації простої process-based логіки.
Як (How):
- Відкриваємо транзакцію
- Завантажуємо завдання
- Конвертуємо файл
- Фіксуємо зміни або відкочуємо при помилці
Ключовий момент: Транзакція забезпечує, що або все виконається успішно, або нічого не зміниться.
Це не так просто, як здається!
Безліч production-проблем, які я допомагав відлагоджувати, зводилися до неправильної реалізації транзакційної поведінки.
Три поширені помилки
Крок 1: Відсутність транзакційної поведінки
Найпростіша помилка — випуск кількох оновлень без транзакції, що їх охоплює.
Крок 2: Розподілені транзакції
Спроба атомарно оновити БД і опублікувати подію в шині повід омлень.
Крок 3: Неявні розподілені транзакції
Навіть однorядкове оновлення може бути розподіленою транзакцією!
Помилка №1: Відсутність транзакції
Розглянемо метод, що оновлює запис у таблиці Users та вставляє запис у VisitsLog:
public class LogVisit
{
private IDatabase _db;
public void Execute(Guid userId, DateTime visitedOn)
{
// ❌ Небезпечно! Дві операції без транзакції
_db.Execute("UPDATE Users SET last_visit=@p1 WHERE user_id=@p2",
visitedOn, userId);
_db.Execute(@"INSERT INTO VisitsLog(user_id, visit_date)
VALUES(@p1, @p2)", userId, visitedOn);
}
}
Users (рядок 8), але до успішного додавання в VisitsLog (рядок 11) станеться будь-яка проблема — система опиниться в неузгодженому стані:- ✅ Таблиця
Usersоновлена - ❌ Запис у
VisitsLogвідсутній
Рішення: Додавання транзакції
public class LogVisit
{
private IDatabase _db;
public void Execute(Guid userId, DateTime visitedOn)
{
try
{
_db.StartTransaction(); // ✅ Початок транзакції
_db.Execute("UPDATE Users SET last_visit=@p1 WHERE user_id=@p2",
visitedOn, userId);
_db.Execute(@"INSERT INTO VisitsLog(user_id, visit_date)
VALUES(@p1, @p2)", userId, visitedOn);
_db.Commit(); // ✅ Фіксація змін
}
catch
{
_db.Rollback(); // ✅ Відкат при помилці
throw;
}
}
}
Але що робити, коли потрібно оновити кілька сховищ даних, які не підтримують розподілені транзакції?
Помилка №2: Розподілені транзакції
У сучасних розподілених системах звичайна практика — внести зміни в БД, а потім опублікувати події в шину повідомлень для оповіщення інших компонентів.
Припустімо, замість логування візиту в таблицю, нам потрібно опублікувати подію:
public class LogVisit
{
private IDatabase _db;
private IMessageBus _messageBus;
public void Execute(Guid userId, DateTime visitedOn)
{
// ❌ Проблема: дві різні системи без загальної транзакції
_db.Execute("UPDATE Users SET last_visit=@p1 WHERE user_id=@p2",
visitedOn, userId);
_messageBus.Publish("VISITS_TOPIC",
new { UserId = userId, VisitDate = visitedOn });
}
}
- ✅ Таблиця
Usersоновлена - ❌ Інші компоненти не оповіщені (публікація дала збій)
- 🐌 Складні
- 📉 Важко масштабуються
- ❌ Не стійкі до помилок
Існують спеціалізовані паттерни для вирішення цієї проблеми:
Transactional Outbox Pattern (Глава 9)
- Зберігаємо подію в БД разом із бізнес-даними в одній транзакції
- Окремий процес читає події та публікує їх
CQRS Pattern (Глава 8)
- Архітектурний паттерн для заповнення кількох сховищ даних
- Розділення команд і запитів
Детальніше розглянемо ці підходи в наступних главах.
Помилка №3: Неявні розподілені транзакції
Розглянемо метод, що виглядає обманливо простим:
public class LogVisit
{
private IDatabase _db;
public void Execute(Guid userId)
{
// Здається просто? Насправді це розподілена транзакція!
_db.Execute("UPDATE Users SET visits=visits+1 WHERE user_id=@p1",
userId);
}
}
Проблема: Втрачений ack (acknowledgment)
Цей приклад є розподіленою транзакцією, оскільки передає інформацію в дві системи:
- 💾 База даних
- 📞 Зовнішній процес (викликач методу)
Execute має тип void — не повертає даних. Але він все одно повідомляє результат: у разі невдачі викликач отримає виключення.Що якщо метод завершився успішно, але повернення результату дало збій?- REST-сервіс: Збій мережі між сервером і клієнтом
- Локальний процес: Процес завершився зі збоєм до того, як викликач встиг відстежити успіх
LogVisit знову. Повторне виконання призведе до некоректного збільшення лічильника: +2 замість +1.Рішення №1: Ідемпотентність через передачу значення
Зробимо операцію ідемпотентною — приводити до однакового результату навіть при багаторазовому повторенні:
public class LogVisit
{
private IDatabase _db;
// ✅ Споживач передає очікуване значення
public void Execute(Guid userId, long visits)
{
// MEETING навіть при повторних викликах буде встановлено те саме значення
_db.Execute("UPDATE Users SET visits = @p1 WHERE user_id=@p2",
visits, userId);
}
}
- Викликач спочатку зчитує поточне значення лічильника
- Локально збільшує його
- Передає оновлене значення як параметр
Рішення №2: Оптимістична блокування
Перед викликом LogVisit викликач зчитує поточне значення і передає його для перевірки:
public class LogVisit
{
private IDatabase _db;
// ✅ Використовуємо expected value для оптимістичної блокування
public void Execute(Guid userId, long expectedVisits)
{
// Оновлення відбудеться ТІЛЬКИ якщо значення не змінилось
var rowsAffected = _db.Execute(
@"UPDATE Users
SET visits = visits + 1
WHERE user_id=@p1 AND visits = @p2",
userId, expectedVisits);
if (rowsAffected == 0)
{
throw new ConcurrencyException("Visits count has changed");
}
}
}
Механізм:
- Викликач зчитує
visits = 5 - Передає
expectedVisits = 5 - UPDATE виконується тільки якщо
visits = 5 - Якщо значення змінилось (інший процес оновив) —
rowsAffected = 0
Наступні виклики з тими ж параметрами не змінять дані, оскільки умова WHERE...visits = @p2 не виконається.
Переваги:
- ✅ Захист від конкурентних оновлень
- ✅ Ідемпотентність операції
- ✅ Явне повідомлення про конфлікти
Коли застосовувати Transaction Script?
Підходить для
НЕ підходить для
Переваги паттерна
| Аспект | Опис |
|---|---|
| Простота | Мінімальна кількість абстракцій |
| Продуктивність | Мінімальні накладні витрати |
| Зрозумілість | Легко розібратися в реалізації |
| Прямолінійність | Прев зв'язок між операцією та кодом |
Недоліки паттерна
Активна запис (Active Record)
Об'єкт, що представляє рядок у таблиці або представленні бази даних, інкапсулює доступ до бази даних і бізнес-логіку, що оперує цими даними.
— Martin Fowler, Patterns of Enterprise Application Architecture
Відмінності від Transaction Script
Active Record, як і Transaction Script, придатний для простої бізнес-логіки. Але відмінність:
Transaction Script працює з простими даними та процедурами.
Active Record може працювати зі складними структурами даних — деревами об'єктів та ієрархіями.
Проблема: Дублювання коду
Розглянемо складнішу модель даних:
Рішення: Active Record Objects
Паттерн використовує спеціальні об'єкти — Active Records — для представлення складних структур даних:
// ✅ Active Record інкапсулює і дані, і доступ до БД
public class User
{
public Guid Id { get; set; }
public string Name { get; set; }
public string Email { get; set; }
// CRUD-методи вбудовані в об'єкт
public void Save() { /* ORM logic */ }
public static User Load(Guid id) { /* ORM logic */ }
public void Delete() { /* ORM logic */ }
}
public class Order
{
public Guid Id { get; set; }
public Guid UserId { get; set; }
public List<OrderLine> Lines { get; set; }
public void Save() { /* ORM logic */ }
public static Order Load(Guid id) { /* ORM logic */ }
}
Використання в Transaction Script
Бізнес-логіка все ще організована як транзакційний сценарій, але замість прямого доступу до БД маніпулює об'єктами Active Record:
public class CreateUser
{
private IDatabase _db;
public void Execute(UserDetails userDetails)
{
try
{
_db.StartTransaction();
// ✅ Працюємо через Active Record, а не з SQL напряму
var user = new User();
user.Name = userDetails.Name;
user.Email = userDetails.Email;
user.Save(); // Active Record сам знає як зберегтись
_db.Commit();
}
catch
{
_db.Rollback();
throw;
}
}
}
Інкапсуляція складності
Головна мета — інкапсулювати складність зіставлення об'єкта в пам'яті на схему бази даних.
Що може містити Active Record?
Крім операцій зі сховищем, об'єкти можуть містити:
- ✅ Валідацію нових значень перед присвоєнням
- ✅ Простні бізнес-процедури для маніпуляції даними
- ✅ Перевірку бізнес-правил
Ключова особливість
Active Record розділяє структури даних і поведінку. Зазвичай поля мають публічні getter/setter, що дозволяє зовнішнім процедурам змінювати стан.
Коли застосовувати Active Record?
По суті, Active Record — це Transaction Script optimization для роботи з БД:
Підходить для
НЕ підходить для
Порівняння з Transaction Script
| Аспект | Transaction Script | Active Record |
|---|---|---|
| Бізнес-логіка | Проста процедурна | Проста CRUD-орієнтована |
| Структури даних | Прості записи | Складні ієрархії об'єктів |
| Доступ до БД | Прямий або тонка абстракція | Через ORM/Active Record |
| Підходить для | ETL, простi операції | CRUD зі складними даними |
| Дублювання коду | Висока ймовірність | Зменшено через ORM |
«Анемічна модель» чи ні?
❌ Може завдати шкоди, якщо застосувати в неправильному місціЯкщо бізнес-логіка не відрізняється особливою складністю, у використанні Active Records немає нічого поганого.Але використання більш складного паттерна для простої логіки також завдасть шкоди — призведе до непотрібної складності.
В наступній главі розкриємо паттерн Domain Model і покажемо, чим він відрізняється від Active Record.
Прагматичний підхід
Коли можна послабити гарантії?
Бувають випадки, коли гарантії узгодженості даних можуть бути послаблені, особливо при високих вимогах масштабованості:
Сценарій: Система щодня приймає мільярди подій з IoT-пристроїв.
Питання: Чи стане великою проблемою те, що 0.001% подій будуть продубльовані або втрачені?
Компроміс:
- 📉 Втрата 0.001% даних
- 📈 Можливість масштабування на мільярди подій
- 💰 Значна економія ресурсів
Сценарій: Лічильник відвідувань сайту
Питання: Чи критична точність до останнього відвідування?
Можливі варіанти:
- 🎯 Eventual consistency — лічильник може бути неточним певний час
- ⚡ Періодична синхронізація — оновлення раз на хвилину замість real-time
- 📊 Approx imation — приблизний підрахунок для великих обсягів
- Області бізнесу — фінанси vs аналітика vs соціальні мережі
- Оцінки ризиків — що станеться при втраті/дублюванні даних?
- Бізнес-наслідків — як це вплине на прибутковість?
Висновок
У цій главі наші розглянули два паттерни для реалізації бізнес-логіки:
Transaction Script
Active Record
Практичні вправи
Питання: Яким із розглянутих паттернів слід скористатися для реалізації бізнес-логіки основного піддомену (Core Subdomain)?
А) Transaction Script
Б) Active Record
В) Для реалізації Core Subdomain не повинен використовуватись жоден із цих паттернів
Г) Для реалізації Core Subdomain можуть використовуватись обидва паттерни
Правильна відповідь: В
Core Subdomains містять найскладнішу бізнес-логіку організації. Transaction Script і Active Record не справляться з цією складністю та призведуть до Big Ball of Mud.
Для Core Subdomains потрібен Domain Model pattern (Глава 6).
::
Подивіться на наступний код:
public void CreateTicket(TicketData data)
{
var agent = FindLeastBusyAgent();
agent.ActiveTickets = agent.ActiveTickets + 1;
agent.Save();
var ticket = new Ticket();
ticket.Id = Guid.NewGuid();
ticket.Data = data;
ticket.AssignedAgent = agent;
ticket.Save();
_alerts.Send(agent, "You have a new ticket!");
}
Питання: Якщо припустити відсутність високорівневого механізму транзакцій, які потенційні проблеми з узгодженістю даних можуть виникнути?
А) Лічильник активних запитів агента може бути збільшений більше ніж на 1
Б) Лічильник може збільшитись, але агенту не буде присвоєно новий запит
В) Агент може отримати новий запит, але не буде про це оповіщений
Г) Можливе виникнення всіх вищеперелічених проблем
Правильна відповідь: Г (All of the above)
Проблема А: Якщо agent.Save() успішно виконався, але process crashed перед поверненням до caller, клієнт повторить операцію → +2 замість +1
Проблема Б: Якщо agent.Save() успішно, але ticket.Save() falls → агент має збільшений лічильник без відповідного ticket
Проблема В: Якщо обидва Save() успішні, але _alerts.Send() falls → ticket створено, але агент не оповіщений
Рішенняvoltage:
- Обгорнути все в транзакцію БД
- Використати Outbox Pattern для alerts
- Додати idempotency keys для захисту від retry
Знайдіть ще одну потенційну проблему в коді з Вправи 2, що може внести розлад у стан системи.
Race Condition при FindLeastBusyAgent()
Якщо два запити виконуються одночасно:
- Обидва викликають
FindLeastBusyAgent()→ отримують того самого агента - Обидва збільшують
ActiveTicketsна 1 - Обидва зберігають зміни
Результат: Лічильник збільшено на 2, але це правильно (2 tickets призначено).
АЛЕ! Якщо використовувалась оптимістична блокування без перевірки версії, один з Save() може перезаписати зміни іншого → лічильник збільшиться лише на 1 замість 2.
Рішення: Оптимістична або песимістична блокування при оновленні agent.ActiveTickets.
::