Архитектура и 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-запрос
    ↓
Controller
    ↓
Service
    ↓
Repository
    ↓
Database

Дополнительные компоненты подключаются через зависимости:

OrderService
├── OrderRepository
├── UserRepository
├── PriceCalculator
└── NotificationService

Содержание


Связанность и зацепление

При проектировании часто оценивают два свойства.

Связанность внутри модуля

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");
  }
}

Класс отвечает за:

У этих задач разные причины изменения.

Разделение ответственности

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

Возможные признаки:

Большой размер компонента может указывать на нарушение 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);
  }
}

Проблемы:

Инверсия зависимости

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.js

Controller

Controller принимает запрос транспортного уровня и формирует ответ.

Для 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,
    };
  }
}

Сервис не должен знать:


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 изолирует:


Маршруты и сборка приложения

Маршруты связывают 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 {
  // Сложная универсальная система
}

Рациональный порядок:

  1. Сначала реализовать понятное решение.
  2. Наблюдать, действительно ли логика повторяется.
  3. Определить, является ли повторение одним знанием.
  4. Выделить абстракцию, когда её назначение стало понятным.

Иногда небольшое дублирование безопаснее неправильной абстракции.


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 модуля

Структура:

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";
  }
}

Сущность не знает:

Она содержит правила заказа.


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

Внешний уровень содержит технические детали:

Например, связывание с веб-фреймворком:

app.post("/orders", async (request, response) => {
  const result =
    await createOrderController.handle(request);

  response
    .status(result.statusCode)
    .json(result.body);
});

Use Case не знает, что его вызвал HTTP-контроллер. Его также можно вызвать:


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);

Эта структура позволяет независимо заменить:


Краткая памятка

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 — ограничивает объект одним экземпляром

Основные правила: