---
title: Repository & Mapper Pattern
type: concept
created: 2026-09-06
updated: 2026-09-06
sources: [codebase snapshot 2026-09-06]
tags: [repository, mapper, persistence, infrastructure]
---

# Repository & Mapper Pattern

Persistence is abstracted behind **repository interfaces** declared in the Domain, implemented in Infrastructure with Eloquent. Translation between the persistence model and the domain aggregate is done by a dedicated **mapper** per aggregate, keeping the Eloquent model "dumb."

> **Note on provenance:** reflects the code as of 2026-09-06; no written ADR exists. This is a core, deliberate convention across all contexts.

## Repository contract shape

Each repository interface (in `src/Domain/{Context}/Repositories/`) declares:

- `nextIdentity(): <Id>` — generate a fresh identity VO.
- `save(<Aggregate> $entity): void` — upsert the aggregate.
- `findById(<Id> $id): ?<Aggregate>` — retrieve or null.
- Context-specific reads, e.g. `TicketRepositoryInterface::findByBoard(BoardId)`.

The Domain interface names all carry the `Interface` suffix **except** `UserRepository` — a flagged inconsistency (see [user](../entities/user.md)).

## Mappers

`src/Infrastructure/Persistence/Eloquent/Mappers/{X}Mapper.php` provides:

- `toDomain(Model $model): Aggregate` — rebuild a domain aggregate via its `reconstitute()` factory.
- `toEloquent(Aggregate $aggregate): Model` — build a persistable model from the aggregate.

The Eloquent models (`Models/{X}Model.php`) are intentionally dumb: they hold persistence state, casts, and Eloquent relations, but no business logic. The mapper owns the translation.

## Eloquent repositories

`src/Infrastructure/Persistence/Eloquent/Repositories/Eloquent{X}Repository.php` implements the Domain interface, injecting the mapper. `save()` does a lookup-then-fill-then-save upsert keyed on the string id:

```php
$model = BoardModel::query()->where('board_id', (string) $board->id())->first();
$model ??= new BoardModel;
$model->fill([...]);
$model->save();
```

## Binding

`src/Infrastructure/Providers/DomainServiceProvider.php` binds each interface to its Eloquent implementation:

```php
$this->app->bind(UserRepository::class, EloquentUserRepository::class);
$this->app->bind(WorkspaceRepositoryInterface::class, EloquentWorkspaceRepository::class);
$this->app->bind(BoardRepositoryInterface::class, EloquentBoardRepository::class);
$this->app->bind(TicketRepositoryInterface::class, EloquentTicketRepository::class);
```

## Observations / open questions

- `nextIdentity()` is generated by the **repository**, not the aggregate — a deliberate choice to centralize identity creation.
- The upsert style (query-then-fill) avoids `updateOrCreate` but is repeated verbatim across all four repositories; a shared base could DRY it.
- `UserRepository::save()` lacks a return type and `nextIdentity()` (unlike the others).

## Related pages

- [ddd-layering](ddd-layering.md) — where these interfaces/implementations sit
- [cqrs](cqrs.md) — how repositories are used by commands/queries
- [identity-value-objects](identity-value-objects.md) — what `nextIdentity()` produces
- [custom-generators](custom-generators.md) — the generator that scaffolds repo + mapper