Лекция 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 dataclassfrom datetime import datetimefrom typing import Optional
@dataclassclass Book: id: Optional[int] isbn: str title: str author: str available: bool = True
@dataclassclass Member: id: Optional[int] name: str email: str
@dataclassclass Loan: id: Optional[int] book_id: int member_id: int loan_date: datetime return_date: Optional[datetime] = NoneUse 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 / MVVM | Controller и Presenter — это адаптеры внешнего слоя |
| Принцип DIP (SOLID) | Технический фундамент правила зависимостей |
4. Границы, порты и адаптеры, инверсия зависимостей
Возникает естественный вопрос: как LibraryService (внутренний слой) может сохранять данные в базу (внешний слой), не нарушая правило зависимостей? Ведь сохранение явно идёт «наружу».
Ответ — инверсия зависимостей (Dependency Inversion Principle). Внутренний слой объявляет порт — абстрактный интерфейс того, что ему нужно. Внешний слой предоставляет адаптер — конкретную реализацию этого интерфейса.
Use Case ──требует──► Repository (порт, ABC) ◄──реализует── SqlBookRepository (адаптер) (внутри) интерфейс во внутреннем слое во внешнем слоеСтрелка реализации направлена внутрь (адаптер зависит от интерфейса), хотя вызов во время работы идёт наружу. Именно так разрешается противоречие из раздела 1.
from abc import ABC, abstractmethodfrom 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; adapters — application и domain; infrastructure и main могут импортировать всё. Обратные импорты — сигнал нарушения архитектуры.
6. Сквозной пример
Соберём приложение библиотеки целиком.
6.1. Адаптеры: реализации репозиториев
from typing import Dict, List, Optional
from application.ports import Repositoryfrom 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._storage6.2. Use Cases: сервис библиотеки
from datetime import datetimefrom 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 и упаковывает результат в ответ. Он не содержит бизнес-логики — только перевод и обработку ошибок.
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). Здесь мы создаём конкретные адаптеры и внедряем их внутрь. Чтобы сменить хранилище на реальную БД, достаточно поменять три строки здесь.
from adapters.repositories import ( InMemoryBookRepository, InMemoryMemberRepository, InMemoryLoanRepository,)from adapters.controllers import LibraryControllerfrom 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 — это компромисс: применяйте её для крупных, изменчивых, долгоживущих систем и не усложняйте простые проекты.
Вопросы для самопроверки
- Сформулируйте правило зависимостей. В какую сторону всегда направлены зависимости в исходном коде?
- Чем Entities отличаются от Use Cases? Приведите пример каждого из примера библиотеки.
- Почему
LibraryServiceзависит от абстрактногоRepository, а не отInMemoryBookRepositoryнапрямую? Какие выгоды это даёт? - Как порты и адаптеры вместе с принципом DIP позволяют ядру «вызывать» базу данных, не нарушая правило зависимостей?
- К какому слою Clean Architecture относится контроллер из MVC? А реализация репозитория для PostgreSQL?
- Что такое composition root и почему важно, чтобы он был единственным местом сборки зависимостей?
- Опишите, какие файлы пришлось бы изменить при переходе библиотеки с in-memory хранилища на реальную СУБД.
- Приведите пример проекта, для которого Clean Architecture была бы избыточной, и обоснуйте, почему.