Index

A Mapper Connects Two Honest Models

An order in the domain has an OrderNumber, a Money total, typed lines, and an operation named place(). The same order in a relational database has an orders row, several order_lines rows, integer minor units, a nullable deadline, check constraints, a foreign key, and an index used by fulfilment.

Forcing those shapes to match exactly damages one side or the other.

Make the domain object mirror the tables and it acquires column names, nullable intermediate state, relationship-loading concerns, and methods shaped around persistence. Store the object as one opaque document and the database loses straightforward constraints and access paths required by the selected query.

A mapper is useful when it lets both models remain honest. It translates between them. It does not make the database an implementation detail.

Structural parity is not the goal

The reproducible fixture uses a deliberately small order model:

Order
  id, number, customer
  status: draft | placed
  currency
  lines: OrderLine[]
  place(fulfilment deadline)
  removeLine(line ID)

Each line carries a SKU, positive quantity, and positive unit price as Money. Every line must use the order currency. A placed order must have at least one line and a fulfilment deadline.

None of its three domain files contains PDO, SQL, Eloquent, or column vocabulary. That isolation is not valuable by itself. It becomes useful because the domain can speak about Money, placement, and line removal without also deciding how those ideas are normalized or queried.

The database has different work to do:

CREATE TABLE orders (
    id TEXT PRIMARY KEY,
    order_number TEXT NOT NULL UNIQUE,
    status TEXT NOT NULL CHECK (status IN ('draft', 'placed')),
    currency TEXT NOT NULL,
    total_minor INTEGER NOT NULL CHECK (total_minor >= 0),
    fulfil_by TEXT NULL,
    CHECK (
        (status = 'draft' AND fulfil_by IS NULL)
        OR (status = 'placed' AND fulfil_by IS NOT NULL)
    )
);

The shortened schema omits several fixture columns, but keeps the important difference visible. Money is one domain value and two stored columns. place() is one domain transition and a relationship between status and fulfil_by. Lines are values owned through the order and separate rows with a foreign key.

Martin Fowler’s Data Mapper description calls for the in-memory objects and database to remain independent. Independence does not mean mutual ignorance. The mapper has to know exactly how the two representations correspond.

The mapper makes loss visible

The fixture’s mapper produces one root row and a list of child rows:

return [
    'root' => [
        'id' => $order->id,
        'order_number' => $order->number,
        'status' => $order->status(),
        'currency' => $order->total()->currency,
        'total_minor' => $order->total()->minor,
        'fulfil_by' => $order->fulfilBy(),
    ],
    'lines' => array_map($this->lineToRow(...), $order->lines()),
];

Loading performs the reverse translation: strings become domain identifiers, minor units and currency become Money, rows become typed lines, and the stored lifecycle becomes order state.

The clean round trip starts with two EUR lines worth 25.00 and 5.00. After placement and reload, the domain object still has its identity, order number, customer, placed status, deadline, EUR 30.00 total, and both typed lines.

A round trip is necessary but weak evidence on its own. Two mutations show why. One stores USD while the lines remain EUR; reconstruction refuses the currency disagreement. Another stores 3001 minor units while the lines total 3000; reconstruction refuses the false total.

The mapper is not plumbing which can safely receive “reasonable defaults.” It is a loss boundary. Every conversion should either preserve meaning or fail in a way the application can investigate.

This article stops short of deciding how new construction differs from loading historical state. That is a separate policy. The mapping point is narrower: whatever reconstruction contract the domain chooses, the mapper must supply complete, explicit values rather than quietly repairing storage.

Child deletion and rollback belong to the translation

Mapping an object graph is not finished after inserts work.

The fixture saves two lines, removes line-1 through the order, and saves again. The database then contains only line-2, and both the stored and reconstructed total are EUR 5.00. Remove the child-deletion step and the test fails: the database still owns a line which the domain no longer has.

This is a common asymmetry. Creating children is obvious because there is a new value to write. Deletion is absence. The adapter has to compare, replace, or otherwise reconcile the stored collection deliberately.

Root and children also form one local persistence decision. The fixture writes them in a transaction. A controlled failure after updating the root leaves the previous draft root, version, and both child rows unchanged. A mutation commits the root early; the exception still occurs, but the root now says placed while the old children remain.

Laravel’s database transaction API supplies the same commit-or-rollback mechanism. The design obligation is deciding what belongs inside it. Hiding SQL behind save() does not prove that root replacement, child deletion, and child insertion share one transaction.

