Skip to content
academia.sh

Lesson 07 / 18

Client–Server

Measuring in code the separation of roles into initiator and waiter: the delivery side's count of names it must know about the pricing side dropping from four to one, which client breaks when the pricing side renames its own internal names, and the polling count as the cost of the waiting side not knowing the initiating side.

Contents

Structural styles addressed how units are arranged and which way the dependency arrow points. In the blackboard arrangement, units did not know each other at all: they wrote to a shared knowledge area, read from it, and the area itself brokered the encounter. This topic asks not about arrangement but about the manner of conversation. In an interaction between two units: who initiates, who waits, and how much must be known about the other side.

The first style gives the sharpest answer to these three questions: one side always initiates, the other always waits, and the obligation to know piles up in a single direction. The Client–Server Model lesson in the Computer Networks curriculum established this separation at the network level; the criterion that determines the role is not hardware but who initiates the connection. Address, listening calls, and port numbers belong there and are not revisited here. The question here sits one level up: how does the same role separation appear between two modules inside a single codebase, and what does it measure.

What Gets Measured

Two contexts of the library face each other in this lesson. The delivery operations context must know a shipment’s fee, but the calculation lives inside the pricing context. Two arrangements of the interaction are compared and three counts are taken: the number of names the calling side must know about the other side, the number of clients broken when the other side reorganizes its own internals, and the number of connections the waiting side sets up toward the initiating side.

The quality attribute it connects to is maintainability, and the quality question is: when the pricing side changes its own internal names, how many files must be edited on the delivery side.

Inside the Pricing Side

The fee calculation is spread across three modules. These three files are identical in both arrangements; the only thing that changes is how the delivery side reaches them.

mkdir -p fee direct client server
// fee/zone.mjs — derives the fee zone from the address
const ZONE = { "34": "near", "06": "mid", "35": "mid", "65": "far" };
export const determineZone = (province) => ZONE[province] ?? "far";
// fee/tariff.mjs — weight tier, zone coefficient, and minimum fee
const COEFFICIENT = { near: 1, mid: 1.4, far: 2.1 };
const MINIMUM = { near: 30, mid: 45, far: 70 };
export const tier = (kg) => (kg <= 1 ? 1 : kg <= 5 ? 2 : kg <= 20 ? 3 : 4);
export const applyTariff = (zone, kg) =>
  Math.max(MINIMUM[zone], 25 * tier(kg) * COEFFICIENT[zone]);
// fee/discount.mjs — contracted customer discount and volume discount
const CONTRACT = { "CUST-1": 0.1, "CUST-2": 0.2 };
export const applyDiscount = (amount, customer, quantity) =>
  amount * (1 - (CONTRACT[customer] ?? 0)) * (quantity >= 10 ? 0.95 : 1);

The Arrangement Where Internal Names Are Called Directly

In the first arrangement, the delivery desk sequences the calculation steps itself. It imports three modules, calls four names, and holds the intermediate values itself.

// direct/operation.mjs — the delivery desk calls pricing's internal names itself
import { determineZone } from "../fee/zone.mjs";
import { tier, applyTariff } from "../fee/tariff.mjs";
import { applyDiscount } from "../fee/discount.mjs";

export function calculatePrice(shipment) {
  const zone = determineZone(shipment.province);
  const raw = applyTariff(zone, shipment.weight);
  const net = applyDiscount(raw, shipment.customer, shipment.quantity);
  return { zone, tier: tier(shipment.weight), raw, net: Math.round(net * 100) / 100 };
}

There is no role in this arrangement. Both sides look equal, because the calling side knows the callee’s internal ordering: which step comes before which, which intermediate value goes into which function. The order of the steps sits on one side, the steps themselves on the other.

Role Separation

In the second arrangement, the pricing side opens a single entry point. None of the internal names leave; what leaves is a request shape. The operation field says what is being requested, and the rest is the request’s data.

// server/pricing.mjs — single entry point; internal names never leave
import { determineZone } from "../fee/zone.mjs";
import { tier, applyTariff } from "../fee/tariff.mjs";
import { applyDiscount } from "../fee/discount.mjs";

let processed = 0;

function price(s) {
  const zone = determineZone(s.province);
  const raw = applyTariff(zone, s.weight);
  const net = applyDiscount(raw, s.customer, s.quantity);
  return { zone, tier: tier(s.weight), raw, net: Math.round(net * 100) / 100 };
}

