---
title: 'Gatekeeper Pattern'
source: 'https://academia.sh/en/courses/traffic-layer/gatekeeper-pattern'
course: 'The Traffic Layer'
language: en
updated: '2026-08-23T07:01:35+00:00'
license: 'CC BY-SA 4.0'
---

# Gatekeeper Pattern

Isolating validation into a separate layer: separating checks that can be given at the gate from those that must stay at the application, measuring the rate and bytes at which invalid requests reach the application, reducing the number of units touching untrusted input, and applying the result to K01's peak edge rate.

Across five lessons, every piece of work placed at the edge was written down with a gain and a
cost, but all of them shared one assumption: a request reaching the edge is a request worth
processing. Part of the peak edge rate, though, is made of requests that will never be
answered — unrecognized paths, bodies with a required field missing, oversized bodies, malformed
tokens.

The **gatekeeper** is a layout that isolates validation into a separate layer: only this layer
sees untrusted input, and the application receives only a validated, normalized request. What
separates it from the gateway is the scope of responsibility; the gateway routes, aggregates, and
does offloaded work, while the gatekeeper asks a single question: should this request reach the
application. This lesson measures how many requests get "no" as the answer to that question.

## Validation the Gate Can Give

The third lesson's portability rule applies here too: a check that looks only at the request
itself can be given at the gate, one that looks at domain data cannot. The model separates this
into two check sets and runs it over a deterministic set of sixty requests. The set is
constructed: each of six flaw types occurs five times, because what is meant to be measured is not
a realistic error rate but **which of the flaws can be held at the gate**.

```js
// gate/request.mjs — deterministic request set and two validation layers; in-process model
export const SHIPMENT = { "TR-4821": { owner: "M7" }, "TR-9007": { owner: "M3" } };
const BASE = { path: "/tracking", trackingNo: "TR-4821", token: "b.M7.sig", bodyBytes: 120 };

// One flaw in every twelfth request; the set is 60 requests, six flaw types each occur five times.
export function set(n = 60) {
  const FLAW = {
    6: { path: "/unknown" },               // unrecognized path
    7: { trackingNo: undefined },          // schema: required field missing
    8: { bodyBytes: 9000 },                // body size exceeded
    9: { token: "broken" },                // malformed token
    10: { trackingNo: "TR-0001" },         // unregistered shipment
    11: { token: "b.M3.sig" },             // someone else's shipment
  };
  return Array.from({ length: n }, (_, i) => ({ ...BASE, ...(FLAW[i % 12] ?? {}) }));
}

// Gatekeeper: looks only at the request itself (path, schema, size, token shape).
export const GATE = {
  path: (r) => r.path === "/tracking",
  schema: (r) => typeof r.trackingNo === "string" && /^TR-\d{4}$/.test(r.trackingNo),
  size: (r) => r.bodyBytes <= 2048,
  token: (r) => /^b\.M\d+\.sig$/.test(r.token),
};

// Application: two checks that look at domain data; cannot be given at the gate.
export const APPLICATION = {
  exists: (r) => SHIPMENT[r.trackingNo] !== undefined,
  authorization: (r) => SHIPMENT[r.trackingNo]?.owner === r.token.split(".")[1],
};

export const passes = (checks, r) => Object.values(checks).every((d) => d(r));
```

Token validation was built in the Authentication and Authorization course; the `token` check here
does not verify the signature, it only checks the shape — that is what can be given at the gate.

```js
// gate/measure.mjs — measures the layout with and without a gatekeeper, then ties it to the K01 calculation
import { set, GATE, APPLICATION, passes } from "./request.mjs";

const requests = set();
const invalid = requests.filter((r) => !(passes(GATE, r) && passes(APPLICATION, r)));
const heldAtGate = invalid.filter((r) => !passes(GATE, r));

const bytes = (list) => list.reduce((t, r) => t + r.bodyBytes, 0);
const reaching = (hasGate) => (hasGate ? requests.filter((r) => passes(GATE, r)) : requests);

const FIELD = ["requests reaching the application", "invalid requests reaching the application",
  "body bytes reaching the application", "units touching untrusted input",
  "application's externally exposed address", "units carrying validation code"];
const measure = (hasGate) => {
  const u = reaching(hasGate);
  return {
    "requests reaching the application": `${u.length}/${requests.length}`,
    "invalid requests reaching the application": u.filter((r) => !passes(APPLICATION, r) || !passes(GATE, r)).length,
    "body bytes reaching the application": bytes(u),
    "units touching untrusted input": hasGate ? 1 : 3,
    "application's externally exposed address": hasGate ? 0 : 1,
    "units carrying validation code": hasGate ? 1 + 3 : 3,
  };
};

const measurement = [["no gate", measure(false)], ["gatekeeper", measure(true)]];
console.log(`${"measure".padEnd(42)}${measurement.map(([a]) => a.padStart(14)).join("")}`);
for (const a of FIELD) console.log(`${a.padEnd(42)}${measurement.map(([, o]) => String(o[a]).padStart(14)).join("")}`);

const ratio = heldAtGate.length / invalid.length;
console.log(`\ninvalid requests = ${invalid.length}/${requests.length}, ` +
  `held at gate = ${heldAtGate.length}, not held at gate = ${invalid.length - heldAtGate.length}`);
console.log(`ratio of invalid held at the gate = ${ratio.toFixed(3)} (${Object.keys(GATE).length} gate ` +
  `checks, ${Object.keys(APPLICATION).length} application checks)`);

// K01 Back-of-the-Envelope Estimation: peak edge 513.89, peak read 416.67 requests/s (computed-value class)
const EDGE_PEAK = 513.89, READ_PEAK = 416.67, BEHIND_CACHE = 41.67;
console.log(`\n${"T3".padEnd(6)}${"requests/s behind gate".padStart(25)}${"reads/s".padStart(11)}` +
  `${"behind cache/s".padStart(17)}`);
for (const T3 of [0.08, 0.16]) {                       // T3: the invalid share of edge requests
  const remaining = 1 - T3 * ratio;
  console.log(`${T3.toFixed(2).padEnd(6)}${(EDGE_PEAK * remaining).toFixed(2).padStart(25)}` +
    `${(READ_PEAK * remaining).toFixed(2).padStart(11)}${(BEHIND_CACHE * remaining).toFixed(2).padStart(17)}`);
}
console.log(`no gate (even for T3 = 0.08) -> ${EDGE_PEAK.toFixed(2)} requests/s, ` +
  `${READ_PEAK.toFixed(2)} reads/s, ${BEHIND_CACHE.toFixed(2)} behind cache/s`);
```

