Всем привет! Я Дмитрий Милов, Python-разработчик компании МУЛЬТИФАКТОР в команде продукта MULTIDIRECTORY, мы разрабатываем собственную службу каталогов.

Как это обычно бывает в процессе развития продукта, код постепенно перестает помещаться в привычные рамки. Мы начали обсуждать, какое архитектурное решение лучше подходит нашей системе. Спор об определениях затянулся: у каждого были аргументы, почему одно решение лучше подходит чем другое.

Есть множество гайдов по архитектуре ПО, паттернам, слоям. Рано или поздно, сталкиваясь с DAO и Repository, начинаешь задаваться вопросом: а как правильно? Как оно должно выглядеть в теории и как лучше реализовать на практике? Одни и те же термины где-то могут быть ближе к строгим определениям, а где-то — к практике.

Я захотел понять не только в чём разница между этими паттернами, но и почему продолжается путаница в концепциях, которые давно описаны и формализованы.

Слева DAO, справа Repository. Или наоборот.
Слева DAO, справа Repository. Или наоборот.

Без паттернов: до 2000-х

Когда-то код для работы с данными был размазан по всей бизнес-логике, и это считалось нормой. 

пример github db.java
пример github db.java

Последовательность действий могла выглядеть так:

  • с помощью драйвера подключиться к БД;

  • написать SQL запрос;

  • выполнить запрос;

  • поработать с данными: получить, присвоить, сравнить, посчитать;

  • залогировать;

  • закрыть соединение.

И всё это в одном слое.

Вместо БД и SQL запроса можно представить обращение к внешнему сервису по HTTP, получение от него JSON данных, их обработку. Или работу с файлом: чтение, парсинг.

Зачем нужны паттерны

Паттерны мы используем, чтобы эффективно решать типовые архитектурные задачи. С помощью паттернов мы выстраиваем ясный дизайн всего приложения. Дизайн позволяет фрагментировать сложность, управлять ею. Хотя сама сложность никуда не исчезает, она просто скрывается от беглого взгляда. Главное, мы избавляемся от хаоса в коде. Принцип “разделяй и властвуй” работает и в программировании. Из физики: сумма двух энтропий меньше, чем энтропия суммы.

“связность” и “связанность”
“связность” и “связанность”

В программировании понятия “связность” и “связанность” используют для оценки уровня декомпозиции, качества дизайна кода, архитектуры в целом.

Подробнее тема раскрывается в докладах:

Object-Relational Mapping (ORM)

Появление

ORM не был изобретён за один день. Подход складывался как практика ещё в 1990-х годах, а затем формализовался через паттерны Data Mapper и Active Record.

Само название ORM достаточно хорошо отражает его задачу: объектно-ориентированное сопоставление/отображение. ORM нужен для того, чтобы не приходилось вручную сопоставлять данные из хранилища данных с объектами приложения.

Мой первый pet-проект был написан на SQLite без ORM. Одно небольшое изменение схемы базы могло спровоцировать каскадные изменения почти во всём пайплайне работы с данными.

Формализация

Насколько я понял по открытым источникам, первым полным описанием ORM было определение для Hibernate ORM в документации в 2002 г.

Основная задача ORM проста: мапим строку в БД на dataclass. Есть ещё нюансы, но об этом ниже. Главное — маппинг. Думаю, можно описать ORM как Адаптер для данных в реляционной БД: со стороны веб-приложения у нас есть dataclass, а со стороны БД — таблицы и строки. Для нереляционной БД устоявшийся термин — ODM (Object-Document Mapping). 

Место ORM в дизайне:

  • инфраструктурный слой;

  • между бизнес сущностью приложения и БД.

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

  • ORM ориентирован под реляционную модель (PostgreSQL, MySQL, SQLite и т.д.);

  • не является универсальным адаптером ко всем хранилищам информации;

  • ORM возвращает экземпляры ORM-моделей.

Устоявшаяся практика

Со временем разработчики начали воспринимать ORM как основной способ работы с базой данных.