export function handleRequest(request) {
  processed += 1;
  if (request.operation === "price") return { state: "ok", result: price(request.shipment) };
  return { state: "unknown-operation", operation: request.operation };
}

export const processedRequests = () => processed;
// client/operation.mjs — recognizes only the request shape and a single name
import { handleRequest } from "../server/pricing.mjs";

export function calculatePrice(shipment) {
  const response = handleRequest({ operation: "price", shipment });
  if (response.state !== "ok") throw new Error(`request not fulfilled: ${response.state}`);
  return response.result;
}

The order of the steps switched sides. It now sits on the waiting side, and the initiating side only states what it wants. When an unknown operation arrives, the response is not a crash but a state value; this too is part of the role — the waiting side must respond to every incoming request.

Measurement

The measurement reads the import lines of the two client files to extract the known name count, verifies that both arrangements produce the same result, then sets up a situation where the pricing side reorganizes its own internals: four internal names are renamed and both clients are run against this new tree.

// count-names.mjs — known name count in both arrangements, result equality, and the client broken by internal renaming
import { cpSync, readFileSync, writeFileSync } from "node:fs";
import { calculatePrice as directPrice } from "./direct/operation.mjs";
import { calculatePrice as clientPrice } from "./client/operation.mjs";
import { processedRequests } from "./server/pricing.mjs";

const IMPORT = /import\s*\{([^}]*)\}\s*from\s*"(\.[^"]+)"/g;
const knownNames = (path) => {
  const bag = [...readFileSync(path, "utf8").matchAll(IMPORT)];
  const names = bag.flatMap((m) => m[1].split(",").map((s) => s.trim().split(" ")[0]));
  return { module: bag.length, names };
};

for (const path of ["direct/operation.mjs", "client/operation.mjs"]) {
  const { module, names } = knownNames(path);
  console.log(`${path.padEnd(24)} known modules=${module} known names=${names.length} [${names.join(" ")}]`);
}

const SHIPMENTS = [
  { province: "34", weight: 0.8, customer: "CUST-1", quantity: 3 },
  { province: "65", weight: 12, customer: "CUST-2", quantity: 12 },
  { province: "06", weight: 30, customer: "CUST-9", quantity: 1 },
];
const a = SHIPMENTS.map(directPrice);
const b = SHIPMENTS.map(clientPrice);
console.log(`result: ${JSON.stringify(a[1])}`);
console.log(`arrangements equal = ${JSON.stringify(a) === JSON.stringify(b)}, requests turned into calls = ${processedRequests()}`);

for (const d of ["fee", "direct", "client", "server"]) cpSync(d, `new/${d}`, { recursive: true });
const NEW_NAME = { determineZone: "findZone", applyTariff: "calculateTariff", applyDiscount: "processDiscount", tier: "weightTier" };
for (const path of ["new/fee/zone.mjs", "new/fee/tariff.mjs", "new/fee/discount.mjs", "new/server/pricing.mjs"]) {
  let text = readFileSync(path, "utf8");
  for (const [oldName, newName] of Object.entries(NEW_NAME)) text = text.replaceAll(oldName, newName);
  writeFileSync(path, text);
}

for (const path of ["./new/direct/operation.mjs", "./new/client/operation.mjs"]) {
  try {
    const { calculatePrice } = await import(path);
    console.log(`${path.padEnd(32)} after internal names changed net=${calculatePrice(SHIPMENTS[1]).net}`);
  } catch (error) {
    console.log(`${path.padEnd(32)} broke: ${error.message.split("\n")[0]}`);
  }
}
node count-names.mjs
direct/operation.mjs     known modules=3 known names=4 [determineZone tier applyTariff applyDiscount]
client/operation.mjs     known modules=1 known names=1 [handleRequest]
result: {"zone":"far","tier":3,"raw":157.5,"net":119.7}
arrangements equal = true, requests turned into calls = 3
./new/direct/operation.mjs       broke: The requested module '../fee/discount.mjs' does not provide an export named 'applyDiscount'
./new/client/operation.mjs       after internal names changed net=119.7

Reading the Numbers

The known module count dropped from 3 to 1, the known name count from 4 to 1. The second number is the measure of the obligation to know: after role separation, the only thing the delivery side knows about the pricing side is one function name and one request shape. The fee for the three shipments came out identical across both arrangements, so the measurement compares two arrangements that do the same job.