Constraints are part of the model too

The domain and database can defend overlapping facts for different reasons.

The domain rejects placing an empty order because that rule belongs to the operation. The database rejects a draft row with a deadline and a placed row without one because stored lifecycle state must remain coherent even when a migration, import, or another write path bypasses the aggregate.

The fixture also proves these storage contracts independently:

  • order_number uniqueness rejects a second root and leaves no partial lines;
  • line checks reject zero quantity and zero unit price; and
  • the foreign key rejects a line whose order does not exist.

Remove the uniqueness or lifecycle constraint and its negative control stops failing. These are not decorative schema declarations.

The database does not enforce everything. In this fixture, it cannot prove that every line currency matches the root or that the stored total equals the sum of child rows. The aggregate and mapper tests cover those facts. A more elaborate database design could add triggers or generated data, but the article would then need to test those mechanisms too.

The useful question is not “domain or database?” Put a rule where the required facts can be observed and the failure can be made atomic. Duplicate keys and orphans are excellent database work. Whether an order may be placed remains a domain decision. Some invariants deserve both boundaries.

A read query need not rebuild the aggregate

Fulfilment needs a list of placed orders whose deadline is at or before a cutoff, ordered by deadline and ID. No order will be changed while producing that list.

The fixture uses a focused query:

SELECT id, order_number, customer_id, total_minor, currency, fulfil_by
FROM orders
WHERE status = 'placed' AND fulfil_by <= :cutoff
ORDER BY fulfil_by, id

It returns two typed DueOrder rows and never calls the aggregate mapper. That is not a shortcut around the domain. There is no domain decision to make. The query has its own contract: selection, order, and result shape.

The schema includes an index on (status, fulfil_by, id). For this exact statement and fixture, SQLite reports:

SEARCH orders USING INDEX idx_orders_due (status=? AND fulfil_by<?)

Remove the index and the plan assertion fails. SQLite’s query-plan documentation distinguishes SEARCH through an index from a full-table SCAN; it also warns that plan output is intended for interactive diagnosis rather than a stable application format. The fixture checks the selected semantic detail, not elapsed time or a universal performance claim.

Loading complete orders for this list would couple a storage-shaped read to write-model reconstruction. It may still be reasonable for an identity lookup or a detail page which already needs the aggregate. The reversal condition is the use case: if no transition follows and selection needs different columns, sorting, or aggregation, a focused read model is usually the more honest shape.

A document is an alternative, not a cautionary prop

The fixture also stores equivalent order documents as JSON. It then applies the same status and deadline predicate without a generated column or JSON index. SQLite reports:

SCAN order_documents
USE TEMP B-TREE FOR ORDER BY

That observation says something precise about one schema and query. It does not say relational storage is always faster, or that document storage is a mistake.

An opaque document is attractive when aggregates are loaded by identity, replacement is the natural write, and required constraints stay within one document. If due-date ranges become contractual, the design can expose and index those fields, maintain a projection, or choose a storage model built for the access path. Pretending the query does not exist is the bad option.

The same honesty applies in reverse. Normalizing every possible field before a query exists can burden writes and migrations for no demonstrated benefit. Storage shape should answer known integrity and access needs, with explicit room to change when those needs change.

Separation has to earn the mapper

A separate domain object and persistence record are not the default prize for serious architecture.

Fowler’s Active Record description combines a row wrapper, database access, and domain logic. Eloquent follows that broad shape and can use custom casts for values such as money or identifiers. For a small model whose columns, lifecycle, and relationships fit its behavior, that is less code and often the clearer choice.

A separate mapper starts earning its cost when one model is being distorted:

  • nullable columns make invalid domain states easy to construct;
  • relationship loading dictates which behavior can run;
  • value objects repeatedly dissolve into scalar arrays;
  • child replacement and transaction rules are scattered;
  • read screens pressure the write model into query-shaped methods; or
  • persistence hooks become the only place domain transitions make sense.

Custom casts may solve the value-conversion problem without a second model. A focused query may solve read pressure without separating the write object. Use the smallest boundary which removes the actual distortion.

When two models do earn their independence, test more than serialization. Prove values round-trip, removed children disappear, partial saves roll back, constraints reject bad rows, and important queries use the access path the schema promises.

The decision is not whether the domain or database gets to be pure. Let each be specific about the job it owns. Add a mapper only when an explicit, tested translation is cheaper than asking either model to lie about what it is.