---
title: 'Developer Portal and Service Catalog'
source: 'https://academia.sh/en/courses/platform-engineering/developer-portal-and-service-catalog'
course: 'Platform Engineering'
language: en
updated: '2026-09-13T05:08:29+00:00'
license: 'CC BY-SA 4.0'
objectives:
    - 'Builds a graph from catalog records and counts three gaps: unowned services, orphan dependencies, and services never registered at all'
    - 'Measures time to reach the correct oncall list in an incident, with and without the catalog, and shows how record aging affects that time'
    - 'Separates the cost of record maintenance and which fields can be derived from the run history, by the numbers'
---

# Developer Portal and Service Catalog

An ownership and dependency graph is built from twelve catalog records; unowned services, orphan dependencies, and unregistered services are counted. Time to reach the correct oncall list across five incidents is measured with and without the catalog, then the record's own cost is derived: how many fields are written by hand and how many records disagree with reality.

The previous lesson generated five services as real files; each one's definition has an owner line.
That line was correct the day it was written. But the three questions the person answering the page
asks at midnight when the billing calculator goes down are different: who is on call for this service,
what does this service depend on, and who is affected if this service goes down. The answer to the
third question is written in no service's own file, because nobody keeps the reverse edge in their own
repository.

A **service catalog** is a list that keeps the ownership and dependency records of running services in
one place; a **developer portal** is the surface that makes that list queryable. What this lesson
measures is not the portal's appearance, but the questions the catalog answers and **how correct the
answer is**.

The measurement runs on the **regional measurement network**: **fictional** software that collects
water-meter readings, validates them, and opens field work orders. The catalog records and the ground
truth are a **model** written in `node`.

**PC13.** The catalog holds twelve records. A record has five fields: name, owner team, oncall list,
dependency list, days since the last manual update.
**PC14.** The ground truth is derived from the last ninety days of run history — observed call edges
and the team that ran the pipeline — and stands as a separate structure in the model.
**PC15.** No person's name appears; what is reached is an **oncall list**.
**PC16.** Five incidents and every step's minutes are **written in advance**, with no randomness.
**PC17.** The owner field can be read from the service definition the scaffold generates; the oncall
list and the dependency list are written by hand.
**PC18.** An **unowned service** is a record whose owner field is empty or names a team that does not
exist. An **orphan dependency** is an edge pointing to a service that has no record in the catalog.

## From Records to a Graph

The catalog is a flat list; what is queryable is not the list itself but the graph built from it. Each
record gives a node, each dependency gives an edge; the ownership field ties the node to a team. The
graph's value comes from two places: the edge's **reverse direction** and the **gap** between record and
reality. Neither can be seen by looking at individual records one at a time.

```js
// catalog.mjs — service catalog records and the ground truth derived from the run history. Model, hand-written.
// record: [name, owner team, oncall list, dependencies, days since last manual update]
export const RECORD = [
  ["reading-collector", "meter-reading", "oncall-meter", ["measurement-store", "identity-edge"], 9],
  ["reading-resolver", "meter-reading", "oncall-meter", ["reading-collector"], 21],
  ["validation-flow", "validation", "oncall-validation", ["reading-collector", "rule-store"], 14],
  ["rule-store", "validation", "oncall-validation", [], 27],
  ["billing-calculator", "billing", "oncall-billing", ["validation-flow", "tariff-service"], 24],
  ["tariff-service", "billing", "oncall-billing", [], 210],
  ["invoice-writer", "billing", "oncall-billing", ["billing-calculator"], 33],
  ["field-order", "field", "oncall-field", ["validation-flow", "map-edge"], 150],
  ["map-edge", "", "", [], 260],
  ["report-compiler", "reporting", "oncall-reporting",
    ["measurement-store", "archive-store", "invoice-writer"], 120],
  ["dashboard-edge", "reporting", "oncall-reporting", ["report-compiler"], 19],
  ["measurement-store", "distributed-team", "oncall-legacy", [], 180]];

export const ACTUAL_DEPS = {                         // call edges observed over the last ninety days
  "reading-collector": ["measurement-store", "identity-edge"], "reading-resolver": ["reading-collector"],
  "validation-flow": ["reading-collector", "rule-store"], "rule-store": [],
  "billing-calculator": ["validation-flow", "tariff-service", "identity-edge"], "tariff-service": [],
  "invoice-writer": ["billing-calculator"], "field-order": ["validation-flow"],
  "map-edge": ["identity-edge"], "report-compiler": ["measurement-store", "invoice-writer"],
  "dashboard-edge": ["report-compiler"], "measurement-store": [], "identity-edge": [] };

export const ACTUAL_OWNER = {                        // team that ran the pipeline over the last ninety days
  "reading-collector": "meter-reading", "reading-resolver": "meter-reading",
  "validation-flow": "validation", "rule-store": "validation", "billing-calculator": "billing",
  "tariff-service": "reporting", "invoice-writer": "billing", "field-order": "field",
  "map-edge": "field", "report-compiler": "reporting", "dashboard-edge": "reporting",
  "measurement-store": "meter-reading", "identity-edge": "validation" };

export const MULTI_TEAM = ["measurement-store", "map-edge"];   // service touched by more than one team
export const TEAM = ["meter-reading", "validation", "billing", "field", "reporting"];
```

