SOLID на живом примере: рефакторинг заказа на TypeScript
Один класс оформляет заказ, считает скидку, списывает деньги и отправляет письмо — пока проект маленький, это незаметно. Разбираем, как поэтапно разделить такую систему по SOLID на TypeScript 5.x и не превратить код в лес интерфейсов.

Когда SOLID действительно нужен
Класс оформления заказа часто начинается с 30 строк: проверить корзину, посчитать сумму, списать деньги и отправить письмо. Через месяц в него добавляются промокоды, оплата по СБП, повторная отправка уведомлений и возвраты. Любое изменение начинает затрагивать соседние сценарии, а тест оформления заказа требует настоящую платёжную систему и почтовый сервер.
Ниже — один живой сценарий на TypeScript 5.x: интернет-магазин оформляет заказ. Мы не будем создавать интерфейс для каждой строки. Сначала увидим конкретную связность, затем разделим её там, где изменение действительно имеет цену.
SOLID — не лицензия на пять интерфейсов. Это способ уменьшить число причин, по которым один и тот же код приходится менять.
1 — Связность означает, насколько много частей системы должны измениться вместе. Чем больше независимых причин для изменения у класса, тем выше его связность.
Исходный код: один сервис знает слишком много
Начнём с намеренно плохого, но реалистичного варианта. Он компилируется в TypeScript 5.x и выглядит удобным, пока магазин принимает только банковские карты.
type CartItem = {
productId: string;
price: number;
quantity: number;
};
class OrderService {
async createOrder(items: CartItem[], cardNumber: string): Promise<string> {
if (items.length === 0) {
throw new Error("Корзина пуста");
}
const total = items.reduce(
(sum, item) => sum + item.price * item.quantity,
0,
);
if (cardNumber.length < 12) {
throw new Error("Неверный номер карты");
}
console.log(`Платёж на ${total} рублей отправлен в банк`);
console.log("Письмо отправлено покупателю");
return `order-${Date.now()}`;
}
}
Проблема не в длине класса. Он одновременно содержит правила заказа, расчёт цены, проверку карты, платёжную интеграцию и уведомления. Если бухгалтерия изменит формулу скидки, разработчик снова откроет этот класс. Если появится СБП, придётся менять тот же метод и его тесты.
S: один модуль — одна причина для изменения
Принцип единственной ответственности не говорит, что класс должен содержать один метод. Он говорит: у класса должна быть одна группа связанных причин для изменения. Платёжный шлюз и письмо покупателю к такой группе не относятся.
Сначала вынесем предметные действия в отдельные классы. Здесь достаточно простых зависимостей без контейнера внедрения зависимостей.
type Order = {
id: string;
items: CartItem[];
total: number;
};
class TotalCalculator {
calculate(items: CartItem[]): number {
return items.reduce(
(sum, item) => sum + item.price * item.quantity,
0,
);
}
}
class OrderValidator {
validate(items: CartItem[]): void {
if (items.length === 0) {
throw new Error("Корзина пуста");
}
for (const item of items) {
if (item.quantity < 1 || item.price < 0) {
throw new Error("Некорректная позиция товара");
}
}
}
}
class OrderService {
constructor(
private readonly validator: OrderValidator,
private readonly calculator: TotalCalculator,
) {}
createOrder(items: CartItem[]): Order {
this.validator.validate(items);
const total = this.calculator.calculate(items);
return {
id: `order-${Date.now()}`,
items,
total,
};
}
}
Теперь изменение проверки товара не требует открывать расчёт суммы. Это ещё не «идеальный» дизайн: генерация идентификатора и сохранение заказа пока не показаны. Мы сознательно не расширяем пример задачами, которые не нужны для демонстрации принципа.
O: расширяем поведение, а не переписываем работающий код
Представим, что скидка зависит от типа клиента. Плохой путь — добавлять в калькулятор новый if для каждого типа. Сначала это выглядит так:
class TotalCalculator {
calculate(items: CartItem[], customerType: string): number {
const subtotal = items.reduce(
(sum, item) => sum + item.price * item.quantity,
0,
);
if (customerType === "vip") {
return subtotal * 0.9;
}
return subtotal;
}
}
При появлении сезонной скидки класс снова меняется. Вместо этого выделим правило скидки в функцию-стратегию. Для каждого нового правила добавляется новый объект, а базовый калькулятор остаётся прежним.
interface DiscountRule {
apply(subtotal: number): number;
}
class NoDiscount implements DiscountRule {
apply(subtotal: number): number {
return subtotal;
}
}
class VipDiscount implements DiscountRule {
apply(subtotal: number): number {
return subtotal * 0.9;
}
}
class TotalCalculator {
constructor(private readonly discountRule: DiscountRule) {}
calculate(items: CartItem[]): number {
const subtotal = items.reduce(
(sum, item) => sum + item.price * item.quantity,
0,
);
return this.discountRule.apply(subtotal);
}
}
O — это не запрет на изменение любого файла. Если меняется само правило скидки, файл с этим правилом менять нормально. Не должен меняться код, который умеет только суммировать товары.
L: подстановка не должна ломать ожидания
Частая ошибка — назвать наследованием несовместимые варианты. Допустим, базовый платёжный клиент обещает вернуть идентификатор операции. Если класс для тестов иногда выбрасывает ошибку на любой платёж или возвращает пустую строку, он формально подходит по типу, но ломает контракт.
interface PaymentGateway {
charge(amount: number): Promise<{ transactionId: string }>;
}
class CardPaymentGateway implements PaymentGateway {
async charge(amount: number): Promise<{ transactionId: string }> {
if (amount <= 0) {
throw new Error("Сумма платежа должна быть положительной");
}
return { transactionId: `card-${amount}` };
}
}
class FakePaymentGateway implements PaymentGateway {
async charge(amount: number): Promise<{ transactionId: string }> {
return { transactionId: `test-${amount}` };
}
}
Оба объекта можно передать в оформление заказа. Клиенту не важно, настоящий это шлюз или тестовый. В продакшене реализация должна добавить сетевой вызов, обработку отказа и идемпотентность2, но не менять обещанный результат.
2 — Идемпотентность — свойство операции, при котором повтор одного и того же запроса не создаёт второй платёж или другой нежелательный результат.
I: интерфейс должен быть узким
Большой интерфейс заставляет классы реализовывать методы, которые им не нужны. Например, общий интерфейс платёжного шлюза с возвратом денег, сохранением карты и оплатой создаёт лишние зависимости для простого клиента.
interface PaymentReader {
charge(amount: number): Promise<{ transactionId: string }>;
}
interface RefundProcessor {
refund(transactionId: string, amount: number): Promise<void>;
}
class CheckoutService {
constructor(private readonly payment: PaymentReader) {}
async pay(total: number): Promise<string> {
const result = await this.payment.charge(total);
return result.transactionId;
}
}
class RefundService {
constructor(private readonly refunds: RefundProcessor) {}
returnMoney(transactionId: string, amount: number): Promise<void> {
return this.refunds.refund(transactionId, amount);
}
}
Теперь изменение возвратов не затрагивает оформление заказа. Это особенно полезно, когда разные команды владеют оплатой и возвратами или когда один из сценариев ещё не поддерживает конкретный метод.
D: зависимости направлены на правила, а не на детали
Финальный сервис не должен сам создавать банковский клиент и почтовый транспорт. Иначе тест обязан знать про реальные настройки, а замена провайдера превращается в правку бизнес-логики.
interface OrderRepository {
save(order: Order): Promise<void>;
}
interface Notifier {
sendOrderCreated(orderId: string): Promise<void>;
}
class CheckoutService {
constructor(
private readonly validator: OrderValidator,
private readonly calculator: TotalCalculator,
private readonly payment: PaymentReader,
private readonly orders: OrderRepository,
private readonly notifier: Notifier,
) {}
async checkout(items: CartItem[]): Promise<Order> {
this.validator.validate(items);
const total = this.calculator.calculate(items);
const payment = await this.payment.charge(total);
const order: Order = {
id: `order-${payment.transactionId}`,
items,
total,
};
await this.orders.save(order);
await this.notifier.sendOrderCreated(order.id);
return order;
}
}
Зависимости передаются через конструктор, поэтому при создании теста можно передать заглушки. Самая важная часть здесь — не интерфейсы, а направление: бизнес-сервис знает абстракции оплаты и хранения, но не знает конкретный SDK банка или базу данных.
Что выбрать на практике
Все пять принципов не нужно внедрять одновременно. Сначала найдите изменение, которое уже дорого обходится, и вынесите только его.
| Ситуация | Первый шаг | Цена решения |
|---|---|---|
| В одном классе проверка, расчёт и отправка писем | SRP: разделить роли | Появится больше файлов и конструкторных зависимостей |
| Для каждого нового правила растёт цепочка if | OCP: выделить стратегию правила | Придётся назвать и зарегистрировать новые реализации |
| Тестовая реализация ведёт себя иначе, чем боевая | LSP: зафиксировать контракт результата и ошибок | Нужно явно описать допустимое поведение |
| Клиент зависит от методов, которыми не пользуется | ISP: разделить интерфейс по сценариям | Появятся несколько маленьких типов вместо одного |
| Сервис создаёт SDK и подключение к базе сам | DIP: передать зависимости снаружи | Точку сборки приложения придётся настроить отдельно |
Если после рефакторинга для простого заказа нужно вручную собрать десять объектов, остановитесь. Возможно, вы вынесли детали слишком рано. Удобная граница — отдельная причина изменения, внешний ресурс или сценарий, который нужно заменить в тесте.
Что ломается чаще всего
- Интерфейс ради интерфейса. Если у типа одна реализация и её не нужно заменять, сначала оставьте обычный класс.
- Сервис-оркестратор превращается в новый монолит. Он может координировать шаги, но не должен считать скидку и разбирать ответ платёжного SDK.
- Наследование используется вместо композиции. Новый способ оплаты обычно лучше передать как зависимость, а не делать наследника огромного базового класса.
- Абстракция скрывает важную ошибку. Если интерфейс не описывает отказ платежа, повтор запроса или пустой результат, проблема появится уже в продакшене.
- Тесты проверяют только структуру. Важнее проверить, что сумма не списывается при пустой корзине, заказ сохраняется после успешной оплаты, а уведомление не подменяет результат платежа.
Что делать дальше
Возьмите один рабочий сервис и выпишите его причины для изменения: бизнес-правило, внешний провайдер, хранение данных, уведомление. Затем найдите одну зависимость, которую сложнее всего заменить в тесте, и передайте её через конструктор. После каждого шага запускайте компилятор TypeScript 5.x и существующие тесты.
В продакшене следующим измерением станет не количество интерфейсов, а число повторных платежей, доля ошибок внешнего шлюза и время оформления заказа. Если после разделения классов эти показатели не стали понятнее или управляемее, рефакторинг пока не достиг цели.
Репетитор 1—11 класс и ВУЗ: Математика и физика методом наведения на мысль, помощь с ДЗ.
Спросить за 8 ₽Источники: TypeScript Handbook: Classes, TypeScript Handbook: Interfaces
Частые вопросы
Нужно ли применять все пять принципов SOLID в каждом проекте?
Не станет ли код слишком сложным из-за интерфейсов?
Чем SOLID отличается от микросервисной архитектуры?
Что делать, если у меня другая версия TypeScript?
Почему после рефакторинга стало больше файлов, а ошибок не меньше?
Какие подводные камни будут в продакшене?
Материал носит информационный характер и подготовлен редакцией «Агентуры». Он не является офертой, рекламой или индивидуальной консультацией. Упомянутые продукты, компании и торговые знаки принадлежат их правообладателям. Перед принятием решений, влекущих юридические или финансовые последствия, обратитесь к профильному специалисту.