DEV Community

Anton Brilliantov
Anton Brilliantov

Posted on

A Service Exists When It Is Declared

The card in a service catalog is a rendering of the manifest, not a table somebody remembers to update.


👋 Hi, I'm Anton - a software engineer working mostly in PHP/Symfony and Go, currently carving a live PHP monolith into Go services. This block is about what a service is to everyone else: whose it is, who calls it, what it promises. This first part takes the plainest of those questions - what a service is at all - and asks where that answer is allowed to live. Running notes are on my GitHub: github.com/brilliant-almazov.

This is how I do it right now, with the price attached - maybe you already do it better, maybe you see it differently.


The build that was green and wrong

Start with a defect that produces no error anywhere.

A Go binary gets its version stamped in at link time: -X <package>.Version=v1.4.0. If the symbol on the left of that flag does not exist - wrong package path, a variable that was renamed, a template that shipped with a dead target - the linker does not complain. It ignores the flag. The build is green, the tests are green, the image is pushed, and the binary keeps reporting the value it was compiled with: dev.

  ldflags  -X  <package>.Version=v1.4.0
                │
                ├── symbol exists      →  version = v1.4.0
                └── symbol missing     →  flag ignored, version = dev
                                            │
                                            ├──→  audit records
                                            └──→  trace attribute service.version
Enter fullscreen mode Exit fullscreen mode

A chain of four cards: a green build, then -X on a missing symbol marked in vermilion, then version equals dev, feeding two sinks - audit records and the trace attribute - with a strip below noting that nothing in the pipeline failed

So the answer to "which service is this and what version is it running" was wrong, in two places that people later use as evidence: the audit trail and the service.version attribute on every trace. Nothing fell over. There was no alert to acknowledge, no red build to bisect, no incident to name.

The fix in the repository is unglamorous: a build target that compiles both binaries with a test tag and then greps the binary for that tag with strings. Empty output means the symbol is wrong. It is a check that exists only because the failure mode is silence.

I keep coming back to this one because it is the exact shape of a different problem. A page about a service, filled in by hand, fails the same way. It does not throw when it stops matching reality. It just sits there, being wrong, in the one place people go to find out what is true.

The card nobody could fill in by hand

Here is the episode that made this concrete for me, on one service.

What we already had. The composition of that service - which daemons it runs, which databases and queues and schedules it needs, whether it owns migrations - was already declared in one place: the service manifest. That is not documentation next to the code; it is the thing the runtime and the deploy pipeline read. Two binaries out of one image, server and worker, exist because the manifest says they do.

What we did not have. "What this service has" also existed as prose - written separately, by a person, at a moment in time. Two descriptions of the same service, one executable and one narrated.

What that cost. Nothing, visibly. That is the entire problem. Prose drifts from the declaration exactly the way dev drifted from v1.4.0: no build notices, no test fails, and the divergence is discovered by whoever trusted the prose at the worst possible moment.

What changed. Both descriptions became machine artefacts of the repository instead: a catalog of environment variables and a snapshot of metrics, each generated from the code and the manifest, each committed, and each with a drift check wired into CI. The numbers in both are further down, in "What the card already has to show". The point of the episode is narrower: the second, hand-written description stopped existing, so it stopped being able to lie.

How this is usually done

The common shape is a service catalog or an internal developer portal: a separate system where a service is entered as a card - owner, links, dependencies, on-call, maturity level. Backstage is the best-known open-source implementation of that idea, and it works from a descriptor file in the repository. Lighter variants of the same instinct: a wiki page per service, or a spreadsheet of services and owners.

None of that is wrong, and I am not comparing tools. The property I care about is narrower and it applies to all three: is the card entered, or is it derived? An entered card is a second source of truth that has to be maintained by somebody who has no feedback when they don't.

One declaration

The declaration we already have looks like this:

service:
  name: <service>
  version: 0.1.0
  daemons:
    - name: server
      handlers: [grpc]
    - name: worker
      handlers: [scheduler]

infra:
  postgres:
    - name: main
      migrations: false
  tx:
    - name: main
      pool: main
  grpc:
    - name: api
      daemons: [server]
  scheduler:
    - name: main
      daemons: [worker]
  resources:
    - type: messaging
      name: main
Enter fullscreen mode Exit fullscreen mode

That file is not a description of the service. It is what raises it. The daemons in it are the processes that exist; the resources in it are the connections that get opened.

And because every resource is a typed, named thing, the name of every resource environment variable follows from the declaration by one convention - <TYPE>_<NAME>_<FIELD>:

  postgres: main    →  POSTGRES_MAIN_DSN
  grpc: api         →  GRPC_API_PORT
  messaging: main   →  MESSAGING_MAIN_RABBITMQ_URL
Enter fullscreen mode Exit fullscreen mode

The naming convention TYPE underscore NAME underscore FIELD with three worked examples - postgres main becoming POSTGRES_MAIN_DSN, grpc api becoming GRPC_API_PORT, messaging main becoming MESSAGING_MAIN_RABBITMQ_URL - and a note that resource variables are not covered by the generated snapshot

One thing that is deliberately not a decision made downstream: the set of binaries is the service owner's call. A new cmd/* is not something an executor introduces because a background job seemed to need its own process; new background work goes into a daemon that already exists. That is written as a prohibition in every control prompt, and it is the reason the daemon list in the manifest stays meaningful.

What lands on the card

