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

Лекция 16. Clean Architecture и структура приложения

Введение

На предыдущих лекциях мы по отдельности разобрали слоистую (layered) архитектуру, паттерн Repository, внедрение зависимостей (DI) и презентационные паттерны (MVC/MVP/MVVM). Сегодня мы соберём всё это в единую, осознанную систему — Clean Architecture (чистую архитектуру).

Clean Architecture — это не новый «волшебный» паттерн, а способ организации кода вокруг одной идеи: бизнес-логика приложения не должна зависеть от деталей — от базы данных, веб-фреймворка, формата UI или внешних сервисов. Эти детали приходят и уходят (сегодня PostgreSQL, завтра MongoDB; сегодня REST, завтра gRPC), а правила предметной области остаются ядром, ради которого приложение вообще существует.

Идея была сформулирована Робертом Мартином (Uncle Bob) и обобщает более ранние подходы: Hexagonal Architecture (порты и адаптеры) Алистера Кокбёрна, Onion Architecture и DDD.


1. Главная идея: концентрические слои

Clean Architecture рисуют как набор вложенных окружностей. Чем ближе к центру — тем более абстрактный и стабильный код; чем дальше к краю — тем более конкретные технические детали.

┌─────────────────────────────────────────────┐
│ Frameworks & Drivers (внешний слой) │
│ web, БД, UI, файлы, сторонние библиотеки │
│ ┌─────────────────────────────────────┐ │
│ │ Interface Adapters │ │
│ │ Controllers, Presenters, Gateways, │ │
│ │ реализации Repository │ │
│ │ ┌─────────────────────────────┐ │ │
│ │ │ Use Cases / Services │ │ │
│ │ │ сценарии использования │ │ │
│ │ │ ┌─────────────────────┐ │ │ │
│ │ │ │ Entities │ │ │ │
│ │ │ │ (Domain Models) │ │ │ │
│ │ │ └─────────────────────┘ │ │ │
│ │ └─────────────────────────────┘ │ │
│ └─────────────────────────────────────┘ │
└─────────────────────────────────────────────┘
Зависимости направлены ТОЛЬКО внутрь →

Правило зависимостей (Dependency Rule)

Это центральный закон Clean Architecture:

Зависимости в исходном коде могут указывать только внутрь — к более высокому уровню абстракции.

Что это значит на практике:

  • Внутренний слой ничего не знает о внешнем. Entity не знает про Service, Service не знает про Controller или базу данных.
  • Имя класса, функции или переменной из внешнего слоя не должно упоминаться во внутреннем коде.
  • Данные пересекают границу в виде простых структур (DTO, dataclass, dict), а не в виде объектов фреймворка.

Стрелки кода всегда смотрят к центру, хотя поток выполнения (вызовы во время работы программы) может идти наружу — это противоречие разрешается за счёт инверсии зависимостей (см. раздел 4).


2. Что лежит в каждом слое

Entities (сущности / доменные модели)

Самый центр. Это объекты, выражающие важнейшие бизнес-правила и данные предметной области. Они не знают ничего о приложении в целом — их можно было бы переиспользовать в другом проекте той же области.

В нашем примере библиотеки это Book, Member, Loan.

from dataclasses import dataclass
from datetime import datetime
from typing import Optional
@dataclass
class Book:
id: Optional[int]
isbn: str
title: str
author: str
available: bool = True
@dataclass
class Member:
id: Optional[int]
name: str
email: str
@dataclass
class Loan:
id: Optional[int]
book_id: int
member_id: int
loan_date: datetime
return_date: Optional[datetime] = None

Use Cases / Services (сценарии использования)

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

В нашем примере роль use cases играет LibraryService (его методы lend_book, return_book и т.д. — это и есть отдельные сценарии).

Interface Adapters (адаптеры)

Слой-переводчик. Он преобразует данные из удобной для use cases формы в форму, удобную для внешнего мира, и обратно. Сюда попадают:

  • Controllers — принимают запрос (HTTP, CLI, событие) и вызывают нужный use case;
  • Presenters — форматируют ответ для отображения;
  • конкретные реализации Repository — переводят доменные объекты в строки таблиц БД и обратно.

Frameworks & Drivers (внешний слой)

Всё, что «снаружи»: веб-фреймворк (Flask, FastAPI, Django), драйвер базы данных, ORM, файловая система, очереди сообщений, UI-библиотеки. По задумке этого кода должно быть мало — это «клей», который соединяет наше приложение с внешним миром.