```js
// graph.mjs — an ownership and dependency graph is built from catalog records and compared to reality.
import { RECORD, ACTUAL_DEPS, ACTUAL_OWNER, TEAM } from "./catalog.mjs";

const registered = new Set(RECORD.map((k) => k[0]));
const reverse = {};                                  // who depends on me: the catalog builds the reverse edge
for (const [name, , , deps] of RECORD) for (const b of deps) (reverse[b] ??= []).push(name);

const unregistered = Object.keys(ACTUAL_DEPS).filter((s) => !registered.has(s));
const unowned = RECORD.filter(([, s]) => !s || !TEAM.includes(s));
const orphaned = RECORD.flatMap(([name, , , deps]) => deps.filter((b) => !registered.has(b)).map((b) => [name, b]));
console.log(`catalog ${RECORD.length} records, ${RECORD.reduce((a, k) => a + k[3].length, 0)} dependency` +
  ` edges, reverse edges derivable for ${Object.keys(reverse).length} services`);
console.log(`services actually running but never registered ${unregistered.length}: ${unregistered.join(", ")}`);
console.log(`unowned services ${unowned.length}: ` +
  unowned.map(([a, s]) => `${a} (${s || "empty field"})`).join(", "));
console.log(`orphan dependencies ${orphaned.length}: ` + orphaned.map(([a, b]) =>
  `${a} -> ${b} [${b in ACTUAL_DEPS ? "service exists, no record" : "no service, stale record"}]`).join("; "));
const affectedByUnregistered = Object.entries(ACTUAL_DEPS)
  .filter(([, b]) => b.some((x) => unregistered.includes(x))).length;
console.log(`${affectedByUnregistered} services actually connect to an unregistered service;` +
  ` the portal's dependency answer is incomplete for those ${affectedByUnregistered} services`);

let mismatchedFields = 0, recordsMismatched = 0, depMismatch = 0, ownerMismatch = 0;   // record aging: record versus reality
const age = { young: [0, 0], old: [0, 0] };
for (const [name, owner, , deps, day] of RECORD) {
  const g = ACTUAL_DEPS[name] ?? [];
  const missing = g.filter((b) => !deps.includes(b)).length;
  const extra = deps.filter((b) => !g.includes(b)).length;
  const ownerDiff = ACTUAL_OWNER[name] !== owner ? 1 : 0;
  const diff = missing + extra + ownerDiff;
  mismatchedFields += diff; depMismatch += missing + extra; ownerMismatch += ownerDiff;
  recordsMismatched += diff > 0 ? 1 : 0;
  const bucket = day <= 30 ? "young" : "old";
  age[bucket][0]++; age[bucket][1] += diff > 0 ? 1 : 0;
}
const manual = RECORD.length * 2, generated = RECORD.length * 2;     // oncall and dependency are written by hand
console.log(`\nrecord fields ${manual + generated}: ${generated} are read from the service` +
  ` definition the scaffold generates, ${manual} (oncall list, dependencies) are written by hand`);
console.log(`records disagreeing with reality ${recordsMismatched}/${RECORD.length}, mismatched fields ${mismatchedFields}`);
console.log(`record age <= 30 days: ${age.young[1]}/${age.young[0]} mismatched` +
  `   > 30 days: ${age.old[1]}/${age.old[0]} mismatched`);
console.log(`source of the mismatch: dependencies ${depMismatch}, ownership ${ownerMismatch};` +
  ` both are derivable from the run history, the oncall list is not`);

const affected = (s) => {                            // walk over the reverse edge
  const stack = [s], result = new Set();
  while (stack.length) for (const u of reverse[stack.pop()] ?? [])
    if (!result.has(u)) { result.add(u); stack.push(u); }
  return [...result];
};
console.log(`\n"who is affected if billing-calculator goes down" — with the catalog ${affected("billing-calculator")
  .length} services (${affected("billing-calculator").join(", ")}), 1 graph walk`);
console.log(`the same question without the catalog: ${RECORD.length} services' dependency lists are scanned by hand,` +
  ` the reverse edge is not written in any file`);
