Перейти к содержимому

Лекция 13. Слоистая архитектура приложений

1. Зачем разделять приложение на слои

Пока программа состоит из десятка функций, её структура держится в голове целиком. Но как только проект вырастает до сотен и тысяч строк, неконтролируемый код превращается в то, что в индустрии называют «большим комом грязи» (Big Ball of Mud) — систему без видимой структуры, где всё связано со всем.

Признаки «кома грязи»:

  • Один и тот же файл одновременно читает данные из базы, проверяет бизнес-правила и формирует HTML-ответ.
  • Изменение формата вывода требует правок в коде, который работает с базой данных.
  • Невозможно протестировать логику, не подняв реальную СУБД и веб-сервер.
  • Новый разработчик неделями разбирается, «где что лежит».

Цена такого подхода растёт нелинейно: каждая новая функция увеличивает связанность, и в какой-то момент стоимость любого изменения становится непредсказуемой. Слоистая (layered) архитектура — простейший и самый распространённый способ навести порядок. Идея проста: разбить приложение на горизонтальные слои, каждый из которых отвечает за свой круг задач и общается только с соседями.

Что мы получаем:

  • Разделение ответственности — каждый слой решает одну задачу.
  • Тестируемость — слои можно проверять изолированно, подменяя соседей заглушками.
  • Заменяемость — реализацию одного слоя можно поменять, не трогая остальные.
  • Понятность — новичок быстро находит, где искать нужный код.

2. Классические слои

Традиционно выделяют три слоя.

┌─────────────────────────────────────────┐
│ Presentation Layer (Представление) │
│ CLI / HTTP-контроллеры / UI │
│ ввод-вывод, форматирование ответа │
└───────────────────┬─────────────────────┘
│ вызывает
┌─────────────────────────────────────────┐
│ Business Layer (Бизнес-логика) │
│ Service-классы, бизнес-правила, │
│ валидация, сценарии использования │
└───────────────────┬─────────────────────┘
│ вызывает
┌─────────────────────────────────────────┐
│ Data Layer (Доступ к данным) │
│ Repository, ORM, SQL, файлы, API │
│ чтение и запись хранилища │
└─────────────────────────────────────────┘

2.1. Presentation Layer (слой представления)

Отвечает за общение с внешним миром: принимает запрос пользователя (HTTP-запрос, команду в консоли, нажатие кнопки) и возвращает результат в нужном формате (JSON, HTML, текст). Здесь живут контроллеры и обработчики.

Что слой представления делает: разбирает входные данные, вызывает бизнес-слой, переводит результат и ошибки в формат ответа.

Чего он не делает: не содержит бизнес-правил и ничего не знает о том, как и где хранятся данные.

2.2. Business Layer (бизнес-логика, Service)

Сердце приложения. Здесь сосредоточены правила предметной области: что считается корректным, какие действия разрешены, в каком порядке они выполняются. Этот слой не зависит ни от способа отображения, ни от конкретного хранилища.

Что слой делает: валидирует данные по бизнес-правилам, координирует сценарии (зарегистрировать пользователя, оформить заказ), обращается к слою данных через абстракции.

Чего не делает: не формирует HTML/JSON, не пишет SQL напрямую.

2.3. Data Layer (слой доступа к данным)

Инкапсулирует работу с хранилищем. Чаще всего реализуется паттерном Repository: бизнес-слой работает с данными как с коллекцией объектов, не зная, лежат ли они в памяти, в PostgreSQL или приходят по сети.

Что слой делает: читает и сохраняет сущности, скрывает детали СУБД/ORM.

Чего не делает: не содержит бизнес-правил («пользователю должно быть 18+» — это не его забота).


3. Правило направления зависимостей

Ключевое правило слоистой архитектуры: зависимости направлены строго сверху вниз.

Presentation ──> Business ──> Data
  • Представление знает о бизнес-слое и вызывает его.
  • Бизнес-слой знает о слое данных (точнее — о его абстракции).
  • Слой данных не знает ни о бизнес-слое, ни о представлении.

Нельзя «перепрыгивать» через слой (контроллер не должен лезть в базу напрямую) и нельзя разворачивать стрелку вверх (репозиторий не должен дёргать контроллер).

