Чиста доменна логіка (Pure Domain Logic) — це фундаментальне ядро будь-якого корпоративного додатку, повністю ізольоване від інфраструктурних залежностей, баз даних, мережевих сокетів та UI-рендерингу. Відповідно до принципів чистої архітектури (Clean Architecture) та предметно-орієнтованого проєктування (DDD), доменне ядро має бути представлене чистими, детермінованими функціями або незмінними агрегатами.
Детермінізм робить чисту бізнес-логіку ідеальним полігоном для Test-Driven Development. Відсутність операцій введення-виведення дозволяє запускати тисячі специфікацій за лічені мілісекунди в оперативній пам'яті, миттєво перевіряючи інваріанти предметної області.
У реальних ентерпрайз-системах правила ціноутворення, розрахунку тарифних планів та знижок постійно ускладнюються. Без жорсткої дисципліни TDD калькулятор вартості стрімко деградує в заплутаний клубок вкладених операторів if-else та switch-case, де додавання нового маркетингового правила ламає раніше закладені граничні інваріанти. TDD дозволяє еволюційно виростити гнучку модульну архітектуру (наприклад, патерни Strategy або Chain of Responsibility) під безперервним захистом автоматизованих специфікацій.
Розробка доменного алгоритму через TDD проходить шлях еволюційного проєктування: від найпростішого обчислення до гнучкої компонованої архітектури оцінювання правил.
+-----------------------------------------------------------------------+
| ПАТЕРН ЕВОЛЮЦІЇ ДОМЕННОГО ЯДРА |
+-----------------------------------------------------------------------+
| |
| [Вхідний контекст: Кошик, Промокоди, Знімок системного годинника] |
| │ |
| ▼ |
| +-------------------------------------+ |
| | 1. Валідація вхідних інваріантів | |
| +-------------------------------------+ |
| │ |
| ▼ |
| +-------------------------------------+ |
| | 2. Розрахунок базової вартості | |
| +-------------------------------------+ |
| │ |
| ▼ |
| +-------------------------------------+ |
| | 3. Пайплайн стратегій знижок | |
| | (Правила: Fixed -> Percent -> Cap) | |
| +-------------------------------------+ |
| │ |
| ▼ |
| [Фінальний розрахунок: Разом у центах, Список застосованих знижок] |
| |
+-----------------------------------------------------------------------+Під час розробки фінансових алгоритмів критично важливо усунути небезпеку похибок обчислень із рухомою комою (стандарт IEEE 754):
Clock), виключаючи недетерміновані виклики new Date().Поставимо завдання: розробити з нуля надійний рушій розрахунку вартості замовлення PricingEngine, що підтримує базову суму позицій, відсоткові промокоди, фіксовані знижки, перевірку терміну дії промокодів та захисний ліміт максимального списання кошика.
Починаємо з написання тесту, який падає (RED): рушій повинен коректно обчислювати суму позицій кошика та негайно викидати помилку, якщо ціна або кількість товарів некоректні.
import { describe, it, expect } from 'vitest';
import { PricingEngine, PricingError } from './pricing-engine';
describe('PricingEngine - Step 1: Base Calculation', () => {
const defaultClock = () => new Date('2026-03-01T10:00:00Z');
it('повинен коректно розраховувати базову вартість для валідних позицій', () => {
const engine = new PricingEngine(defaultClock);
const result = engine.calculate({
items: [
{ productId: 'item-1', unitPriceCents: 1000, quantity: 2 }, // 2000 центів
{ productId: 'item-2', unitPriceCents: 500, quantity: 1 }, // 500 центів
],
promotions: [],
});
expect(result.subtotalCents).toBe(2500);
expect(result.totalCents).toBe(2500);
expect(result.discountCents).toBe(0);
});
it('повинен викидати PricingError у разі від’ємної ціни або нульової кількості', () => {
const engine = new PricingEngine(defaultClock);
expect(() =>
engine.calculate({
items: [{ productId: 'item-1', unitPriceCents: -100, quantity: 1 }],
promotions: [],
})
).toThrow(PricingError);
});
});Реалізуємо мінімально достатній код (GREEN) для задоволення специфікації.
export class PricingError extends Error {
constructor(message: string) {
super(message);
this.name = 'PricingError';
}
}
export interface CartItem {
readonly productId: string;
readonly unitPriceCents: number;
readonly quantity: number;
}
export interface PricingCalculationResult {
readonly subtotalCents: number;
readonly discountCents: number;
readonly totalCents: number;
}
export interface PricingContext {
readonly items: readonly CartItem[];
readonly promotions: readonly unknown[];
}
export class PricingEngine {
constructor(private readonly clock: () => Date) {}
public calculate(context: PricingContext): PricingCalculationResult {
let subtotalCents = 0;
for (const item of context.items) {
if (item.unitPriceCents < 0 || item.quantity <= 0) {
throw new PricingError('Invalid item price or quantity');
}
subtotalCents += item.unitPriceCents * item.quantity;
}
return {
subtotalCents,
discountCents: 0,
totalCents: subtotalCents,
};
}
}Тести кроку 1 виконуються успішно (PASS).
Сформулюємо вимоги бізнесу до промокодів:
validUntil.describe('PricingEngine - Step 2: Promotions & Date Validation', () => {
const currentDate = new Date('2026-03-01T12:00:00Z');
const clock = () => currentDate;
it('повинен коректно застосовувати відсоткову знижку', () => {
const engine = new PricingEngine(clock);
const result = engine.calculate({
items: [{ productId: 'item-1', unitPriceCents: 10000, quantity: 1 }],
promotions: [
{
code: 'SPRING10',
type: 'PERCENTAGE',
value: 10,
validUntil: new Date('2026-04-01T00:00:00Z'),
},
],
});
expect(result.subtotalCents).toBe(10000);
expect(result.discountCents).toBe(1000);
expect(result.totalCents).toBe(9000);
});
it('повинен ігнорувати промокод, термін дії якого минув', () => {
const engine = new PricingEngine(clock);
const result = engine.calculate({
items: [{ productId: 'item-1', unitPriceCents: 10000, quantity: 1 }],
promotions: [
{
code: 'EXPIRED',
type: 'FIXED',
value: 2000,
validUntil: new Date('2026-02-01T00:00:00Z'), // Закінчився місяць тому
},
],
});
expect(result.discountCents).toBe(0);
expect(result.totalCents).toBe(10000);
});
});Тест падає (FAIL). Оновлюємо нашу доменну модель до фази GREEN.
export interface PercentagePromotion {
readonly code: string;
readonly type: 'PERCENTAGE';
readonly value: number; // 0 - 100
readonly validUntil: Date;
}
export interface FixedPromotion {
readonly code: string;
readonly type: 'FIXED';
readonly value: number; // У центах
readonly validUntil: Date;
}
export type Promotion = PercentagePromotion | FixedPromotion;
export interface PricingContext {
readonly items: readonly CartItem[];
readonly promotions: readonly Promotion[];
}
export class PricingEngine {
constructor(private readonly clock: () => Date) {}
public calculate(context: PricingContext): PricingCalculationResult {
let subtotalCents = 0;
for (const item of context.items) {
if (item.unitPriceCents < 0 || item.quantity <= 0) {
throw new PricingError('Invalid item price or quantity');
}
subtotalCents += item.unitPriceCents * item.quantity;
}
const now = this.clock();
let totalDiscountCents = 0;
for (const promo of context.promotions) {
if (promo.validUntil.getTime() < now.getTime()) {
continue; // Прострочено
}
if (promo.type === 'PERCENTAGE') {
const discount = Math.round((subtotalCents * promo.value) / 100);
totalDiscountCents += discount;
} else if (promo.type === 'FIXED') {
totalDiscountCents += promo.value;
}
}
const totalCents = Math.max(0, subtotalCents - totalDiscountCents);
return {
subtotalCents,
discountCents: totalDiscountCents,
totalCents,
};
}
}Усі 4 тести кроків 1 та 2 проходять (PASS).
Додамо складні доменні обмеження:
PricingError).describe('PricingEngine - Step 3: Complex Rules & Caps', () => {
const now = new Date('2026-03-01T12:00:00Z');
const clock = () => now;
it('повинен обмежувати сумарну знижку захисним порогом у 50% від суми кошика', () => {
const engine = new PricingEngine(clock);
const result = engine.calculate({
items: [{ productId: 'item-1', unitPriceCents: 10000, quantity: 1 }], // 10000 центів
promotions: [
{
code: 'SUPERFIXED',
type: 'FIXED',
value: 8000, // Намагається списати 80%
validUntil: new Date('2026-04-01T00:00:00Z'),
},
],
});
expect(result.subtotalCents).toBe(10000);
expect(result.discountCents).toBe(5000); // Обмежено 50%
expect(result.totalCents).toBe(5000);
});
it('повинен викидати помилку у разі передачі двох конфліктуючих відсоткових промокодів', () => {
const engine = new PricingEngine(clock);
expect(() =>
engine.calculate({
items: [{ productId: 'item-1', unitPriceCents: 10000, quantity: 1 }],
promotions: [
{
code: 'SALE10',
type: 'PERCENTAGE',
value: 10,
validUntil: new Date('2026-04-01T00:00:00Z'),
},
{
code: 'SALE20',
type: 'PERCENTAGE',
value: 20,
validUntil: new Date('2026-04-01T00:00:00Z'),
},
],
})
).toThrow('Multiple percentage promotions are not allowed');
});
});Тести падають. Розширюємо функцію обчислення:
public calculate(context: PricingContext): PricingCalculationResult {
let subtotalCents = 0;
for (const item of context.items) {
if (item.unitPriceCents < 0 || item.quantity <= 0) {
throw new PricingError('Invalid item price or quantity');
}
subtotalCents += item.unitPriceCents * item.quantity;
}
const now = this.clock();
let percentagePromosCount = 0;
for (const promo of context.promotions) {
if (promo.type === 'PERCENTAGE') {
percentagePromosCount++;
}
}
if (percentagePromosCount > 1) {
throw new PricingError('Multiple percentage promotions are not allowed');
}
let rawDiscountCents = 0;
for (const promo of context.promotions) {
if (promo.validUntil.getTime() < now.getTime()) {
continue;
}
if (promo.type === 'PERCENTAGE') {
rawDiscountCents += Math.round((subtotalCents * promo.value) / 100);
} else if (promo.type === 'FIXED') {
rawDiscountCents += promo.value;
}
}
// Захисний ліміт: максимум 50% від суми кошика
const maxAllowedDiscount = Math.floor(subtotalCents * 0.5);
const finalDiscountCents = Math.min(rawDiscountCents, maxAllowedDiscount);
const totalCents = subtotalCents - finalDiscountCents;
return {
subtotalCents,
discountCents: finalDiscountCents,
totalCents,
};
}Усі 6 тестів кроків 1-3 переходять у зелений статус (PASS).
Поточний код працює коректно, але порушує принцип відкритості/закритості (Open/Closed Principle): додавання нового механізму знижок (знижка на категорію, прогресивні бонуси) вимагатиме модифікації методу calculate. Виконаємо рефакторинг у поліморфну архітектуру Strategy під надійним захистом зелених тестів.
export class PricingError extends Error {
constructor(message: string) {
super(message);
this.name = 'PricingError';
}
}
export interface CartItem {
readonly productId: string;
readonly unitPriceCents: number;
readonly quantity: number;
}
export interface PercentagePromotion {
readonly code: string;
readonly type: 'PERCENTAGE';
readonly value: number;
readonly validUntil: Date;
}
export interface FixedPromotion {
readonly code: string;
readonly type: 'FIXED';
readonly value: number;
readonly validUntil: Date;
}
export type Promotion = PercentagePromotion | FixedPromotion;
export interface PricingCalculationResult {
readonly subtotalCents: number;
readonly discountCents: number;
readonly totalCents: number;
}
export interface PricingContext {
readonly items: readonly CartItem[];
readonly promotions: readonly Promotion[];
}
// 1. Інтерфейс стратегії розрахунку знижки
export interface DiscountStrategy {
calculateDiscount(subtotalCents: number, promo: Promotion, now: Date): number;
}
class PercentageDiscountStrategy implements DiscountStrategy {
calculateDiscount(subtotalCents: number, promo: Promotion, now: Date): number {
if (promo.type !== 'PERCENTAGE' || promo.validUntil.getTime() < now.getTime()) {
return 0;
}
return Math.round((subtotalCents * promo.value) / 100);
}
}
class FixedDiscountStrategy implements DiscountStrategy {
calculateDiscount(subtotalCents: number, promo: Promotion, now: Date): number {
if (promo.type !== 'FIXED' || promo.validUntil.getTime() < now.getTime()) {
return 0;
}
return promo.value;
}
}
// 2. Валідатор доменних інваріантів
class PricingValidator {
public static validate(context: PricingContext): void {
for (const item of context.items) {
if (item.unitPriceCents < 0 || item.quantity <= 0) {
throw new PricingError('Invalid item price or quantity');
}
}
const percentageCount = context.promotions.filter(
(p) => p.type === 'PERCENTAGE'
).length;
if (percentageCount > 1) {
throw new PricingError('Multiple percentage promotions are not allowed');
}
}
}
// 3. Фасад розрахункового рушія
export class PricingEngine {
private readonly strategies: ReadonlyMap<Promotion['type'], DiscountStrategy>;
private static readonly MAX_DISCOUNT_RATIO = 0.5; // 50% Cap
constructor(private readonly clock: () => Date) {
this.strategies = new Map<Promotion['type'], DiscountStrategy>([
['PERCENTAGE', new PercentageDiscountStrategy()],
['FIXED', new FixedDiscountStrategy()],
]);
}
public calculate(context: PricingContext): PricingCalculationResult {
PricingValidator.validate(context);
const subtotalCents = context.items.reduce(
(sum, item) => sum + item.unitPriceCents * item.quantity,
0
);
const now = this.clock();
let accumulatedDiscountCents = 0;
for (const promo of context.promotions) {
const strategy = this.strategies.get(promo.type);
if (strategy) {
accumulatedDiscountCents += strategy.calculateDiscount(subtotalCents, promo, now);
}
}
// Застосування захисного ліміту
const maxDiscountAllowed = Math.floor(subtotalCents * PricingEngine.MAX_DISCOUNT_RATIO);
const finalDiscountCents = Math.min(accumulatedDiscountCents, maxDiscountAllowed);
const totalCents = Math.max(0, subtotalCents - finalDiscountCents);
return {
subtotalCents,
discountCents: finalDiscountCents,
totalCents,
};
}
}Запуск Vitest підтверджує: 100% тестів залишаються зеленими. Ми суттєво оптимізували архітектуру рушія, відокремили валідацію та впровадили поліморфізм, не змінивши жодного рядка у специфікаціях.
Збереження грошових цін у вигляді дробових чисел (19.99). Операція 0.1 + 0.2 у JavaScript повертає 0.30000000000000004. У фінансових розрахунках це спричиняє накопичення помилок округлення та розбіжності бухгалтерських балансів. Завжди оперуйте цілими числами (cents, minor units).
Прямий виклик const now = new Date() всередині розрахункових методів позбавляє тести детермінізму. Тест із валідацією терміну дії промокоду, що успішно проходить сьогодні, гарантовано впаде через кілька місяців після дати експірації. Завжди передавайте провайдер часу clock: () => Date через конструктор або параметри методу.
Модифікація переданого об'єкта кошика (наприклад, додавання прапорця item.discountApplied = true). Це породжує приховані сайд-ефекти в дереві React-компонентів і руйнує передбачуваність стану. Доменні методи мають бути чистими функціями, що повертають нові імутабельні об'єкти.
Спроба побудувати складний конфігуратор правил з AST-деревом на першому ж кроці. Допоки специфікації не продемонструють реальну складність і дублювання, пишіть найпростіший прямий код. Архітектурні абстракції повинні кристалізуватися на фазі Refactor.
Написати найпростіший тест без побічних ефектів. Перевірити граничні вхідні дані (від'ємні числа, порожні масиви).
Реалізувати пряму логіку в одному методі до досягнення зеленого статусу тестів.
Додати тести на часові інтервали, ліміти списання (Caps) та конфліктуючі промокоди.
Ізолювати умовні розгалуження в окремі класи-стратегії під захистом тестів.
Перевірити ділення на нуль, переповнення цілих чисел та точність округлення до цента.