Where does the data live?
Every backend answers one question early: when the process stops, what survives?
- In memory (a plain array or object): fastest, and gone the instant the server restarts. Fine for caches and prototypes.
- A JSON file: survives restarts, using the
fsskills from lesson 2-3 plusJSON.stringify. Breaks down when two requests write at once, and searching means reading everything. - A database: a separate program purpose-built for storing, searching, and safely updating data from many clients at once. Real apps live here.
The repository below is in-memory, and that is deliberate: the interface (create, find, remove) stays the same when you later swap the array for a database, so route handlers never know the difference. This boundary is called the persistence layer.
The seed of a repository
create stamps each row with an auto-incrementing id, which is the same job SQL's INTEGER PRIMARY KEY does in the next lesson, and returns the stored row so callers immediately know the new id.
function createRepo() { const rows = []; let nextId = 1; return { create(data) { const row = { id: nextId, ...data }; nextId++; rows.push(row); return row; }, all() { return rows; }, }; } const users = createRepo(); console.log(JSON.stringify(users.create({ name: "Ada" }))); console.log(JSON.stringify(users.create({ name: "Grace" }))); console.log(JSON.stringify(users.all()));
Output
{"id":1,"name":"Ada"}
{"id":2,"name":"Grace"}
[{"id":1,"name":"Ada"},{"id":2,"name":"Grace"}]{ id: nextId, ...data } copies the caller's fields into a new object with the id in front. Spreading second would let a caller's own id field overwrite the assigned one, so the order is a small piece of defensive design.
rows stays private inside createRepo, so callers can only reach the data through the repo's functions. That closure boundary is what makes swapping storage possible, since no handler can be depending on the array itself.
Returning the created row is the detail that makes a POST handler work. The client needs the new id to build a link or a follow-up request, and a create that returned nothing would force an immediate second lookup.
The id counter living in a local variable is the honest limitation of in-memory storage. Restarting the process resets it to 1, and two processes would each hand out id 1, which is exactly the problem a database sequence solves.
Note that all() returns the private array itself rather than a copy, so a caller could mutate it. Returning rows.slice() would seal the boundary properly, and the version here is the common shortcut worth recognizing as one.
Two simultaneous writes to one JSON file
One request's user can vanish, because both read the same starting file and the second write overwrites the first.
This is a race condition, meaning read-modify-write with no coordination. Request B reads the file before A writes, so B's write is based on stale data and silently erases A's user.
Nothing about this produces an error. Both requests get a 201, both clients believe they succeeded, and the file ends up with one of the two users, which is why the bug is usually discovered by a confused customer rather than by monitoring.
It also cannot be fixed by being careful in the handler. The gap between the read and the write is where the damage happens, and no amount of validation inside the handler closes a gap created by two processes running at once.
| Storage | Concurrent writes |
|---|---|
| in-memory array in one process | safe, one thread at a time |
| JSON file | unsafe, last write wins |
| database | safe, that is what it is for |
The single-process in-memory row is safe for a reason specific to Node, which is the single thread from lesson 2-2. Two handlers cannot interleave mid-update, and that guarantee disappears the moment the data lives outside the process.
Databases exist largely to make concurrent writes safe, which is why file storage stops at the prototype stage.
Finishing the repository
findById returns the matching row or null, and remove deletes by id and reports whether anything was removed.
function createRepo() { const rows = []; let nextId = 1; return { create(data) { const row = { id: nextId, ...data }; nextId++; rows.push(row); return row; }, findById(id) { return rows.find((r) => r.id === id) || null; }, remove(id) { const i = rows.findIndex((r) => r.id === id); if (i === -1) return false; rows.splice(i, 1); return true; }, all() { return rows; }, }; } const users = createRepo(); users.create({ name: "Ada" }); users.create({ name: "Grace" }); console.log(JSON.stringify(users.findById(2))); console.log(users.remove(1)); console.log(users.remove(99)); console.log(JSON.stringify(users.all()));
Output
{"id":2,"name":"Grace"}
true
false
[{"id":2,"name":"Grace"}]rows.find((r) => r.id === id) gives the row or undefined, and the || null normalizes the miss to a single value the caller can check. Picking one of the two empty values and using it everywhere saves a class of bug where half the code tests for undefined and half for null.
findIndex returns -1 when nothing matches, which is the false case, and rows.splice(i, 1) removes exactly one row at that position. Checking for -1 first is mandatory, since splice(-1, 1) removes the last row, which is a silent and destructive wrong answer.
remove returning a boolean is what lets the handler choose between 204 and 404. A void remove would leave the handler unable to tell a successful delete from a delete of something that never existed.
Note that findById(2) works because the ids are numbers on both sides. A handler passing req.params.id straight through would pass "2", find nothing, and answer 404, which is the conversion trap from lesson 5-1 arriving at the storage layer.
Every function here is one line of intent over an array, and that is the point of the layer. Replacing the array with SQL in the next lesson changes these four bodies and nothing above them.