Нужен класс? Есть. Нужен select? Есть. Join? Есть. Filter? Тоже есть. При этом можно не задумываться о том, какой SQL в итоге будет сгенерирован. В ответ мы получаем объект с заполненными полями и можем сразу работать с ним.

Сейчас ORM воспринимается как дефолтный API для БД: прячет большую часть SQL-рутины, берёт на себя маппинг, жизненный цикл сущностей, отслеживание изменений.

В результате кажется, что ORM — это весь слой доступа к данным. Но в рабочей архитектуре ORM относится к инфраструктурному слою и является лишь инструментом для работы с данными.

Data Access Object

Появление

DAO (Data Access Object) закрепился как enterprise-паттерн в начале 2000-х годов в Java-экосистеме. Oracle и Core J2EE Patterns в 2002 г. описали его как способ вынести доступ к данным из бизнес-логики в отдельный слой.

Изначальная идея была довольно простой: бизнес-код не должен знать детали подключения, SQL-запросы и особенности конкретного источника данных. Поэтому всё это должно выноситься в отдельное место.

Формализация

Oracle и Core J2EE Patterns: DAO инкапсулирует доступ к хранилищу данных.

Core J2EE Patterns: DAO
Core J2EE Patterns: DAO

Расположение в проекте:

  • в инфраструктурном слое;

  • между бизнес-логикой и инфраструктурой хранения.

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

  • бизнес-логика не обращается к источнику данных напрямую;

  • бизнес-логика не знает, как именно данные получены;

  • источник может быть любым (внешний сервис, БД, файл, очередь);

  • DAO возвращает потребителю DTO.

Устоявшаяся практика

На практике почти всегда, когда говорят о DAO, подразумевают работу с БД, потому что большинство данных живёт именно в БД. Из-за этого DAO стали воспринимать как класс для "запросов в таблицы".

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

Запутываю

Чувствуете, что мы как будто говорим не о DAO, а об ORM? Заменим одно на другое (да, с небольшими оговорками), но суть не поменяется. Оба решения инкапсулируют запросы, оба плотно работают с хранилищем данных, оба возвращают экземпляры дата-классов. Получается, что ORM — это частный случай DAO, а DAO — это очень тонкая прослойка между бизнес-логикой и БД. Всё, что нам остается: вместо того, чтобы прятать сырой SQL в DAO, мы будем прятать ORM-запросы в DAO.

ORM забрал значительную часть "работы DAO", из-за этого границы размылись.

DDD и Repository

Появление

Техническое разделение слоев уже есть, а концептуального разделения между бизнес доменом и хранилищем данных нет: нужен был слой, который говорит на языке домена и скрывает источники данных.

Repository как enterprise-паттерн был описан Мартином Фаулером (Martin Fowler) в книге Patterns of Enterprise Application Architecture в 2002 г., а затем активно вошел в DDD-практику.

Предметно-ориентированное проектирование (What is DDD - Eric Evans - DDD Europe 2019) — это не один паттерн, а сдвиг мышления: от "моделируем базу данных" к "моделируем бизнес домен".

Формализация

Мысль, которая не сразу укладывается в голове при первом знакомстве с DDD, такая: бизнес модель не обязана точь-в-точь повторять структуру таблиц. Repository посредничает между бизнес-доменом и data-mapping слоем, предоставляя доступ к доменным сущностям.

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

  • возвращает бизнес сущности;

  • язык домена != язык хранения;

  • для сборки одной доменной сущности может использоваться несколько источников: Repository может обращаться к одному или более DAO;

  • автор запроса к Repository не знает, как именно была собрана доменная сущность и из каких источников.

Устоявшаяся практика

Получается, Repository — конкретное решение для разделения инфраструктуры и бизнес-логики. Если сильно упростить, то это Адаптер для бизнеса и технического доступа к данным. Repository помогает закрепить общий словарь предметной области в местах взаимодействия аналитиков и разработчиков, а не в SQL/ORM деталях.

Пример, можно представить бизнес сущность АктивностьПользователя. Пусть это будет отдельный дашборд в личном кабинете пользователя. 

