Архитектура и SOLID
Архитектура программного обеспечения — набор решений о структуре системы, границах модулей, распределении ответственности и способах взаимодействия компонентов.
Хорошая архитектура помогает:
- понимать устройство приложения;
- локализовать изменения;
- повторно использовать код;
- тестировать компоненты независимо;
- заменять детали реализации;
- уменьшать связанность;
- развивать проект без постоянной переработки всей системы.
Архитектура не сводится к структуре каталогов. Два проекта могут иметь одинаковые папки, но совершенно разную архитектуру из-за различий в зависимостях и распределении ответственности.
Пример плохо разделённого обработчика:
async function createOrder(request, response) {
const data = request.body;
if (!data.userId || !data.items?.length) {
return response.status(400).json({
message: "Некорректные данные",
});
}
const user = await database.query(
"SELECT * FROM users WHERE id = ?",
[data.userId],
);
let total = 0;
for (const item of data.items) {
total += item.price * item.quantity;
}
await database.query(
"INSERT INTO orders (user_id, total) VALUES (?, ?)",
[data.userId, total],
);
await emailClient.send({
to: user.email,
subject: "Заказ создан",
});
return response.status(201).json({
total,
});
}Одна функция одновременно:
- работает с HTTP;
- проверяет входные данные;
- обращается к базе данных;
- рассчитывает стоимость;
- создаёт заказ;
- отправляет уведомление;
- формирует HTTP-ответ.
Такой код трудно тестировать и изменять. Например, замена базы данных, изменение правил расчёта или добавление нового способа уведомлений затрагивают одну и ту же функцию.
После разделения ответственности структура может выглядеть так:
HTTP-запрос
↓
Controller
↓
Service
↓
Repository
↓
DatabaseДополнительные компоненты подключаются через зависимости:
OrderService
├── OrderRepository
├── UserRepository
├── PriceCalculator
└── NotificationServiceСодержание
- Связанность и зацепление
- Single Responsibility Principle
- Open/Closed Principle
- Liskov Substitution Principle
- Interface Segregation Principle
- Dependency Inversion Principle
- Controller
- Service
- Repository
- Маршруты и сборка приложения
- Направление зависимостей в слоях
- Совместное применение DRY, KISS и YAGNI
- Constructor Injection
- Function Injection
- Method Injection
- Property Injection
- DI-контейнер
- Тестирование с Dependency Injection
- Domain Entities
- Use Cases
- Interface Adapters
- Frameworks and Drivers
- Dependency Rule
- Возможная структура проекта
- Слоистая и чистая архитектура
- Бизнес-логика в контроллере
- HTTP-объекты в сервисе
- SQL в сервисе
- Репозиторий с бизнес-решениями
- Универсальный Service или Manager
- Чрезмерное количество абстракций
- Доменная сущность
- Стратегия скидки
- Репозиторий
- Прикладной сценарий
- Фабрика стратегий
- Контроллер
- Сборка зависимостей
Связанность и зацепление
При проектировании часто оценивают два свойства.
Связанность внутри модуля
Cohesion, или внутренняя связанность, показывает, насколько элементы одного модуля относятся к одной задаче.
Модуль с высокой внутренней связанностью:
export function calculateSubtotal(items) {
return items.reduce((sum, item) => {
return sum + item.price * item.quantity;
}, 0);
}
export function calculateDiscount(subtotal, discountPercent) {
return subtotal * (discountPercent / 100);
}
export function calculateTotal(items, discountPercent) {
const subtotal = calculateSubtotal(items);
const discount = calculateDiscount(
subtotal,
discountPercent,
);
return subtotal - discount;
}Все функции связаны с расчётом стоимости.
Модуль с низкой внутренней связанностью:
export function calculateTotal(items) {
// Расчёт стоимости
}
export function sendEmail(message) {
// Отправка письма
}
export function parseCsv(content) {
// Разбор CSV
}Функции решают несвязанные задачи и должны находиться в разных модулях.
Зацепление между модулями
Coupling, или внешняя связанность, показывает, насколько сильно один модуль зависит от деталей другого.
Жёсткая зависимость:
class OrderService {
async createOrder(data) {
const database = new MySqlDatabase({
host: "localhost",
database: "shop",
});
return database.insert("orders", data);
}
}OrderService сам создаёт конкретную базу данных и знает её устройство.
Более слабая зависимость:
class OrderService {
constructor(orderRepository) {
this.orderRepository = orderRepository;
}
async createOrder(data) {
return this.orderRepository.create(data);
}
}Сервис зависит от предоставленного объекта, поддерживающего нужный контракт.
Общая цель проектирования:
Высокая связанность внутри модулей
Низкое зацепление между модулямиПринципы SOLID
SOLID — набор из пяти принципов объектно-ориентированного проектирования:
| Буква | Принцип |
|---|---|
S |
Single Responsibility Principle |
O |
Open/Closed Principle |
L |
Liskov Substitution Principle |
I |
Interface Segregation Principle |
D |
Dependency Inversion Principle |
SOLID помогает проектировать компоненты, которые проще изменять, тестировать и заменять.
Это не абсолютные правила. Избыточное применение SOLID в небольшом проекте может привести к большому количеству интерфейсов, файлов и абстракций без практической пользы.
Single Responsibility Principle
SRP — принцип единственной ответственности.
Компонент должен иметь одну основную ответственность и одну связанную группу причин для изменения.
Упрощённая формулировка:
Один модуль — одна роль.Это не означает, что в классе обязательно должен быть только один метод. Несколько методов могут относиться к одной ответственности.
Нарушение SRP
class UserService {
async createUser(data) {
if (!data.email.includes("@")) {
throw new Error("Некорректный email");
}
const user = await database.insert("users", data);
await emailClient.send({
to: user.email,
subject: "Добро пожаловать",
text: "Регистрация завершена",
});
logger.info("Пользователь создан", {
userId: user.id,
});
return user;
}
generateCsvReport(users) {
return users
.map((user) => `${user.id},${user.email}`)
.join("\n");
}
}Класс отвечает за:
- проверку данных;
- сохранение пользователя;
- отправку сообщений;
- журналирование;
- создание CSV-отчёта.
У этих задач разные причины изменения.
Разделение ответственности
class UserValidator {
validate(data) {
if (!data.email?.includes("@")) {
throw new Error("Некорректный email");
}
}
}
class UserRepository {
constructor(database) {
this.database = database;
}
async create(data) {
return this.database.insert("users", data);
}
}
class NotificationService {
constructor(emailClient) {
this.emailClient = emailClient;
}
async sendWelcomeEmail(user) {
await this.emailClient.send({
to: user.email,
subject: "Добро пожаловать",
text: "Регистрация завершена",
});
}
}
class UserReport {
toCsv(users) {
return users
.map((user) => `${user.id},${user.email}`)
.join("\n");
}
}Сервис координирует операцию:
class UserService {
constructor({
userValidator,
userRepository,
notificationService,
logger,
}) {
this.userValidator = userValidator;
this.userRepository = userRepository;
this.notificationService = notificationService;
this.logger = logger;
}
async createUser(data) {
this.userValidator.validate(data);
const user = await this.userRepository.create(data);
await this.notificationService.sendWelcomeEmail(user);
this.logger.info("Пользователь создан", {
userId: user.id,
});
return user;
}
}Ответственность UserService — координация сценария создания пользователя.
Как распознать нарушение SRP
Возможные признаки:
- класс имеет слишком много несвязанных методов;
- изменение одной функции ломает другую;
- компонент зависит от большого количества инфраструктурных библиотек;
- тест требует настройки базы данных, почты, файлов и HTTP одновременно;
- название класса слишком общее:
Manager,Helper,Utils,Processor; - функцию трудно описать одним коротким предложением.
Большой размер компонента может указывать на нарушение SRP, но количество строк само по себе не является точным критерием.
Open/Closed Principle
OCP — принцип открытости/закрытости.
Компоненты должны быть:
открыты для расширения;
закрыты для изменения.Новая функциональность по возможности добавляется через новые реализации, а не через постоянное изменение стабильного кода.
Нарушение OCP
function calculateDeliveryPrice(order, deliveryType) {
if (deliveryType === "courier") {
return order.weight * 10;
}
if (deliveryType === "pickup") {
return 0;
}
if (deliveryType === "express") {
return order.weight * 20 + 500;
}
throw new Error("Неизвестный способ доставки");
}При добавлении каждого способа доставки нужно менять функцию:
courier
pickup
express
post
droneФункция будет разрастаться и становиться источником ошибок.
Расширение через стратегии
class CourierDelivery {
calculate(order) {
return order.weight * 10;
}
}
class PickupDelivery {
calculate() {
return 0;
}
}
class ExpressDelivery {
calculate(order) {
return order.weight * 20 + 500;
}
}Основной компонент работает с общей операцией:
class DeliveryPriceCalculator {
constructor(strategy) {
this.strategy = strategy;
}
calculate(order) {
return this.strategy.calculate(order);
}
}Использование:
const calculator = new DeliveryPriceCalculator(
new ExpressDelivery(),
);
const price = calculator.calculate({
weight: 4,
});
console.log(price);Новый вариант добавляется новой реализацией:
class PostalDelivery {
calculate(order) {
return 200 + order.weight * 5;
}
}Основной калькулятор изменять не нужно.
OCP без классов
В JavaScript расширяемость можно реализовать функциями:
const deliveryStrategies = {
courier: (order) => order.weight * 10,
pickup: () => 0,
express: (order) => order.weight * 20 + 500,
};
function calculateDeliveryPrice(order, deliveryType) {
const strategy = deliveryStrategies[deliveryType];
if (!strategy) {
throw new Error("Неизвестный способ доставки");
}
return strategy(order);
}Добавление варианта:
deliveryStrategies.post = (order) => {
return 200 + order.weight * 5;
};Не любая конструкция if или switch нарушает OCP. Условие допустимо, если набор вариантов стабилен и не является точкой частого расширения.
Liskov Substitution Principle
LSP — принцип подстановки Барбары Лисков.
Объект производного типа должен корректно заменять объект базового типа, не нарушая ожидаемое поведение программы.
Упрощённо:
Если код ожидает объект определённого контракта,
любая реализация этого контракта должна работать корректно.Важно не только наличие методов, но и их поведение.
Нарушение LSP
class FileStorage {
async save(path, content) {
// Сохраняет файл
}
async delete(path) {
// Удаляет файл
}
}
class ReadOnlyStorage extends FileStorage {
async save() {
throw new Error("Сохранение не поддерживается");
}
async delete() {
throw new Error("Удаление не поддерживается");
}
}ReadOnlyStorage формально наследуется от FileStorage, но не может заменить его в коде, который ожидает сохранение:
async function createBackup(storage, content) {
await storage.save("backup.json", content);
}Передача ReadOnlyStorage неожиданно нарушит контракт.
Лучше выделить разные возможности:
class StorageReader {
async read(path) {
throw new Error("Метод не реализован");
}
}
class StorageWriter {
async save(path, content) {
throw new Error("Метод не реализован");
}
}В JavaScript необязательно использовать наследование:
class BackupService {
constructor(storageWriter) {
this.storageWriter = storageWriter;
}
async createBackup(content) {
await this.storageWriter.save(
"backup.json",
content,
);
}
}Поведенческий контракт
Реализация не должна неожиданно:
- требовать более строгие входные данные;
- возвращать несовместимый результат;
- нарушать заявленные гарантии;
- выбрасывать исключения для нормальных операций;
- менять смысл метода;
- создавать неожиданные побочные эффекты.
Пусть контракт репозитория требует вернуть пользователя или null:
class InMemoryUserRepository {
constructor(users = []) {
this.users = users;
}
async findById(id) {
return (
this.users.find((user) => user.id === id) ??
null
);
}
}Некорректная замена:
class ApiUserRepository {
async findById(id) {
const response = await fetch(`/users/${id}`);
if (response.status === 404) {
throw new Error("Пользователь не найден");
}
return response.json();
}
}Одна реализация возвращает null, другая выбрасывает исключение для того же результата.
Согласованное поведение:
class ApiUserRepository {
async findById(id) {
const response = await fetch(`/users/${id}`);
if (response.status === 404) {
return null;
}
if (!response.ok) {
throw new Error(
`Не удалось получить пользователя: ${response.status}`,
);
}
return response.json();
}
}Теперь обе реализации поддерживают одинаковые ожидания.
Interface Segregation Principle
ISP — принцип разделения интерфейсов.
Клиент не должен зависеть от методов, которые ему не нужны.
Лучше несколько небольших специализированных контрактов, чем один универсальный интерфейс.
В JavaScript нет обязательных интерфейсов на уровне языка, но принцип применяется через документированные контракты, соглашения и duck typing.
Слишком широкий контракт
class UserRepository {
async findById(id) {}
async create(data) {}
async update(id, data) {}
async delete(id) {}
async exportToCsv() {}
async sendWelcomeEmail(id) {}
}Репозиторий одновременно содержит:
- чтение;
- запись;
- экспорт;
- уведомления.
Компонент, которому нужно только чтение, всё равно получает объект с большим набором обязанностей.
Разделение возможностей
Контракт чтения:
class UserReader {
async findById(id) {
throw new Error("Метод не реализован");
}
}Контракт записи:
class UserWriter {
async create(data) {
throw new Error("Метод не реализован");
}
async update(id, data) {
throw new Error("Метод не реализован");
}
}Отдельная работа с отчётами:
class UserExporter {
toCsv(users) {
throw new Error("Метод не реализован");
}
}В JavaScript достаточно передавать минимально необходимую зависимость:
class UserProfileService {
constructor(userReader) {
this.userReader = userReader;
}
async getProfile(userId) {
return this.userReader.findById(userId);
}
}UserProfileService не интересуют методы создания, удаления или экспорта.
Функциональный вариант
function createUserProfileService({ findUserById }) {
return {
async getProfile(userId) {
return findUserById(userId);
},
};
}Создание:
const userProfileService = createUserProfileService({
findUserById: async (userId) => {
return database.users.findById(userId);
},
});Клиент получает только нужную функцию.
Dependency Inversion Principle
DIP — принцип инверсии зависимостей.
Высокоуровневая бизнес-логика не должна зависеть от низкоуровневых технических деталей. И та и другая должны зависеть от абстракций.
Также детали должны соответствовать контрактам, определяемым потребностями бизнес-логики.
Прямая зависимость от детали
class OrderService {
constructor() {
this.database = new MySqlDatabase();
}
async createOrder(data) {
return this.database.insert("orders", data);
}
}Проблемы:
- невозможно легко использовать другую базу;
- тест зависит от реальной инфраструктуры;
- настройки соединения скрыты внутри класса;
- бизнес-логика знает технический API базы данных.
Инверсия зависимости
class OrderService {
constructor(orderRepository) {
this.orderRepository = orderRepository;
}
async createOrder(data) {
return this.orderRepository.create(data);
}
}Реализация репозитория:
class SqlOrderRepository {
constructor(database) {
this.database = database;
}
async create(data) {
return this.database.insert("orders", data);
}
}Сборка зависимостей выполняется снаружи:
const database = new MySqlDatabase();
const orderRepository = new SqlOrderRepository(database);
const orderService = new OrderService(orderRepository);Для теста можно передать простую реализацию:
class InMemoryOrderRepository {
constructor() {
this.orders = [];
}
async create(data) {
const order = {
id: this.orders.length + 1,
...data,
};
this.orders.push(order);
return order;
}
}Тестируемый объект:
const repository = new InMemoryOrderRepository();
const service = new OrderService(repository);
const order = await service.createOrder({
userId: 42,
total: 1500,
});
console.log(order);Бизнес-логика не изменилась. Была заменена только инфраструктурная реализация.
Слоистая архитектура
Слоистая архитектура разделяет приложение на уровни, каждый из которых имеет собственную ответственность.
Распространённый вариант для серверного приложения:
Controller
↓
Service
↓
Repository
↓
DatabaseЧасто добавляются:
Routes
Validation
Domain
Mapping
InfrastructureПример структуры:
src/
├── controllers/
│ └── user-controller.js
├── services/
│ └── user-service.js
├── repositories/
│ └── user-repository.js
├── validators/
│ └── user-validator.js
├── errors/
│ └── application-errors.js
├── routes/
│ └── user-routes.js
└── app.jsController
Controller принимает запрос транспортного уровня и формирует ответ.
Для HTTP контроллер обычно:
- получает параметры;
- извлекает тело запроса;
- вызывает сервис;
- преобразует результат в HTTP-ответ;
- выбирает статус-код;
- передаёт ошибку обработчику.
Контроллер не должен содержать основную бизнес-логику.
class UserController {
constructor(userService) {
this.userService = userService;
}
create = async (request, response, next) => {
try {
const user = await this.userService.createUser({
name: request.body.name,
email: request.body.email,
});
return response.status(201).json(user);
} catch (error) {
return next(error);
}
};
getById = async (request, response, next) => {
try {
const user = await this.userService.getUserById(
request.params.userId,
);
return response.status(200).json(user);
} catch (error) {
return next(error);
}
};
}Контроллер знает о HTTP:
request
response
status 200
status 201
route parametersНо сервису эти понятия обычно не нужны.
Нежелательно передавать весь request в сервис:
await userService.createUser(request);Предпочтительно передать только необходимые данные:
await userService.createUser({
name: request.body.name,
email: request.body.email,
});Это уменьшает зависимость бизнес-логики от веб-фреймворка.
Service
Service реализует прикладной сценарий и координирует бизнес-операции.
Сервис может:
- проверять бизнес-условия;
- получать данные из репозиториев;
- изменять доменные объекты;
- управлять последовательностью операций;
- вызывать внешние сервисы;
- управлять транзакцией через абстракцию;
- возвращать результат контроллеру.
class UserService {
constructor({
userRepository,
passwordHasher,
notificationService,
}) {
this.userRepository = userRepository;
this.passwordHasher = passwordHasher;
this.notificationService = notificationService;
}
async createUser(data) {
const existingUser =
await this.userRepository.findByEmail(data.email);
if (existingUser) {
throw new ConflictError(
"Пользователь уже существует",
);
}
const passwordHash =
await this.passwordHasher.hash(data.password);
const user = await this.userRepository.create({
name: data.name,
email: data.email,
passwordHash,
});
await this.notificationService.sendWelcome(user);
return {
id: user.id,
name: user.name,
email: user.email,
};
}
}Сервис не должен знать:
- какой HTTP-статус будет отправлен;
- как устроен объект
response; - какой SQL-запрос выполняет репозиторий;
- в какой таблице хранятся пользователи;
- какой пакет используется для хеширования.
Repository
Repository скрывает детали хранения и получения данных.
Он предоставляет методы на языке предметной области:
class SqlUserRepository {
constructor(database) {
this.database = database;
}
async findByEmail(email) {
const row = await this.database.queryOne(
`
SELECT id, name, email, password_hash
FROM users
WHERE email = ?
`,
[email],
);
return row ? this.mapToEntity(row) : null;
}
async create(data) {
const result = await this.database.execute(
`
INSERT INTO users (
name,
email,
password_hash
)
VALUES (?, ?, ?)
`,
[
data.name,
data.email,
data.passwordHash,
],
);
return {
id: result.insertId,
...data,
};
}
mapToEntity(row) {
return {
id: row.id,
name: row.name,
email: row.email,
passwordHash: row.password_hash,
};
}
}Сервис вызывает:
userRepository.findByEmail(email);а не:
database.query(
"SELECT * FROM users WHERE email = ?",
[email],
);Repository изолирует:
- SQL;
- ORM;
- имена таблиц и столбцов;
- формат данных хранилища;
- особенности внешнего API;
- кэширование данных.
Маршруты и сборка приложения
Маршруты связывают URL с контроллерами:
function createUserRouter({
router,
userController,
}) {
router.post(
"/users",
userController.create,
);
router.get(
"/users/:userId",
userController.getById,
);
return router;
}Сборка зависимостей выполняется в отдельном месте:
const database = createDatabase();
const userRepository = new SqlUserRepository(database);
const passwordHasher = new BcryptPasswordHasher();
const notificationService =
new EmailNotificationService(emailClient);
const userService = new UserService({
userRepository,
passwordHasher,
notificationService,
});
const userController = new UserController(userService);Это место называют composition root — точкой, где создаются и связываются основные зависимости приложения.
Направление зависимостей в слоях
Простой вариант:
Controller → Service → Repository → DatabaseЗависимости не должны идти в обратную сторону:
Нежелательно:
Repository → Controller
Service → HTTP Response
Domain → Web FrameworkНижний технический слой не должен принимать решения о пользовательском сценарии.
Например, репозиторий не должен самостоятельно отправлять HTTP-ответ:
class UserRepository {
async findById(id, response) {
const user = await database.find(id);
if (!user) {
return response.status(404).json({
message: "Пользователь не найден",
});
}
return user;
}
}Предпочтительно вернуть данные или определённый результат:
class UserRepository {
async findById(id) {
return database.find(id);
}
}Решение принимает сервис:
const user = await this.userRepository.findById(id);
if (!user) {
throw new NotFoundError(
"Пользователь не найден",
);
}А HTTP-ошибку формирует соответствующий адаптер:
function errorHandler(error, request, response, next) {
if (error instanceof NotFoundError) {
return response.status(404).json({
error: "not_found",
message: error.message,
});
}
return response.status(500).json({
error: "internal_error",
message: "Внутренняя ошибка",
});
}DRY
DRY — Don’t Repeat Yourself, или «не повторяйся».
Главная идея DRY — не допускать существования нескольких независимых источников одной и той же информации.
Повторение логики
function calculateOrderTotal(order) {
const subtotal = order.items.reduce(
(sum, item) => sum + item.price * item.quantity,
0,
);
return subtotal * 0.8;
}
function calculateInvoiceTotal(invoice) {
const subtotal = invoice.items.reduce(
(sum, item) => sum + item.price * item.quantity,
0,
);
return subtotal * 0.8;
}Если правило скидки изменится, придётся менять две функции.
Общую бизнес-логику можно выделить:
function calculateItemsTotal(items) {
return items.reduce((sum, item) => {
return sum + item.price * item.quantity;
}, 0);
}
function applyDiscount(total, discountRate) {
return total * (1 - discountRate);
}Использование:
function calculateOrderTotal(order) {
const subtotal = calculateItemsTotal(order.items);
return applyDiscount(subtotal, 0.2);
}
function calculateInvoiceTotal(invoice) {
const subtotal = calculateItemsTotal(invoice.items);
return applyDiscount(subtotal, 0.2);
}Не всякое сходство является дублированием
Два фрагмента могут выглядеть одинаково, но относиться к разным правилам.
function validateUserName(name) {
return name.length <= 50;
}
function validateProductName(name) {
return name.length <= 50;
}Сегодня ограничения совпадают, но причины у них разные. Позже имя пользователя может быть ограничено 80 символами, а название товара — 120.
Преждевременное объединение:
function validateName(name) {
return name.length <= 50;
}создаёт ложную зависимость между несвязанными правилами.
DRY относится прежде всего к дублированию знания и бизнес-правил, а не к любым похожим строкам.
Признаки полезного выделения
Абстракция обычно оправдана, если:
- фрагменты представляют одно и то же правило;
- они изменяются по одной причине;
- исправление приходится повторять;
- общее имя получается понятным;
- вызовы становятся проще, а не сложнее.
KISS
KISS — Keep It Simple, или «делай проще».
Следует выбирать самое простое решение, которое корректно решает текущую задачу и остаётся понятным.
Избыточно сложный вариант:
class BooleanValueStrategyFactory {
create(value) {
return new BooleanValueStrategy(value);
}
}
class BooleanValueStrategy {
constructor(value) {
this.value = value;
}
execute() {
return Boolean(this.value);
}
}Если требуется только преобразование значения:
const result = Boolean(value);Простое решение обычно:
- содержит меньше состояний;
- имеет меньше зависимостей;
- легче тестируется;
- быстрее читается;
- проще изменяется;
- оставляет меньше мест для ошибки.
KISS не означает отказ от архитектуры. Простота — это не обязательно один большой файл. Иногда разделение на несколько небольших модулей делает систему проще.
Сложность оправдана, если она решает реальную проблему:
- несколько реализаций;
- сложные правила;
- заменяемая инфраструктура;
- необходимость независимого тестирования;
- требования к масштабированию;
- подтверждённые точки расширения.
YAGNI
YAGNI — You Aren’t Gonna Need It, или «вам это пока не понадобится».
Не следует реализовывать функциональность до появления реальной потребности.
Преждевременное проектирование:
class ReportService {
exportToCsv() {}
exportToJson() {}
exportToXml() {}
exportToPdf() {}
exportToExcel() {}
exportToCustomBinaryFormat() {}
}Если задача требует только CSV, достаточно:
class CsvReportExporter {
export(rows) {
return rows
.map((row) => row.join(","))
.join("\n");
}
}При появлении подтверждённой потребности можно добавить общий контракт и новые реализации.
YAGNI помогает не создавать заранее:
- неиспользуемые настройки;
- гипотетические расширения;
- универсальные фабрики;
- сложные системы плагинов;
- дополнительные уровни абстракции;
- поддержку форматов, которые никто не использует.
YAGNI не запрещает думать о будущем. Оно предлагает не платить стоимость разработки и поддержки функции, необходимость которой не подтверждена.
Совместное применение DRY, KISS и YAGNI
Принципы могут конфликтовать, если применять их механически.
Например, два похожих фрагмента появились впервые:
function calculateUserScore(user) {
return user.completedTasks * 10;
}
function calculateTeamScore(team) {
return team.completedTasks * 10;
}Немедленное создание универсального механизма может нарушить KISS и YAGNI:
class ConfigurableEntityMetricCalculationEngine {
// Сложная универсальная система
}Рациональный порядок:
- Сначала реализовать понятное решение.
- Наблюдать, действительно ли логика повторяется.
- Определить, является ли повторение одним знанием.
- Выделить абстракцию, когда её назначение стало понятным.
Иногда небольшое дублирование безопаснее неправильной абстракции.
Dependency Injection
Dependency Injection, или внедрение зависимостей, — передача зависимостей компоненту извне вместо их создания внутри компонента.
Без внедрения:
class PaymentService {
constructor() {
this.paymentGateway = new StripeGateway();
this.logger = new ConsoleLogger();
}
}Класс жёстко связан с конкретными реализациями.
С внедрением:
class PaymentService {
constructor({ paymentGateway, logger }) {
this.paymentGateway = paymentGateway;
this.logger = logger;
}
}Создание:
const paymentService = new PaymentService({
paymentGateway: new StripeGateway(),
logger: new ConsoleLogger(),
});Constructor Injection
Зависимости передаются через конструктор:
class ProductService {
constructor(productRepository) {
this.productRepository = productRepository;
}
async getProduct(id) {
return this.productRepository.findById(id);
}
}Преимущества:
- зависимости видны сразу;
- объект нельзя создать без обязательных зависимостей;
- зависимости удобно заменять в тестах;
- компонент не управляет жизненным циклом инфраструктуры.
Это основной и обычно наиболее удобный способ.
Function Injection
Для функций зависимости можно передавать аргументами:
function createProductService({
findProductById,
}) {
return {
async getProduct(id) {
return findProductById(id);
},
};
}Использование:
const productService = createProductService({
findProductById: (id) => {
return database.products.findById(id);
},
});В JavaScript функциональная DI часто проще классов.
Method Injection
Зависимость передаётся конкретному методу:
class ReportService {
generate(data, formatter) {
return formatter.format(data);
}
}Использование:
const report = reportService.generate(
users,
csvFormatter,
);Этот способ подходит, когда зависимость нужна только для одной операции или может различаться при каждом вызове.
Property Injection
Зависимость устанавливается после создания:
const service = new UserService();
service.repository = userRepository;Недостаток — объект некоторое время существует в некорректном состоянии:
const service = new UserService();
await service.getUser(42);
// repository ещё не установленПоэтому для обязательных зависимостей предпочтительнее constructor injection.
DI-контейнер
В больших приложениях зависимости может собирать DI-контейнер.
Концептуальный пример:
container.register("userRepository", () => {
return new SqlUserRepository(database);
});
container.register("userService", ({
userRepository,
}) => {
return new UserService(userRepository);
});Контейнер может управлять временем жизни объектов:
singleton — один экземпляр на приложение
scoped — один экземпляр на запрос или область
transient — новый экземпляр при каждом полученииДля небольшого проекта ручная сборка обычно проще:
const repository = new SqlUserRepository(database);
const service = new UserService(repository);
const controller = new UserController(service);DI не требует специальной библиотеки.
Тестирование с Dependency Injection
Основное преимущество DI — возможность заменить внешнюю зависимость.
class FakePaymentGateway {
constructor() {
this.payments = [];
}
async charge(payment) {
this.payments.push(payment);
return {
id: "payment-1",
status: "succeeded",
};
}
}Тестируем сервис:
const paymentGateway = new FakePaymentGateway();
const paymentService = new PaymentService({
paymentGateway,
logger: {
info() {},
error() {},
},
});
const result = await paymentService.pay({
orderId: 42,
amount: 1500,
});
console.assert(result.status === "succeeded");
console.assert(paymentGateway.payments.length === 1);Тест не обращается к реальной платёжной системе.
Factory
Factory — паттерн, который скрывает логику создания объектов.
Фабрика полезна, если:
- создание зависит от параметров;
- объект требует сложной настройки;
- вызывающему коду не нужно знать конкретный класс;
- набор создаваемых реализаций может расширяться;
- создание необходимо централизовать.
Простая фабрика
class EmailNotification {
async send(message) {
console.log("Email:", message);
}
}
class SmsNotification {
async send(message) {
console.log("SMS:", message);
}
}
class PushNotification {
async send(message) {
console.log("Push:", message);
}
}Фабрика:
function createNotification(type) {
switch (type) {
case "email":
return new EmailNotification();
case "sms":
return new SmsNotification();
case "push":
return new PushNotification();
default:
throw new Error(
`Неизвестный тип уведомления: ${type}`,
);
}
}Использование:
const notification = createNotification("email");
await notification.send({
title: "Заказ создан",
});Фабрика через реестр
const notificationFactories = new Map([
[
"email",
() => new EmailNotification(),
],
[
"sms",
() => new SmsNotification(),
],
[
"push",
() => new PushNotification(),
],
]);
function createNotification(type) {
const factory = notificationFactories.get(type);
if (!factory) {
throw new Error(
`Неизвестный тип уведомления: ${type}`,
);
}
return factory();
}Регистрация новой реализации:
notificationFactories.set(
"telegram",
() => new TelegramNotification(),
);Когда фабрика не нужна
Избыточно:
function createUserService(repository) {
return new UserService(repository);
}Если фабрика ничего не скрывает и создание выполняется в одном месте, можно написать:
const userService = new UserService(repository);Strategy
Strategy — паттерн, который выносит взаимозаменяемые алгоритмы в отдельные реализации.
Контекст использует стратегию через общий контракт и не знает деталей алгоритма.
Стратегии скидки
class NoDiscount {
calculate(total) {
return total;
}
}
class PercentageDiscount {
constructor(percent) {
this.percent = percent;
}
calculate(total) {
return total * (1 - this.percent / 100);
}
}
class FixedDiscount {
constructor(amount) {
this.amount = amount;
}
calculate(total) {
return Math.max(0, total - this.amount);
}
}Контекст:
class PriceCalculator {
constructor(discountStrategy) {
this.discountStrategy = discountStrategy;
}
calculate(items) {
const subtotal = items.reduce((sum, item) => {
return sum + item.price * item.quantity;
}, 0);
return this.discountStrategy.calculate(subtotal);
}
}Использование:
const calculator = new PriceCalculator(
new PercentageDiscount(15),
);
const total = calculator.calculate([
{
price: 1000,
quantity: 2,
},
]);Strategy через функции
В JavaScript стратегией может быть обычная функция:
const noDiscount = (total) => total;
const percentageDiscount = (percent) => {
return (total) => {
return total * (1 - percent / 100);
};
};
const fixedDiscount = (amount) => {
return (total) => {
return Math.max(0, total - amount);
};
};Калькулятор:
function calculatePrice(items, discountStrategy) {
const subtotal = items.reduce((sum, item) => {
return sum + item.price * item.quantity;
}, 0);
return discountStrategy(subtotal);
}Использование:
const total = calculatePrice(
[
{
price: 1000,
quantity: 2,
},
],
percentageDiscount(15),
);Strategy полезен, когда алгоритмы:
- имеют одинаковое назначение;
- выбираются во время выполнения;
- должны тестироваться отдельно;
- часто добавляются или изменяются.
Singleton
Singleton — паттерн, который ограничивает создание класса одним экземпляром и предоставляет доступ к нему.
Классический вариант:
class ApplicationConfig {
static instance;
constructor() {
if (ApplicationConfig.instance) {
return ApplicationConfig.instance;
}
this.values = new Map();
ApplicationConfig.instance = this;
}
set(key, value) {
this.values.set(key, value);
}
get(key) {
return this.values.get(key);
}
}Использование:
const firstConfig = new ApplicationConfig();
const secondConfig = new ApplicationConfig();
console.log(firstConfig === secondConfig);
// trueМодуль как Singleton
ES-модули кэшируются после первой загрузки, поэтому экспортированный экземпляр фактически может работать как singleton.
class Logger {
info(message, context = {}) {
console.log(message, context);
}
}
export const logger = new Logger();Другие модули получают тот же экземпляр:
import { logger } from "./logger.js";Проблемы Singleton
Singleton часто превращается в глобальное состояние:
import { database } from "./database.js";
import { logger } from "./logger.js";
import { config } from "./config.js";Это может привести к проблемам:
- скрытые зависимости;
- сложная изоляция тестов;
- состояние между тестами;
- невозможность создать две конфигурации;
- сложная параллельная обработка;
- сильная связанность модулей.
Предпочтительнее явно внедрять экземпляр:
class UserService {
constructor({ database, logger }) {
this.database = database;
this.logger = logger;
}
}Единственный экземпляр всё равно можно создать в composition root:
const logger = new Logger();
const database = new Database(config);
const userService = new UserService({
database,
logger,
});Получается один экземпляр без скрытого глобального доступа.
Singleton оправдан для некоторых инфраструктурных объектов, но его следует применять осторожно:
- конфигурация приложения;
- реестр метрик;
- пул соединений;
- централизованный логгер;
- кэш уровня процесса.
Даже в этих случаях Dependency Injection обычно делает зависимость прозрачнее.
Разделение ответственности
Разделение ответственности означает, что разные части системы решают разные типы задач.
Например:
Router — сопоставляет URL и обработчик
Controller — переводит HTTP-запрос в вызов приложения
Service — выполняет пользовательский сценарий
Domain — содержит бизнес-правила
Repository — получает и сохраняет данные
Mapper — преобразует форматы данных
Validator — проверяет входные данные
Presenter — формирует представление результатаРазделение в одной операции
function validateCreateProductInput(data) {
if (!data.name) {
throw new ValidationError(
"Название обязательно",
);
}
if (data.price < 0) {
throw new ValidationError(
"Цена не может быть отрицательной",
);
}
}Бизнес-сервис:
class ProductService {
constructor(productRepository) {
this.productRepository = productRepository;
}
async createProduct(data) {
validateCreateProductInput(data);
return this.productRepository.create({
name: data.name,
price: data.price,
});
}
}Представление ответа:
function toProductResponse(product) {
return {
id: product.id,
name: product.name,
price: product.price,
};
}Контроллер:
class ProductController {
constructor(productService) {
this.productService = productService;
}
create = async (request, response, next) => {
try {
const product =
await this.productService.createProduct(
request.body,
);
return response
.status(201)
.json(toProductResponse(product));
} catch (error) {
return next(error);
}
};
}Каждый компонент можно изменять и тестировать отдельно.
Модульность
Модульность — организация системы в независимые части с явными границами и контрактами.
Хороший модуль:
- решает связанную группу задач;
- скрывает внутренние детали;
- предоставляет небольшой публичный API;
- минимально зависит от других модулей;
- не требует знания своей внутренней структуры;
- может тестироваться отдельно.
Публичный API модуля
Структура:
users/
├── index.js
├── user-service.js
├── user-repository.js
├── user-validator.js
└── internal-user-mapper.jsФайл index.js определяет публичный API:
export { UserService } from "./user-service.js";
export {
SqlUserRepository,
} from "./user-repository.js";Внешний код импортирует:
import {
UserService,
SqlUserRepository,
} from "./users/index.js";Нежелательный импорт внутренней детали:
import {
mapUserDatabaseRow,
} from "./users/internal-user-mapper.js";Если внутренние файлы доступны повсюду, граница модуля становится условной.
Модули по техническим слоям
src/
├── controllers/
├── services/
├── repositories/
└── models/Такая структура проста для небольшого проекта.
Но в большом приложении один пользовательский сценарий распределяется по множеству каталогов.
Модули по функциональности
src/
├── users/
│ ├── user-controller.js
│ ├── user-service.js
│ ├── user-repository.js
│ └── index.js
├── orders/
│ ├── order-controller.js
│ ├── order-service.js
│ ├── order-repository.js
│ └── index.js
└── shared/Такой подход группирует код по бизнес-возможностям.
Внутри каждого модуля по-прежнему могут существовать слои.
Осторожно с shared
Каталог shared, common или utils может превратиться в набор несвязанных функций.
Нежелательно:
shared/
└── utils.jsгде находятся:
export function formatDate() {}
export function calculateDiscount() {}
export function validateUser() {}
export function sendEmail() {}
export function retryRequest() {}Предпочтительнее создавать тематические модули:
shared/
├── date/
├── validation/
├── http/
└── logging/Бизнес-правила конкретного модуля не следует переносить в shared только потому, что они используются в двух местах.
Чистая архитектура
Чистая архитектура — подход, в котором бизнес-правила отделяются от инфраструктуры, фреймворков, базы данных и пользовательского интерфейса.
Главная идея:
Внутренние правила не зависят от внешних деталей.
Внешние детали зависят от внутренних контрактов.Упрощённая схема:
Frameworks and Drivers
↓
Interface Adapters
↓
Application Use Cases
↓
Domain EntitiesНаправление исходных зависимостей должно идти внутрь.
Domain Entities
Entities содержат основные бизнес-правила.
class Order {
constructor({ id, items, status = "new" }) {
if (!items.length) {
throw new Error(
"Заказ должен содержать товары",
);
}
this.id = id;
this.items = items;
this.status = status;
}
calculateTotal() {
return this.items.reduce((sum, item) => {
return sum + item.price * item.quantity;
}, 0);
}
cancel() {
if (this.status === "shipped") {
throw new Error(
"Отправленный заказ нельзя отменить",
);
}
this.status = "cancelled";
}
}Сущность не знает:
- HTTP;
- Express;
- SQL;
- ORM;
- конкретную базу;
- формат JSON-ответа.
Она содержит правила заказа.
Use Cases
Use Case описывает конкретный пользовательский или прикладной сценарий.
Примеры:
Создать заказ
Отменить заказ
Зарегистрировать пользователя
Изменить пароль
Опубликовать статьюclass CreateOrder {
constructor({
orderRepository,
productRepository,
}) {
this.orderRepository = orderRepository;
this.productRepository = productRepository;
}
async execute(input) {
const items = [];
for (const requestedItem of input.items) {
const product =
await this.productRepository.findById(
requestedItem.productId,
);
if (!product) {
throw new Error(
`Товар ${requestedItem.productId} не найден`,
);
}
items.push({
productId: product.id,
price: product.price,
quantity: requestedItem.quantity,
});
}
const order = new Order({
items,
});
return this.orderRepository.save(order);
}
}Use Case зависит от контрактов репозиториев, а не от SQL или ORM.
Interface Adapters
Адаптеры преобразуют данные между внешним миром и внутренними сценариями.
HTTP-контроллер:
class CreateOrderController {
constructor(createOrder) {
this.createOrder = createOrder;
}
async handle(request) {
const order = await this.createOrder.execute({
userId: request.body.userId,
items: request.body.items,
});
return {
statusCode: 201,
body: {
id: order.id,
total: order.calculateTotal(),
status: order.status,
},
};
}
}Repository adapter:
class SqlOrderRepository {
constructor(database) {
this.database = database;
}
async save(order) {
const row = await this.database.orders.insert({
status: order.status,
total: order.calculateTotal(),
});
return new Order({
id: row.id,
items: order.items,
status: row.status,
});
}
}Контроллер адаптирует HTTP к Use Case, а репозиторий адаптирует внутренний контракт к базе данных.
Frameworks and Drivers
Внешний уровень содержит технические детали:
- веб-фреймворк;
- база данных;
- файловая система;
- внешние API;
- очередь сообщений;
- почтовый сервис;
- интерфейс командной строки;
- браузерный UI.
Например, связывание с веб-фреймворком:
app.post("/orders", async (request, response) => {
const result =
await createOrderController.handle(request);
response
.status(result.statusCode)
.json(result.body);
});Use Case не знает, что его вызвал HTTP-контроллер. Его также можно вызвать:
- из CLI;
- из очереди сообщений;
- из фоновой задачи;
- из автоматического теста.
Dependency Rule
Основное правило чистой архитектуры:
Зависимости исходного кода направлены внутрь.Допустимо:
Controller → Use Case
SQL Repository → Domain Contract
Framework → ControllerНежелательно:
Entity → Express
Use Case → конкретная ORM
Domain → HTTP Response
Domain → SQLЧтобы внешний слой реализовал внутреннюю потребность, внутренний слой определяет контракт.
Use Case ожидает:
class CreateOrder {
constructor(orderRepository) {
this.orderRepository = orderRepository;
}
}Инфраструктура предоставляет реализацию:
const orderRepository =
new SqlOrderRepository(database);
const createOrder =
new CreateOrder(orderRepository);Это и есть инверсия зависимости.
Возможная структура проекта
src/
├── domain/
│ ├── entities/
│ │ └── order.js
│ └── errors/
│ └── domain-error.js
├── application/
│ ├── use-cases/
│ │ ├── create-order.js
│ │ └── cancel-order.js
│ └── ports/
│ ├── order-repository.js
│ └── product-repository.js
├── adapters/
│ ├── controllers/
│ │ └── create-order-controller.js
│ ├── presenters/
│ │ └── order-presenter.js
│ └── repositories/
│ └── sql-order-repository.js
├── infrastructure/
│ ├── database/
│ │ └── database.js
│ ├── http/
│ │ └── server.js
│ └── config/
│ └── config.js
└── main.jsТочка сборки:
const database = createDatabase(config);
const orderRepository =
new SqlOrderRepository(database);
const productRepository =
new SqlProductRepository(database);
const createOrder = new CreateOrder({
orderRepository,
productRepository,
});
const createOrderController =
new CreateOrderController(createOrder);
startHttpServer({
createOrderController,
});Слоистая и чистая архитектура
Слоистая архитектура:
Controller → Service → Repository → DatabaseЧистая архитектура:
Framework → Adapter → Use Case → Domain
↑
Infrastructure adapterОсновное различие связано не с названиями каталогов, а с направлением зависимостей.
В простой слоистой архитектуре сервис может напрямую зависеть от конкретного репозитория:
import {
SqlUserRepository,
} from "../repositories/sql-user-repository.js";В чистой архитектуре Use Case получает абстракцию извне:
class CreateUser {
constructor(userRepository) {
this.userRepository = userRepository;
}
}Для небольшого CRUD-приложения классическая схема controller-service-repository часто достаточна. Полная чистая архитектура оправдана, если:
- бизнес-логика сложна;
- инфраструктура может меняться;
- приложение имеет несколько интерфейсов;
- нужны независимые тесты сценариев;
- проект развивается долго;
- над системой работает несколько команд.
Архитектурные ошибки
Бизнес-логика в контроллере
Нежелательно:
async function createOrder(request, response) {
const total = request.body.items.reduce(
(sum, item) => {
return sum + item.price * item.quantity;
},
0,
);
if (total > 10000) {
// Правило скидки
}
}Предпочтительно:
const order = await createOrder.execute({
items: request.body.items,
});HTTP-объекты в сервисе
Нежелательно:
class UserService {
async getUser(request, response) {
const user = await repository.findById(
request.params.id,
);
return response.json(user);
}
}Предпочтительно:
class UserService {
async getUser(id) {
return this.repository.findById(id);
}
}SQL в сервисе
Нежелательно:
class UserService {
async getUser(id) {
return this.database.query(
"SELECT * FROM users WHERE id = ?",
[id],
);
}
}Предпочтительно:
class UserService {
async getUser(id) {
return this.userRepository.findById(id);
}
}Репозиторий с бизнес-решениями
Нежелательно:
class OrderRepository {
async save(order) {
if (order.total > 10000) {
order.discount = 0.1;
}
return this.database.save(order);
}
}Правило скидки относится к домену или прикладному сценарию:
class Order {
calculateTotal() {
const subtotal = this.calculateSubtotal();
if (subtotal > 10000) {
return subtotal * 0.9;
}
return subtotal;
}
}Универсальный Service или Manager
Нежелательно:
class ApplicationManager {
createUser() {}
createOrder() {}
sendEmail() {}
exportReport() {}
clearCache() {}
processPayment() {}
}Такой компонент становится центральной точкой связанности.
Лучше разделить:
UserService
OrderService
NotificationService
ReportExporter
CacheService
PaymentServiceЧрезмерное количество абстракций
Не каждой функции требуется:
interface
factory
strategy
repository
adapter
facade
DI containerИзбыточный вариант:
class AdditionOperationStrategy {
execute(left, right) {
return left + right;
}
}
class OperationStrategyFactory {
createAdditionStrategy() {
return new AdditionOperationStrategy();
}
}Для простой операции достаточно:
function add(left, right) {
return left + right;
}Абстракция должна решать конкретную проблему, а не добавляться только ради следования паттерну.
Практический пример
Рассмотрим создание заказа с разделением ответственности.
Доменная сущность
class Order {
constructor({
id = null,
userId,
items,
status = "new",
}) {
if (!userId) {
throw new Error(
"Не указан пользователь",
);
}
if (!items.length) {
throw new Error(
"Заказ должен содержать товары",
);
}
for (const item of items) {
if (item.quantity <= 0) {
throw new Error(
"Количество должно быть положительным",
);
}
}
this.id = id;
this.userId = userId;
this.items = items;
this.status = status;
}
calculateSubtotal() {
return this.items.reduce((sum, item) => {
return sum + item.price * item.quantity;
}, 0);
}
calculateTotal(discountStrategy) {
return discountStrategy.calculate(
this.calculateSubtotal(),
);
}
}Стратегия скидки
class CustomerDiscount {
constructor(discountRate) {
this.discountRate = discountRate;
}
calculate(subtotal) {
return subtotal * (1 - this.discountRate);
}
}Репозиторий
class SqlOrderRepository {
constructor(database) {
this.database = database;
}
async save(order, total) {
const row = await this.database.orders.insert({
userId: order.userId,
status: order.status,
total,
});
return {
id: row.id,
userId: order.userId,
items: order.items,
status: order.status,
total,
};
}
}Прикладной сценарий
class CreateOrder {
constructor({
productRepository,
orderRepository,
discountStrategyFactory,
}) {
this.productRepository = productRepository;
this.orderRepository = orderRepository;
this.discountStrategyFactory =
discountStrategyFactory;
}
async execute(input) {
const items = [];
for (const requestedItem of input.items) {
const product =
await this.productRepository.findById(
requestedItem.productId,
);
if (!product) {
throw new Error(
`Товар ${requestedItem.productId} не найден`,
);
}
items.push({
productId: product.id,
price: product.price,
quantity: requestedItem.quantity,
});
}
const order = new Order({
userId: input.userId,
items,
});
const discountStrategy =
this.discountStrategyFactory.create(
input.customerType,
);
const total = order.calculateTotal(
discountStrategy,
);
return this.orderRepository.save(
order,
total,
);
}
}Фабрика стратегий
class NoDiscount {
calculate(subtotal) {
return subtotal;
}
}
class DiscountStrategyFactory {
create(customerType) {
switch (customerType) {
case "regular":
return new CustomerDiscount(0.05);
case "premium":
return new CustomerDiscount(0.15);
default:
return new NoDiscount();
}
}
}Контроллер
class CreateOrderController {
constructor(createOrder) {
this.createOrder = createOrder;
}
handle = async (request, response, next) => {
try {
const order = await this.createOrder.execute({
userId: request.body.userId,
customerType: request.body.customerType,
items: request.body.items,
});
return response.status(201).json(order);
} catch (error) {
return next(error);
}
};
}Сборка зависимостей
const productRepository =
new SqlProductRepository(database);
const orderRepository =
new SqlOrderRepository(database);
const discountStrategyFactory =
new DiscountStrategyFactory();
const createOrder = new CreateOrder({
productRepository,
orderRepository,
discountStrategyFactory,
});
const createOrderController =
new CreateOrderController(createOrder);Эта структура позволяет независимо заменить:
- SQL-репозиторий;
- источник товаров;
- стратегию скидки;
- HTTP-контроллер;
- базу данных.
Краткая памятка
SOLID:
SRP — один компонент отвечает за одну связанную область
OCP — расширяйте через новые реализации
LSP — реализации должны соблюдать общий контракт
ISP — предоставляйте минимальные специализированные контракты
DIP — бизнес-логика зависит от абстракций, а не деталейСлоистая архитектура:
Controller
↓
Service
↓
Repository
↓
DatabaseОтветственность слоёв:
Controller — транспорт и HTTP
Service — прикладной сценарий
Domain — бизнес-правила
Repository — доступ к данным
Adapter — преобразование между форматамиБазовые принципы:
DRY — не дублируйте одно и то же знание
KISS — выбирайте понятное простое решение
YAGNI — не создавайте неподтверждённую функциональностьВнедрение зависимостей:
class UserService {
constructor(userRepository) {
this.userRepository = userRepository;
}
}Паттерны:
Factory — создаёт подходящий объект
Strategy — заменяет один алгоритм другим
Singleton — ограничивает объект одним экземпляромОсновные правила:
- разделяйте HTTP, бизнес-логику и доступ к данным;
- не передавайте
requestиresponseв бизнес-сервисы; - скрывайте SQL и ORM за репозиториями;
- передавайте зависимости извне;
- собирайте приложение в отдельном composition root;
- проектируйте небольшие публичные контракты;
- используйте наследование только при настоящей взаимозаменяемости;
- предпочитайте композицию жёсткому наследованию;
- не создавайте абстракцию без понятной причины;
- не объединяйте код только из-за внешнего сходства;
- тестируйте бизнес-логику без реальной инфраструктуры;
- направляйте зависимости от внешних деталей к внутренним правилам;
- используйте паттерны как инструменты, а не как обязательные шаблоны;
- выбирайте архитектуру, соответствующую размеру и сложности проекта.