Перейти к содержанию

Проектирование от интерфейсов

Обычный способ строить модуль — сначала писать реализацию, потом выносить интерфейсы, когда «стало достаточно сложно». 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 интерфейса, рядом с контрактом, который они описывают.


Пиши интерфейс до тестов

Проектирование от интерфейсов естественно ведёт к разработке через тестирование:

  1. Пишешь OrderServiceABC — решаешь что означают place() и cancel()
  2. Пишешь StubOrderRepo возвращающий фиксированные данные
  3. Пишешь тесты против OrderServiceABC с заглушкой
  4. Пишешь 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 поймает нарушение границы слоя при старте.


Инкрементальное внедрение

Не нужно переделывать весь проект. Выбери один модуль, вынеси его интерфейсы:

  1. Создай app/users/interfaces.py с UserRepoABC и UserServiceABC
  2. Сделай так, чтобы существующие классы наследовали их
  3. Добавь AppModule("app.users") в инжектор

Остальная кодовая база не трогается. Мигрируй ограниченный контекст за контекстом, в своём темпе.