В дашборде нужно показать, сколько статей пользователь открыл и сколько лайкнул. Просто и понятно. Храниться эти данные могут в нескольких таблицах: Пользователь, АктивностьПоEndpoints и Лайки. Можно, конечно, отдавать на фронт результат выборки из каждой таблицы отдельно, но зачем, если можно описать одну бизнес-сущность — АктивностьПользователя? Помимо данных о пользователе, в ней будут свойства «открыто статей» и «лайкнуто статей». Сборкой такой сущности из нескольких источников занимается Repository.

Таким образом аналитики (бизнес) и разработчики (техническая часть) имеют единый язык.

Запутываю

Repository можно назвать точкой входа для данных. DAO, по сути, выполняет ту же роль. И в том, и в другом случае источником данных может быть что угодно: cache, база данных, файлы или внешний сервис. Repository возвращает потребителю бизнес-модель, DAO тоже возвращает объект с данными. На первый взгляд разница не явная.

Представим типичную ситуацию. Вы прочитали книгу Эрика Эванса, увидели паттерн Repository и решили, что он поможет лучше провести границы в приложении. Приносите эту идею на обсуждение в команду. Проходит неделя, потом вторая и третья, а вы всё ещё спорите: нужны DAO или Repository? Узнали, согласны?

Здесь вполне может скрываться методологическая ошибка. Зачем использовать Repository, если в проекте нет DDD, нет доменных сущностей и нет языка предметной области? В таком случае Repository действительно начинает выглядеть как DAO.

Repository = DAO + бизнес-ориентированность

Рассуждаем дальше: если убрать из определения Repository его ключевую особенность — бизнес-ориентированность, то мы практически получим DAO. В таком контексте разницы действительно нет.

Repository = DAO + бизнес-ориентированность

DAO vs Repository: в чём разница?

Если упростить: DAO отвечает на вопрос "как достать и сохранить данные", а Repository — на вопрос "какие доменные объекты нужны бизнес-сценарию".

DAO 

Repository 

Технический адаптер к источнику

Доменная точка входа для данных

Выражается запросом и форматом данных

Выражается сценарием и языком предметной области

Должен работать с одним источником

Может собрать объект из нескольких источников

Gateway в этой картине не конкурент Repository. Это отдельный инфраструктурный адаптер для внешнего мира: API, очередей, файлов, брокеров.

DAO ближе к хранилищу данных. Repository ближе к Use Case (бизнесу).

Gateway

Появление

Gateway оформился как enterprise-паттерн в PoEAA: это объект, который инкапсулирует доступ к внешнему ресурсу и изолирует интеграционные детали от прикладного кода.

Причина появления ровно та же, что и у DAO в свое время: не пускать в бизнес-логику сетевые протоколы, transport-level ошибки, таймауты и ретраи.

Формализация

Академически Gateway — это адаптер к внешней системе.

Расположение в проекте:

  • инфраструктурный слой интеграций;

  • рядом с клиентами внешних API, очередей, файловых и message-broker адаптеров.

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

  • use case и доменные сервисы не знают про HTTP/gRPC/AMQP протоколы, заголовки, статусы, форматы ошибок;

  • детали отказоустойчивости (retry, timeout, circuit breaker) локализованы в Gateway;

  • все обращения во внешний мир проходят через Gateway;

  • Gateway возвращает потребителю согласованные DTO/контракты, а не сырые ответы транспорта.

Устоявшаяся практика

В прикладной разработке Gateway меньше всего отклоняется от определения:

  • DAO (в широком смысле) закрывает технический доступ к данным;

  • Gateway закрывает технический доступ к внешним данным. Или Gateway — частный случай DAO.

Gateway — обертка над внешним ресурсом (API, файл, интеграция), а доступ к БД остается в зоне DAO/ORM. Repository склеивает данные от разных поставщиков в одну бизнес-сущность.

Запутываю

В микросервисном мире можно услышать, что всё есть сервис. С таким видением получается, что БД — тоже отдельный сервис. Теперь граница между Repository и Gateway размывается:

  • Repository — доступ к данным своего сервиса.

  • Gateway — доступ к данным сервиса.