```

```
catalog 12 records, 14 dependency edges, reverse edges derivable for 11 services
services actually running but never registered 1: identity-edge
unowned services 2: map-edge (empty field), measurement-store (distributed-team)
orphan dependencies 2: reading-collector -> identity-edge [service exists, no record]; report-compiler -> archive-store [no service, stale record]
3 services actually connect to an unregistered service; the portal's dependency answer is incomplete for those 3 services

record fields 48: 24 are read from the service definition the scaffold generates, 24 (oncall list, dependencies) are written by hand
records disagreeing with reality 6/12, mismatched fields 7
record age <= 30 days: 1/6 mismatched   > 30 days: 5/6 mismatched
source of the mismatch: dependencies 4, ownership 3; both are derivable from the run history, the oncall list is not

"who is affected if billing-calculator goes down" — with the catalog 3 services (invoice-writer, report-compiler, dashboard-edge), 1 graph walk
the same question without the catalog: 12 services' dependency lists are scanned by hand, the reverse edge is not written in any file
```

Building the graph shows three gaps at once. **There are two unowned services**, and they are unowned
in different ways: map-edge's owner field is empty, while measurement-store's owner is a team that no
longer exists. The second is more dangerous, because the record looks filled in and no empty-field scan
catches it. **There are also two orphan dependencies**, and their causes are opposite: identity-edge is
a service that genuinely runs but has no record at all in the catalog, while archive-store is a service
that sits in a record but does not exist in reality. One is the record's absence; the other is the
record's staleness.

The reverse edge is the answer only the catalog can give on its own. When the billing calculator goes
down, the three affected services come out with a single graph walk; answering the same question
without the catalog requires scanning all twelve services' dependency lists one by one, because no
service knows who depends on it.

## The Correct Oncall List in an Incident

```js
// incident.mjs — time to reach the correct oncall list across five incidents: with and without the catalog.
import { RECORD, ACTUAL_OWNER, MULTI_TEAM } from "./catalog.mjs";

const STEP = { repo: 6, firstMessage: 8, teamChannel: 15, findOncall: 12, readCatalog: 2, call: 3,
  wrongList: 10, review: 4 };                       // minutes, written in advance; no randomness
const INCIDENT = ["billing-calculator", "map-edge", "tariff-service", "field-order", "measurement-store"];

const withoutCatalog = (s) => STEP.repo + STEP.firstMessage + STEP.findOncall +
  (MULTI_TEAM.includes(s) ? STEP.teamChannel : 0);  // repo history, message, team channel if needed
const withCatalog = (s) => {
  const rec = RECORD.find(([a]) => a === s);
  if (!rec || !rec[2]) return [STEP.readCatalog + withoutCatalog(s), rec ? "oncall field empty" : "no record"];
  if (ACTUAL_OWNER[s] !== rec[1])
    return [STEP.readCatalog + STEP.wrongList + withoutCatalog(s), "record pointed to the wrong list"];
  return [STEP.readCatalog + STEP.call, "record up to date"];
};

console.log("incident             without cat  with cat  diff  status");
let a = 0, b = 0, faster = 0;
for (const s of INCIDENT) {
  const x = withoutCatalog(s), [y, note] = withCatalog(s);
  a += x; b += y; faster += y < x ? 1 : 0;
  console.log(`${s.padEnd(20)}${String(x).padStart(9)} min${String(y).padStart(7)} min` +
    `${String(y - x).padStart(6)}  ${note}`);
}
const upToDate = INCIDENT.length * (STEP.readCatalog + STEP.call);
console.log(`total ${a} min -> ${b} min   average ${(a / INCIDENT.length).toFixed(1)} min -> ` +
  `${(b / INCIDENT.length).toFixed(1)} min   incidents shortened ${faster}/${INCIDENT.length}`);
console.log(`if every record were up to date: ${upToDate} min, average ` +
  `${(upToDate / INCIDENT.length).toFixed(1)} min; current gain ${a - b} min, possible gain ${a - upToDate} min`);

const maintenance = RECORD.length * STEP.review;    // monthly review: four minutes per record
const perIncident = (a - upToDate) / INCIDENT.length;
console.log(`\nmonthly review ${maintenance} min (${RECORD.length} records x ${STEP.review} min);` +
  ` an up-to-date record's gain per incident ${perIncident} min`);
console.log(`threshold: review pays for itself after ${Math.ceil(maintenance / perIncident)} incidents a month;` +
  ` it does not pay off at ${Math.ceil(maintenance / perIncident) - 1} incident`);
