Lesson 17 / 18
Serverless Architecture
The runtime responsibility leaving the application: counting the run lines left in the function files, measuring the number of files edited when composition moves into the manifest, measuring the result deviation module state produces on a cold run and the cost of moving state outside, and counting cold start by setup count while leaving its duration to a model.
Contents
The four styles so far shared an implicit assumption: a process standing before any request arrives. One process in the monolithic arrangement, three started and stopped by hand in the microservice arrangement; in the event-driven arrangement, the bus also had to run somewhere. Keeping a process standing, listening, and routing a request to the right unit was code the application carried.
Serverless architecture takes this code out of the application. The deployment unit is not a process but a function called when an event or a request arrives; opening, holding, replicating, and closing the process is the runtime’s job. The name does not describe the absence of a server — a server exists, but it is not the application’s responsibility. This lesson counts both sides of the shift: the run lines that leave the application, and the assumptions that turn into constraints in exchange.
A Principle That Turns Into a Constraint
The Server-Side Fundamentals course’s Twelve-Factor App Principles lesson counted keeping processes stateless and share-nothing as a principle; the same course’s The Application Runtime lesson measured the process as the scaling unit. In the serverless style, stateless process stops being a choice: the runtime can open and close a function’s instance whenever it wants, so two calls cannot be guaranteed to see the same memory region. Violating the principle here is not a style flaw but a measurable result deviation.
The second constraint concerns composition. A function does not bind itself to a route, import its neighbor, or know the call order. Composition moves outside the functions, into a manifest the runtime reads.
Three Functions and One Manifest
To count how many times setup work runs, the function modules leave a line on a shared trace, which exists for the measurement and has nothing to do with the function’s actual work.
// serverless/counter.mjs — setup trace: the first load of each function module leaves a mark here export const setup = [];
The pricing context’s price function carries a single export, with no server, listening, route-resolution, or port code.
// serverless/fee.mjs — pricing function: no server, listening, or routing code; only a handler is exported import { setup } from "./counter.mjs"; const TARIFF = { tier: [[1, 3000], [5, 4800], [20, 9600]], zone: { "34": 100, "06": 115, "35": 125 }, minimum: 2500 }; setup.push("fee"); // runs once when the module loads: setup work export function handle(event) { const tier = TARIFF.tier.find(([k]) => event.weight <= k) ?? [0, 9600]; const base = Math.max(TARIFF.minimum, Math.round((tier[1] * TARIFF.zone[event.zone]) / 100)); return { id: event.id, net: base - Math.round(base * Math.min(event.rate, 0.4)) }; }
The delivery operation’s plan function has the same shape; the only evidence that it is a separate deployment unit is that it is a separate file.
// serverless/operation.mjs — plan function: a separate deployment unit, the same call shape import { setup } from "./counter.mjs"; const TREE = { "34": ["34"], "06": ["34", "06"], "35": ["34", "41", "35"] }; setup.push("plan"); export function handle(event) { const route = TREE[event.zone] ?? ["34"]; return { route, day: route.length, carrier: route.length > 2 ? "MT" : "AN" }; }
The third function accumulates weight per contract for the volume discount. Accumulation inherently remembers something between calls; below, two designs of the same work stand side by side — one keeps the total at module level, the other writes it to a repository the runtime provides.
// serverless/volume.mjs — volume discount function: the module-state and repository-state design for the same work import { setup } from "./counter.mjs"; const total = new Map(); // MODULE STATE: empties out when the module reloads setup.push("volume"); export function handle(event) { total.set(event.contractNo, (total.get(event.contractNo) ?? 0) + event.weight); return { contract: total.size, volume: [...total.values()].reduce((t, a) => t + a, 0) }; } export function handleStored(event, repository) { const key = `volume:${event.contractNo}`; repository.write(key, (repository.read(key) ?? 0) + event.weight); const list = repository.keys(); return { contract: list.length, volume: list.reduce((t, k) => t + repository.read(k), 0) }; }
The manifest consists of two mappings: which name corresponds to which file’s export, and which route runs which sequence of functions. This file holds no field rule at all — only composition.
// serverless/route.mjs — manifest: which name maps to which file's export, which route runs which sequence export const manifest = { functions: { fee: "./fee.mjs#handle", plan: "./operation.mjs#handle", volume: "./volume.mjs#handle", "volume-stored": "./volume.mjs#handleStored", }, routes: { "/offer": ["fee", "plan"], "/record": ["fee", "volume"], "/record-stored": ["fee", "volume-stored"], }, };
The Runtime’s Local Stop
In this lesson, the runtime is modeled with a local stop. Its work has three steps: finding the
function’s location in the manifest, loading the module, and serializing and parsing the body to
hand it to the function. Container is the instance a function runs inside, opened and closed
by the runtime. The cold option mimics the container decision: false sets the module up once
for the container’s lifetime, true sets it up again on every call. The query suffix forces the
reload; in a real runtime, the counterpart is opening a new container.
// serverless/runtime.mjs — the function runtime's local stop: reads the manifest, calls the functions let sequence = 0; // load number that increases through the process's lifetime export function runtime(manifest, log, { cold = false } = {}) { const container = ++sequence; // this instance is a container: the module is set up once on a warm run const memory = new Map(); const raw = new Map(); const repository = { read(k) { log.repository += 1; return raw.get(k); }, write(k, v) { log.repository += 1; raw.set(k, v); }, keys() { log.repository += 1; return [...raw.keys()]; }, }; return { async call(routeName, event) { const result = {}; for (const name of manifest.routes[routeName]) { const [file, exportName] = manifest.functions[name].split("#"); if (cold || memory.has(name) === false) { memory.set(name, await import(`${file}?k=${cold ? ++sequence : container}`)); } const text = JSON.stringify(event); log.transform += 2; log.bytes += text.length; log.crossing.push(`${routeName} -> ${name}`); result[name] = memory.get(name)[exportName](JSON.parse(text), repository); } return result; }, }; }
There is no aggregator function. The runtime builds the response object itself: each name in the route’s sequence contributes its result under its own name.
Five Measurements
The driver script answers five questions: how many run lines remain in the function files; how many functions a request touches and how many bytes cross the boundary; what result the same three shipments produce warm and cold; the line and access cost of moving state to a repository; and how many function files adding a function to the composition edits.
// serverless/measure.mjs — server lines, functions touched, setup count, state deviation, manifest cost import { readFileSync, writeFileSync } from "node:fs"; import { createHash } from "node:crypto"; import { setup } from "./counter.mjs"; import { runtime } from "./runtime.mjs"; import { manifest } from "./route.mjs"; const SHIPMENT = [ { id: "G1", weight: 4, zone: "35", contractNo: "S7", rate: 0.15 }, { id: "G2", weight: 1, zone: "34", contractNo: "S7", rate: 0 }, { id: "G3", weight: 12, zone: "06", contractNo: "S9", rate: 0.25 }, ]; const FUNCTION = ["serverless/fee.mjs", "serverless/operation.mjs", "serverless/volume.mjs"]; const SERVER = /createServer|\.listen\(|writeHead|response\.end|process\.argv/; const CALL = /await import|manifest\.|JSON\.(stringify|parse)/; const digest = (d) => createHash("sha256").update(readFileSync(d)).digest("hex").slice(0, 12); for (const d of [...FUNCTION, "serverless/runtime.mjs"]) { const s = readFileSync(d, "utf8").split("\n").filter((x) => x.trim() !== ""); const count = (r) => s.filter((x) => r.test(x)).length; console.log(`${d.padEnd(26)} lines = ${String(s.length).padStart(2)}, server = ${count(SERVER)}, call = ${count(CALL)}`); } async function run(map, routeName, shipments, cold) { const k = { crossing: [], transform: 0, bytes: 0, repository: 0 }; const before = setup.length; const c = runtime(map, k, { cold }); let result; for (const s of shipments) result = await c.call(routeName, s); return { k, setup: setup.length - before, result }; } const format = (name, r) => `${name.padEnd(20)} boundary crossing = ${r.k.crossing.length}, transform = ${r.k.transform}, bytes = ${r.k.bytes}, setup = ${r.setup}, repository access = ${r.k.repository}`; const t = await run(manifest, "/offer", [SHIPMENT[0]], false); console.log(format("/offer warm", t)); console.log(` result = ${JSON.stringify(t.result)}`); const warm = await run(manifest, "/record", SHIPMENT, false); const cold = await run(manifest, "/record", SHIPMENT, true); const stored = await run(manifest, "/record-stored", SHIPMENT, true); for (const [name, r, key] of [["/record warm", warm, "volume"], ["/record cold", cold, "volume"], ["/record-stored cold", stored, "volume-stored"]]) { console.log(`${format(name, r)}\n ${key} = ${JSON.stringify(r.result[key])}`); } console.log(`module state deviation = ${warm.result.volume.volume - cold.result.volume.volume} weight units, ${warm.result.volume.contract - cold.result.volume.contract} contracts`); const source = readFileSync("serverless/volume.mjs", "utf8"); const lineCount = (name) => source.split(`export function ${name}(`)[1].split("\n}")[0].split("\n").filter((s) => s.trim() !== "").length; console.log(`cost of moving state outside = ${lineCount("handleStored") - lineCount("handle")} lines`); const before2 = FUNCTION.map(digest); const old = readFileSync("serverless/route.mjs", "utf8"); writeFileSync("serverless/route.mjs", old.replace('"plan"]', '"plan", "volume"]')); const updated = readFileSync("serverless/route.mjs", "utf8").split("\n"); const changed = old.split("\n").filter((s, i) => s !== updated[i]).length; const { manifest: wider } = await import("./route.mjs?s=2"); const g = await run(wider, "/offer", [SHIPMENT[0]], false); writeFileSync("serverless/route.mjs", old); console.log(`volume added to the manifest: function files edited = ${FUNCTION.filter((d, i) => digest(d) !== before2[i]).length}, lines changed in manifest = ${changed}`); console.log(format("/offer redone", g)); console.log(` crossing = ${g.k.crossing.join(", ")}`);
node serverless/measure.mjs
serverless/fee.mjs lines = 9, server = 0, call = 0
serverless/operation.mjs lines = 8, server = 0, call = 0
serverless/volume.mjs lines = 14, server = 0, call = 0
serverless/runtime.mjs lines = 29, server = 0, call = 5
/offer warm boundary crossing = 2, transform = 4, bytes = 128, setup = 2, repository access = 0
result = {"fee":{"id":"G1","net":5100},"plan":{"route":["34","41","35"],"day":3,"carrier":"MT"}}
/record warm boundary crossing = 6, transform = 12, bytes = 380, setup = 2, repository access = 0
volume = {"contract":2,"volume":17}
/record cold boundary crossing = 6, transform = 12, bytes = 380, setup = 6, repository access = 0
volume = {"contract":1,"volume":12}
/record-stored cold boundary crossing = 6, transform = 12, bytes = 380, setup = 6, repository access = 13
volume-stored = {"contract":2,"volume":17}
module state deviation = 5 weight units, 1 contracts
cost of moving state outside = 2 lines
volume added to the manifest: function files edited = 0, lines changed in manifest = 1
/offer redone boundary crossing = 3, transform = 6, bytes = 192, setup = 3, repository access = 0
crossing = /offer -> fee, /offer -> plan, /offer -> volume
Reading the Numbers
The first four lines count the code that leaves the application. None of the three function files
has any run lines: lines containing createServer, listen, writeHead, or process.argv
number 0. The same scan found 6 per deployment unit in the microservice lesson, 18 total across
three units. Those lines were not deleted, they moved: the runtime’s local stop is 29 lines, and
the call scan finds 5 in it — the lines deciding which function runs and serializing and parsing
the body. In a real runtime these 29 lines never live in the application’s repository; they exist
here only so the measurement can run with a single command.
The second gain is the absence of an aggregator. In the microservice arrangement, a third
deployment unit served the offer request: its own server, two services known by name, and a
hand-aggregated response. Here, the /offer route’s counterpart is a single line in the manifest,
and the runtime builds the result object. For a single request, boundary crossing is 2, transform
point is 4, bytes crossing the boundary is 128 — 64 per shipment, twice across two crossings.
The last two lines give the cost of moving composition into the manifest. Making the offer request also trigger the volume discount changed 1 line in the manifest and left the function files’ digests unchanged: function files edited is 0. In the event-driven lesson, the same addition cost 1 line of code in the composition root; here, even that line left the code. The counterpart sits in the same line: boundary crossing rose from 2 to 3, transform from 4 to 6, bytes from 128 to 192. A request’s cost is now read in the manifest, not the code, and no resolver warns when the manifest is written incorrectly.
Where State Goes
The three /record runs fed the same three shipments in the same order; all three produced
boundary crossing 6, transform 12, and bytes 380 — no difference on the runtime’s side. Only the
result diverges. The warm run’s volume discount function reported {"contract":2,"volume":17};
the cold run’s reported {"contract":1,"volume":12}, a deviation of 5 weight units and 1
contract. The source is volume.mjs’s module-level total map: set up again on every call, it
emptied out each time, so only the last shipment was counted.
This is the style’s quietest defect. The code is correct, the result is wrong, and there is no error message. Because the same code produces the correct result on a warm run, the defect is visible or invisible depending on the runtime’s container decision — a test running in a warm container never sees it at all.
The fix is to take the state out of the function. handleStored writes the same total to the
repository the runtime provides and returns {"contract":2,"volume":17} on the cold run —
deviation 0. Its cost: the body grew by 2 lines, and three requests produced 13 repository
accesses, against 0 in the module-state design. Here the repository is a local map; once it
becomes a separate deployment unit, each of those 13 accesses is a boundary crossing, added to the
network calls counted in the microservice lesson.
Cold Start Is a Model
The setup count is a real number: on the warm run, three requests did 2 setups; on the cold run, 6, each of the two functions set up again on every request. The setup’s duration, however, was not measured — a module’s load time depends on the machine, the file system, and the runtime’s own readiness, so the account below is a model, not a measurement.
A call’s duration splits into two parts: setup work and the actual work . If a fraction of calls starts on a new instance, the average duration is
The model says two things. First, results from the traffic’s shape, not the application’s:
heavy, continuous traffic reuses the container, and sparse, irregular traffic pushes toward
1 — the measurement’s cold run is the counterpart of . Second, the only way to shrink
is to shrink the setup work itself. fee.mjs sets up only a tariff object at module level; a
large table or a connection pool set up in the same place would grow . This is the limit the
style places on how much setup work a function can carry.
The numbers describe a quality attribute trade-off. The quality gained is maintainability: run lines carried by the application is 0, and function files edited for a new composition is 0. Two qualities are given up. Performance efficiency loses predictability: the same request does 2 or 6 setups depending on a container decision the application does not make. Reliability loses ground because every value accumulated in memory is a candidate for the kind of 5-unit deviation measured here. This is why the style fits work that is independent per call; accumulating work qualifies only by moving state outside, paying 13 accesses for it.
Summary
- In serverless architecture, the deployment unit is not a process but a function that runs when called; keeping a process standing and routing it moves outside the application.
- Run lines measured 0 in the three function files, against 6 per unit and 18 total in the microservice lesson’s scan. Those lines’ local counterpart is the 29-line runtime stop.
- There is no aggregator function: the
/offerroute’s counterpart is a single manifest line, and the runtime builds the result object; a single request produced 2 boundary crossings, 4 transform points, and 128 bytes. - Adding a function to the composition edited 0 function files and changed 1 manifest line; the same request rose to 3 boundary crossings, 6 transform points, and 192 bytes.
- State accumulated at module level deviated by 5 weight units and 1 contract on a cold run; moving state to a repository brought the deviation to 0, at a cost of 2 lines and 13 repository accesses.
- Cold start’s counted face is the setup count: three requests did 2 setups warm and 6 cold. Duration is told through a model, not measured.
Next Step
The five styles left five separate sets of measures: files published together, boundary crossing, bytes crossing the boundary, units still standing when one unit stops, and files edited for a new capability. No style led on all of these measures; every gain grew another number. How, then, is a choice made, and what does its defense rest on. The next lesson ties the measures to quality attributes, measures the same rules on a common footing across two boundary arrangements, and shows under which threshold a style decision holds up and which threshold flips it; the numbers measured across the course are gathered there in a single decision table.
To keep your progress and take notes, Log in
My notes
Log in to take notes.