Repository = Gateway, значит Gateway подойдет на все случаи жизни.

Просто? Да. Явно? Ну почти.

Проблема в цене такого упрощения: в коде появляются Gateway-и, которые выглядят одинаково, но часть из них “говорит” на языке домена, а часть — на языке интеграции. Это замедляет чтение кода.

Если в проекте нет договоренности о бизнес-сущностях и бизнес-языке, правило "любой поставщик данных — это Gateway" действительно может быть уместным. Но тогда нужно честно признать, что Repository как доменный слой не используется. Правильно ли это? Да, если об этом условились явно.

Можно пойти ещё дальше и договориться, что равнозначные модули в рамках одного сервиса мы будем воспринимать тоже как сервисы по отношению друг к другу. Значит, чтобы получить данные от модуля, нужно определить Gateway для доступа к этим данным. Кажется, это чрезмерное усложнение, такое решение не ультимативно и не нужно каждому проекту.

Финал

Где проходит практическая граница

Можно встретить проекты со следующим дизайном:

  1. Use case работает с бизнес-сущностями: по определенным правилам перекладывает данные из одной сущности в другую.

  2. Repository собирает бизнес-сущности из одного и более источников.

  3. DAO инкапсулирует технический доступ к хранилищу данных*.

    1. ORM прячет чистый SQL и мапит таблицы на модели.

  4. Gateway инкапсулирует технический доступ к внешнему хранилищу данных*.

* DAO и Gateway на одном уровне

Паттерн 

Что делает

Кто потребитель

Что не должен делать

ORM (инструмент)

Маппинг и работа с БД

DAO, Repository

Определять доменные правила

DAO

Доступ к хранилищу

Repository, application-сервис

Знать про бизнес-логику

Repository

Собирает доменную сущность как DTO

Use case, Repository, application-сервис

Протаскивать наружу ORM-модели

Gateway

Технический доступ к внешнему хранилищу

Repository, application-сервис

Знать про бизнес-логику

Ранее в статье было:

Repository = DAO + бизнес-ориентированность,

..но если бизнес-ориентированности нет, тогда:

Repository = DAO,

..но если функции DAO полностью представлены в ORM, тогда:

Repository = ORM,

зачем нужен DAO?

Где же мы запутались?

Это моя любимая часть.

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

Посмотрим на возможный граф зависимостей:

  1. API ручка;

  2. UseCase;

  3. DAO;

  4. ORM;

  5. База Данных.

Пример. Допустим, нам нужно получить дашборд для юзера с id=42, пайплайн данных сверху вниз и обратно выглядит так: 

  1. Обращаемся по маршруту user/board/42

  2. В контейнере зависимостей для области REQUEST создается новая сессия для работы с БД 

  3. В маршруте вызывается метод get_user_board класса UseCase с аргументом id=42 

  4. В методе get_user_board класса UseCase: 

    4.а Вызывается метод get_user_posts у PostDAO с аргументом user_id=42

    4.б Вызывается метод get_user у UserDAO с аргументом id=42

    4.в Вызывается метод get_user_likes у LikesDAO с аргументом user_id=42

      5. Каждый из методов DAO работает с ORM и получает объект, который возвращается в класс UseCase 

      6. UseCase из трех объектов собирает один DTO, возвращает этот DTO "наверх" в функцию для маршрута 

      7. Функция для маршрута извлекает все необходимые данные из DTO, кладёт их в словарь и возвращает потребителю

Допустим, такая практика распространена по всему проекту, стало тяжело вносить правки, одно изменение цепляет другое. Хочется порядка, границ, дизайна! Но с чего начать?

Можно подумать над чистой архитектурой, над слоями, DDD. Изучить популярные подходы, выбрать один и постепенно его внедрять. Но что-то здесь не так, мы как будто вернулись в начало статьи. Это похоже на бой с тенью. Будет много созвонов: на каждом новое мнение и новое обсуждение.

Я считаю, что проблема в том, как мы используем ORM. ORM неявно нарушает все границы, начиная с точки входа, проходя через DAO и заканчивая на точке входа, когда Request закрывается и все объекты уничтожаются. Нельзя договориться об одном общем подходе, если границы везде нарушены.

