Repositories в DDD: что они должны делать
Repositories в DDD: что они должны делать
Репозиторий - не «слой над БД» и не свалка GetByWhatever. Разберём, что он должен делать, а что - точно нет.
Проблема: репозиторий превращается в свалку
Проект растёт, и в CourseRepository появляются методы: GetByID, GetBySlug, GetByAuthor, GetPublishedByCategory, GetWithLessonsAndQuizzes, SearchByTitleAndDifficulty, GetTopRated... Двадцать методов, каждый с уникальным SQL-запросом. Тесты - кошмар. Новый разработчик не знает, какой метод использовать.
Репозиторий в DDD - это не query builder и не DAO. Это коллекция агрегатов с минимальным интерфейсом.
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 репозиторием (без БД)