The database you choose on day one is rarely the database you want on day one thousand. Read patterns change, scale arrives, a vendor relationship shifts. The teams that survive those changes cheaply are the ones whose domain logic never knew which database it was talking to in the first place. That property has a name - persistence ignorance - and it is achievable with a small amount of discipline.
The domain depends on a contract, not a database
The core idea is an inversion: instead of business logic calling a database, it calls an interface that the domain itself defines. The interface speaks the domain's language - aggregates and intentions - never SQL or collections:
public interface IOrderRepository
{
Task<Order?> GetByIdAsync(OrderId id, CancellationToken ct);
Task AddAsync(Order order, CancellationToken ct);
Task<IReadOnlyList<Order>> FindAsync(OrderSpecification spec, CancellationToken ct);
}
The domain layer ships this interface and depends on nothing else for data. Where the orders actually live is somebody else's problem - specifically, the infrastructure layer's.
Implementations live at the edge
Each storage technology gets its own implementation in infrastructure, behind the same contract. A Dapper implementation writes hand-tuned SQL for read-heavy paths; an ORM implementation handles rich aggregate mapping; a document implementation targets MongoDB. The domain cannot tell them apart because it only ever sees IOrderRepository:
public sealed class DapperOrderRepository : IOrderRepository
{
public async Task<Order?> GetByIdAsync(OrderId id, CancellationToken ct)
{
const string sql = "select * from orders where id = @id";
// map rows to the Order aggregate
}
}
Swapping stores - or running two side by side during a migration - becomes a dependency-injection change, not a rewrite of business rules.
Specifications keep queries out of the domain
The place persistence ignorance usually leaks is querying. The moment a service builds a LINQ-to-EF expression, it is coupled to EF. A specification object - a declarative description of "which orders" - keeps that intent in the domain while letting each repository translate it to its own dialect. The domain says what it wants; infrastructure decides how to fetch it.
Tip: The leak to watch for is
IQueryableescaping the repository. The moment a caller composes a query againstIQueryable, your provider's quirks become your domain's problem and the abstraction is gone.
Enforce the boundary with architecture tests
Discipline that relies on memory erodes. We back persistence ignorance with architecture tests that fail the build if the domain or application layers reference a persistence package. The rule is mechanical and unambiguous: domain code that imports a database driver is a bug, and CI says so before the bad dependency spreads. The contract is only as strong as the thing that enforces it.
When to keep it simple
Like any abstraction, this one has a cost, and it is not free for a tiny app that will only ever use one database. But the moment you anticipate scale, a migration, multiple read models, or the need to test domain logic without a database, persistence ignorance pays for itself many times over - and adding it later is far more expensive than designing for it now.
Wrapping up
Persistence ignorance comes down to three habits: define data access as a domain-owned contract, put every storage implementation behind that contract in infrastructure, and use specifications plus architecture tests to stop database concerns from leaking inward. Do that and your most disruptive infrastructure change - swapping the database - becomes one of your least disruptive code changes.
Weighing a data-layer decision or migration? Let's talk architecture.