```

```
incident             without cat  with cat  diff  status
billing-calculator         26 min      5 min   -21  record up to date
map-edge                   41 min     43 min     2  oncall field empty
tariff-service             26 min     38 min    12  record pointed to the wrong list
field-order                26 min      5 min   -21  record up to date
measurement-store          41 min     53 min    12  record pointed to the wrong list
total 160 min -> 144 min   average 32.0 min -> 28.8 min   incidents shortened 2/5
if every record were up to date: 25 min, average 5.0 min; current gain 16 min, possible gain 135 min

monthly review 48 min (12 records x 4 min); an up-to-date record's gain per incident 27 min
threshold: review pays for itself after 2 incidents a month; it does not pay off at 1 incident
```

Where the catalog pays off is clear: for the two services whose records are up to date, the time to
reach the right list drops from twenty-six minutes to five. Where it does not pay off is more
instructive. In three of the five incidents, the catalog **lengthened** the time. The record with an
empty oncall field adds two minutes of reading cost and says nothing; the two records with the wrong
owner add twelve minutes, because the person paging first writes to the wrong oncall list, waits for a
reply, and only then falls back to the no-catalog path. **A wrong record is more expensive than no
record at all** — the absence of a record misleads nobody.

Overall, the average drops from 32 minutes to 28.8 minutes; the measured gain is sixteen minutes. If
every record agreed with reality, the same five incidents would finish in twenty-five minutes total and
the gain would be a hundred thirty-five minutes. Only **twelve percent** of the value the catalog offers
is actually captured, and what is missing is not the portal — it is the record's accuracy.

## The Record's Own Cost

The record's cost comes down to two numbers. Half of the catalog's forty-eight fields are read from the
service definition the scaffold generates and cost nothing; the other half — the oncall list and the
dependency list — are written by hand. The hand-written fields age: six of twelve records disagree with
reality, and seven fields are wrong. The aging tracks with age itself; one of the six records younger
than thirty days disagrees with reality, against five of the six records older than thirty days.

The math on maintenance follows from this. Monthly review is forty-eight minutes at four minutes per
record; an up-to-date record's gain per incident is twenty-seven minutes. **The threshold is two
incidents a month**: in a network with only one incident a month, review does not pay for itself; it
pays off after two. In a place with as few services as the measurement network, this threshold sits
close by, and that is exactly why catalog maintenance looks good on paper and does not happen in
practice.

The layer the catalog hides is this: every team no longer has to read other teams' repositories, channel
history, and deployment records — it reads a single record instead. The leak sits at exactly this point
too — the record behaves like a single source of truth but is not one, and when it leaks it gives no
sign of doing so. Those left outside are countable: three services actually connect to the one
unregistered service, meaning the portal's dependency answer is incomplete for those three services. The
teams behind those three services look at the catalog and see a false sense of completeness; a team
working without the catalog at least knows it is looking at an incomplete picture.

There is a third path, and it changes the number at its root. Four of the seven mismatches come from
dependencies, three from ownership; dependency edges can be derived from run records, and ownership from
pipeline runs. If both are derived, the mismatch drops to zero and the hand-written fields drop from
twenty-four to twelve. The one field left is the **oncall list**, and it cannot be derived from any run
history, because oncall is not an observation — it is a decision.

## Summary

- The graph built from twelve records showed three gaps: 2 unowned services, 2 orphan dependencies, and
  1 service that actually runs but has no record at all in the catalog.
- The two cases of unownedness and the two orphan dependencies were different in kind: one an empty
  field, the other a team that no longer exists; one the record's absence, the other the record's
  staleness.
- The reverse edge was the answer only the catalog could give: when the billing calculator went down,
  the 3 affected services came out with a single walk, while the same question without the catalog
  would require scanning 12 dependency lists.
- Across five incidents, the average time to reach the right list dropped from 32 minutes to 28.8
  minutes, but in 3 of the 5 incidents the catalog lengthened the time; a wrong record added 12 minutes,
  an empty field added 2 minutes.
- If the records had been up to date, the same five incidents would have finished in 25 minutes: the
  measured gain was 16 minutes, the possible gain was 135 minutes.
- 24 of 48 record fields are written by hand and 6 of 12 records disagree with reality; all 7 mismatches
  can be closed by deriving them from run history — the one field that cannot be closed is the oncall
  list.

## Next Step

The catalog says who is responsible, but it sets no rule anyone is required to follow. For the
measurement network's three services carrying personal data, retention period, log masking, and signed
packages are mandatory; today these rules are written in a document, and every team is expected to read
and apply them. The next lesson compares the same rule set in two arrangements: publishing it as a
document and embedding it in the pipeline. What is measured is the compliance rate, the number of
violations, and the time teams spend — and the real number is how many teams comply without ever seeing
the rule.
