---
title: 'Serverless Architecture'
source: 'https://academia.sh/en/courses/architectural-styles/serverless-architecture'
course: 'Architectural Styles'
language: en
updated: '2026-08-23T07:01:09+00:00'
license: 'CC BY-SA 4.0'
---

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

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.

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

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

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

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

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

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

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

```sh
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 $B$ and the actual work $I$. If a fraction $c$
of calls starts on a new instance, the average duration is

$$\bar{T} = I + c \cdot B$$

The model says two things. First, $c$ results from the traffic's shape, not the application's:
heavy, continuous traffic reuses the container, and sparse, irregular traffic pushes $c$ toward
1 — the measurement's cold run is the counterpart of $c = 1$. Second, the only way to shrink $B$
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 $B$. 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 `/offer` route'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.