Чтобы зависимость была не жёсткой, а гибкой, бизнес-слой зависит не от конкретной реализации репозитория, а от абстракции (интерфейса). Это применение принципа инверсии зависимостей (DIP из SOLID): конкретную реализацию передают извне через конструктор (внедрение зависимостей).

3.1. DTO и модели между слоями

Слои обмениваются не «голыми» переменными, а структурированными объектами. Для этого используют:

  • Модели предметной области (User, Product) — сущности, с которыми работает бизнес-слой.
  • DTO (Data Transfer Object) — простые объекты для передачи данных через границу слоя, например то, что контроллер возвращает наружу в виде словаря/JSON.

Зачем разделять модель и DTO: внутренняя модель может содержать поля, которые нельзя показывать наружу (хеш пароля), а формат ответа API не должен жёстко повторять структуру таблицы в базе. В простых проектах разделение может быть условным, но важно понимать саму границу.


4. Сквозной пример на Python

Соберём небольшое приложение по управлению пользователями. Пройдём сценарий «зарегистрировать пользователя» через все три слоя.

4.1. Слой данных

from abc import ABC, abstractmethod
from typing import List, Optional
from dataclasses import dataclass
@dataclass
class User:
"""Модель предметной области."""
id: int
name: str
email: str
age: int
class UserRepository(ABC):
"""Абстракция репозитория — контракт для бизнес-слоя."""
@abstractmethod
def find_by_id(self, user_id: int) -> Optional[User]: ...
@abstractmethod
def find_all(self) -> List[User]: ...
@abstractmethod
def save(self, user: User) -> User: ...
class InMemoryUserRepository(UserRepository):
"""Конкретная реализация — хранение в памяти."""
def __init__(self) -> None:
self._storage: dict[int, User] = {}
self._next_id = 1
def find_by_id(self, user_id: int) -> Optional[User]:
return self._storage.get(user_id)
def find_all(self) -> List[User]:
return list(self._storage.values())
def save(self, user: User) -> User:
if not user.id:
user.id = self._next_id
self._next_id += 1
self._storage[user.id] = user
return user

Обратите внимание: репозиторий ничего не знает о правилах вроде «возраст 18+». Он просто хранит и отдаёт объекты. Завтра InMemoryUserRepository можно заменить на PostgresUserRepository — остальной код не изменится.

4.2. Бизнес-слой

class UserValidationError(Exception):
"""Нарушение бизнес-правила."""
class UserService:
"""Бизнес-логика работы с пользователями."""
def __init__(self, repository: UserRepository) -> None:
# Зависим от абстракции, реализацию получаем извне.
self.repository = repository
def register_user(self, name: str, email: str, age: int) -> User:
# Бизнес-правила
if not name.strip():
raise UserValidationError("Имя не может быть пустым")
if "@" not in email:
raise UserValidationError("Email должен содержать '@'")
if age < 18:
raise UserValidationError("Пользователь должен быть совершеннолетним")
# Бизнес-правило уникальности email
if any(u.email == email.lower() for u in self.repository.find_all()):
raise UserValidationError(f"Email {email} уже занят")
user = User(id=0, name=name.strip(), email=email.lower(), age=age)
return self.repository.save(user)
def get_user(self, user_id: int) -> Optional[User]:
return self.repository.find_by_id(user_id)

Сервис не знает, как пришёл запрос (HTTP или консоль) и куда сохраняются данные. Он только применяет правила и координирует работу.

4.3. Слой представления

class UserController:
"""Слой представления: вход-выход, формирование ответа (DTO)."""
def __init__(self, service: UserService) -> None:
self.service = service
def _to_dto(self, user: User) -> dict:
"""Превращаем модель в безопасный для отдачи словарь."""
return {"id": user.id, "name": user.name,
"email": user.email, "age": user.age}
def handle_register(self, name: str, email: str, age: int) -> dict:
try:
user = self.service.register_user(name, email, age)
return {"success": True, "user": self._to_dto(user)}
except UserValidationError as e:
# Бизнес-ошибку переводим в формат ответа
return {"success": False, "message": str(e)}

Контроллер только перекладывает данные и переводит ошибки в формат ответа. Никаких правил «18+» здесь нет — за это отвечает сервис.

4.4. Сборка и запуск сценария

