Skip to content
academia.sh

Lesson 01 / 16

What the Backend Does

The three responsibilities the server side takes on: keeping state in a single place, the point where business rules are enforced, and integration with systems the client cannot reach.

Contents

The Application Architecture course in the Frontend Development curriculum established that an application running in the browser fetches data through a request layer: the application sends a request to an address, parses the response it receives, maps the status code to an error contract, and updates the screen. In that course, the side the request went to was a fixed assumption. The How the Internet Works course defined the shape of the request — the request line, headers, body — but not what the program running on the other side does.

This lesson fills that gap and answers a single question: what does the side facing the client take on, and why can this work not be left to the client? The answer splits into three responsibilities — persistence, business rules, and integration. All three rest on the same reasoning, and that reasoning is demonstrated through measurement in this lesson. A single application will run through the curriculum: a library loan service. The concepts of book, member, loan transaction, and branch will carry the same meaning across every lesson.

State Cannot Stay in a Single Client’s Memory

The loan service’s core data is a list: which member holds which book. What happens if this list is kept on the client? The program below represents a single client; the catalog is fixed, and the loan record lives in the process’s own memory.

// client-side-state.mjs — a client that keeps its loan record only in its own memory
const CATALOG = new Map([
  ["978-0201896831", { title: "The Art of Computer Programming", copies: 2 }],
  ["978-0262033848", { title: "Introduction to Algorithms", copies: 1 }],
]);

const loans = []; // specific to this process: another run never sees it

const borrowBook = (isbn, member) => {
  const book = CATALOG.get(isbn);
  const held = loans.filter((o) => o.isbn === isbn).length;
  if (held >= book.copies) return "rejected: no copies";
  loans.push({ isbn, member });
  return "given";
};

const [member, isbn] = process.argv.slice(2);
console.log(`${member} -> ${borrowBook(isbn, member)}`);
console.log(`${member} sees total loans: ${loans.length}`);

Two separate members request the same book, which has only one copy. Each member runs their own client:

node client-side-state.mjs U-1001 978-0262033848
node client-side-state.mjs U-1002 978-0262033848
U-1001 -> given
U-1001 sees total loans: 1
U-1002 -> given
U-1002 sees total loans: 1

The code is not wrong: the copy check exists and is written correctly. The flaw is that the check has no data to check against. Each run starts with its own empty list, counts its own list correctly, and reaches the correct result. But the system has one copy, and it has been given to two people. This is a conflict born not of concurrency but of isolation.

The server side’s first task is to break this isolation: the loan record is kept in a single place shared by all clients. The server below applies the same rule in the same way, with one difference — the list sits outside the processes, in the place where requests meet.

// loan-server.mjs — server that keeps the loan record and the rules in one place
import { createServer } from "node:http";

const CATALOG = new Map([
  ["978-0201896831", { title: "The Art of Computer Programming", copies: 2 }],
  ["978-0262033848", { title: "Introduction to Algorithms", copies: 1 }],
]);
const MEMBER_LIMIT = 2;
const loans = []; // shared record across all clients

const readBody = (req) =>
  new Promise((resolve, reject) => {
    let data = "";
    req.on("data", (chunk) => (data += chunk));
    req.on("end", () => { try { resolve(data ? JSON.parse(data) : {}); } catch (e) { reject(e); } });
  });

const respond = (res, code, obj) => {
  res.writeHead(code, { "content-type": "application/json; charset=utf-8" });
  res.end(JSON.stringify(obj));
};

createServer(async (req, res) => {
  res.sendDate = false;
  if (req.method === "GET" && req.url === "/loans") return respond(res, 200, loans);
  if (req.method !== "POST" || req.url !== "/loans") return respond(res, 404, { error: "route_not_found" });

  const body = await readBody(req);          // body.eligible field is not read
  const book = CATALOG.get(body.isbn);
  if (!book) return respond(res, 404, { error: "book_not_found" });

  const held = loans.filter((o) => o.isbn === body.isbn).length;
  if (held >= book.copies) return respond(res, 409, { error: "no_copies", remaining: 0 });

  const open = loans.filter((o) => o.member === body.member).length;
  if (open >= MEMBER_LIMIT) return respond(res, 409, { error: "member_limit", limit: MEMBER_LIMIT, open });

  loans.push({ member: body.member, isbn: body.isbn });
  respond(res, 201, { status: "given", remaining: book.copies - held - 1 });
}).listen(8421, "127.0.0.1", () => console.log("listening 127.0.0.1:8421"));

The server is a long-lived process; once started from the command line, it keeps listening until it is stopped. For that reason, the example ships with a script that starts the server and stops it at the end. The port used, 8421, is arbitrary and must be free; if it is taken, it is changed in both files.

#!/usr/bin/env bash
# Starts loan-server.mjs, sends five requests in sequence, reads the record, stops it.
node loan-server.mjs & server=$!
sleep 0.5

