Repositories в DDD: что они должны делать

Repositories в DDD: что они должны делать

Репозиторий - не «слой над БД» и не свалка GetByWhatever. Разберём, что он должен делать, а что - точно нет.

Проблема: репозиторий превращается в свалку

Проект растёт, и в CourseRepository появляются методы: GetByID, GetBySlug, GetByAuthor, GetPublishedByCategory, GetWithLessonsAndQuizzes, SearchByTitleAndDifficulty, GetTopRated... Двадцать методов, каждый с уникальным SQL-запросом. Тесты - кошмар. Новый разработчик не знает, какой метод использовать.

Репозиторий в DDD - это не query builder и не DAO. Это коллекция агрегатов с минимальным интерфейсом.

Repository как коллекция: use case вызывает интерфейс Get/Save/Delete, реализации - GORM в проде, in-memory map в тестах

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 возвращает полностью собранный агрегат 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.

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
    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:

func TestCompleteLessonUseCase(t *testing.T) {
    repo := memory.NewInMemoryProgressRepo()
    eventBus := &mockEventBus{}

    // Подготовка: создать прогресс с уроками
    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)

    // Проверяем, что агрегат обновился
    updated, _ := repo.Get(context.Background(), "u1", "c1")
    assert.Equal(t, 50, updated.Percent())
    assert.True(t, eventBus.published)
}
<?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 репозиторий позволяет тестировать домен изолированно за миллисекунды.

Мини-задание

  • Определи интерфейс QuizResultRepository в пакете domain (Get, Save, Delete)
  • Реализуй in-memory версию для тестов
  • Создай QuizReadModel с методом ListByUser для страницы «Мои результаты»
  • Проверь свои репозитории: не стали ли они DAO с десятком GetBy... методов
  • Напиши тест use case с in-memory репозиторием (без БД)

Зарегистрируйтесь бесплатно, чтобы пройти квиз, решить задание с автопроверкой и вести прогресс.