def main() -> None:
# Сборка зависимостей снизу вверх
repository = InMemoryUserRepository()
service = UserService(repository)
controller = UserController(service)
print(controller.handle_register("Иван Иванов", "ivan@example.com", 25))
# {'success': True, 'user': {'id': 1, ...}}
print(controller.handle_register("", "bad-email", 15))
# {'success': False, 'message': 'Имя не может быть пустым'}
if __name__ == "__main__":
main()

Проследим путь запроса handle_register("Иван", "ivan@example.com", 25):

  1. Presentation — контроллер принимает аргументы и вызывает service.register_user(...).
  2. Business — сервис проверяет правила, формирует User, просит репозиторий сохранить.
  3. Data — репозиторий присваивает id, кладёт объект в хранилище и возвращает его.
  4. Результат поднимается обратно: сервис отдаёт User, контроллер превращает его в DTO-словарь.

5. Плюсы и типичные ошибки

5.1. Плюсы

  • Тестируемость. Бизнес-логику проверяют без базы и сети: в UserService подставляют тестовый репозиторий-заглушку (mock).
class FakeRepo(UserRepository):
def __init__(self): self.saved = []
def find_by_id(self, _): return None
def find_all(self): return self.saved
def save(self, user): self.saved.append(user); return user
def test_register_rejects_minor():
service = UserService(FakeRepo())
try:
service.register_user("Петя", "p@e.com", 15)
assert False, "ожидалась ошибка"
except UserValidationError:
pass
  • Заменяемость. Переход с in-memory на реальную СУБД — это новый класс репозитория; бизнес-слой и представление не меняются.
  • Параллельная разработка. Команды могут работать над разными слоями, договорившись об интерфейсах.
  • Понятная навигация. Структура проекта отражает слои: presentation/, services/, repositories/.

5.2. Типичные ошибки

  • Протекание бизнес-логики в представление. Самая частая ошибка: проверку «age < 18» пишут в контроллере. Тогда при добавлении второго интерфейса (например, CLI рядом с HTTP) правило придётся дублировать. Все бизнес-правила должны жить в бизнес-слое.
  • SQL/ORM в бизнес-слое. Если сервис напрямую пишет запросы к базе, слой данных теряет смысл, а логику нельзя протестировать без СУБД.
  • Перепрыгивание через слой. Контроллер обращается к репозиторию напрямую, минуя сервис, — бизнес-правила обходятся.
  • Анемичная модель. Модель данных превращается в пустой контейнер полей, а вся логика расползается по сервисам. Иногда это допустимо, но за этим стоит следить.
  • Избыточность для мелких задач. Для скрипта в 50 строк три слоя — лишнее усложнение. Архитектуру вводят, когда сложность реально растёт.

Краткие итоги

  • «Большой ком грязи» — система без структуры, где всё связано со всем; стоимость изменений в ней растёт лавинообразно.
  • Слоистая архитектура делит приложение на слои: Presentation (ввод-вывод и форматирование), Business (правила предметной области), Data (доступ к хранилищу).
  • Каждый слой имеет одну зону ответственности и не выполняет работу соседей.
  • Зависимости направлены строго сверху вниз; бизнес-слой зависит от абстракции репозитория, а конкретную реализацию получает через внедрение зависимостей.
  • Между слоями передаются модели и DTO; граница между внутренней моделью и форматом ответа важна.
  • Выигрыш — тестируемость, заменяемость и понятность; главная опасность — протекание бизнес-логики в представление или в слой данных.

Вопросы для самопроверки

  1. Что такое «большой ком грязи» и какими симптомами он проявляется?
  2. Перечислите три классических слоя и опишите ответственность каждого.
  3. В каком направлении должны идти зависимости между слоями? Почему репозиторий не должен знать о контроллере?
  4. Зачем бизнес-слой зависит от абстракции UserRepository, а не от InMemoryUserRepository напрямую?
  5. Чем модель предметной области отличается от DTO и зачем их разделять?
  6. В каком слое должна находиться проверка «возраст не меньше 18»? Что произойдёт, если поместить её в контроллер?
  7. Как слоистая архитектура помогает тестировать бизнес-логику без реальной базы данных?
  8. Проследите путь запроса «получить пользователя по ID» через три слоя.
  9. Назовите две типичные ошибки при реализации слоёв и объясните, чем они опасны.
  10. В каких случаях введение трёх слоёв будет избыточным?