---
title: 'Identity Map'
source: 'https://academia.sh/en/courses/design-patterns/identity-map'
course: 'Design Patterns'
language: en
updated: '2026-08-23T07:01:14+00:00'
license: 'CC BY-SA 4.0'
---

# 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.

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.

```js
// 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)); },
};
```

```js
// 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.

```js
// 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.

```js
// 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.

```js
// 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());
```

```sh
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.

```js
// 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}`);
}
```

```sh
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.
