Repositories в DDD: что они должны делать
Repositories в DDD: что они должны делать
Репозиторий - не «слой над БД» и не свалка GetByWhatever. Разберём, что он должен делать, а что - точно нет.
Проблема: репозиторий превращается в свалку
Проект растёт, и в CourseRepository появляются методы: GetByID, GetBySlug, GetByAuthor, GetPublishedByCategory, GetWithLessonsAndQuizzes, SearchByTitleAndDifficulty, GetTopRated... Двадцать методов, каждый с уникальным SQL-запросом. Тесты - кошмар. Новый разработчик не знает, какой метод использовать.
Репозиторий в DDD - это не query builder и не DAO. Это коллекция агрегатов с минимальным интерфейсом.
Соблазн решить это методом AddItem(orderID, item) в репозитории. Он
работает и разрушает агрегат: инварианты, которые проверял агрегат
(лимит суммы, максимум позиций), теперь не проверяются вовсе - никто
не собрал состояние целиком.
Правильный порядок вопросов другой:
- Не велика ли граница агрегата? Дорогая загрузка - это обычно не проблема репозитория, а сигнал, что в один агрегат сложили то, что не обязано меняться одной транзакцией. «Заказ со всеми позициями» и «позиция как отдельный агрегат со ссылкой на заказ» - разные модели, и вторая часто честнее;
- не путаем ли мы чтение с изменением? Показать список из десяти тысяч позиций через агрегат не нужно вовсе: для этого есть отдельный путь чтения (CQRS-lite ниже в этом уроке) - он читает проекцию и никаких инвариантов не поддерживает, потому что ничего не меняет;
- и только если оба ответа «всё в порядке» - оптимизировать загрузку (ленивые коллекции, частичная материализация), помня, что каждая такая оптимизация ослабляет проверку инвариантов.
Короткая версия: агрегат читается целиком, а если целиком дорого - меняют границы агрегата или путь чтения, но не интерфейс репозитория.
Repository = коллекция агрегатов
Представь, что агрегаты хранятся в обычной Go map. Репозиторий - абстракция с таким же простым API: получить, сохранить, удалить.
// domain/repository.go - интерфейс в пакете domain
// Репозиторий знает только про агрегаты, ничего про БД.
type CourseProgressRepository interface {
// Get загружает агрегат целиком (со всеми внутренними объектами)
Get(ctx context.Context, userID, courseID string) (*CourseProgress, error)
// Save сохраняет агрегат целиком (insert или update)
Save(ctx context.Context, progress *CourseProgress) error
// Delete удаляет агрегат
Delete(ctx context.Context, userID, courseID string) error
}
<?php
declare(strict_types=1);
namespace App\Learning\Domain;
// Интерфейс в namespace Domain. Репозиторий знает только про агрегаты, ничего про БД.
// final НЕ ставится - это интерфейс.
interface CourseProgressRepository
{
// get загружает агрегат целиком (со всеми внутренними объектами)
public function get(string $userId, string $courseId): CourseProgress;
// save сохраняет агрегат целиком (insert или update)
public function save(CourseProgress $progress): void;
// delete удаляет агрегат
public function delete(string $userId, string $courseId): void;
}
Три метода. Не тридцать. Репозиторий работает с агрегатом как с единым целым - загружает все его внутренние объекты (LessonProgress) и сохраняет всё вместе.
Отсюда и требование к Get: он обязан отдать собранный агрегат, а не строку таблицы. Значит, объекты-значения внутри придётся восстанавливать из колонок через их конструкторы, а не присваивать поля напрямую - иначе невалидное состояние попадёт в домен прямо из базы. Устройство таких типов - в уроке Entity и Value Object.
Метод Get возвращает полностью собранный агрегат CourseProgress со всеми LessonProgress внутри. Не DTO, не строку из таблицы, не плоскую структуру. Агрегат готов к вызову бизнес-методов сразу после загрузки.
Интерфейс в домене, реализация в инфраструктуре
Это ключевой принцип гексагональной архитектуры. Домен определяет что нужно, инфраструктура решает как.
// domain/repository.go - порт (интерфейс)
type CourseProgressRepository interface {
Get(ctx context.Context, userID, courseID string) (*CourseProgress, error)
Save(ctx context.Context, progress *CourseProgress) error
Delete(ctx context.Context, userID, courseID string) error
}
// Domain/CourseProgressRepository.php - порт (интерфейс)
namespace App\Learning\Domain;
interface CourseProgressRepository
{
public function get(string $userId, string $courseId): CourseProgress;
public function save(CourseProgress $progress): void;
public function delete(string $userId, string $courseId): void;
}
// infrastructure/postgres/progress_repo.go - адаптер (реализация)
type ProgressRepo struct {
db *gorm.DB
}
func NewProgressRepo(db *gorm.DB) *ProgressRepo {
return &ProgressRepo{db: db}
}
func (r *ProgressRepo) Get(ctx context.Context, userID, courseID string) (*domain.CourseProgress, error) {
var model progressModel
err := r.db.WithContext(ctx).
Where("user_id = ? AND course_id = ?", userID, courseID).
First(&model).Error
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, domain.ErrProgressNotFound
}
return nil, fmt.Errorf("query progress: %w", err)
}
var lessonModels []lessonProgressModel
err = r.db.WithContext(ctx).
Where("progress_id = ?", model.ID).
Find(&lessonModels).Error
if err != nil {
return nil, fmt.Errorf("query lessons: %w", err)
}
// Собираем агрегат из моделей БД
return toDomainProgress(model, lessonModels), nil
}
func (r *ProgressRepo) Save(ctx context.Context, progress *domain.CourseProgress) error {
return r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
model := toProgressModel(progress)
if err := tx.Save(&model).Error; err != nil {
return err
}
// Сохраняем внутренние объекты агрегата
for _, lp := range toLessonModels(progress) {
if err := tx.Save(&lp).Error; err != nil {
return err
}
}
return nil
})
}
<?php
declare(strict_types=1);
// Infrastructure/Doctrine/DoctrineProgressRepository.php - адаптер (реализация)
namespace App\Learning\Infrastructure\Doctrine;
use App\Learning\Domain\CourseProgress;
use App\Learning\Domain\CourseProgressRepository;
use App\Learning\Domain\ProgressNotFoundException;
use Doctrine\ORM\EntityManagerInterface;
final class DoctrineProgressRepository implements CourseProgressRepository
{
public function __construct(
private readonly EntityManagerInterface $em,
) {}
public function get(string $userId, string $courseId): CourseProgress
{
$model = $this->em->getRepository(ProgressModel::class)
->findOneBy(['userId' => $userId, 'courseId' => $courseId]);
if ($model === null) {
throw new ProgressNotFoundException();
}
// Doctrine подгрузит дочерние LessonProgressModel по association
// Собираем агрегат из моделей БД
return $this->toDomain($model);
}
public function save(CourseProgress $progress): void
{
// EntityManager управляет транзакцией; flush - атомарно
$model = $this->toModel($progress);
$this->em->persist($model);
// Сохраняем внутренние объекты агрегата
foreach ($this->toLessonModels($progress) as $lp) {
$this->em->persist($lp);
}
$this->em->flush();
}
public function delete(string $userId, string $courseId): void
{
$this->em->createQuery(
'DELETE FROM ' . ProgressModel::class . ' p
WHERE p.userId = :u AND p.courseId = :c'
)->setParameters(['u' => $userId, 'c' => $courseId])->execute();
}
}
Обрати внимание: реализация использует GORM-модели (progressModel, lessonProgressModel) и маппит их в доменные объекты. Домен не знает о GORM.
Вызывает репозиторий не сам агрегат и не handler, а сценарий поверх домена: он загружает агрегат, дёргает его метод и сохраняет результат. Кто из трёх типов сервисов за это отвечает, разобрано в уроке Domain Service vs Application Service.
Repository vs DAO: в чём разница
| Аспект | Repository (DDD) | DAO |
|---|---|---|
| Работает с | Агрегатами | Таблицами |
| Единица операции | Агрегат целиком | Одна строка |
| Интерфейс | 3-5 методов | Десятки методов |
| Запросы для чтения | Отдельный Read Model | В том же классе |
| Знает о БД | Нет (интерфейс в домене) | Да |
| Маппинг | Модель БД -> агрегат | Строка = объект |
DAO (Data Access Object) привязан к таблице и предоставляет CRUD для строк. Repository привязан к агрегату и предоставляет операции для бизнес-объектов.
Что принадлежит репозиторию, а что нет
Принадлежит - операции с агрегатами:
Get(id)- загрузить агрегатSave(aggregate)- сохранить (insert + update)Delete(id)- удалить агрегатNextID()- сгенерировать идентификатор (если нужно)
Не принадлежит - сложные выборки для UI:
- Список курсов с пагинацией и фильтрами
- Поиск по названию
- Статистика и отчёты
- JOIN'ы через несколько агрегатов
Для сложных запросов на чтение используй отдельный слой.
CQRS-lite: отдельные Read Models
CQRS (Command Query Responsibility Segregation) в лёгкой форме: команды (изменение) идут через агрегат и репозиторий, запросы (чтение для UI) идут через отдельный Read Model.
// domain/repository.go - для команд (write)
type CourseProgressRepository interface {
Get(ctx context.Context, userID, courseID string) (*CourseProgress, error)
Save(ctx context.Context, progress *CourseProgress) error
}
// application/query.go - для запросов (read)
type ProgressReadModel interface {
// Список прогресса пользователя по всем курсам - для страницы "Мои курсы"
ListByUser(ctx context.Context, userID string, page, size int) ([]ProgressSummary, error)
// Прогресс конкретного курса для отображения - не агрегат, а DTO
GetCourseDashboard(ctx context.Context, userID, courseID string) (*CourseDashboard, error)
}
// ProgressSummary - плоская структура для UI, не агрегат
type ProgressSummary struct {
CourseID string
CourseTitle string
Percent int
TotalLessons int
CompletedLessons int
}
<?php
declare(strict_types=1);
// Domain/CourseProgressRepository.php - для команд (write)
namespace App\Learning\Domain;
interface CourseProgressRepository
{
public function get(string $userId, string $courseId): CourseProgress;
public function save(CourseProgress $progress): void;
}
// Application/Query/ProgressReadModel.php - для запросов (read)
namespace App\Learning\Application\Query;
interface ProgressReadModel
{
// Список прогресса пользователя по всем курсам - для страницы «Мои курсы»
/** @return ProgressSummary[] */
public function listByUser(string $userId, int $page, int $size): array;
// Прогресс конкретного курса для отображения - не агрегат, а DTO
public function getCourseDashboard(string $userId, string $courseId): CourseDashboard;
}
// ProgressSummary - плоский DTO для UI, не агрегат
final readonly class ProgressSummary
{
public function __construct(
public string $courseId,
public string $courseTitle,
public int $percent,
public int $totalLessons,
public int $completedLessons,
) {}
}
Read Model может использовать SQL напрямую, JOIN'ить таблицы, применять фильтры - ему не нужно собирать агрегат. Это просто данные для отображения.
CQRS-lite не значит «делай что хочешь на стороне чтения». Read Model всё равно должен быть в отдельном интерфейсе, тестироваться и не содержать бизнес-логики. Он просто читает данные, не меняет их.
Specification: фильтры без взрыва методов
Если Read Model нуждается в комбинациях фильтров, вместо метода на каждую комбинацию используй паттерн Specification:
// Specification для фильтрации курсов
type CourseFilter struct {
Difficulty *string
TrackID *string
Search *string
Page int
Size int
}
type CourseReadModel interface {
List(ctx context.Context, filter CourseFilter) ([]CourseSummary, int, error)
}
<?php
declare(strict_types=1);
namespace App\Learning\Application\Query;
// Specification для фильтрации курсов
final readonly class CourseFilter
{
public function __construct(
public ?string $difficulty = null,
public ?string $trackId = null,
public ?string $search = null,
public int $page = 1,
public int $size = 20,
) {}
}
interface CourseReadModel
{
/** @return array{items: CourseSummary[], total: int} */
public function list(CourseFilter $filter): array;
}
Один метод list с параметрами фильтрации вместо getByDifficulty, getByTrack, getByDifficultyAndTrack, searchByTitle...
In-memory реализация для тестов
Репозиторий - интерфейс, значит можно подставить реализацию для тестов без БД:
// infrastructure/memory/progress_repo.go
type InMemoryProgressRepo struct {
mu sync.RWMutex
// Значения, а не указатели.
//
// С map[string]*domain.CourseProgress репозиторий отдавал бы тот же
// объект, что лежит внутри: агрегат мутируется по указателю, поэтому
// изменение «сохранялось» бы само, без вызова Save. Тест на таком
// фейке зелёный, а в проде тот же код теряет изменения - фейк прятал
// бы ошибку вместо того, чтобы её показать.
data map[string]domain.CourseProgress // ключ: "userID:courseID"
}
func NewInMemoryProgressRepo() *InMemoryProgressRepo {
return &InMemoryProgressRepo{
data: make(map[string]domain.CourseProgress),
}
}
func (r *InMemoryProgressRepo) Get(ctx context.Context, userID, courseID string) (*domain.CourseProgress, error) {
r.mu.RLock()
defer r.mu.RUnlock()
key := userID + ":" + courseID
p, ok := r.data[key]
if !ok {
return nil, domain.ErrProgressNotFound
}
// Отдаём адрес копии: у вызывающего свой объект.
return &p, nil
}
func (r *InMemoryProgressRepo) Save(ctx context.Context, progress *domain.CourseProgress) error {
r.mu.Lock()
defer r.mu.Unlock()
key := progress.UserID() + ":" + progress.CourseID()
r.data[key] = *progress
return nil
}
func (r *InMemoryProgressRepo) Delete(ctx context.Context, userID, courseID string) error {
r.mu.Lock()
defer r.mu.Unlock()
key := userID + ":" + courseID
delete(r.data, key)
return nil
}
<?php
declare(strict_types=1);
// Infrastructure/InMemory/InMemoryProgressRepository.php - для тестов
namespace App\Learning\Infrastructure\InMemory;
use App\Learning\Domain\CourseProgress;
use App\Learning\Domain\CourseProgressRepository;
use App\Learning\Domain\ProgressNotFoundException;
final class InMemoryProgressRepository implements CourseProgressRepository
{
/** @var array<string, CourseProgress> */
private array $data = [];
public function get(string $userId, string $courseId): CourseProgress
{
$key = $userId . ':' . $courseId;
if (!isset($this->data[$key])) {
throw new ProgressNotFoundException();
}
return $this->data[$key];
}
public function save(CourseProgress $progress): void
{
$key = $progress->userId() . ':' . $progress->courseId();
$this->data[$key] = $progress;
}
public function delete(string $userId, string $courseId): void
{
unset($this->data[$userId . ':' . $courseId]);
}
}
Теперь use case тестируется без Docker, без миграций, без PostgreSQL:
// fakeEventBus записывает опубликованные события вместо отправки.
type fakeEventBus struct {
events []domain.Event
}
func (b *fakeEventBus) Publish(_ context.Context, events ...domain.Event) {
b.events = append(b.events, events...)
}
func TestCompleteLessonUseCase(t *testing.T) {
repo := memory.NewInMemoryProgressRepo()
eventBus := &fakeEventBus{}
// Подготовка: создать прогресс с уроками
progress := domain.NewCourseProgress("p1", "u1", "c1", []string{"lesson-1", "lesson-2"})
_ = repo.Save(context.Background(), progress)
uc := NewCompleteLessonUseCase(repo, eventBus)
err := uc.Execute(context.Background(), "u1", "c1", "lesson-1")
require.NoError(t, err)
// Проверяем, что агрегат обновился В ХРАНИЛИЩЕ.
//
// Это работает только потому, что репозиторий хранит копии: иначе тест
// читал бы тот же объект, который мутировал use case, и прошёл бы даже
// если убрать из use case вызов Save. См. врезку под тестом.
updated, _ := repo.Get(context.Background(), "u1", "c1")
assert.Equal(t, 50, updated.Percent())
assert.Len(t, eventBus.events, 1)
}
<?php
declare(strict_types=1);
namespace App\Tests\Learning\Application;
use App\Learning\Application\CompleteLessonUseCase;
use App\Learning\Domain\CourseProgress;
use App\Learning\Infrastructure\InMemory\InMemoryProgressRepository;
use PHPUnit\Framework\TestCase;
final class CompleteLessonUseCaseTest extends TestCase
{
public function testCompletesLessonAndUpdatesPercent(): void
{
$repo = new InMemoryProgressRepository();
$eventBus = new InMemoryEventBus();
// Подготовка: создать прогресс с уроками
$progress = CourseProgress::start('p1', 'u1', 'c1', ['lesson-1', 'lesson-2']);
$repo->save($progress);
$useCase = new CompleteLessonUseCase($repo, $eventBus);
$useCase->execute('u1', 'c1', 'lesson-1');
// Проверяем, что агрегат обновился
$updated = $repo->get('u1', 'c1');
self::assertSame(50, $updated->percent());
self::assertTrue($eventBus->wasPublished());
}
}
Если для проверки инварианта CompleteLesson нужно поднять PostgreSQL и отправить HTTP-запрос - тест хрупкий и медленный. In-memory репозиторий позволяет тестировать домен изолированно за миллисекунды.
Если хранить map[string]*domain.CourseProgress и возвращать тот же
указатель, то граница между «объектом в памяти» и «сохранённым состоянием»
исчезает. Агрегат мутируется через методы по указателю, значит изменение
попадает в хранилище само, без вызова Save.
Последствие конкретное: уберите из use case шаг «сохранить агрегат» - и тест выше всё равно пройдёт. Он проверяет не цепочку, а собственную иллюзию; настоящий репозиторий в этот момент ничего бы не записал.
Проверить свой фейк можно одним тестом: прочитать агрегат, изменить его, не сохранять, прочитать снова - изменения быть не должно. Если оно есть, фейк моделирует не хранилище, а разделяемую память.
Интерфейсы репозиториев редко живут в вакууме: рядом обязательно оказываются агрегат, доменный сервис и сценарий, который всё это связывает. Как эти части выглядят вместе в одном контексте, показано в мини-проекте трека.
Мини-задание
- Определи интерфейс
QuizResultRepositoryв пакетеdomain(Get, Save, Delete) - Реализуй in-memory версию для тестов
- Создай
QuizReadModelс методомListByUserдля страницы «Мои результаты» - Проверь свои репозитории: не стали ли они DAO с десятком
GetBy...методов - Напиши тест use case с in-memory репозиторием (без БД)