Allocation and Copies
Immutability, boundary adapters and DTO mapping layers all buy reasoning guarantees with copies. That is usually a good trade and it is never a free one, so the design should be able to say what it bought.
The requirement, the obvious build, and why it breaks
Every lesson starts where the work starts: someone asked for something, and the first implementation that comes to mind survives until the requirement changes.
Which copies does my design force, what do they buy, and where does that stop being worth it?
"The nightly price file has to be applied before the shop opens." Two million rows arrive as CSV, are validated, mapped to domain objects, mapped again to persistence records, and written.
Use the same layered mapping the request path uses: parse each row into a DTO, map to a domain entity, map to a persistence record, save. It is consistent with the rest of the codebase, every layer is testable, and consistency is worth a lot.
It is not wrong; it is the same design at a volume it was not designed for. Three representations per row means three allocations per row, and the request path — one row at a time — never showed that.
- It is not wrong; it is the same design at a volume it was not designed for. Three representations per row means three allocations per row, and the request path — one row at a time — never showed that.
- The copies are invisible in the code. Each mapping function is short, obvious and clearly correct, and nothing at any call site says "this happens two million times" (Cost-Aware Interfaces).
- Under sustained allocation the cost stops being CPU and becomes garbage-collection pauses, which look like unrelated latency in something else entirely (Observability & Performance owns what collector pauses look like).
- The obvious rescue — mutate one object in place and reuse it — quietly breaks the guarantee the copies were buying, and does so in a way tests rarely catch, because a reused buffer is correct until something retains a reference.
What limits the solution, and what must never stop being true
This domain leads with these two. A design that ignores its constraints is not a design, and an invariant nobody named is one nothing is protecting.
- The maintenance window is fixed by the business, so the import has a deadline rather than a target (Functional and Non-Functional Requirements).
- The same domain types are used by the request path, where correctness and clarity matter far more than allocation (Local Reasoning).
- The team is not going to adopt a different language or a manual memory strategy for one job.
- No component may observe a partially-updated price. Whatever the representation, a reader sees a consistent snapshot (Consistency Boundaries).
- Validation happens once, and everything after it can assume validity — copies must not become an excuse to re-check (Trust Boundaries).
Who owns what, and where the seams fall
Responsibilities decide boundaries; boundaries decide what an interface has to say.
- Each boundary owns whether it copies. A boundary that copies is making a claim — "callers cannot corrupt my state" — and that claim should be deliberate (Encapsulation).
- The domain owns being immutable if it wants local reasoning; that is a choice with a price, not a default virtue (Immutability).
- The batch path owns its own decisions, and is allowed to differ from the request path. Insisting on one shape for both is where this usually goes wrong (When Design Does Not Pay).
- Copies concentrate at boundaries: parse, adapt, map, serialise. That is by design, and it means the number of copies is a count of layers rather than an accident of implementation (Boundary Adapters).
- The useful question at each layer is whether it exists to change the *shape* of the data or only its *type*. A layer that renames three fields is a copy bought for very little (Over-Decomposition).
- Streaming moves the boundary from "the whole file" to "one row", which changes the memory bound without changing any of the mappings (Effect Boundaries).
Four shapes of the same row
The layered version below is not bad code. It is the design the request path uses, applied to a job that runs it two million times — and that is the point worth internalising: the copy cost is a property of the *call count*, not of the code, so it is invisible everywhere the code was written and reviewed.
The streaming version keeps the validation boundary and drops the representations that only changed type. Notice what stayed: parsing is still a boundary, validation still happens once, and the domain still does not know about CSV.
const rows = parseCsv(await readFile(path)) // whole file const dtos = rows.map(toDto) // copy 1 const prices = dtos.map(toDomainPrice) // copy 2 const records = prices.map(toPersistenceRecord) // copy 3 await repo.saveAll(records) // memory is bounded by the file; allocation is 3x the row count. // Every mapping function is short, tested and obviously correct.
for await (const row of streamCsv(path)) {
const price = parsePrice(row) // parse + validate, one boundary
if (!price.ok) { rejected.push(price.error); continue }
await writer.add(price.value) // batched inside the writer
}
await writer.flush()
// memory is bounded by the batch size, not the file.
// The domain type survived; the two pass-through DTOs did not.The saving is not primarily allocation — it is that memory is now bounded by a number the design chooses rather than by a file whose size a supplier chooses. The two removed layers were type changes rather than shape changes, which is the specific test for whether collapsing them costs you a boundary. Had toDto been doing real translation from a foreign vocabulary, it would have earned its copy and stayed (Anti-Corruption Layer).
What immutability actually costs
This module has to be honest about the thing the effects module recommends. Immutable values allocate; there is no version of copy-on-write that does not. What they buy is that holding a reference tells you something permanent, which is the largest single reduction in what a reader has to keep in their head.
The options below are the real ones, and the scores are about shape rather than measurement. The row that surprises people is the last: a mutable object with a convention that nobody mutates it scores well on everything except the property you wanted.
| Option | Simplicity | Flexibility | Performance | Testability | Operational | Note |
|---|---|---|---|---|---|---|
| Immutable values everywhere | Reasoning is local and permanent; equality and caching become easy. Allocation is the price and in a hot loop it is a real one (Immutability). | |||||
| Defensive copy at the boundary | The guarantee holds where it matters and mutation stays cheap inside. Costs one copy per crossing, and it is easy to forget one — the guarantee is then partial, which is the worst state. | |||||
| Mutable, single owner, documented | Fastest and simplest to write. Correct exactly as long as ownership is respected, and nothing enforces that; the bug it eventually produces is a retained reference, which is hard to reproduce (State Ownership). | |||||
| Copy-on-write / persistent structures | Structural sharing gets most of the guarantee for a fraction of the copying, at the cost of a library dependency and a less familiar idiom for the whole team. | |||||
| Freeze by convention ("do not mutate this") | Costs nothing and guarantees nothing. It works while everyone remembers, and its failure is silent and remote from its cause — the same argument as elsewhere: a convention is not a design (Sensitive State). |
caveat The performance column is the least trustworthy thing here. It reflects allocation pressure in a managed-runtime application, and it inverts in Rust, changes shape in a language with value types, and is usually irrelevant next to the round trips the same code makes. Treat the row scores as "what this shape makes easy" and never as a claim that one option is faster in your system — that question needs a measurement, and this module does not have one.
Who owns the representation
When a datum has four shapes, it is worth asking what each layer is for. The one below is the layer teams most often defend on principle and most often cannot justify on inspection — and the diagnostic is not "how many lines is it" but "how many distinct reasons does it have to change".
- — The CSV column names and their order.
- — The domain type's constructor and field names.
- — Nothing else — no rules, no validation, no defaults.
- — Renames three fields.
- — Parses two numbers.
- — Allocates a new object per row.
- — The file format on one side, the domain type on the other.
- — The supplier changes the file format.
- — The domain type changes.
Two reasons to change, and they are the two sides it sits between — which is exactly what an adapter should look like, so the layer is coherent. The question is whether it is *needed*: here it renames fields and parses numbers, which the parse boundary already does, so this particular layer is a copy bought for a rename. Contrast a real anti-corruption layer, which changes vocabulary and absorbs a foreign model — that one earns its copy every time the supplier changes, and it is the same shape on paper (Anti-Corruption Layer).
How to build it
Most important first.
- Count the representations of one datum on its way through. Four is common, and each one is a copy and a type to maintain.
- Collapse layers whose only job is a type change, not by deleting boundaries but by noticing which of them were never load-bearing (Backend Engineering makes the same argument about tiers).
- Stream instead of materialising. The nightly job's real fix is usually that the file never becomes a list, which bounds memory regardless of file size and does not change the domain design at all.
- Keep immutability where reasoning matters and allow a mutable local buffer inside one function, where nothing can observe it. Purity at the boundary and mutation inside is the standard, honest compromise (Functional Core, Imperative Shell).
- Where a copy is defensive, ask what it defends against. A copy protecting a value that no caller retains is ceremony; a copy protecting a cached object from a caller that mutates it is preventing a bug that is very hard to find (Shared-State Coupling).
- Change the batch path, not the domain. Optimising the shared domain model for the job that runs once a night is how a codebase acquires performance-shaped types that everything else has to live with (Premature Optimization, Reclaimed).
What the next change costs
The field this whole domain exists for. A structure is only better if it makes the change after this one cheaper — and it is worth saying which changes it does not help.
- Adding a field with four representations: four types, four mapping functions, four tests, every time. That is the recurring cost of layered copying, and it is paid on every change rather than once.
- Adding a field with a streamed, two-representation import: two edits. The saving is not the allocation, it is the number of places that know about the field (Duplicate Knowledge).
- Making the domain immutable later: every mutation site becomes a construction site. It is mechanical, it is large, and it is the kind of change that stalls halfway and leaves a codebase with both idioms (Incremental Migration).
- Making a shared object mutable later, to save copies: cheap to do and expensive to own, because every existing caller was written assuming it could hold the reference. The cost lands on people who did not make the change (What Technical Debt Actually Is).
- Immutability costs allocation and, in most languages, a less direct style for updates. It buys the ability to hold a reference without wondering who else can change it — which is the single largest reduction in reasoning load available, and it is not free (Immutability).
- Collapsing layers to reduce copies removes the boundaries those layers enforced. Sometimes that is right; it should be argued as a boundary decision, not smuggled in as an optimisation.
- Streaming trades random access and simple error handling for bounded memory. A failure halfway through a stream is a harder recovery story than a failure over a list you still hold (Partial Failure).
What can go wrong
- Copies are removed by sharing a mutable object, and something downstream retains it. The resulting bug is timing-dependent and nearly unreproducible, which is the worst possible exchange rate for the allocation saved (Shared-State Coupling).
- Immutability is adopted for the domain and abandoned in one hot path, so the codebase now has two contracts and no way to tell which applies to a given object (Mutability, Used Deliberately).
- Streaming is introduced and something in the middle collects everything anyway — a sort, a group-by, a progress list — restoring the memory cost while the code claims to stream.
- Layers are collapsed for allocation reasons and the boundary they enforced goes with them, so the persistence shape starts leaking into the domain a year later (Backend Engineering calls the outcome schema leakage).
- The team optimises allocation without measuring and the actual cost was the round trips all along (Observability & Performance is exactly right about this, and it is their ground).
- Immutable domain types mean every consumer depends on a construction API rather than on setters, which is a wide, deliberate coupling that also makes invalid intermediate states impossible (Making Illegal States Unrepresentable).
- A streaming import depends on the source being streamable, and on nothing downstream needing the whole set — a dependency on the *shape* of the work that is easy to violate later with an innocent "sort them first".
- Mapping layers depend on both sides, which is their purpose, and is why each one added is a place that changes when either side changes (Change Amplification).
- "Immutability is a performance problem." It has a cost and buys a guarantee, and in most application code the guarantee is worth far more than the allocation. This lesson exists to name the price honestly, not to argue against paying it (Immutability).
- "Fewer layers is faster, so fewer layers is better." Layers are removed when they do not earn their keep as boundaries. If the only argument is allocation, the change is a performance bet against a design property (Architecture Boundaries).
- "Object pooling will fix it." Sometimes, in a narrow hot path, with a benchmark. In application code it usually reintroduces shared mutable state and the class of bug that goes with it (Hidden Global State).
- "The GC handles it." It handles correctness. Sustained allocation shows up as pause behaviour rather than as slow code, which is why it is diagnosed late and blamed on the wrong component.
- divergent-change
- shotgun-surgery
Testing it, and how it ages
- Test the guarantee the copy buys, not the copy: assert that mutating a returned value does not change the source. If nothing breaks when you remove the copy, the copy was not buying anything.
- Bound memory in a test for the streaming path — process a fixture larger than any sane buffer and assert it completes. That catches the "something in the middle collects everything" failure, which is otherwise found in production (Testing as Design Feedback).
- Property-test round trips through mapping layers, because each layer is a chance to silently drop a field (Property-Based Testing).
- Leave allocation-rate assertions to a benchmark harness, and treat their numbers as belonging to that harness rather than to production.
- Mapping layers accumulate. Each new integration adds a representation, and no individual addition is unreasonable, which is how a datum ends up with six shapes (Divergent Change).
- Immutability tends to hold in the domain and erode at the edges — caches, buffers, request-scoped accumulators — and that is usually fine as long as the erosion is at boundaries rather than inside domain types.
- The batch path drifts away from the request path over time, and the right response is to let it, with a shared validation rule rather than a shared object graph (Vertical Slices).
Where this applies
This domain's advice is contested more than most. These labels say what each claim is specific to — and where CONTESTED appears, the note gives the strongest form of the opposing view rather than a caricature.
- LANGUAGE-SPECIFICIn Rust, ownership makes most copies explicit and borrowing removes them without giving up safety, so the trade barely exists; in Java, C# or JavaScript, immutability means allocation and the GC decides what it costs; in Python the object overhead dominates so the argument is usually about representations rather than copies. Advice from any one of these transfers badly to the others.
- SCALE-SPECIFICBelow roughly a page of data per operation this is not a design question at all and treating it as one is waste; the argument turns on iteration count, and the flip happens when the same code starts running per row of a large set rather than per request.
- CONTESTEDThe strongest opposing view: layered mapping with a distinct type per boundary is defended by experienced engineers as the thing that keeps a large codebase decoupled, and they argue that allocation cost is a red herring modern runtimes absorb — the four types are four independent evolution points, and collapsing them to save copies is trading a durable structural property for a transient performance gain. That is right often enough that "collapse the layers" should never be the first move; the counter is that many such layers change in lockstep, which means they were never independent evolution points at all.
Where the depth lives
This domain teaches the codebase-level structure and hands the rest off.
- — Programming Languages & Runtime Internals — whether an immutable value costs an allocation at all is a language and runtime question: escape analysis, value types, structural sharing and ownership each change the answer, which is why allocation advice travels so badly between ecosystems.