3. Связь с ранее изученным

Clean Architecture не отменяет, а упорядочивает уже знакомые вам паттерны:

Изучали ранееМесто в Clean Architecture
Слоистая архитектураКонцентрические слои — её развитие с жёстким правилом зависимостей
RepositoryИнтерфейс — внутри (use cases), реализация — в адаптерах
Service LayerСлой Use Cases
Dependency InjectionМеханизм, который собирает слои в Application Setup
MVC / MVP / MVVMController и Presenter — это адаптеры внешнего слоя
Принцип DIP (SOLID)Технический фундамент правила зависимостей

4. Границы, порты и адаптеры, инверсия зависимостей

Возникает естественный вопрос: как LibraryService (внутренний слой) может сохранять данные в базу (внешний слой), не нарушая правило зависимостей? Ведь сохранение явно идёт «наружу».

Ответ — инверсия зависимостей (Dependency Inversion Principle). Внутренний слой объявляет порт — абстрактный интерфейс того, что ему нужно. Внешний слой предоставляет адаптер — конкретную реализацию этого интерфейса.

Use Case ──требует──► Repository (порт, ABC) ◄──реализует── SqlBookRepository (адаптер)
(внутри) интерфейс во внутреннем слое во внешнем слое

Стрелка реализации направлена внутрь (адаптер зависит от интерфейса), хотя вызов во время работы идёт наружу. Именно так разрешается противоречие из раздела 1.

from abc import ABC, abstractmethod
from typing import Generic, List, Optional, TypeVar
T = TypeVar("T")
class Repository(ABC, Generic[T]):
"""Порт: абстрактный интерфейс хранилища (живёт во внутреннем слое)."""
@abstractmethod
def find_by_id(self, entity_id: int) -> Optional[T]: ...
@abstractmethod
def find_all(self) -> List[T]: ...
@abstractmethod
def save(self, entity: T) -> T: ...
@abstractmethod
def delete(self, entity_id: int) -> None: ...
@abstractmethod
def exists(self, entity_id: int) -> bool: ...

LibraryService зависит только от этого Repository. Заменим in-memory хранилище на PostgreSQL — изменится лишь адаптер, ядро останется нетронутым. Это же делает код тестируемым: в тестах подставляем фейковый репозиторий в памяти, без реальной БД.


5. Структура каталогов приложения

Слои удобно отражать прямо в структуре пакетов проекта. Один из распространённых вариантов:

library_app/
├── domain/ # Entities — самый центр
│ ├── __init__.py
│ └── models.py # Book, Member, Loan
├── application/ # Use Cases + порты
│ ├── __init__.py
│ ├── ports.py # Repository (ABC) — интерфейсы
│ └── services.py # LibraryService
├── adapters/ # Interface Adapters
│ ├── __init__.py
│ ├── repositories.py # InMemory*Repository (реализации портов)
│ └── controllers.py # LibraryController
├── infrastructure/ # Frameworks & Drivers
│ ├── __init__.py
│ └── web.py # связка с Flask/FastAPI, CLI и т.п.
└── main.py # Application Setup (композиция + DI)

Правило простое: импорты разрешены только «внутрь». domain ничего не импортирует из проекта; application импортирует только domain; adaptersapplication и domain; infrastructure и main могут импортировать всё. Обратные импорты — сигнал нарушения архитектуры.


6. Сквозной пример

Соберём приложение библиотеки целиком.

6.1. Адаптеры: реализации репозиториев