send() {
  printf '%-54s -> ' "$1"
  curl -sS -w ' [%{http_code}]\n' -X POST -H 'content-type: application/json' \
    -d "$1" http://127.0.0.1:8421/loans
}

send '{"member":"U-1001","isbn":"978-0262033848"}'
send '{"member":"U-1002","isbn":"978-0262033848"}'
send '{"member":"U-1002","isbn":"978-0262033848","eligible":true}'
send '{"member":"U-1001","isbn":"978-0201896831"}'
send '{"member":"U-1001","isbn":"978-0201896831"}'

echo "--- record on the server ---"
curl -sS http://127.0.0.1:8421/loans; echo
kill "$server"
listening 127.0.0.1:8421
{"member":"U-1001","isbn":"978-0262033848"}            -> {"status":"given","remaining":0} [201]
{"member":"U-1002","isbn":"978-0262033848"}            -> {"error":"no_copies","remaining":0} [409]
{"member":"U-1002","isbn":"978-0262033848","eligible":true} -> {"error":"no_copies","remaining":0} [409]
{"member":"U-1001","isbn":"978-0201896831"}            -> {"status":"given","remaining":1} [201]
{"member":"U-1001","isbn":"978-0201896831"}            -> {"error":"member_limit","limit":2,"open":2} [409]
--- record on the server ---
[{"member":"U-1001","isbn":"978-0262033848"},{"member":"U-1001","isbn":"978-0201896831"}]

The second request has been rejected. Same rule, same code, different result: the only thing that changed is that the list the rule looks at is now the list both requests write to. The record on the server has two lines, and the single-copy book has been given out once.

This is called persistence. Here the word does not narrowly mean “writing to disk”; in later lessons the record will move to a database. Its meaning here is that state outlives the lifetime and visibility of a single client. The loan record continues to exist after the request that created it; another client can arrive and read it. The question of which data in a system must live on the server side reduces to this criterion: is it enough for a single client to see this data?

Where a Rule Is Enforced and Where It Is Displayed

The third line of the output above deserves a closer look. That request added an "eligible": true field to its body — the client’s own calculation saying “this loan is eligible.” The server gave the same response: no_copies. Because the server never reads that field; it recomputes the decision from the list it holds.

This distinction is the essence of the business rule responsibility. The library’s rule — “a member can hold at most two books at a time” — can appear in two places at once:

  • Where it is displayed. The client shows the borrow button as disabled to a member who has hit the limit, writes out the reason, and avoids an unnecessary network round trip. This is a user experience decision.
  • Where it is enforced. The server re-evaluates the rule on every incoming request and rejects the request if it is not satisfied. This is a correctness decision.

Because the two are implementations of the same rule, they are assumed to substitute for each other. They do not. The check on the client reduces requests; the check on the server binds them. Which client a request reaching the server actually came from, what code that client ran, and whether its check even ran are all outside the server’s knowledge — the curl calls above are the concrete form of this. The rule’s only binding implementation is the one on the side the request reaches.

This yields a design criterion: if enforcing a rule incorrectly leaves the data inconsistent, that rule belongs on the server side. Exceeding the limit corrupts the loan record, so it belongs on the server. The format in which a date is written on screen does not corrupt data, so it can stay on the client.

Systems the Client Cannot Reach

The third responsibility rests on a different rationale than the first two. The library’s penalty records live in a separate service run by another team, and access to that service is protected by a credential.

// penalty-service.mjs — penalty record service run by a separate team
import { createServer } from "node:http";

const KEY = process.env.PENALTY_KEY ?? "";
const PENALTIES = new Map([["U-1001", 0], ["U-1002", 12.5]]);

createServer((req, res) => {
  res.sendDate = false;
  res.setHeader("content-type", "application/json; charset=utf-8");
  if (req.headers.authorization !== `Bearer ${KEY}`) {
    return res.writeHead(401).end(JSON.stringify({ error: "unauthorized" }));
  }
  const member = new URL(req.url, "http://local").searchParams.get("member");
  res.writeHead(200).end(JSON.stringify({ member, penalty: PENALTIES.get(member) ?? 0 }));
}).listen(8422, "127.0.0.1", () => console.log("penalty service 127.0.0.1:8422"));

When producing a member’s status, the loan application merges its own loan record with the penalty service’s response. The application is the side that carries the credential.

// application.mjs — application facing the client; it goes to the penalty service with the credential itself
import { createServer } from "node:http";

const KEY = process.env.PENALTY_KEY ?? "";
const LOANS = [{ member: "U-1001", isbn: "978-0262033848" }, { member: "U-1002", isbn: "978-0201896831" }];

