The database is the most expensive detail in most systems and the least interesting. Silence means the domain can be reasoned about without knowing which engine, which ORM, or whether a transaction is open.
A repository is a collection
The word is literal. A repository behaves like an in-memory collection of aggregates: you get one, you add one, you save what you have. No query builders, no SQL fragments, no lazy-loading proxies crossing the boundary.
namespace App\Domain\Account;
interface Accounts
{
public function get(AccountId $id): Account;
public function save(Account ...$accounts): void;
}namespace App\Infrastructure\Persistence;
final class DoctrineAccounts implements Accounts
{
public function __construct(private EntityManagerInterface $entityManager) {}
public function get(AccountId $id): Account
{
$record = $this->entityManager->find(AccountRecord::class, $id->value());
return $record === null
? throw new AccountNotFound($id)
: $record->toDomain();
}
public function save(Account ...$accounts): void
{
foreach ($accounts as $account) {
$this->entityManager->persist(AccountRecord::fromDomain($account));
}
$this->entityManager->flush();
}
}The port has no findByEmailLikedSince. Query objects exist for reports; the repository serves the use cases’ need for aggregates.
Plain objects
Entities are POPO, POCO, POJO — the acronym is the only thing that changes. Equality is by identity, state is private, behaviour is public, and the object does not know it is persisted.
namespace App.Domain.Account;
public sealed class Account
{
public AccountId Id { get; }
public Money Balance { get; private set; }
private Account(AccountId id, Money balance)
{
Id = id;
Balance = balance;
}
public static Account Open(AccountId id, Money initial) =>
new(id, initial);
public void Withdraw(Money amount) =>
Balance = Balance.Subtract(amount);
}package app.domain.account;
public record Money(long cents, String currency) {
public Money {
if (cents < 0) throw new IllegalArgumentException("negative");
Objects.requireNonNull(currency);
}
}export class Email {
private constructor(private readonly value: string) {}
static fromString(value: string): Email {
if (!value.includes('@')) {
throw new Error('Invalid email address');
}
return new Email(value);
}
equals(other: Email): boolean {
return other.value === this.value;
}
}Mapping belongs to the boundary
Two models, two jobs. The domain model protects invariants; the persistence model protects a schema. They usually start identical and must be allowed to diverge — the mapping class is where that divergence is allowed to exist.
In C# with EF Core that is a DbContext configuration class. In Java with JPA, a mapper or a separate entity. In C++ with a handwritten store, a mapper function. In PHP with Doctrine, an XML/YAML mapping or attributes kept outside the domain namespace.
Transactions are infrastructure
A use case says “save these two accounts together”; whether that becomesBEGIN/COMMIT, a unit of work, or a saga is the adapter’s decision. If the domain calls beginTransaction(), the database has become part of the business rules.
The in-memory implementation
Write it on day one. It is the cheapest proof that the port is honest and the use case is free of infrastructure:
namespace app::domain {
class Accounts {
public:
virtual ~Accounts() = default;
virtual Account get(const AccountId& id) const = 0;
virtual void save(const Account& account) = 0;
};
}| Language | Typical persistence detail | Where it belongs |
|---|---|---|
| PHP | Doctrine EntityManager, PDO | Infrastructure/ |
| C# | DbContext, Dapper, EF migrations | adapter project |
| Java | JPA EntityManager, JDBC | infrastructure module |
| C++ | SQL client, connection pool | adapter translation unit |
What silence buys you
- Unit tests that run in milliseconds, with no database container.
- The ability to change the engine without touching the domain.
- Errors that mean something:
InsufficientFunds, notSQLSTATE[23000]. - A domain that can be read by someone who has never seen the schema.