The last two lines answer the maintainability question. When the four internal names changed, the directly calling client broke at run time and the failure message named the missing name by its own name; the role-separated client kept producing the same result and its file was not touched by a single character. The number of files edited is 4 internal files plus 1 client file in the first arrangement, 4 internal files plus 0 client files in the second. The difference grows with the number of clients: in a library with ten clients, the same renaming requires editing ten more files in the first arrangement, none in the second.

The gain is not free. The pricing side gained one file (the entry point), and an unfulfilled request is now carried as a state value; the caller must check this state on every response. The unknown-operation response appears at run time, not at compile time — a reference to a name that does not exist would have been caught earlier, as an import error, in the directly calling arrangement.

The Cost of Asymmetry

The second face of role separation is this: the waiting side does not know the initiating side. The measure of this is the number of imports going from the pricing side to the delivery side. The result determines how the delivery desk learns when the tariff version changes — since the server cannot deliver the news, the client must ask. The script below sets up three separate tariff versions across a twelve-slice calendar and counts the case where the client asks once per slice.

// polling.mjs — because the waiting side does not know the initiating side, news is learned only by asking
import { readFileSync } from "node:fs";

const SERVER_SIDE = ["server/pricing.mjs", "fee/zone.mjs", "fee/tariff.mjs", "fee/discount.mjs"];
const backImports = SERVER_SIDE
  .flatMap((path) => [...readFileSync(path, "utf8").matchAll(/from "(\.[^"]+)"/g)])
  .filter((m) => m[1].includes("client")).length;
console.log(`imports from server side to client = ${backImports}`);

const CALENDAR = ["V1", "V1", "V1", "V2", "V2", "V2", "V2", "V2", "V3", "V3", "V3", "V3"];
let asked = 0;
const handleRequest = (request) => {
  asked += 1;
  return { state: "ok", version: CALENDAR[request.slice] };
};

let known = null, learned = 0, emptyQuery = 0;
for (let slice = 0; slice < CALENDAR.length; slice += 1) {
  const { version } = handleRequest({ operation: "tariffVersion", slice });
  if (version === known) emptyQuery += 1;
  else { known = version; learned += 1; }
}
console.log(`versions in calendar = ${new Set(CALENDAR).size}, asked = ${asked}, learned = ${learned}, no-change questions = ${emptyQuery}`);
node polling.mjs
imports from server side to client = 0
versions in calendar = 3, asked = 12, learned = 3, no-change questions = 9

The back-import count is zero, and this follows from the definition of the role. The cost is given by the last line: learning three version changes required twelve questions, and nine questions brought no news at all. The ratio moves with how often the client asks — if the client asks less often, the empty questions decrease but it learns of the change later. This ratio is a trade-off and one of the two ends must be chosen, because there is no third option inside the style. The calendar here is a model, not a measurement; what is counted is the gap between the number of questions and the news learned, not any duration.

Summary

  • The criterion separating client from server is the direction of initiation in code as well: one side builds the request, the other must respond to every incoming request.
  • Role separation switches the side that holds the order of steps; the module count known by the delivery side dropped from 3 to 1, the name count from 4 to 1, and both arrangements produced the same fee.
  • When the pricing side renamed its four internal names, the directly calling client broke at run time; the role-separated client kept working without its file being touched.
  • The cost is one entry-point file, a state value to check on every response, and the error shifting from import time to run time.
  • The number of imports going from the waiting side to the initiating side is zero; the result of this is that learning three version changes required twelve questions to be asked, and nine of them returned empty.

Next Step

Asymmetry in this arrangement is deliberate: one side always initiates, the other always waits, and the waiting side is singular. This singularity has a counterpart in how the library operates — all fee questions going to a single place means that place is known by everyone. In an arrangement where multiple transfer hubs carry out the same work, the question reverses: if every hub is both asker and answerer, the obligation to know spreads out instead of piling up in one direction. The next lesson measures this arrangement: the number of neighbors a unit must know, the number of messages required for a tariff update to reach every unit, and the number of units that keep working when one unit is withdrawn.

To keep your progress and take notes, Log in

My notes

Log in to take notes.

Start typing to search.

↑↓ Esc navigate · open · close