adapters/repositories.py
from typing import Dict, List, Optional
from application.ports import Repository
from domain.models import Book, Member, Loan
class InMemoryBookRepository(Repository[Book]):
def __init__(self):
self._storage: Dict[int, Book] = {}
self._next_id = 1
def find_by_id(self, book_id: int) -> Optional[Book]:
return self._storage.get(book_id)
def find_all(self) -> List[Book]:
return list(self._storage.values())
def find_by_isbn(self, isbn: str) -> Optional[Book]:
return next((b for b in self._storage.values() if b.isbn == isbn), None)
def find_available(self) -> List[Book]:
return [b for b in self._storage.values() if b.available]
def save(self, book: Book) -> Book:
if not book.id:
book.id = self._next_id
self._next_id += 1
self._storage[book.id] = book
return book
def delete(self, book_id: int) -> None:
self._storage.pop(book_id, None)
def exists(self, book_id: int) -> bool:
return book_id in self._storage
class InMemoryMemberRepository(Repository[Member]):
def __init__(self):
self._storage: Dict[int, Member] = {}
self._next_id = 1
def find_by_id(self, member_id: int) -> Optional[Member]:
return self._storage.get(member_id)
def find_all(self) -> List[Member]:
return list(self._storage.values())
def save(self, member: Member) -> Member:
if not member.id:
member.id = self._next_id
self._next_id += 1
self._storage[member.id] = member
return member
def delete(self, member_id: int) -> None:
self._storage.pop(member_id, None)
def exists(self, member_id: int) -> bool:
return member_id in self._storage
class InMemoryLoanRepository(Repository[Loan]):
def __init__(self):
self._storage: Dict[int, Loan] = {}
self._next_id = 1
def find_by_id(self, loan_id: int) -> Optional[Loan]:
return self._storage.get(loan_id)
def find_all(self) -> List[Loan]:
return list(self._storage.values())
def find_active_by_book(self, book_id: int) -> Optional[Loan]:
return next(
(l for l in self._storage.values()
if l.book_id == book_id and l.return_date is None),
None,
)
def find_active_by_member(self, member_id: int) -> List[Loan]:
return [l for l in self._storage.values()
if l.member_id == member_id and l.return_date is None]
def save(self, loan: Loan) -> Loan:
if not loan.id:
loan.id = self._next_id
self._next_id += 1
self._storage[loan.id] = loan
return loan
def delete(self, loan_id: int) -> None:
self._storage.pop(loan_id, None)
def exists(self, loan_id: int) -> bool:
return loan_id in self._storage

6.2. Use Cases: сервис библиотеки

application/services.py
from datetime import datetime
from typing import List
from domain.models import Book, Member, Loan
class LibraryService:
"""Сценарии использования библиотеки. Зависит только от портов."""
def __init__(self, book_repo, member_repo, loan_repo):
self.book_repo = book_repo
self.member_repo = member_repo
self.loan_repo = loan_repo
def add_book(self, isbn: str, title: str, author: str) -> Book:
if self.book_repo.find_by_isbn(isbn):
raise ValueError(f"Книга с ISBN {isbn} уже существует")
book = Book(id=None, isbn=isbn, title=title, author=author)
return self.book_repo.save(book)
def register_member(self, name: str, email: str) -> Member:
if not name or not email:
raise ValueError("Имя и email обязательны")
return self.member_repo.save(Member(id=None, name=name, email=email))
def lend_book(self, book_id: int, member_id: int) -> Loan:
book = self.book_repo.find_by_id(book_id)
if not book:
raise ValueError(f"Книга с ID {book_id} не найдена")
if not book.available:
raise ValueError(f"Книга '{book.title}' уже выдана")
if not self.member_repo.find_by_id(member_id):
raise ValueError(f"Читатель с ID {member_id} не найден")
loan = self.loan_repo.save(
Loan(id=None, book_id=book_id, member_id=member_id, loan_date=datetime.now())
)
book.available = False
self.book_repo.save(book)
return loan
def return_book(self, book_id: int) -> None:
book = self.book_repo.find_by_id(book_id)
if not book:
raise ValueError(f"Книга с ID {book_id} не найдена")
active_loan = self.loan_repo.find_active_by_book(book_id)
if not active_loan:
raise ValueError(f"Книга '{book.title}' не выдана")
active_loan.return_date = datetime.now()
self.loan_repo.save(active_loan)
book.available = True
self.book_repo.save(book)
def get_available_books(self) -> List[Book]:
return self.book_repo.find_available()

6.3. Адаптер: контроллер

Контроллер переводит «сырой» запрос в вызов use case и упаковывает результат в ответ. Он не содержит бизнес-логики — только перевод и обработку ошибок.

adapters/controllers.py
from application.services import LibraryService
class LibraryController:
def __init__(self, service: LibraryService):
self.service = service
def handle_add_book(self, isbn: str, title: str, author: str) -> dict:
try:
book = self.service.add_book(isbn, title, author)
return {"success": True, "message": f"Книга '{title}' добавлена",
"book_id": book.id}
except ValueError as e:
return {"success": False, "message": str(e)}
def handle_lend_book(self, book_id: int, member_id: int) -> dict:
try:
loan = self.service.lend_book(book_id, member_id)
return {"success": True, "message": "Книга выдана", "loan_id": loan.id}
except ValueError as e:
return {"success": False, "message": str(e)}
def handle_return_book(self, book_id: int) -> dict:
try:
self.service.return_book(book_id)
return {"success": True, "message": "Книга возвращена"}
except ValueError as e:
return {"success": False, "message": str(e)}