Зачем в примере выше вообще нужны паттерны, когда все эти слои с п.1 до п.7 очень тонкие, и по сути это один большой ORM?

Границы ORM

Не поймите меня неправильно: я не предлагаю отказываться от ORM (хотя подобные холивары в интернете тоже есть). Я предлагаю осознанно относиться к границам, выводить из тени то, что очень умело прячется.

  • Не обязательно управлять сессией на уровне запроса. Сессия нужна только для того, чтобы работать с данными в БД, значит открываем_сессию — работаем_с_данными — закрываем_сессию: забираем у ORM управление сессией и перекладываем эту задачу на UoW и возвращаем себе контроль.

    • p.s. UoW ещё одна абстракция, но уже над Repository. UoW формализован в PoEEA М.Фаулером в 2002.

  • Не нужно передавать ORM объекты по всем слоям. Достаточно получить с помощью ORM мапинг строки на ORM объект, а затем вручную конвертировать ORM объект в DTO: забираем у ORM право создавать один экземпляр класса на все случаи жизни и возвращаем себе контроль.

  • Можно запретить ленивую загрузку на уровне ORM моделей: забираем у ORM право без нашего согласия отправлять в БД дополнительные запросы и возвращаем себе контроль.

ORM — мощный и удобный инструмент современной разработки. С ним можно проще и быстрее создавать прототипы и иногда даже production-версии веб-приложений. Если вовремя не ограничить полномочия ORM, то сделать это позже будет существенно сложнее.

Бонус: размытие границ в Python мире

Хочу показать пример того, как могут размываться границы в веб-приложении на Python. Классический современный Python-стек для веб-приложений: фреймворк FastAPI и ORM SQLAlchemy.

SQLAlchemy is the Python SQL toolkit and Object Relational Mapper that gives application developers the full power and flexibility of SQL. sqlalchemy.org

Lazy Loading

SQLAlchemy, как и любая другая ORM, имеет много фич у себя под капотом, некоторые фичи уникальны.

Чего стоит всеми любимая ленивая подгрузка, порождающая проблему N+1, про решение которой можно героически написать в CV. Вся сложность в том, что это неявное поведение. Ситуация непроста из-за сложности дебага: падение производительности будет постепенным, пропорционально скорости наполнения хранилища данных.

SQLAlchemy-objects

Зато, наверное, с помощью SQLAlchemy удобно и безопасно работать с бизнес-моделями? Для решения этой задачи мы используем специальные классы — Data Transfer Object (DTO). ORM позволяет маппить бизнес-модель (класс) на конкретную таблицу, в результате выполнения запроса к БД средствами ORM мы в ответе получим один или несколько “алхимических объектов”.

Нюанс таких "алхимических объектов" в том, что они выглядят как утка DTO, крякают как утка DTO, ведут себя как утка DTO, но уткой DTO они не являются.

Эти "алхимические объекты" мутабельны (Mutable vs immutable objects). Получается так: в результате безобидного запроса мы получаем "алхимический объект", передаем его наверх по цепочке вызовов. Срабатывает человеческий фактор, и вместо того, чтобы безобидно изменить свойство DTO, происходит изменение свойства этого "алхимического объекта". "Алхимический объект" индексируется средствами ORM как измененный, в конце запроса делается commit… и вот у нас в БД летит update запрос.

О мутабельности часто и подробно рассказывает на своих стримах и в докладах Егор Бугаенко. Например, лекция: Взлет и падение ООП: история объектно-ориентированного программирования | XX Ершовская лекция.

Думаю у каждого, у кого есть даже небольшой опыт в IT, есть история, как он дебажил-дебажил и вышел на ORM.

SQLAlchemy и Repository

Рассмотрим детальнее “алхимические объекты”. Сравним два дата-класса.

@dataclass
class GPO:
    id: int = field(init=False)
    updater_id: int = field()
    creator_id: int = field()
    name: str = ""
    description: str | None = None
    instructions: list[GPOInstruction] 

    created_at: datetime = field(...)
    updated_at: datetime = field(...)

    directories: list[Directory] = ...
    user: User = field(...)
