Чистая доменная логика (Pure Domain Logic) — это ядро любого приложения, свободное от инфраструктурных зависимостей, баз данных, сетевых сокетов и UI-рендеринга. В соответствии с принципами чистой архитектуры (Clean Architecture) и предметно-ориентированного проектирования (DDD), доменное ядро должно быть представлено чистыми, детерминированными функциями или неизменяемыми агрегатами.
Детерминизм делает чистую бизнес-логику идеальным полигоном для Test-Driven Development. Отсутствие внешнего ввода-вывода позволяет запускать тысячи тестов за миллисекунды, мгновенно проверяя инварианты предметной области.
В реальных энтерпрайз-системах правила ценообразования, расчета тарифов и скидок постоянно усложняются. Без TDD код калькулятора быстро деградирует в гигантский клубок вложенных операторов if-else и switch-case, где добавление нового условия ломает ранее заложенные граничные сценарии. TDD позволяет эволюционно вырастить гибкую архитектуру (например, паттерны Strategy или Chain of Responsibility) под непрерывной защитой автоматизированных спецификаций.
Разработка доменного алгоритма через TDD следует пути эволюционного проектирования: от простейшего вычисления к гибкой компонуемой архитектуре правил.
+-----------------------------------------------------------------------+
| ПАТТЕРН ЭВОЛЮЦИИ ДОМЕННОГО ЯДРА |
+-----------------------------------------------------------------------+
| |
| [Входной контекст: Корзина, Промокоды, Временная метка] |
| │ |
| ▼ |
| +-------------------------------------+ |
| | 1. Валидация входных инвариантов | |
| +-------------------------------------+ |
| │ |
| ▼ |
| +-------------------------------------+ |
| | 2. Расчет базовой стоимости (Base) | |
| +-------------------------------------+ |
| │ |
| ▼ |
| +-------------------------------------+ |
| | 3. Пайплайн стратегий скидок | |
| | (Rules: Fixed -> Percent -> Cap) | |
| +-------------------------------------+ |
| │ |
| ▼ |
| [Финальный расчет: Итог в центах, Список примененных скидок] |
| |
+-----------------------------------------------------------------------+При разработке финансовых алгоритмов критически важно избегать ошибок вычислений с плавающей запятой (стандарт IEEE 754).
Clock), предотвращая неявные вызовы new Date().Описать простейший тест без побочных эффектов. Проверить входные границы (отрицательные числа, пустые массивы).
Написать прямой алгоритм в одном методе. Добиться зеленого статуса тестов.
Добавить тесты на временные интервалы, лимиты (Caps) и несовместимые модификаторы.
Изолировать ветвления в отдельные классы стратегий или функции-редьюсеры под защитой тестов.
Покрыть тестами деление на ноль, переполнение числовых типов и точность округления до цента.
Почему при проектировании биллинговых калькуляторов и правил скидок по TDD категорически запрещено использовать тип Number с плавающей запятой и прямой вызов new Date()?
Поставим задачу: разработать с нуля движок расчета стоимости заказа PricingEngine, поддерживающий базовую стоимость товаров, процентные промокоды, фиксированные скидки, проверку срока действия промокодов и ограничение максимального процента списания.
Начинаем с написания падающего теста: движок должен корректно рассчитывать сумму позиций корзины и выбрасывать ошибку, если количество или цена отрицательны.
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 cents
{ productId: 'item-2', unitPriceCents: 500, quantity: 1 }, // 500 cents
],
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), так как тип promotions не обрабатывается. Переходим к фазе 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[];
}
// Расширяем класс PricingEngine
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).
Добавим сложные бизнес-правила:
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('должен выбрасывать PricingError при передаче двух конфликтующих процентных промокодов', () => {
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');
});
});Тесты падают. Пишем код для прохождения тестов.
// Обновляем метод calculate
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;
}
}
// Бизнес-правило Cap: максимум 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 (OCP): добавление новых типов скидок (например, скидка на категорию или промокод 2+1) потребует редактирования основного метода calculate. Проведем рефакторинг в архитектуру стратегий под защитой зеленых тестов.
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% тестов остаются зелеными. Мы полностью изменили архитектуру движка, внедрили полиморфные стратегии и выделили валидатор, не изменив ни одной строчки в тестах спецификации.
В какой момент сложный метод расчета скидок с многочисленными if-else ветвлениями должен быть отрефакторен в паттерн Strategy под защитой TDD-тестов?
Хранение цен в виде дробных чисел (19.99). Операция 0.1 + 0.2 в JavaScript возвращает 0.30000000000000004. В финансовых расчетах это приводит к накапливающейся ошибке округления и несоответствию бухгалтерским балансам. Всегда оперируйте целыми числами (cents, minor units).
Прямой вызов const now = new Date() внутри расчетных методов лишает тесты детерминизма. Тест с валидацией даты промокода, успешно проходящий сегодня, гарантированно упадет через месяц, когда наступит дата экспирации. Передавайте провайдер времени clock: () => Date в конструктор доменного сервиса.
Модификация переданного объекта корзины (например, добавление поля item.discountApplied = true). Это вызывает скрытые сайд-эффекты в React-компонентах и нарушает предсказуемость рендера. Доменные методы должны быть чистыми функциями, возвращающими новые неизменяемые структуры.
Попытка сразу на первом шаге построить мета-конфигуратор правил с графом AST и динамическими резолверами. До тех пор пока тесты не продемонстрировали реальную сложность и дублирование, пишите простейший код. Архитектура должна естественно кристаллизоваться на фазе Refactor.