The Database Is an Implementation Detail
Why Should We Focus on the Domain First?
Third architecture meeting this quarter. Someone proposes a feature change. Within ninety seconds the conversation has stopped being about the feature, and started being about a customer_id foreign key, a migration window, and whether on-call will sign off on a column rename.
The feature loses. The schema wins. And nobody calls it a loss. Nobody named what was on the table.
When the Schema Starts Driving
Look at any long-lived enterprise codebase and we’ll find the same shape. The entity objects are the table rows. The table rows are the entity objects. There’s nothing between them: no model, no behavior, just a careful mapping from a database column to a Java field, a C# property, a Ruby attribute.
Whether we’re in Spring Boot + JPA, .NET + Entity Framework, or Rails + Active Record, the trap looks the same: the entity is the table, and the table is the entity. One artifact pretending to be two.
The early symptoms are familiar. Tables full of nullable columns “because we might need them”. Joins that exist because somebody once thought we’d report on this. Speculative generality at the schema level (fields and tables for things we might need one day), locked into production data on day fifteen, and outliving the requirement that produced them by a decade.
By the time the codebase is two years old, nobody is designing the domain anymore. Everyone is negotiating with the schema.
Naming the Principle
Eric Evans wrote it down in Domain-Driven Design (2003). Vaughn Vernon made it concrete in Implementing Domain-Driven Design (2013). The argument is simple to state and hard to live with: domain modeling is discovery, not specification. We don’t know the right shape of a domain in advance. We find it by running the model against real use cases until it stops moving.
Schemas can’t do this. A schema is a commitment to a shape, frozen in production data the moment it ships. We can’t iterate on a schema the way we iterate on code. Once data lives there, every change has to bring the data along. So the schema has to be the last thing we commit to, not the first.
Bob Martin gave the argument its forwardable phrase in Clean Architecture (2017): “The database is a detail.” He meant it precisely. Not unimportant. Detail, in the sense that the system’s structure shouldn’t depend on it.
The order of operations is straightforward. Write the domain. Run it in-memory until the shape stabilizes. Derive the schema from what the domain turned out to need. The 2026 DDD revival is engineering teams across Java and .NET re-learning this order of operations after a decade of frameworks that blur it.
What It Looks Like in Code
Start with the version most Spring Boot codebases ship today:
@Entity
@Table(name = "orders")
public class Order {
@Id
private Long id;
@Enumerated(EnumType.STRING)
private OrderStatus status;
private LocalDate placedOn;
private LocalDate shippedOn;
// boilerplate skipped
}
@Service
public class OrderService {
public boolean isShippable(Order order) {
return order.getStatus() == OrderStatus.PAID
&& order.getShippedOn() == null;
}
public void cancel(Order order) {
if (order.getStatus() == OrderStatus.SHIPPED) {
throw new IllegalStateException(
"Cannot cancel a shipped order");
}
order.setStatus(OrderStatus.CANCELLED);
}
}The entity is a row pretending to be a model. The rules of what an order is live in the service, where they translate between row and answer. Worse, the domain class itself is a JPA artifact. It imports jakarta.persistence, it exists to be saved, and we can’t reason about an order without involving the persistence framework.
The shape Evans names instead has four parts. Start with the domain object that has nothing to do with persistence:
// shop/orders/Order.java - the domain. No framework imports.
package shop.orders;
// imports skipped
public class Order {
private final OrderId id;
private OrderStatus status;
private final LocalDate placedOn;
private LocalDate shippedOn;
public Order(OrderId id, LocalDate placedOn) {
this.id = id;
this.status = OrderStatus.PENDING;
this.placedOn = placedOn;
}
public boolean isShippable() {
return status == OrderStatus.PAID
&& shippedOn == null;
}
public void cancel() {
if (status == OrderStatus.SHIPPED) {
throw new IllegalStateException(
"Cannot cancel a shipped order");
}
status = OrderStatus.CANCELLED;
}
// boilerplate skipped
}Grep this class for import org.springframework or import jakarta.persistence and we find nothing. The domain class compiles, runs, and tests with no framework on the classpath.
The domain owns the port1:
// shop/orders/db/OrderRepository.java - owned by the domain.
package shop.orders.db;
// imports skipped
public interface OrderRepository {
Optional<Order> findById(OrderId id);
void save(Order order);
}Now we can run the application end-to-end with no database at all:
// shop/orders/db/InMemoryOrderRepository.java - runs without a database.
package shop.orders.db;
// imports skipped
@Component
public class InMemoryOrderRepository implements OrderRepository {
private final Map<OrderId, Order> orders = new HashMap<>();
@Override
public Optional<Order> findById(OrderId id) {
return Optional.ofNullable(orders.get(id));
}
@Override
public void save(Order order) {
orders.put(order.id(), order);
}
}This is the move that lets us discover the schema. We build the domain, run it against the in-memory repository, exercise it through the use cases that matter, and watch what shape fits. We learn what the application actually accesses. We learn what fields turn out to be optional, what relationships turn out to be one-to-one in practice, what state transitions actually fire. The schema is the thing we write last, after the model has stopped moving.
An in-memory repository is cheap to change and cheap to throw away. That cheapness is the point. We can iterate on storage shape at the speed of code edits, not the speed of database migrations.
The right schema is the one our domain told us it needed.
Once the model is stable, we introduce the persistence adapter2 (we name the JPA class OrderRow, not OrderEntity3):
// shop/orders/db/jpa/OrderRow.java - JPA imports confined here.
package shop.orders.db.jpa;
// imports skipped
@Entity
@Table(name = "orders")
class OrderRow {
@Id
private Long id;
// mapping fields skipped
static OrderRow from(Order order) { /* ... */ }
Order toDomain() { /* ... */ }
// boilerplate skipped
}
// shop/orders/db/jpa/OrderRowRepository.java - Spring Data extension.
package shop.orders.db.jpa;
// imports skipped
@Repository
interface OrderRowRepository extends JpaRepository<OrderRow, Long> {}
// shop/orders/db/jpa/JpaOrderRepository.java - the adapter.
package shop.orders.db.jpa;
// imports skipped
@Component
public class JpaOrderRepository implements OrderRepository {
private final OrderRowRepository rows;
// constructor skipped
@Override
public Optional<Order> findById(OrderId id) {
return rows.findById(id.value()).map(OrderRow::toDomain);
}
@Override
public void save(Order order) {
rows.save(OrderRow.from(order));
}
}The JPA imports live in one package, shop.orders.db.jpa. The domain doesn’t know about them. The mapping between Order and OrderRow is the only translation cost in the system, and it’s paid in one place, with one explicit purpose.
The same shape works in .NET + Entity Framework: a pure domain class, a separate OrderRow POCO with EF attributes, a repository adapter. Same in Rails: a pure Order PORO and a separate OrderRecord Active Record class behind a repository. The framework is incidental. The separation is the move.
Once the domain owns its behavior, the question of how much ORM we actually need (a heavy mapper, a thin adapter, or hand-written persistence) becomes a question worth its own post. We’ll get to it later.
What We Gain (And What It Costs)
Two payoffs follow from keeping the domain and the persistence adapter on opposite sides of a port.
The domain and the schema evolve on independent clocks. A domain change responds to behavior (a new state transition, a tightened invariant, a use case the product team named). A schema change responds to different forces (a slow query, an index that didn’t hold up, a column that turned out to be hot). Coupling them through a JPA entity means every domain edit is a schema edit, and every schema edit ripples back through the model. Decoupling them lets each move at its own pace, against its own pressure.
The data-access layer stays invisible to the domain. The domain calls OrderRepository.findById() and gets back an Order. It doesn’t know whether the answer came from a JPA query, a raw SELECT, or a HashMap in a test. That ignorance is the whole point. We can change storage technology without the domain noticing. We rarely will. But when we do, the change is local to the adapter. The rest of the system doesn’t move. That same abstraction lets different aggregates live in different stores (relational here, document there, key-value elsewhere) without the domain ever knowing4.
The cost: every read maps OrderRow to Order, and every save maps the other way. At scale, that’s allocation and copying. The rule bends where it has to. Hot read paths bypass the domain (a query directly against OrderRow for a report, a projection that never inflates). Batch operations work on rows in bulk. The exceptions are local, named, and documented as exceptions, not the operating mode. The hand-written mapping is a small developer-time cost, paid once per aggregate.
The Takeaway
If our domain class imports a framework, our domain is the framework’s hostage.
Build the domain. Discover the schema. In that order. The database will still be there when we’re ready for it.
Forward This
If your team’s architecture discussions always start with “but the table structure is...”, this is the post you forward.
Here’s the version your engineering manager can read in two minutes:
Teams locked into database-first architecture spend disproportionate time on schema migrations instead of feature development. Domain-first design defers the database until the domain has stabilized through in-memory iteration. The investment: a separate domain layer with no knowledge of persistence, an interface the domain owns, and an adapter that translates to whatever database the team eventually chooses. The payoff: schemas designed from evidence (what the application actually does) instead of foresight (what we guessed it would do), fewer schema migrations, and the ones you do ship stay inside the persistence adapter instead of rippling through service code. If your team’s architecture discussions always start with “but the table structure is...”, this explains why that’s the wrong starting point, and what to put first instead.
Forward this. Bring a migration story to the next architecture meeting. Watch what happens when the conversation finally gets to name what’s on the table.
Media attributions:
Cover image by the author (generated with Gemini)
"Port" is the hexagonal-architecture term for an interface owned by the domain. The class that satisfies it (the JPA repository, the in-memory implementation) lives outside the domain and is called an "adapter." Alistair Cockburn coined the pattern in 2005.
“Introduce” means replace, not coexist. Both InMemoryOrderRepository and JpaOrderRepository carry @Component in the snippets for readability, but only one OrderRepository bean lives in the running application, typically gated by a Spring profile so the in-memory adapter runs in tests and the JPA adapter runs in production. Two @Component adapters in the same default profile would fail at startup with a duplicate-bean error.
"Row" instead of "Entity" deliberately. In DDD, "entity" means a domain object with identity that persists across state changes. Reusing the term for the JPA class is the exact mix-up this post is arguing against. The JPA class is a row in a table. Call it that.
Polyglot persistence is a topic for another post.