Given that declaration, a service card has seven rows, and every one of them is read rather than typed:

  • daemons and their role - synchronous handles, background processing;
  • resources - databases, queues, schedules, migrations;
  • environment variables - the generated catalog, with drift caught by the build;
  • default metrics and the snapshot - what the service exposes without anyone adding it;
  • owner and team - a field of the declaration, changed by a commit;
  • incoming links - who actually calls this service;
  • declared commitments - what it promises, and by which metric.

Left, the service manifest with its daemons and resources; right, the seven rows of a service card; one wide arrow between them labelled rendered, not filled in, with the last three rows tagged as later parts of this block

The last three are the next three parts of this block, so I'll leave them as one line each here: ownership as a declared field rather than a chat agreement, the call map built from incoming traffic rather than from self-reported dependencies, and commitments that only get declared when a metric exists to measure them.

What the card already has to show

Two of the seven rows are already machine artefacts, and these are the numbers behind them.

The environment catalog: 59 variables. 45 of them are declared by the platform, 14 by the service's own configuration. A platform variable records which catalog owns it. A service variable records defined_in - the file and the line number where it is read. So "where does this setting come from" is answered by the artefact, not by grep archaeology.

The metrics snapshot: 67 records. Name, type, help text, labels, histogram buckets, source, and where it is declared. 59 records come from the platform, 6 from the service, plus a <dynamic> entry standing in for the dynamic-metric factory.

Two panels side by side - the environment catalog at 59 variables split into 45 from the platform and 14 from the service, and the metrics snapshot at 67 records with 59 from the platform, 6 from the service and one dynamic entry - each bar scaled to its own panel maximum

Neither file is written by a person. Both are generated, both are committed, and the header of each says so in the first line. Which is what makes the next part possible.

Why: drift is a build failure

A generated file that nobody verifies is just a stale file with better formatting. So the same generator runs in CI in --check mode, and it runs on any change to a .go file, to the manifest, to the modules file, or to the snapshot itself.

One detail in that check matters more than it looks: the generator is built at the platform version pinned in the service's own modules file. The version is resolved from the modules file, the platform is fetched at that ref, and the binary is compiled from it. Otherwise the check would be comparing this service's snapshot against somebody else's version of the catalog - and a green check would mean nothing.

The metrics snapshot has its own check, wired the same way. Drift there fails the build too.

Three declared-versus-real pairs - 59 environment variables, 67 metric records and 2 daemons - with a band across them labelled regenerate in check mode on any change to code, manifest, modules file or snapshot, and a closing strip reading any mismatch fails the build

Locally the same thing runs as a script, with one nicety: if the only change it produced is the generation timestamp, it reverts the file. Noise in the history is not evidence of anything.

The general formulation, the one I actually use as a test when someone proposes a new document: a drift check is how "declared" and "actual" are kept as the same object. If a fact about a service can be stated in two places, one of them has to be generated from the other, and the difference has to break something.

The rule in one line

A service exists when it is declared. A service that is not declared does not deploy - so it is not in the catalog, and it is not in the conversation either.

The useful consequence is a negative one: the card cannot be forgotten, because there is nothing on it to update by hand. "The wiki is out of date" stops being a sentence anybody can say, in the same way that a version stops being able to sit at dev once something greps the binary for the tag.

What it costs

This is not free, and three of the costs are structural rather than annoying.

The card is exactly as rich as the declaration. Anything not in the manifest is not on the page. If you want a field the manifest does not have, you don't get to write a paragraph - you change the format of the declaration and regenerate the snapshots of every service that uses it. That is a migration, not an edit.

The snapshot has a hole, and I would rather name it than paper over it. Resource variables do not appear in the environment catalog: the platform builds those names by concatenation at runtime, while the snapshot reads configuration declarations. So POSTGRES_MAIN_DSN follows from the manifest by convention, but it is not a row in the generated file. Those names are documented in a table instead, and the snapshot test checks the presence of the platform names and the absence of forbidden ones - for example, names belonging to a broker the service does not use. A known hole beats an unknown one; it does not stop being a hole.

A drift check fails builds over changes the author considered cosmetic. Renaming a config field, moving a line, adding a metric - all of those now produce a red build until the snapshot is regenerated. That is the mechanism working as designed, and it is still friction that lands on somebody at 6pm.

When not to do this

One service and one person: the declaration costs more than it returns. While there are few services and they change more slowly than a page gets edited, a hand-written page is fine - the drift never gets long enough to hurt.

And the honest caveat, because this block would be misleading without it. We do not have that catalog in a web interface. What exists is the manifest, the deploy that follows from it, the generated environment catalog, the metrics snapshot and the drift checks. A catalog page with a call map, owners and commitments is a direction that follows from a declaration we already have - not a running installation I am reporting on. A platform control panel exists as a separate internal tool; I have no verified facts about its screens, so nothing about them appears here.

Everything above is the argument for the direction, and the price list. It is not a screenshot.

The multiplier line

Regenerating both snapshots is mechanical work: run the generator, commit the diff, let the check pass. An automated executor does that faster than a person and never forgets the second file. What it cannot do is decide what belongs in a declaration - which facts about a service are load-bearing enough to be generated from, and which are just prose. That call is the whole exercise, and speed applied to the wrong declaration only produces a wrong page more reliably.


Services and commitments - Part 1. Next: ownership as a field of the declaration rather than a verbal agreement - who accepts requirements, who accepts the contract, who accepts the release, and what changes when that name is a commit instead of a chat message.

If you do this better, tell me which fields of your service card are generated and which are still typed. If you have been through this, what did your wiki page turn out to be wrong about, and how long had it been wrong? If you see it differently, say where a declared catalog costs more than it returns. How is it solved on your side, and what broke there?

Top comments (0)