Lesson 27 / 30
Identity Map
Representing the same identity with a single object: measuring the separate instance count produced without and with the map, the number of fields lost when two code paths change the same record, and the number of objects the map holds in memory; showing the stale read that appears once the map's lifetime is extended.
Contents
The unit of work collected dirty rows under the table:id key, and even when the same row was
marked dirty three times, it was written once. That is a uniqueness problem solved on the write
side. On the read side, the same problem is still open: when the same shipment id is looked up
twice, two separate objects are born. One code path changes the first object’s discount, a
second code path adds a transfer point to the second object’s route; when both are saved,
whichever writes last erases the other’s change.
Identity Map puts a one-sentence solution to this problem: the object produced for an identity is stored, and when the same identity is looked up again, no new object is produced — the stored one is returned. The pattern was measured in The Data Access Layer and Business Logic course by the number of queries it produced. Here the measure is at the object level: how many separate instances were born, how many fields were lost, how many objects the map held in memory.
Problem: Same Identity, Two Objects
The repository’s read behavior is the source of the problem. A repository returns a copy of the row, not the row itself; otherwise the calling side could change the stored row directly.
// memory-repository.mjs — simple repository that returns a fresh copy of the row on every read const TABLES = new Map(); const table = (name) => TABLES.get(name) ?? TABLES.set(name, new Map()).get(name); export const repository = { read: (name, id) => structuredClone(table(name).get(id) ?? null), write: (name, row) => { table(name).set(row.id, structuredClone(row)); }, };
// shipment.mjs — domain object with an identity export class Shipment { constructor({ id, weight, discount, transfers }) { Object.assign(this, { id, weight, discount, transfers }); } net() { return Math.round(3900 * this.weight * (1 - this.discount)); } }
Returning a copy is the right decision, but it has a side effect: two lookups produce two independent objects, and neither knows it represents the same record.
Solution
The map takes the work of production into itself. It does not know how the object should be produced; it only knows whether it has been produced before.
// identity-map.mjs — one object per identity, for the length of a request export function identityMap() { const map = new Map(); return { get(type, id, create) { const key = `${type}:${id}`; if (map.has(key)) return map.get(key); const object = create(); map.set(key, object); return object; }, held: () => map.size, }; }
The key must also include the type name: it is an ordinary situation for two separate types to carry the same identity. The access point stays in a single place.
// source.mjs — the same mapper; if a map is given, the same identity returns the same object import { repository } from "./memory-repository.mjs"; import { Shipment } from "./shipment.mjs"; export const source = (map) => ({ find(id) { const create = () => { const row = repository.read("shipment", id); return row === null ? null : new Shipment(row); }; return map === null ? create() : map.get("shipment", id, create); }, save: (s) => repository.write("shipment", { id: s.id, weight: s.weight, discount: s.discount, transfers: s.transfers }), });
Counting Uniqueness
The measurement makes two runs. Five lookups go to three separate identities; then the same identity is looked up twice, two code paths change separate fields, and both are saved.
// count-uniqueness.mjs — separate instance, lost field, and held object counts, without and with the map import { repository } from "./memory-repository.mjs"; import { source } from "./source.mjs"; import { identityMap } from "./identity-map.mjs"; const IDS = ["G1", "G2", "G3"]; const LOOKUPS = ["G1", "G2", "G3", "G2", "G1"]; function run(name, map) { for (const id of IDS) repository.write("shipment", { id, weight: 3, discount: 0, transfers: ["34"] }); const src = source(map); const instances = new Set(LOOKUPS.map((id) => src.find(id))); const discountEdit = src.find("G2"), transferEdit = src.find("G2"); discountEdit.discount = 0.15; transferEdit.transfers.push("06"); src.save(discountEdit); src.save(transferEdit); const after = repository.read("shipment", "G2"); const expected = { discount: 0.15, transfers: 2 }; const lost = (after.discount === expected.discount ? 0 : 1) + (after.transfers.length === expected.transfers ? 0 : 1); console.log(`${name}`); console.log(` ${LOOKUPS.length} lookups -> separate instances = ${instances.size} same object for same identity = ${discountEdit === transferEdit}`); console.log(` discount after write = ${after.discount}, transfers = ${after.transfers.length}, lost fields = ${lost}`); console.log(` objects held in map = ${map === null ? 0 : map.held()}`); } run("without map", null); run("with identity map", identityMap());
node count-uniqueness.mjs
without map 5 lookups -> separate instances = 5 same object for same identity = false discount after write = 0, transfers = 2, lost fields = 1 objects held in map = 0 with identity map 5 lookups -> separate instances = 3 same object for same identity = true discount after write = 0.15, transfers = 2, lost fields = 0 objects held in map = 3
Five lookups produced 5 separate objects without the map, and 3 with it — as many as the
identity count. The two code paths that looked up the same identity twice got two different
objects without the map, and the === comparison came out false; with the map they got the same
object.
The lost field count is the most concrete form of the difference: in the run without the map, the discount change was lost, and the discount stayed at 0 in the saved row. In the run with the map, both changes accumulated on the same object and were written together.
There is one more number on the other side of the trade: the map holds 3 objects in memory. In the arrangement without the map, the held object count is 0 — produced objects are dropped once used. This is exactly the pattern’s cost, and it produces a second consequence.
The Map’s Lifetime
For as long as the map holds an object, that object does not see the repository’s later state.
// stale-read.mjs — while the map is alive, if the repository changes, which value is read import { repository } from "./memory-repository.mjs"; import { source } from "./source.mjs"; import { identityMap } from "./identity-map.mjs"; for (const [name, map] of [["without map", null], ["with identity map", identityMap()]]) { repository.write("shipment", { id: "G9", weight: 3, discount: 0.1, transfers: ["34"] }); const src = source(map); const first = src.find("G9").discount; repository.write("shipment", { id: "G9", weight: 3, discount: 0.3, transfers: ["34"] }); const second = src.find("G9").discount; console.log(`${name.padEnd(20)} first read = ${first}, repository became 0.3, second read = ${second}, stale = ${second !== 0.3}`); }
node stale-read.mjs
without map first read = 0.1, repository became 0.3, second read = 0.3, stale = false with identity map first read = 0.1, repository became 0.3, second read = 0.1, stale = true
The second read gave the new value in the arrangement without the map, and the first value in the arrangement with the map. This is not a defect; it follows from the pattern’s definition: the map guarantees an object’s uniqueness, not its freshness. This is where the pattern’s boundary of application comes from. The map lives at the boundary of a unit of work — a single request or a single unit of work — and is dropped once that boundary ends. A map that lives for the life of the process turns into a cache, and the question of freshness starts to be asked; that is not the question the identity map answers.
When It Does Not Apply
The pattern’s gain arises from two conditions: the object having an identity, and being mutable. If either is missing, the gain drops to zero.
Value objects have no identity; if two zone coefficients carry the same value, they stand in for each other, and equality is established by value. In an immutable value object, it is also impossible for two separate instances to overwrite each other, so the lost field count in the measurement is 0 even without the map. The gain is 0, and the cost is as large as the number of objects the map holds.
There is also no gain in scenarios that make a single read: the separate instance count is already equal to the lookup count, and the lookup count is 1. The last boundary concerns scale. The map is local to the process; if two separate processes load the same record, each process’s map holds its own object, and uniqueness is not preserved across processes. The problem of concurrent writes across processes belongs to locking, and was measured in The Data Access Layer and Business Logic course.
Summary
- The identity map stores the object produced for an identity, and when the same identity is looked up again, it returns the stored object instead of producing a new one.
- Five lookups produced 5 separate instances without the map, and 3 instances — as many as the
identity count — with it; the two code paths that looked up the same identity failed the
===comparison without the map. - When two code paths changed separate fields of the same record, 1 field was lost in the run without the map; in the run with the map, both changes accumulated on the same object and the loss was 0.
- The cost is the number of objects held in memory (3 against 0), and the second cost is a stale read: once the repository changed, the arrangement with the map kept giving the first value.
- The map lives at the boundary of a single unit of work; the gain drops to zero for value objects and single-read scenarios, and uniqueness is not preserved across processes.
Next Step
The four patterns so far arranged the boundary between business logic and persistence. The domain object now carries the rule, does not know about persistence, and is unique in memory. But this object will also step outside the library: an interface will list it, a carrier system will ask it for its route, an invoice statement will be produced from it. Handing the domain object out as is turns its internal structure into an external contract, and the external contract changes every time the internal structure changes. The next lesson builds cross-layer data transport with data transfer objects and mappers, and counts the number of files touched when a field is renamed in the internal model, and the mapping’s cost per field.
To keep your progress and take notes, Log in
My notes
Log in to take notes.