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

Практика 12. Практическая работа 12. Слоистая архитектура

Цель

  • Научиться проектировать приложение из трёх горизонтальных слоёв: Presentation, Service (Business) и Data.
  • Закрепить принцип разделения ответственности: каждый слой решает только свою задачу.
  • Отработать паттерн Repository как абстракцию доступа к данным.
  • Понять направление зависимостей (сверху вниз) и применить инверсию зависимостей (DIP) с внедрением через конструктор.
  • Различать модель предметной области и DTO, передаваемый наружу.

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

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

Три классических слоя:

Presentation ──> Service (Business) ──> Data
  • Presentation Layer — общение с внешним миром: принимает ввод (CLI, HTTP), вызывает сервис, переводит результат и ошибки в формат ответа (dict/JSON). Бизнес-правил не содержит.
  • Service / Business Layer — сердце приложения: валидация по бизнес-правилам, сценарии использования, координация. Не знает, как пришёл запрос и где хранятся данные.
  • Data Layer — доступ к хранилищу через паттерн Repository: читает и сохраняет сущности, скрывает детали хранения. Бизнес-правил не содержит.

Ключевые правила:

  • Направление зависимостей строго сверху вниз. Представление знает о сервисе, сервис — об абстракции репозитория, слой данных не знает о соседях выше. Нельзя «перепрыгивать» через слой (контроллер напрямую в репозиторий) и нельзя разворачивать стрелку вверх.
  • Зависимость от абстракции. Сервис зависит от интерфейса репозитория (ABC/Protocol), а конкретную реализацию получает извне через конструктор (внедрение зависимостей, DIP).
  • Модель и DTO. Внутри слоёв ходят модели предметной области (Task, Product), наружу контроллер отдаёт DTO — простой словарь без внутренних полей.

Подробности и сквозной пример — в лекции 13.


Задания

В работе проектируем одно приложение — менеджер задач (Task Manager) — и развиваем его от слоя к слою. Задания 1–3 обязательны, 4–5 углубляют тему.

Рекомендуемое дерево модулей:

task_manager/
├── models.py # Task — модель предметной области
├── exceptions.py # TaskValidationError, TaskNotFoundError
├── data/
│ ├── repository.py # TaskRepository (ABC)
│ └── in_memory.py # InMemoryTaskRepository
├── service/
│ └── task_service.py # TaskService — бизнес-логика
├── presentation/
│ └── controller.py # TaskController — ввод-вывод, DTO
└── main.py # сборка зависимостей и запуск сценария

Задание 1. Data Layer — модель и репозиторий

Реализуйте нижний слой.

Требования:

  • Модель Task (@dataclass): id: int, title: str, description: str, completed: bool = False, created_at: datetime.
  • Абстрактный интерфейс TaskRepository(ABC) с методами:
    • find_by_id(task_id: int) -> Optional[Task]
    • find_all() -> List[Task]
    • save(task: Task) -> Task (присваивает id, если его нет)
    • delete(task_id: int) -> None
  • Реализация InMemoryTaskRepository — хранение в dict, автоинкремент id.

Ограничения:

  • Репозиторий не содержит ни одного бизнес-правила (никаких проверок «title не пустой»).
  • Слой не импортирует ничего из service/ и presentation/.

Задание 2. Service Layer — бизнес-логика

Реализуйте TaskService, инкапсулирующий правила предметной области.

Скелет:

class TaskService:
def __init__(self, repository: TaskRepository) -> None:
self.repository = repository # зависим от абстракции
def create_task(self, title: str, description: str = "") -> Task: ...
def complete_task(self, task_id: int) -> Task: ...
def get_tasks(self, filter_completed: Optional[bool] = None) -> List[Task]: ...
def delete_task(self, task_id: int) -> None: ...

Требования:

  • create_task: title не пустой и не длиннее 100 символов, иначе TaskValidationError.
  • complete_task / delete_task: если задачи нет — TaskNotFoundError.
  • get_tasks(filter_completed): None — все, True/False — фильтрация по статусу.
  • Сервис обращается к данным только через TaskRepository, напрямую к хранилищу не лезет.
  • Сервис ничего не печатает и не формирует словарей-ответов — это работа представления.