createServer(async (req, res) => {
  res.sendDate = false;
  res.setHeader("content-type", "application/json; charset=utf-8");
  const member = new URL(req.url, "http://local").searchParams.get("member");

  const reply = await fetch(`http://127.0.0.1:8422/penalty?member=${member}`, {
    headers: { authorization: `Bearer ${KEY}` },
  });
  if (!reply.ok) return res.writeHead(502).end(JSON.stringify({ error: "penalty_service_unreachable" }));

  const { penalty } = await reply.json();
  const open = LOANS.filter((o) => o.member === member).length;
  res.writeHead(200).end(JSON.stringify({ member, openLoans: open, penalty, canBorrow: penalty === 0 }));
}).listen(8423, "127.0.0.1", () => console.log("application 127.0.0.1:8423"));
#!/usr/bin/env bash
# Starts both servers with the same shared key, measures the surface visible to the client.
export PENALTY_KEY="k-9f3a-example"
node penalty-service.mjs & penalty=$!
node application.mjs & app=$!
sleep 0.5

echo "--- client goes directly to the penalty service ---"
curl -sS -w ' [%{http_code}]\n' 'http://127.0.0.1:8422/penalty?member=U-1002'

echo "--- client goes to the application ---"
curl -sS -w ' [%{http_code}]\n' 'http://127.0.0.1:8423/member?member=U-1002'
curl -sS -w ' [%{http_code}]\n' 'http://127.0.0.1:8423/member?member=U-1001'

reply=$(curl -sS -D - 'http://127.0.0.1:8423/member?member=U-1001')
echo "--- number of lines the key appears on in the application's response (headers + body) ---"
printf '%s' "$reply" | grep -c 'k-9f3a-example'

kill "$penalty" "$app"
application 127.0.0.1:8423
penalty service 127.0.0.1:8422
--- client goes directly to the penalty service ---
{"error":"unauthorized"} [401]
--- client goes to the application ---
{"member":"U-1002","openLoans":1,"penalty":12.5,"canBorrow":false} [200]
{"member":"U-1001","openLoans":1,"penalty":0,"canBorrow":true} [200]
--- number of lines the key appears on in the application's response (headers + body) ---
0

Three things have been measured. The penalty service cannot be reached without the credential. Going through the application merges data from two sources into a single response. And in the application’s response — headers included — the number of lines the key appears on is zero.

The last measurement is decisive. Every byte sent to the client is a byte the person running that client can read; code running in the browser cannot hide the script it downloaded or the response it received. So if the penalty service’s key had been given to the client, the key would have been given to everyone. The integration responsibility belongs on the server, because the credential integration requires cannot be carried to the client.

Integration is not limited to secrecy alone, either. What happens when the penalty service slows down, which status code is produced when it does not respond, how multiple sources are merged — these are decisions made on the server side. The application above makes the simplest of these decisions: if the upstream service fails, it produces 502 and does not break its own error contract.

The Common Name for the Three Responsibilities

Persistence, business rules, and integration look like three independent jobs, but they rest on a single concept: the source of truth. In a system, the question “which component decides this” must have exactly one answer for every piece of data.

For the loan record, the answer is the server, because the record is shared. For the loan rule, it is the server, because the decision must be binding. For penalty information, it is the server, because access to the source is protected there. In all three cases, the client produces a view: it formats the data it gets from the server, previews the rule, and gives the user a response without making them wait. Producing a view is not a small job — the entire Frontend Development curriculum is devoted to it — but being the source of truth is a different job.

Once this distinction is established, the next question follows on its own: should every computation move to the server? No — every computation moved to the server means a network round trip, a response delay, and a scaling cost. Where exactly the boundary runs is a separate decision.

Summary

  • When state is kept in a single client’s memory, every client makes a decision that is correct from its own view but wrong for the system as a whole; the example run demonstrates this by giving a single-copy book to two members.
  • Persistence means data outlives the lifetime and visibility of a single client; the criterion is whether other clients need to see that data too.
  • A business rule is displayed on the client and enforced on the server; the ready-made decision field the client adds to the body is not read by the server — the decision is recomputed from the data on hand.
  • Every rule that leaves data inconsistent when enforced incorrectly belongs on the server side; only decisions that affect presentation can stay on the client.
  • Integration belongs on the server, because the credentials of upstream services cannot be carried to the client; in the measured response, the number of lines the key appears on is zero.
  • The three responsibilities rest on a single concept: for every piece of data, which component is the source of truth must be determined in exactly one way.

Next Step

The server being the source of truth does not mean every computation happens on the server. Sorting a book list, the amount of a late fee, the results of a search box — each of these can be computed on either side, and the choice produces measurable results: bytes transferred, number of round trips, response time, and how binding the decision is. The next lesson draws that boundary: it performs the same computation on both sides and measures the data and time transferred, then shows an item measurement cannot decide — where a computation that must be binding needs to sit comes from trust, not performance.

To keep your progress and take notes, Log in

My notes

Log in to take notes.

Start typing to search.

↑↓ Esc navigate · open · close