@dataclass
class GPO:
    id: int | None = None
    updater: str | None = None
    creator: str | None = None
    name: str
    description: str | None
    instructions:list[GPOInstructionDTO]

    created_at: datetime | None = None
    updated_at: datetime | None = None

    objects: list[str]
    user_id: int | None = None

Кажется, что это одно и то же. Возможно, разные версии кода. Но нет, это модели для разных задач, нижний класс используется для передачи данных между слоями. А верхний класс …

(немного контекста)

. . .

# Описание таблицы “GPOs” (gpo_table)

gpo_table = Table(
    "GPOs",
    metadata,
    Column("id", Integer, primary_key=True),
    Column("name", String(255), nullable=False),
    Column("description", String(255), nullable=True),
    Column("updater_id", Integer, ForeignKey("Users.id", ondele...),
    Column("creator_id", Integer, ForeignKey("Users.id", ondele...),
    Column("created_at", DateTime(timezone=True), server_def...),
    Column("updated_at", DateTime(timezone=True), server_def...),
    Index("ix_GPOs_id_hash", "id", postgresql_using="hash"),
    Index("ix_GPOs_name", "name", unique=True),
)

. . .

# Соотношение таблицы “GPOs” (gpo_table) И модели “GPO”
mapper_registry.map_imperatively(
    GPO,
    gpo_table,
    properties={
        "directories": relationship(...),
        "instructions": relationship(GPOInstruction, ...),
        "creator": relationship(User, uselist=False, lazy="raise", ...),
        "updater": relationship(User, uselist=False, lazy="raise", ...),
    },
)

. . .

…верхний класс — это “алхимическая модель”. Я немного слукавил, когда показал одинаковые названия. Нижний dataclass в реальности называется GPODTO.

Получается, если продолжать жить в парадигме одна модель - одна таблица, не использовать явные бизнес правила, то ORM может успешно мимикрировать под Repository. ORM модели напоминают обычные дата-классы (DTO), которыми мы привыкли обмениваться между слоями, но обмениваться ORM моделями между слоями не надо.

SQLAlchemy, UoW, DI

Можно использовать контейнер зависимостей (например, библиотека Dishka), чтобы для каждого запроса по API (scope=Request) инициализировать единственный объект сессии SQLAlchemy. Так это же почти Unit of Work! И вот, например, индексация состояний объектов, загруженных из хранилища данных, ведется посредством паттерна Identity Map (sqlalchemy.orm.Session.identity_map).

Важно понимать, какие паттерны уже реализованы в используемых библиотеках, чтобы при интеграции их с приложением не было хаоса из-за наслоения одних решений на другие, из-за протекания зависимостей за проведенные границы.

Дядя Бен говорил: “с большой силой приходит большая ответственность”, это он говорил Питеру Паркеру про SQLAlchemy. Используйте этот мощный инструмент явно и осознанно.

TL;DR

Паттерны (DAO, Repo, Gateway, UoW) были формализованы 20 лет назад в контексте того времени, а инструменты (ORM) и общепринятые подходы развивались и изменялись. Протекание ORM во все слои происходило постепенно для всех и совершенно незаметно для начинающих разработчиков, у которых нет богатого опыта разного времени, и которые доверяют решениям в устоявшейся практике. Современный ORM может мимикрировать почти под любой паттерн для работы с данными. Здесь и была ошибка, зависимость каждого слоя от ORM не замечалась мной явно, поэтому попытки провести границы с применением паттернов заранее были обречены.

Неважно, будет класс называться DAO, Repository или Gateway, если есть четкие границы. Без границ любое ревью превращается в холивар об определениях.

В любом случае, ограничивайте ваши классы, а не себя. <3


P.S.

Статья написана, и перед финальной проверкой ссылок я нахожу доклад Денис Цветцих "Repository и UnitOfWork в 2020 году, must have или антипаттерн?" с очень похожим нарративом. Тут текст, там видео. Для полноты картины рекомендую.