Проектирование от интерфейсов
Обычный способ строить модуль — сначала писать реализацию, потом выносить интерфейсы, когда «стало достаточно сложно». archtool переворачивает это: сначала пиши интерфейс, потом реализацию.
Это не стилевое предпочтение. Это меняет то, как ты думаешь о системе.
interfaces.py — это твой проектный документ
В каждом archtool-модуле есть interfaces.py. Этот файл — не бойлерплейт. Это архитектурная спецификация данного ограниченного контекста.
# app/orders/interfaces.py
from abc import abstractmethod
from archtool.layers.default_layer_interfaces import ABCRepo, ABCService
class OrderRepoABC(ABCRepo):
"""Персистентность для агрегатов Order."""
@abstractmethod
async def get(self, order_id: UUID) -> Order:
"""Загрузить заказ по ID. Бросает OrderNotFound если не найден."""
@abstractmethod
async def save(self, order: Order) -> None:
"""Сохранить новый или обновлённый заказ."""
@abstractmethod
async def list_by_user(self, user_id: UUID) -> list[Order]:
"""Вернуть все заказы данного пользователя."""
class OrderServiceABC(ABCService):
"""Бизнес-правила ограниченного контекста Заказов."""
@abstractmethod
async def place(self, user_id: UUID, items: list[Item]) -> Order:
"""Валидировать позиции, списать остатки, создать заказ, эмитировать OrderPlaced."""
@abstractmethod
async def cancel(self, order_id: UUID) -> None:
"""Отменить заказ, если он ещё не отправлен."""
Читая этот файл, новый разработчик понимает: - Что делает ограниченный контекст — не читая ни строчки реализации - Какие операции возможны — публичный контракт - Какие инварианты действуют — задокументированы в docstring'ах, а не разбросаны по файлам реализации
Файлы реализации (repos.py, services.py) — это просто исполнение этого контракта.
Архитектурные решения живут в интерфейсах
Когда пишешь интерфейс, принимаешь решения:
get()бросаетOrderNotFoundили возвращаетNone— выбери одно, задокументируйplace()асинхронный — нижестоящий вызов I/O-bound? Да. Задокументируй почему.cancel()принимает толькоorder_id— вызывающему никогда не нужен полный объект
Эти решения не должны жить в описании PR, которое никто не читает через полгода. Они должны быть в docstring интерфейса, рядом с контрактом, который они описывают.
Пиши интерфейс до тестов
Проектирование от интерфейсов естественно ведёт к разработке через тестирование:
- Пишешь
OrderServiceABC— решаешь что означаютplace()иcancel() - Пишешь
StubOrderRepoвозвращающий фиксированные данные - Пишешь тесты против
OrderServiceABCс заглушкой - Пишешь
OrderServiceиOrderRepoчтобы тесты прошли
На каждом шаге интерфейс — источник истины, а не реализация.
Опыт нового разработчика
Когда каждый проект использует одну структуру, новый разработчик сразу знает куда смотреть:
app/
├── users/interfaces.py ← начни здесь, пойми контекст Users
├── orders/interfaces.py ← начни здесь, пойми контекст Orders
└── payments/interfaces.py ← начни здесь, пойми контекст Payments
Никаких README. Никаких диаграмм архитектуры. Никакого рытья в файлах реализации. Файлы interfaces.py и есть архитектура.
Ограниченные контексты остаются ограниченными
Так как интерфейсы живут в отдельных модулях, кросс-контекстные зависимости явны:
# app/orders/services.py
class OrderService(OrderServiceABC):
repo: OrderRepoABC
user_service: UserServiceABC # кросс-контекстная зависимость — объявлена явно
Если OrderService нужно что-то из Users, он объявляет это через аннотацию интерфейса. Нет никакого способа случайно импортировать конкретный класс из другого модуля и обойти контракт — archtool поймает нарушение границы слоя при старте.
Инкрементальное внедрение
Не нужно переделывать весь проект. Выбери один модуль, вынеси его интерфейсы:
- Создай
app/users/interfaces.pyсUserRepoABCиUserServiceABC - Сделай так, чтобы существующие классы наследовали их
- Добавь
AppModule("app.users")в инжектор
Остальная кодовая база не трогается. Мигрируй ограниченный контекст за контекстом, в своём темпе.