Задание 3. Presentation Layer — контроллер и DTO

Реализуйте TaskController — границу с внешним миром.

Требования:

  • Конструктор принимает TaskService.
  • Приватный метод _to_dto(task: Task) -> dict превращает модель в словарь для отдачи наружу.
  • Методы возвращают dict единого формата:
    • handle_create_task(title, description) -> dict
    • handle_complete_task(task_id) -> dict
    • handle_list_tasks(filter_completed=None) -> dict
    • handle_delete_task(task_id) -> dict
  • Успех: {"success": True, "task": {...}} или {"success": True, "tasks": [...]}.
  • Ошибка: контроллер ловит TaskValidationError / TaskNotFoundError и возвращает {"success": False, "message": str(e)}.

Ограничения:

  • В контроллере нет бизнес-правил (никаких проверок длины title здесь).
  • Контроллер не обращается к репозиторию напрямую — только через сервис.

В main.py соберите зависимости снизу вверх и прогоните сценарий:

repository = InMemoryTaskRepository()
service = TaskService(repository)
controller = TaskController(service)
print(controller.handle_create_task("Изучить ООП", "Прочитать лекцию 13"))
print(controller.handle_create_task("", "пусто")) # success: False
print(controller.handle_complete_task(1))
print(controller.handle_list_tasks(filter_completed=True))

Задание 4. Заменяемость слоя данных

Покажите, что слой данных можно заменить, не трогая сервис и контроллер.

Требования:

  • Добавьте вторую реализацию FileTaskRepository(TaskRepository), сохраняющую задачи в JSON-файл (модуль json).
  • Реализуйте сериализацию/десериализацию Task (учтите datetime).
  • В main.py переключите приложение на файловый репозиторий, изменив только одну строку сборки.
  • Объясните в комментарии, почему TaskService и TaskController при этом не меняются.

Задание 5*. Проверка границ слоёв тестами

Подтвердите тестами, что архитектура соблюдена.

Требования:

  • Напишите класс-заглушку FakeTaskRepository(TaskRepository) (без реального хранилища).
  • Тест: TaskService с FakeTaskRepository отклоняет пустой title (TaskValidationError) — без базы и файлов.
  • Тест: complete_task для несуществующего id даёт TaskNotFoundError.
  • (По желанию) тест контроллера: при бизнес-ошибке возвращается {"success": False, ...}, а не выбрасывается исключение.
  • Используйте pytest или модуль unittest.

Критерии оценки

КритерийВес
Задание 1: модель + абстрактный и in-memory репозиторий, слой данных без бизнес-правил20%
Задание 2: TaskService с валидацией и исключениями, зависит от абстракции25%
Задание 3: TaskController с DTO и обработкой ошибок, без бизнес-правил25%
Задание 4: вторая реализация репозитория, замена без правок сервиса/контроллера15%
Соблюдение направления зависимостей и структуры модулей10%
Задание 5* (тесты границ слоёв)+5% бонус

Снижение оценки:

  • Бизнес-правило в контроллере или в репозитории — минус до 15%.
  • Контроллер обращается к репозиторию, минуя сервис, — минус до 10%.
  • Сервис зависит от конкретного InMemoryTaskRepository, а не от абстракции, — минус до 10%.

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

  1. Перечислите три слоя приложения и назовите зону ответственности каждого.
  2. В каком направлении идут зависимости между слоями? Почему репозиторий не должен знать о контроллере?
  3. В каком слое должна находиться проверка «title не длиннее 100 символов»? Что сломается, если перенести её в контроллер?
  4. Зачем TaskService зависит от TaskRepository (ABC), а не от InMemoryTaskRepository напрямую?
  5. Чем модель Task отличается от DTO, который контроллер возвращает наружу? Зачем их разделять?
  6. Что такое внедрение зависимостей и где в main.py оно происходит?
  7. Почему при переходе с in-memory на файловый репозиторий (задание 4) не меняются сервис и контроллер?
  8. Проследите путь запроса «завершить задачу по id» через все три слоя сверху вниз и обратно.
  9. Назовите две типичные ошибки реализации слоёв и объясните, чем они опасны.
  10. В каком случае три слоя были бы избыточны для задачи?