---
title: Client–Server
source: 'https://academia.sh/en/courses/architectural-styles/client-server'
course: 'Architectural Styles'
language: en
updated: '2026-08-23T07:01:09+00:00'
license: 'CC BY-SA 4.0'
---

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

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.

```sh
mkdir -p fee direct client server
```

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

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

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

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

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

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

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

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

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

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