Пошук уроків, статей та іншого контенту
Спроєктуєте невелику систему класів, оберете між успадкуванням і композицією та протестуєте її поведінку.
Під час проєктування системи класів важливо не просто розкласти код на class, а визначити:
які об’єкти є частиною предметної області;
які правила повинні залишатися незмінними;
які залежності варто передавати ззовні;
де доречне успадкування;
де безпечнішою буде композиція;
як перевірити поведінку системи без реальної бази даних або платіжного сервісу.
У цьому занятті спроєктуємо спрощену систему оформлення замовлення:
кошик містить товари;
до кошика застосовується політика знижок;
система створює замовлення;
платіжний сервіс списує кошти;
сервіс сповіщень повідомляє про успішну оплату.
Почнемо з відповідальностей класів.
ProductОписує товар:
ідентифікатор;
назву;
ціну в копійках.
Ціна зберігається в найменших грошових одиницях, а не у float. Це уникає помилок на кшталт:
0.1 + 0.2 !== 0.3CartВідповідає за:
додавання товарів;
зміну кількості;
обчислення суми;
перевірку, що кількість товару додатна.
Кошик не повинен знати, як саме працює оплата або надсилання сповіщень.
DiscountPolicyОписує правило обчислення знижки. Система може використовувати:
відсутність знижки;
відсоткову знижку;
фіксовану знижку;
комбінацію кількох знижок.
Замість створення підкласів кошика для кожного виду знижки передамо політику в кошик або сервіс оформлення замовлення як залежність.
OrderПредставляє вже створене замовлення. Воно має життєвий цикл:
pending → paidЗамовлення не можна повторно оплатити.
PaymentGatewayЦе контракт платіжного сервісу. Справжня реалізація може працювати із зовнішнім API, а тестова — просто записувати виклики.
NotifierВідповідає за сповіщення клієнта. У production-коді це може бути електронна пошта, push-повідомлення або повідомлення в месенджері.
CheckoutServiceОркеструє процес:
перевіряє кошик;
обчислює знижку;
створює замовлення;
викликає платіжний сервіс;
змінює стан замовлення;
надсилає сповіщення.
Це приклад сервісного класу, який координує кілька об’єктів, але не володіє всією їхньою логікою.
Успадкування доречне, якщо виконується справжнє відношення «є різновидом»:
PaymentError є Error
ValidationError є ErrorПідклас повинен бути взаємозамінним із базовим класом. Якщо функція очікує Error, вона повинна коректно працювати і з PaymentError.
У JavaScript для цього можна успадкуватися від стандартного Error:
class PaymentError extends Error {
constructor(message) {
super(message);
this.name = "PaymentError";
}
}Композиція означає, що один об’єкт отримує інший об’єкт як залежність:
const checkout = new CheckoutService({
paymentGateway,
notifier,
discountPolicy,
});Це корисно, коли:
поведінку потрібно легко замінювати;
залежність може мати кілька реалізацій;
не існує стабільного відношення «є різновидом»;
класи не повинні знати деталі інфраструктури;
потрібно спростити тестування.
Наприклад, CheckoutService не повинен успадковуватися від PaymentGateway. Він лише використовує платіжний шлюз.
Поганий варіант:
Cart
├── CartWithPercentageDiscount
├── CartWithFixedDiscount
└── CartWithVipDiscountУ такій моделі зміна знижки змушує створювати новий підклас. Якщо згодом з’являться податки, промокоди та безкоштовна доставка, кількість комбінацій швидко зросте.
Краще передати окрему політику:
Cart + DiscountPolicyКошик залишається незмінним, а поведінку знижки можна комбінувати.
Нижче наведено повний приклад, який можна зберегти у файл checkout.js і запустити командою:
node checkout.js"use strict";
const assert = require("node:assert/strict");
class ValidationError extends Error {
constructor(message) {
super(message);
this.name = "ValidationError";
}
}
class PaymentError extends Error {
constructor(message) {
super(message);
this.name = "PaymentError";
}
}
class Product {
constructor({ id, name, priceCents }) {
if (!id || typeof id !== "string") {
throw new ValidationError("Товар повинен мати ідентифікатор");
}
if (!name || typeof name !== "string") {
throw new ValidationError("Товар повинен мати назву");
}
if (!Number.isInteger(priceCents) || priceCents < 0) {
throw new ValidationError(
"Ціна повинна бути невід'ємним цілим числом у копійках",
);
}
this.id = id;
this.name = name;
this.priceCents = priceCents;
}
}
class Cart {
#items = new Map();
add(product, quantity = 1) {
if (!(product instanceof Product)) {
throw new ValidationError("До кошика можна додавати лише товари");
}
if (!Number.isInteger(quantity) || quantity <= 0) {
throw new ValidationError("Кількість повинна бути додатним цілим числом");
}
const currentQuantity = this.#items.get(product.id)?.quantity ?? 0;
this.#items.set(product.id, {
product,
quantity: currentQuantity + quantity,
});
return this;
}
remove(productId) {
this.#items.delete(productId);
return this;
}
get isEmpty() {
return this.#items.size === 0;
}
get items() {
return [...this.#items.values()].map(({ product, quantity }) => ({
product,
quantity,
}));
}
get totalCents() {
return this.items.reduce(
(total, item) => total + item.product.priceCents * item.quantity,
0,
);
}
}
class NoDiscount {
discountCents() {
return 0;
}
}
class PercentageDiscount {
constructor(rate) {
if (typeof rate !== "number" || rate < 0 || rate > 1) {
throw new ValidationError(
"Розмір відсоткової знижки повинен бути між 0 і 1",
);
}
this.rate = rate;
}
discountCents(subtotalCents) {
return Math.floor(subtotalCents * this.rate);
}
}
class FixedDiscount {
constructor(amountCents) {
if (!Number.isInteger(amountCents) || amountCents < 0) {
throw new ValidationError(
"Фіксована знижка повинна бути невід'ємним цілим числом",
);
}
this.amountCents = amountCents;
}
discountCents(subtotalCents) {
return Math.min(this.amountCents, subtotalCents);
}
}
class SequentialDiscount {
constructor(discounts) {
if (!Array.isArray(discounts)) {
throw new ValidationError("Знижки повинні бути масивом");
}
for (const discount of discounts) {
if (
!discount ||
typeof discount.discountCents !== "function"
) {
throw new ValidationError(
"Кожна знижка повинна мати метод discountCents",
);
}
}
this.discounts = discounts;
}
discountCents(subtotalCents) {
let currentTotal = subtotalCents;
for (const discount of this.discounts) {
const discountAmount = discount.discountCents(currentTotal);
if (
!Number.isInteger(discountAmount) ||
discountAmount < 0 ||
discountAmount > currentTotal
) {
throw new ValidationError("Некоректна сума знижки");
}
currentTotal -= discountAmount;
}
return subtotalCents - currentTotal;
}
}
class Order {
constructor({ id, items, totalCents }) {
this.id = id;
this.items = items.map(({ product, quantity }) => ({
product,
quantity,
}));
this.totalCents = totalCents;
this.status = "pending";
this.transactionId = null;
}
markPaid(transactionId) {
if (this.status !== "pending") {
throw new ValidationError(
"Оплатити замовлення можна лише зі статусом pending",
);
}
if (!transactionId) {
throw new ValidationError("Потрібен ідентифікатор транзакції");
}
this.status = "paid";
this.transactionId = transactionId;
}
}
class CheckoutService {
constructor({ paymentGateway, notifier, discountPolicy, idGenerator }) {
if (
!paymentGateway ||
typeof paymentGateway.charge !== "function"
) {
throw new ValidationError(
"Платіжний шлюз повинен мати метод charge",
);
}
if (!notifier || typeof notifier.send !== "function") {
throw new ValidationError(
"Сервіс сповіщень повинен мати метод send",
);
}
if (
!discountPolicy ||
typeof discountPolicy.discountCents !== "function"
) {
throw new ValidationError(
"Політика знижок повинна мати метод discountCents",
);
}
if (typeof idGenerator !== "function") {
throw new ValidationError("Потрібен генератор ідентифікаторів");
}
this.paymentGateway = paymentGateway;
this.notifier = notifier;
this.discountPolicy = discountPolicy;
this.idGenerator = idGenerator;
}
checkout(cart, customerId) {
if (!(cart instanceof Cart) || cart.isEmpty) {
throw new ValidationError("Неможливо оформити порожній кошик");
}
if (!customerId || typeof customerId !== "string") {
throw new ValidationError("Потрібен ідентифікатор клієнта");
}
const subtotalCents = cart.totalCents;
const discountCents =
this.discountPolicy.discountCents(subtotalCents);
const totalCents = subtotalCents - discountCents;
if (totalCents <= 0) {
throw new ValidationError(
"Сума замовлення повинна бути більшою за нуль",
);
}
const order = new Order({
id: this.idGenerator(),
items: cart.items,
totalCents,
});
const transaction = this.paymentGateway.charge({
orderId: order.id,
customerId,
amountCents: order.totalCents,
});
order.markPaid(transaction.id);
this.notifier.send({
type: "order.paid",
orderId: order.id,
customerId,
amountCents: order.totalCents,
});
return order;
}
}
class FakePaymentGateway {
constructor({ shouldFail = false } = {}) {
this.shouldFail = shouldFail;
this.charges = [];
}
charge({ orderId, customerId, amountCents }) {
if (this.shouldFail) {
throw new PaymentError("Платіж відхилено");
}
const transaction = {
id: `transaction-${this.charges.length + 1}`,
orderId,
customerId,
amountCents,
};
this.charges.push(transaction);
return transaction;
}
}
class MemoryNotifier {
constructor() {
this.messages = [];
}
send(message) {
this.messages.push(message);
}
}
function createIdGenerator() {
let counter = 0;
return () => {
counter += 1;
return `order-${counter}`;
};
}
function testSuccessfulCheckout() {
const coffee = new Product({
id: "coffee",
name: "Кава",
priceCents: 1200,
});
const cake = new Product({
id: "cake",
name: "Чізкейк",
priceCents: 1800,
});
const cart = new Cart()
.add(coffee, 2)
.add(cake);
assert.equal(cart.totalCents, 4200);
const discountPolicy = new SequentialDiscount([
new PercentageDiscount(0.1),
new FixedDiscount(200),
]);
const paymentGateway = new FakePaymentGateway();
const notifier = new MemoryNotifier();
const checkout = new CheckoutService({
paymentGateway,
notifier,
discountPolicy,
idGenerator: createIdGenerator(),
});
const order = checkout.checkout(cart, "customer-1");
// Після знижки 10% залишається 3780 копійок,
// після фіксованої знижки — 3580 копійок.
assert.equal(order.id, "order-1");
assert.equal(order.totalCents, 3580);
assert.equal(order.status, "paid");
assert.equal(order.transactionId, "transaction-1");
assert.deepEqual(paymentGateway.charges, [
{
id: "transaction-1",
orderId: "order-1",
customerId: "customer-1",
amountCents: 3580,
},
]);
assert.deepEqual(notifier.messages, [
{
type: "order.paid",
orderId: "order-1",
customerId: "customer-1",
amountCents: 3580,
},
]);
}
function testEmptyCartIsRejected() {
const checkout = new CheckoutService({
paymentGateway: new FakePaymentGateway(),
notifier: new MemoryNotifier(),
discountPolicy: new NoDiscount(),
idGenerator: createIdGenerator(),
});
assert.throws(
() => checkout.checkout(new Cart(), "customer-1"),
{
name: "ValidationError",
message: "Неможливо оформити порожній кошик",
},
);
}
function testPaymentFailureDoesNotSendNotification() {
const product = new Product({
id: "book",
name: "Книга",
priceCents: 2500,
});
const cart = new Cart().add(product);
const paymentGateway = new FakePaymentGateway({
shouldFail: true,
});
const notifier = new MemoryNotifier();
const checkout = new CheckoutService({
paymentGateway,
notifier,
discountPolicy: new NoDiscount(),
idGenerator: createIdGenerator(),
});
assert.throws(
() => checkout.checkout(cart, "customer-1"),
{
name: "PaymentError",
message: "Платіж відхилено",
},
);
assert.equal(notifier.messages.length, 0);
assert.equal(paymentGateway.charges.length, 0);
}
function testOrderCannotBePaidTwice() {
const order = new Order({
id: "order-1",
items: [],
totalCents: 1000,
});
order.markPaid("transaction-1");
assert.throws(
() => order.markPaid("transaction-2"),
{
name: "ValidationError",
message: "Оплатити замовлення можна лише зі статусом pending",
},
);
}
testSuccessfulCheckout();
testEmptyCartIsRejected();
testPaymentFailureDoesNotSendNotification();
testOrderCannotBePaidTwice();
console.log("Усі тести успішно пройдено");Поле #items є приватним:
class Cart {
#items = new Map();
}Зовнішній код не може змінити його безпосередньо. Усі зміни проходять через add() і remove().
Це дозволяє контролювати інваріанти:
кількість товару завжди додатна;
ключем колекції є ідентифікатор товару;
повторне додавання товару збільшує кількість, а не створює дубль.
Якби items був публічним масивом, будь-який код міг би записати туди некоректні дані.
У прикладі:
priceCents: 1200означає 12 гривень.
Переваги такого підходу:
немає похибок двійкової арифметики з дробами;
легше порівнювати суми;
значення можна передавати до платіжного API без додаткового округлення.
У реальній системі також потрібно враховувати валюту. Якщо замовлення може містити різні валюти, одного числа буде недостатньо.
Класи NoDiscount, PercentageDiscount і FixedDiscount мають спільний очікуваний метод:
discountCents(subtotalCents)У JavaScript немає обов’язкового ключового слова interface, як у TypeScript. Тому контракт перевіряється під час створення сервісу:
if (
!discountPolicy ||
typeof discountPolicy.discountCents !== "function"
) {
throw new ValidationError(
"Політика знижок повинна мати метод discountCents",
);
}Це приклад структурної сумісності: об’єкт підходить не через належність до певного класу, а через наявність потрібної поведінки.
Тому політикою може бути навіть звичайний об’єкт:
const weekendDiscount = {
discountCents(subtotalCents) {
return Math.floor(subtotalCents * 0.15);
},
};Такий об’єкт можна передати до CheckoutService, якщо він дотримується контракту.
SequentialDiscount демонструє композицію об’єктів:
const discountPolicy = new SequentialDiscount([
new PercentageDiscount(0.1),
new FixedDiscount(200),
]);Знижки застосовуються послідовно:
сума 4200;
мінус 10% — залишається 3780;
мінус 200 — залишається 3580.
Порядок має значення. Якби спочатку застосовувалася фіксована знижка, а потім відсоткова, результат був би іншим.
У production-системі порядок таких правил потрібно зробити явною частиною бізнес-вимог.
CheckoutService не створює самостійно платіжний шлюз або сервіс сповіщень:
const checkout = new CheckoutService({
paymentGateway,
notifier,
discountPolicy,
idGenerator: createIdGenerator(),
});Це називається ін’єкцією залежностей.
Переваги:
легко замінити реальний платіжний шлюз тестовим;
клас не прив’язаний до конкретного способу сповіщень;
залежності видно з конструктора;
тести не потребують мережі або бази даних.
У прикладі успадкування застосоване для помилок:
class ValidationError extends Error {
// ...
}
class PaymentError extends Error {
// ...
}Це доречне успадкування, оскільки обидва класи є різновидами Error, але додають семантичний тип.
Код може розрізняти помилки:
try {
checkout.checkout(cart, "customer-1");
} catch (error) {
if (error instanceof PaymentError) {
console.log("Потрібно запропонувати інший спосіб оплати");
} else if (error instanceof ValidationError) {
console.log("Потрібно виправити дані замовлення");
} else {
throw error;
}
}Водночас PercentageDiscount і FixedDiscount не успадковуються від спільного базового класу. Для цієї задачі достатньо однакового контракту методу. Створення абстрактного класу на кшталт BaseDiscount не додало б корисної спільної реалізації.
Замовлення має просту модель станів:
pending → paidМетод markPaid() захищає перехід:
markPaid(transactionId) {
if (this.status !== "pending") {
throw new ValidationError(
"Оплатити замовлення можна лише зі статусом pending",
);
}
this.status = "paid";
this.transactionId = transactionId;
}У складнішій системі можуть з’явитися стани:
pending
payment_failed
paid
processing
shipped
cancelled
refundedТоді варто явно описати дозволені переходи. Один із можливих підходів:
const allowedTransitions = {
pending: ["paid", "payment_failed", "cancelled"],
paid: ["processing", "refunded"],
processing: ["shipped", "cancelled"],
shipped: [],
payment_failed: ["pending", "cancelled"],
cancelled: [],
refunded: [],
};Окремий об’єкт переходів часто зрозуміліший, ніж велика кількість умов у методах класу.
У прикладі спочатку викликається платіжний шлюз:
const transaction = this.paymentGateway.charge({
orderId: order.id,
customerId,
amountCents: order.totalCents,
});
order.markPaid(transaction.id);Якщо charge() викидає помилку:
замовлення не переходить у стан paid;
сповіщення не надсилається;
тестовий шлюз не записує успішну транзакцію.
У реальній системі можуть виникати складніші сценарії:
платіж успішний, але сповіщення не відправилося;
відповідь платіжного шлюзу втрачена через мережеву помилку;
клієнт повторив запит;
платіжний шлюз виконав операцію двічі.
Для таких випадків потрібні додаткові механізми:
ідемпотентний ключ платежу;
повторні спроби;
журнал подій;
черга повідомлень;
шаблон transactional outbox;
окремий процес синхронізації стану платежу.
Тобто класова модель — лише частина надійної системи. Вона не замінює транзакції та протоколи взаємодії із зовнішніми сервісами.
Тести перевіряють не внутрішні поля класів, а спостережувану поведінку:
правильну суму замовлення;
зміну статусу на paid;
виклик платіжного шлюзу з правильною сумою;
надсилання сповіщення;
відхилення порожнього кошика;
реакцію на помилку платежу;
заборону повторної оплати.
Для прикладу використано вбудований модуль Node.js:
const assert = require("node:assert/strict");Це дозволяє запускати тести без стороннього тестового фреймворку.
FakePaymentGateway і MemoryNotifier — це тестові реалізації залежностей.
FakePaymentGateway не виконує справжню оплату, а зберігає виклики:
paymentGateway.chargesMemoryNotifier не надсилає повідомлення мережею, а зберігає їх у масиві:
notifier.messagesЗавдяки цьому тест може перевірити взаємодію між об’єктами без нестабільних зовнішніх систем.
Податок можна представити окремою політикою:
class TaxPolicy {
calculateCents(subtotalCents) {
return Math.floor(subtotalCents * 0.2);
}
}Однак потрібно вирішити, від якої суми обчислюється податок:
до знижки;
після знижки;
окремо для кожної позиції.
Це бізнес-правило, тому його не варто приховувати в довільному місці коду.
Вартість доставки також може бути залежністю:
class ShippingPolicy {
calculateCents({ totalCents }) {
return totalCents >= 10000 ? 0 : 800;
}
}Тоді CheckoutService працюватиме з кількома політиками, але кожна відповідатиме лише за одну частину розрахунку.
Справжні реалізації можуть мати різні класи:
class StripePaymentGateway {
charge({ orderId, customerId, amountCents }) {
// Виклик зовнішнього платіжного API
}
}
class BankPaymentGateway {
charge({ orderId, customerId, amountCents }) {
// Виклик банківського API
}
}CheckoutService не повинен змінюватися, якщо обидва шлюзи реалізують метод charge() із тим самим контрактом.
Не кожну спільну властивість потрібно переносити до базового класу.
Якщо класи мають лише один однаковий метод, але різну логіку, часто достатньо спільного контракту та композиції.
Погано:
class CheckoutService {
constructor() {
this.paymentGateway = new RealPaymentGateway();
this.notifier = new EmailNotifier();
}
}Такий клас складно тестувати й неможливо легко налаштувати для іншого середовища.
Краще:
class CheckoutService {
constructor({ paymentGateway, notifier }) {
this.paymentGateway = paymentGateway;
this.notifier = notifier;
}
}Кошик не повинен:
списувати гроші;
відправляти електронні листи;
створювати замовлення;
звертатися до бази даних.
Якщо один клас змінюється через багато різних причин, його відповідальність, імовірно, надто широка.
Не варто покладатися на те, що зовнішній код завжди передаватиме правильні значення.
Перевіряти потрібно щонайменше:
ідентифікатори;
кількість;
ціни;
статуси;
суми знижок;
ідентифікатори транзакцій.
Після створення замовлення його позиції повинні бути знімком стану кошика. Якщо замовлення безпосередньо посилається на змінний кошик, подальша зміна кошика може несподівано змінити вже створене замовлення.
Не слід повідомляти клієнта про успішну оплату до того, як платіжний шлюз повернув підтвердження.
Правильний порядок:
charge → markPaid → notifyУ production-системі цей процес може бути реалізований через події та чергу, але бізнес-послідовність залишається такою самою.
Тест, який перевіряє приватну структуру Map, слабший за тест, який перевіряє поведінку кошика:
assert.equal(cart.totalCents, 4200);Тести повинні описувати правила системи, а не конкретний спосіб їх реалізації.
Клас потрібно створювати навколо чіткої відповідальності.
Успадкування доречне для стабільного відношення «є різновидом» і взаємозамінної поведінки.
Композиція краще підходить для змінних політик, стратегій і зовнішніх залежностей.
Платіжний шлюз і сервіс сповіщень потрібно передавати через конструктор.
Політики знижок можна комбінувати без створення великої ієрархії підкласів.
Приватні поля допомагають захищати інваріанти об’єкта.
Грошові значення краще зберігати як цілі числа в копійках.
Стани замовлення потрібно змінювати лише через контрольовані переходи.
Тестові реалізації залежностей дозволяють перевіряти поведінку системи без мережі та зовнішніх сервісів.
Хороша система класів описує не лише дані, а й допустимі операції та правила переходу між станами.