```
measure                                          no gate    gatekeeper
requests reaching the application                  60/60         40/60
invalid requests reaching the application             30            10
body bytes reaching the application                51600          4800
units touching untrusted input                         3             1
application's externally exposed address               1             0
units carrying validation code                         3             4

invalid requests = 30/60, held at gate = 20, not held at gate = 10
ratio of invalid held at the gate = 0.667 (4 gate checks, 2 application checks)

T3       requests/s behind gate    reads/s   behind cache/s
0.08                     486.48     394.45            39.45
0.16                     459.08     372.23            37.23
no gate (even for T3 = 0.08) -> 513.89 requests/s, 416.67 reads/s, 41.67 behind cache/s
```

## Reading the Numbers

Twenty of the thirty invalid requests are held at the gate, ten are not: the ratio is 0.667. The
ones not held are requests asking about an unregistered shipment and requests for someone else's
shipment; neither can be told apart without reading the shipment record. The gatekeeper therefore
does not **remove** validation, it splits it in two, and `units carrying validation code` rises
from 3 to 4. The pattern's name can be misleading: a gate does not stand in for the application's
own validation.

The byte row gives the biggest difference: body bytes reaching the application fall from 51,600 to
4,800 bytes, 0.093 times. Nearly all of the drop comes from the five oversized requests. A request
that never reaches the application is not just a saved operation, it is an unparsed body.

The last two rows carry the pattern's real purpose. `units touching untrusted input` falls from 3
to 1, `application's externally exposed address` from 1 to 0. Code that parses the raw body,
checks its shape, and rejects what exceeds the limit gathers into a single unit; application units
see only a request that has already passed the gate and whose shape is known. This is the same
trust-boundary question from the third lesson, and it carries the same cost: if the gate can be
bypassed, the 0/3 protection assumption collapses.

## Back to the Calculation

The model's invalid ratio (30/60) is not realistic and is not presented as such. The real ratio is
**this course's assumption** (T3 = 0.08: eight percent of edge requests are formally invalid). The
reasoning is that stale client versions, expired tokens, and browser retries produce a steady
baseline; it is not added to K01's table, because K01 only counted answered requests. The 0.667
coming from the model, by contrast, is not an assumption but a ratio that follows from the check
lists.

Combined, the two move three of K01's numbers at once. The peak edge rate, 513.89 requests/s,
passes to 486.48 requests/s behind the gate; peak reads fall from 416.67 to 394.45, `reads behind
cache/s` from 41.67 to 39.45. Without a gate, none of the three change. When T3 doubles — sixteen
percent invalid — the numbers become 459.08, 372.23, and 37.23: the gain grows in direct
proportion to T3, because the share the gate holds stays fixed.

The size of the numbers is also worth reading: when two-thirds of the eight-percent-invalid
requests are held, the edge rate falls to only 0.947 times. A gatekeeper is not a scaling tool;
the real gain it produces sits in the table above — units touching untrusted input and the
application's externally exposed address.

## Summary

- The gatekeeper isolates validation into a separate layer: only that layer sees untrusted input,
  the application receives a validated request.
- A check that can be given at the gate is one that looks at the request itself; in the
  sixty-request set, twenty of thirty invalid requests were held at the gate, ten were not (ratio
  0.667).
- Validation is not removed, it is split: units carrying validation code rise from 3 to 4.
- Requests reaching the application fall from 60/60 to 40/60, the body from 51,600 to 4,800 bytes
  (0.093 times); nearly all of the drop comes from oversized requests.
- The pattern's real gain is isolation: units touching untrusted input fall from 3 to 1, the
  application's externally exposed address from 1 to 0.
