OTAKU / Silent infrastructure

Track K — Keep Infrastructure Silent

Infrastructure stays silent

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;
};

}
LanguageTypical persistence detailWhere it belongs
PHPDoctrine EntityManager, PDOInfrastructure/
C#DbContext, Dapper, EF migrationsadapter project
JavaJPA EntityManager, JDBCinfrastructure module
C++SQL client, connection pooladapter 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, not SQLSTATE[23000].
  • A domain that can be read by someone who has never seen the schema.