6.4. Application Setup: сборка через DI

Единственное место, где слои «встречаются», — точка композиции (composition root). Здесь мы создаём конкретные адаптеры и внедряем их внутрь. Чтобы сменить хранилище на реальную БД, достаточно поменять три строки здесь.

main.py
from adapters.repositories import (
InMemoryBookRepository, InMemoryMemberRepository, InMemoryLoanRepository,
)
from adapters.controllers import LibraryController
from application.services import LibraryService
def create_library_app() -> LibraryController:
# внешний слой (адаптеры) создаётся здесь...
book_repo = InMemoryBookRepository()
member_repo = InMemoryMemberRepository()
loan_repo = InMemoryLoanRepository()
# ...и внедряется внутрь (Dependency Injection)
service = LibraryService(book_repo, member_repo, loan_repo)
return LibraryController(service)
def main():
app = create_library_app()
print(app.handle_add_book("978-0-123456-78-9", "Python для начинающих", "Иван Иванов"))
member = app.service.register_member("Алексей", "alex@example.com")
print(f"Зарегистрирован читатель: {member.name} (id={member.id})")
print(app.handle_lend_book(1, member.id))
print(app.handle_return_book(1))
if __name__ == "__main__":
main()

Обратите внимание: main.py знает обо всех слоях, но ни один слой не знает о main.py. Зависимости текут строго внутрь.


7. Когда Clean Architecture избыточна

Clean Architecture — это инвестиция: больше файлов, интерфейсов и «церемоний». Она окупается не всегда.

Стоит применять, когда:

  • проект большой и долгоживущий, бизнес-правил много и они сложные;
  • вероятна смена технологий (БД, фреймворк, протокол);
  • важна высокая покрываемость тестами без зависимости от инфраструктуры;
  • над кодом работает большая команда, нужны чёткие границы.

Скорее избыточна, когда:

  • это небольшой скрипт, прототип или одноразовый инструмент;
  • приложение по сути CRUD без существенной бизнес-логики — тогда лишние слои только добавляют «проброс» данных без пользы;
  • команда маленькая, сроки короткие, требования ещё не устоялись.

Полезное правило: начинайте с простой слоистой структуры и выделяйте границы тогда, когда чувствуете боль от связанности. Архитектура — инструмент для решения проблем, а не самоцель. Избыточные абстракции вредят не меньше, чем их отсутствие.


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

  • Clean Architecture организует код в концентрические слои: Entities → Use Cases → Interface Adapters → Frameworks & Drivers.
  • Правило зависимостей: исходные зависимости направлены только внутрь; внутренние слои ничего не знают о внешних.
  • Entities — доменные модели и важнейшие правила; Use Cases/Services — сценарии конкретного приложения; адаптеры (контроллеры, презентеры, реализации репозиториев) переводят между ядром и внешним миром.
  • Порты и адаптеры + инверсия зависимостей позволяют ядру «обращаться наружу», оставаясь независимым: внутренний слой объявляет интерфейс, внешний его реализует.
  • Слои отражаются в структуре каталогов; импорты разрешены только внутрь.
  • Composition root (Application Setup) — единственное место сборки слоёв через DI; смена технологии затрагивает только его и адаптеры.
  • Clean Architecture — это компромисс: применяйте её для крупных, изменчивых, долгоживущих систем и не усложняйте простые проекты.

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

  1. Сформулируйте правило зависимостей. В какую сторону всегда направлены зависимости в исходном коде?
  2. Чем Entities отличаются от Use Cases? Приведите пример каждого из примера библиотеки.
  3. Почему LibraryService зависит от абстрактного Repository, а не от InMemoryBookRepository напрямую? Какие выгоды это даёт?
  4. Как порты и адаптеры вместе с принципом DIP позволяют ядру «вызывать» базу данных, не нарушая правило зависимостей?
  5. К какому слою Clean Architecture относится контроллер из MVC? А реализация репозитория для PostgreSQL?
  6. Что такое composition root и почему важно, чтобы он был единственным местом сборки зависимостей?
  7. Опишите, какие файлы пришлось бы изменить при переходе библиотеки с in-memory хранилища на реальную СУБД.
  8. Приведите пример проекта, для которого Clean Architecture была бы избыточной, и обоснуйте, почему.