- Back to K01 (with the T3 = 0.08 assumption): the 513.89 requests/s at the edge falls to 486.48
  behind the gate, peak reads from 416.67 to 394.45, reads behind cache from 41.67 to 39.45; at
  T3 = 0.16, 459.08, 372.23, and 37.23 respectively.

## Course Wrap-Up

The course designed the request's path from the user to the application stop by stop, and at
every stop it asked the same three questions: what decision does this stop make, which number in
the introductory course's calculation does it move, and what number grows in exchange. The
measures the lessons left behind are gathered in the table below.

| Lesson | The stop's decision | K01 number it moves | What grows in exchange |
|---|---|---|---|
| Domain Name System Design | routing policy | 1.171 distance units for latency-based (geolocation 1.710, weighted 2.144) | failover window 39–905 s, 0.50%–14.28% of the monthly outage budget |
| Content Delivery Networks | cache location | reads reaching the application 416.67 → 41.76 requests/s | 480-byte body takes 649 bytes on the wire (1.352); requests reaching the store stayed at 138.89 |
| Push and Pull Based Distribution | content propagation model | pull reaches the origin at 42.34 requests/s — K01's 41.67 preserved | push peak 1,166.64 pushes/s, 4.48 Mbit/s and 6.91 GB held at the edges |
| Static Content Hosting | content path separation | application process 3,291.67 → 513.89 requests/s, 241.60 → 1.60 Mbit/s | 2,777.78 requests/s and 240.00 Mbit/s moved to the content process |
| Role of the Load Balancer | distribution + health check | 513.89 requests/s needs at least 3 replicas; dropped requests per failure 102,778 → 34.26 | the check's own load 15 requests/s, 2.9% of the edge load |
| Layer 4 Balancing | connection-level distribution | decision 51.39 decisions/s (6 for 63 requests) | request skew 4.00; holding the target needs 5 replicas instead of 3 |
| Layer 7 Balancing | content-aware routing | request skew 4.00 → 1.07, streams in separate pools | decision 51.39 → 513.89 decisions/s; 4 replicas instead of 3 |
| Balancing Algorithms | distribution algorithm | displaced key 74.85% → 26.48% | warmup store-reaching 138.89 → 238.18 requests/s (419.57 with modulo hashing) |
| Load Balancer and Reverse Proxy | role separation | when the replica-list process dropped, content path answered 6/6 | hop 2 → 3; single-copy-hop monthly outage 215.6 → 258.6 min |
| Session Stickiness | state bound to a replica | replicas needed for the 0.50 utilization target 3 → 4 | imbalance 1.000 → 1.530 (sticky-modulo), 1.411 (sticky-consistent); when a replica drops, sticky-consistent loses 30/100 sessions and 21.78% of requests |
| API Gateway | edge responsibility placement | repeated lines 135 → 45, units per responsibility 3 → 1 | 513.89 requests/s at the edge collects into one component; hop-requests/s 513.89 → 1027.78; flows answered when the gateway stops 0/3 |
| Gateway Routing and Aggregation | multiple calls in one request | edge requests 833.34 → 416.67 requests/s (×0.50) | body 402 → 741 bytes; egress 1.60 → 2.47 Mbit/s |
| Gateway Offloading | check moved to the edge | lines 171 → 107; 4/6 checks at the edge, running at 513.89 requests/s | unprotected service 0/3 → 3/3, or validation 513.89 → 1027.78 times/s |
| Backends for Frontends | client-specific body | unused fields 37 → 0; 583.34 → 416.67 requests/s, 3.46 → 1.93 Mbit/s | edge units 1 → 3; batch endpoints the service must offer 0 → 2 |
| Ambassador and Sidecar Patterns | common work handed to a separate process | contracts the service sees 1 (external 2); units that can use it 4/6 → 6/6 | total processes 6 → 12; hop-requests/s 1027.78 → 1541.67 |
| Gatekeeper Pattern | validation isolated | behind the gate 513.89 → 486.48 requests/s; reads 416.67 → 394.45 | units carrying validation code 3 → 4; invalid not held at the gate 10/30 |

The table's rule deserves to be named: **every stop was tied back to a calculation that comes out
of the introductory course's assumption table.** A decision made at the edge is measured by one of
three numbers — requests reaching the application, bytes crossing the boundary, and the
answered-request ratio when a unit drops. An edge decision that ties to none of these three is not
a defensible decision; the right column never being empty in any row is the other half of that
rule. The three assumptions the course added (the tracking page also asking for the fee, the
client mix, the invalid-request ratio) were not mixed into K01's table; they were written down
under their own names, reasoning, and sensitivities.

The question the course leaves behind sits one layer forward. The request has now reached the
application: its name was resolved, it passed through the edge cache, it landed on a replica, it
passed through the gateway and the gate. But the application's own internal service interaction
was not designed — whether the services are stateless, how they find each other's addresses, how
long they wait and how many times they retry when a call does not answer, and whether a workflow
spread across several services is built with choreography or orchestration. This course's gateway,
while calling two services in parallel and merging their responses, silently assumed an answer to
every one of these questions. The next course, **The Application Layer and Service Interaction**,
opens those assumptions.
