Platform Engineering: Consumers and Services

A platform team receives a message from a product team: “We need a Kubernetes cluster.” The request names a mechanism. What the team is trying to do is probably different: run an HTTP service, give it a stable address, see its logs, and get paged when it breaks. If the platform team delivers a cluster, the requester has received a large set of new responsibilities (upgrades, networking, capacity, access control) and still has to solve the original problem. If the platform team delivers “a place to run an HTTP service with these guarantees,” the requester may never need to know what is underneath.

That gap between a named mechanism and a consumer’s task is the subject of this article. Platform engineering is often described by its tools, but the part that decides whether it works is how the platform is defined from the outside: who consumes it, what it promises, who answers when the promise fails, and how a consumer obtains it without waiting for a person. The scenarios in this article are illustrative and hypothetical. The code is original, written for this article, and uses only the Python standard library. It models a tiny platform capability so that every idea has something concrete to run against.

This is the first article in the Platform Engineering series. It assumes general computer science and programming knowledge: you can read Python, you know what an HTTP request is, and you have deployed or at least run a service. It assumes no prior platform engineering background. The later articles in the series go into the Linux, networking, cloud and delivery layers that implement a platform. Here the question is the one that comes before any implementation: what exactly is being built, for whom, and how will anyone know it works?

What a platform is, and what it is for

The CNCF Platforms White Paper describes a platform as an integrated collection of capabilities, such as databases, delivery pipelines and observability, presented to internal users through consistent interfaces so that they can deliver their own products with less complexity. It names the users as application developers, data scientists and other information workers inside the organization. The paper lists web portals, APIs and command-line tools, golden-path templates, build and deployment automation, observability, data services and identity and secrets handling among the capabilities a platform may include. It also emphasizes that the platform is designed around its users’ needs, offers self-service, documents its offerings, and treats the platform as a product.

Three terms recur and are easy to blur. Platform engineering is the practice of building and running such a platform. An internal developer platform is what the platform team ships: integrated capabilities such as environments, pipelines and credentials, which consumers reach through interfaces, templates and integrations. Running it as a product is an operating stance, in which the team observes what consumers are actually trying to do and measures whether changes help. These are three different claims. A team can build an interface without running it as a product, and a team can use product vocabulary while shipping an unchanging script collection.

The reason to work from the consumer’s side is cognitive load. Team Topologies argues that a team can only hold so much complexity, and that a platform exists to take some of that complexity off the teams that build customer-facing software. Team Topologies frames the preferred interaction as consuming a service with minimal interaction and clear boundaries. That framing has a testable implication: if consuming the platform requires a conversation each time, the boundary is not yet clear.

Platform consumers: who they are and what they need

A consumer is a person or team that uses a platform capability to get their own work done. The definition sounds trivial, but several different people are often lumped together under the word “user,” and they need different things from the platform. Separating them early prevents a common error: optimizing for the people who talk to the platform team most often rather than for the people who consume the platform most.

RoleWhat they do with the platformExample question they ask
Direct consumerRequests and uses a capability to run their own workHow do I get a place to run this service by this afternoon?
Indirect beneficiaryDepends on what direct consumers build, never touches the platformWill the checkout page stay up during a platform upgrade?
Platform operatorRuns the capability behind the interfaceWhich consumers are affected if I change this runtime?
Governance stakeholderSets requirements the capability must meet (security, cost, data rules)Can I show that every deployed service has a named owner?

Only the first row is a consumer in the sense used here. The others still matter: the indirect beneficiary is the reason reliability is a requirement, and the governance stakeholder is the reason some requests need review. But the contract described later in this article is written for direct consumers, and a platform that is designed mainly to satisfy stakeholders or operators tends to be painful for the people who have to use it.

From stated requests to tasks

Consumers state needs as mechanisms (“a cluster,” “a pipeline,” “a bucket”) because mechanisms are what they know from outside. A useful translation is to rewrite each request as a task with a completion condition. The task is what the consumer is trying to do; the completion condition is how they would know it is done. The following is a hypothetical set of requests rewritten in that form.

Stated request (hypothetical)Underlying taskCompletion condition
“We need a Kubernetes cluster”Run a new HTTP service with a stable addressA request to the service address returns the application’s response and the service’s health is visible to its owner
“Give us a bigger CI quota”Get feedback on a change in a tolerable timeA representative change reports a result within the time the team considers acceptable
“Open the firewall to the database”Let one service read one data storeThe service can read, and only that service can
“We need Terraform access”Create a queue for a new featureThe queue exists, is owned, and is cleaned up if the feature is dropped

The third and fourth rows show why the translation matters. “Open the firewall” and “Terraform access” are requests for a means that may well be the wrong one: broad access to change infrastructure is a large grant when the task needs one queue. The completion conditions also contain two properties that are easy to omit when a request is fulfilled as stated, namely ownership and cleanup. Those are the properties that decide what the platform will cost to operate after delivery.

How do you obtain real tasks rather than invented ones? Sources that exist before any platform does include the request queue or chat history of whatever team currently does this work, the steps in an onboarding document for a new service, and direct observation of a consumer performing the task while the observer stays quiet. Each source has a bias. A request queue shows the tasks that were painful enough to ask about and hides the ones people worked around. An onboarding document shows the intended path and hides where people deviate. Observation sees both but covers few people. Using at least two of these and comparing them is a reasonable minimum, and any task list built this way is a hypothesis about consumer needs rather than a measurement of them.

Capability boundaries: what the platform promises and what it does not

A capability is an ability to perform a defined kind of work, and it is demonstrated by an observable action. “We have a Kubernetes cluster” describes an asset. “A team can deploy an HTTP service and see it respond within one working day without help” describes a capability that can be checked. For a platform, a capability boundary is the line that says which parts of the work the platform performs and guarantees, and which parts remain with the consumer.

Drawing that line is the central design decision, and it is a trade-off, not a technical fact. A boundary drawn low (the platform provides compute and the consumer assembles everything above it) leaves the consumer with the integration work and the cognitive load the platform was supposed to remove. A boundary drawn high (the platform runs the whole application lifecycle from a single configuration file) is easy to consume until a consumer needs something the abstraction does not model, at which point they must leave the platform entirely. The thinnest viable platform idea from Team Topologies is a scoping rule for this decision: provide just enough capability to help consumers do their work, and extend from evidence rather than from anticipation. DORA’s platform engineering guidance warns against the opposite approach, building a comprehensive platform before testing it with users, because needs change during a long build.

A boundary is a division of responsibility

The most practical way to state a boundary is as two lists: what the consumer owns and what the platform owns. The contract used later in this article does exactly that in two short arrays.

ConcernConsumer ownsPlatform owns
Application code and its bugsYesNo
Runtime the code runs on (patching, capacity of the hosting layer)NoYes
Health probe that decides whether the service is upDeclares what healthy meansRuns the probe and reports the result
Catalog record (name, owner, lifecycle)Supplies the owner and keeps it currentCreates and serves the record
Data the service storesYes (including backup decisions)No, unless a separate data capability says otherwise

Any row can be moved, but a row that is not written down will be decided during an incident. The row about data is a typical example. If the hosting capability says nothing about stored data, a consumer may assume it is backed up, and the platform team may assume it is the consumer’s concern. The boundary table forces that conversation to happen before delivery.

A boundary also needs an exit. A consumer whose task falls outside the supported path needs to know how to proceed: request an extension, use an alternative with stated responsibilities, or be told no. A golden path packages the supported route for a common task, and its value depends on that route being paired with a documented process for needs it does not serve. Without such a process the choice available to the consumer is to take the supported route or to build something unsupported and invisible, and the platform team loses the information about what the path is missing.

Service ownership: who answers when the capability fails

A capability that nobody owns degrades in predictable ways. Upgrades are not scheduled, because nobody is accountable for them. Consumer questions go to whoever happens to answer in chat. A bug found by one consumer is rediscovered by the next. Service ownership is the assignment of accountability for a service or capability across its life: development, operation, improvement, support, and retirement. The service owner glossary entry makes a point that is easy to skip: a name on a record is not an ownership arrangement. Ownership works only if the owner has decisions they are allowed to make, an escalation route for the decisions they are not, and capacity to act.

For a platform capability, ownership has two layers that must not be confused. The platform team owns the capability, which means the interface, its promises and the operation of what runs behind it. Each consumer team owns what they have put onto it, such as their application and its configuration. The CNCF maturity model describes the progression in how operations are handled, from capabilities delivered only on request to capabilities whose lifecycle is managed in a standardized, automated way with defined responsibilities under a shared responsibility model. A shared responsibility arrangement is workable only when the split is written down in terms like the table above.

Ownership has to be machine-readable to be useful

An ownership statement in a wiki page is stale the first time a team reorganizes. A service catalog records software and its owner as data that tools can query. Backstage, an open source developer portal, represents each entity as a descriptor with an apiVersion, a kind, metadata with a name that is unique within its kind and namespace, and a spec whose fields depend on the kind. According to the descriptor format documentation, a Component requires a type (such as service), a lifecycle (such as experimental, production or deprecated) and an owner, and it may declare relationships such as dependsOn, providesApis and consumesApis. The record in this article’s toy platform has the same shape in miniature: an owner and a lifecycle derived from the provisioning outcome.

What a catalog record cannot do is as important as what it records. It states a declared owner; it does not prove that anyone is on call, that the owner has capacity, or that the dependencies listed are current. Coverage and freshness decide what the catalog can answer. A practical check is to make provisioning refuse to complete without an owner field that matches an allowed pattern, which the contract in the next section does, and to review the owner field when a team’s membership changes.

Ownership also extends past delivery. Day 2 operations is the informal name for the work that follows first delivery: upgrades, resizing, backup and recovery, configuration changes, ownership transfers, support, and deprecation and deletion. A platform that makes creation easy and leaves these unspecified shifts costs to the future. Each capability should state, at minimum, who upgrades the runtime, how consumers are notified of a breaking change, and what happens to resources when their owner team no longer exists.

Self-service contracts

A self-service contract is the written, checkable agreement between consumer and platform for one capability. It states what the consumer provides, what the consumer receives, what the platform guarantees, what happens when a request is not acceptable, and which requests need a person to decide. The adjective self-service describes the interaction: a consumer can request and receive the capability through an automated path without a person in the loop for requests inside the contract.

The self-service provisioning entry separates three places where a human can hide inside a process that looks automated. Approval is someone deciding yes. Execution is someone running a script on request, which looks automated from the platform team’s side and is a ticket from the consumer’s side. Interpretation is someone explaining the result afterwards. A contract addresses each. Approval is replaced by rules written down in advance, so that requests inside the rules need no approval and only exceptions escalate. Execution is replaced by an automated path that the consumer can invoke. Interpretation is reduced by outputs and error messages that say what happened in terms the consumer can act on.

A contract in code

This article is mainly about design and judgment, so the code is there to make one idea concrete: a contract that a program can check. The listings are excerpts of three small scripts (a contract file, a provisioning function, and a conformance check) plus unit tests. The quoted outputs are what those scripts printed when run once on a laptop with Python 3.12; timings vary by machine. The following contract is original, written for this article, and describes one imaginary capability, http-service-hosting. It is deliberately small: four inputs, two tiers, a guarantee per tier, a review rule, and the two ownership lists.

{
  "capability": "http-service-hosting",
  "contract_version": "1.0",
  "inputs": {
    "name":  {"required": true,  "pattern": "^[a-z][a-z0-9-]{2,30}$"},
    "owner": {"required": true,  "pattern": "^team-[a-z0-9-]+$"},
    "tier":  {"required": false, "default": "standard", "allowed": ["standard", "critical"]},
    "port":  {"required": false, "default": 8080, "min": 1024, "max": 65535}
  },
  "guarantees": {
    "standard": {"slo_good_fraction": 0.99, "latency_threshold_ms": 200},
    "critical": {"slo_good_fraction": 0.999, "latency_threshold_ms": 100}
  },
  "needs_review": ["tier == critical"],
  "consumer_owns": ["application code", "request handlers", "data stored by the app"],
  "platform_owns": ["hosting runtime", "health probe", "catalog entry", "upgrades of the runtime"]
}

Each part answers a specific question a consumer would otherwise ask a person. Required inputs and patterns say what a valid request looks like. Defaults record what the platform chooses when the consumer says nothing, which is itself part of the contract because a changed default changes consumers’ systems. Guarantees state the service level the platform is willing to be measured against. needs_review states, in advance, which requests escalate to a human and why. The two ownership arrays are the boundary table from the previous section in machine-readable form.

The validation function turns the contract into behavior. The excerpt below is the core of the toy provisioning code. It collects every error rather than stopping at the first, and each error names the field and a stable code, so that both a person and a script can act on the result.

def validate(req):
    errors, out = [], {}
    for field, rule in CONTRACT["inputs"].items():
        if field not in req:
            if rule.get("required"):
                errors.append({"field": field, "code": "required"})
            else:
                out[field] = rule["default"]
            continue
        v = req[field]
        if "pattern" in rule and not (isinstance(v, str) and re.match(rule["pattern"], v)):
            errors.append({"field": field, "code": "pattern", "expected": rule["pattern"]})
        elif "allowed" in rule and v not in rule["allowed"]:
            errors.append({"field": field, "code": "not_allowed", "allowed": rule["allowed"]})
        elif "min" in rule and not (isinstance(v, int) and rule["min"] <= v <= rule["max"]):
            errors.append({"field": field, "code": "range", "min": rule["min"], "max": rule["max"]})
        else:
            out[field] = v
    unknown = set(req) - set(CONTRACT["inputs"])
    for f in sorted(unknown):
        errors.append({"field": f, "code": "unsupported_option"})
    if errors: raise Rejected(errors)
    return out

Rejecting unknown options by name is a design choice. The alternative, silently ignoring an option the platform does not support, tells a consumer their request succeeded when part of it was dropped. An explicit unsupported_option error is also a signal to the platform team: if many consumers request the same unsupported option, it is evidence about where the boundary may be wrong. A request deliberately built to violate the contract produced this output when run (the order of errors follows the order of fields in the contract, then unknown options), printed here as the program emitted it:

[{"field": "name", "code": "pattern", "expected": "^[a-z][a-z0-9-]{2,30}$"}, {"field": "owner", "code": "pattern", "expected": "^team-[a-z0-9-]+$"}, {"field": "tier", "code": "not_allowed", "allowed": ["standard", "critical"]}, {"field": "gpu", "code": "unsupported_option"}]

Idempotency and the second request

Consumers will submit the same request twice. A browser double-click, a retried pipeline step, or a re-run of a script does this routinely. A contract has to say what happens. In this toy, provisioning is idempotent by name: the same name with the same validated specification returns the existing record and reports that nothing new was created, and the same name with a different specification is rejected as a conflict rather than overwritten. The relevant property is that repeating the same logical request has the same intended effect as applying it once. It does not mean the code runs only once, and it does not mean any two requests with the same name are the same request. That is why the conflict case exists.

The toy stores records in a dictionary, so the check and the write cannot race. A real implementation backed by a shared store needs an atomic mechanism, such as a uniqueness constraint, because two concurrent requests can both observe that a name is absent. This is an assumption that the toy removes and a real platform has to restore.

Review is part of the contract

The critical tier routes to pending_review. This is the contract’s way of encoding a legitimate reason for a human decision: the critical tier makes a stronger promise (a higher good-event fraction and a tighter latency threshold) and so needs capacity and risk checks before it is granted. The consumer sees a different status and the reason is in the contract, which is better than an undocumented queue. A request inside the envelope completes without a person, and only the exception waits.

Seven unit tests encode the behaviors discussed above (defaults, required fields, unsupported options, idempotent repeat, conflict, review routing and a port range check). They were run with Python’s unittest in verbose mode and all passed:

test_bad_port (__main__.T.test_bad_port) ... ok
test_critical_tier_routes_to_review (__main__.T.test_critical_tier_routes_to_review) ... ok
test_defaults_applied (__main__.T.test_defaults_applied) ... ok
test_idempotent_same_request (__main__.T.test_idempotent_same_request) ... ok
test_missing_owner_rejected (__main__.T.test_missing_owner_rejected) ... ok
test_same_name_different_spec_conflicts (__main__.T.test_same_name_different_spec_conflicts) ... ok
test_unsupported_option_rejected_by_name (__main__.T.test_unsupported_option_rejected_by_name) ... ok
----------------------------------------------------------------------
Ran 7 tests in 0.001s
OK

These tests check that the contract is enforced by the code. They do not show that the contract is the right one. Whether ^team-[a-z0-9-]+$ matches how an organization names its teams, or whether 1024 is the right lowest port, are questions only consumers and operators can answer.

Implementation and validation path

A contract on paper does not make an operated service. The path from consumer needs to a service that is delivered and checked can be stated as ordered steps. The order matters because each step produces the evidence the next step needs.

  1. Choose one task. Pick a task that many consumers perform and that has a clear completion condition, such as deploying a stateless HTTP service. Resist starting with the full platform.
  2. Write the boundary and the contract. Use the consumer-owns and platform-owns lists, the inputs, defaults, guarantees and review rules. Review them with at least one real consumer who was not involved in writing them.
  3. Build the thinnest implementation that honors the contract. It can be a script, a template, or a manual process behind a form. The contract is what the consumer depends on; the implementation can change underneath it.
  4. Validate the contract mechanically. Unit tests for accepted and rejected requests, as above, confirm the interface behaves as written.
  5. Validate the operated service against its guarantee. Provision a real instance through the interface, exercise it, and compute the measured service level against what the contract promised.
  6. Validate with a consumer. Ask a consumer who has not seen the contract to complete the task using only the documentation, and record where they stop. This is the only step that tests whether the interface is understandable.
  7. Operate and review. Keep measuring, handle exceptions through the documented path, and revisit the contract using what the measurements and consumer feedback show.

Steps 4 and 5 are different kinds of checks and can disagree. A unit test can pass for a service whose measured behavior misses its guarantee, and a service can meet its guarantee while the interface that creates it rejects reasonable requests. Both checks are needed, together with step 6.

Measuring the guarantee: SLI, SLO and the validation run

The guarantee in the contract is expressed as a target on a measurement. The Google SRE book defines a service level indicator (SLI) as a carefully defined quantitative measure of some aspect of the service level provided, and a service level objective (SLO) as a target value or range of values for a service level measured by an SLI. A service level agreement (SLA) adds an explicit or implicit contract with consequences. SLI, SLO and SLA are different things, and the toy platform’s guarantee is an SLO: a target the platform measures itself against, with no consequences attached in this example. The SRE book chapter on service level objectives also advises starting with a loose target and tightening it as experience accumulates, which is a reason to treat the numbers in the toy contract as placeholders to revise.

The indicator used here is the fraction of valid events that were good. A valid event is a request the SLI counts. A good event is a valid event that returned success within the tier’s latency threshold. Defining valid events matters: a request for a path that does not exist is a client error and does not say anything about the service’s quality, so it is excluded. The conformance check below sends one such request and does not count it.

thr = rec["guarantee"]["latency_threshold_ms"]; good = valid = 0
for i in range(n):
    t0 = time.perf_counter()
    try:
        r = urllib.request.urlopen(base + "/work", timeout=2); ok = r.status == 200
    except Exception: ok = False
    ms = (time.perf_counter() - t0) * 1000
    valid += 1; good += (ok and ms <= thr)
frac = good / valid   # the SLI for this run

The script provisions a standard-tier instance through the contract, starts a toy HTTP server on the loopback interface, confirms the health probe path answers, sends 200 sequential requests, and compares the good fraction to the tier’s target of 0.99 with a 200 ms threshold. In the first run, the server was healthy. In the second run, the server was configured to sleep for 250 ms on a random 5 percent of requests, using a fixed seed. The second condition is injected on purpose to show the check can fail; it is not a measurement of any real system. The two runs printed:

{"slow_fraction_injected": 0.0, "valid_events": 200, "good_events": 200, "sli": 1.0, "slo_target": 0.99, "meets_slo": true, "p50_ms": 0.5, "max_ms": 0.9}
{"slow_fraction_injected": 0.05, "valid_events": 200, "good_events": 189, "sli": 0.945, "slo_target": 0.99, "meets_slo": false, "p50_ms": 0.7, "max_ms": 256.7}

Reading the result requires care. In the second run 11 of 200 requests exceeded the threshold, giving 189 / 200 = 0.945, below 0.99, so the check reports a miss. Notice that the median latency is under a millisecond in both runs. A check that reported only the median would pass a service that failed 5.5 percent of its requests, which is the reason the SLI counts events against a threshold instead of summarizing latency with an average or median.

What this run does not show is also worth stating. The client is sequential and local, so there is no network, no concurrency and no queueing. A single run of 200 events cannot tell a 99 percent service from a 98 percent one with any confidence. The injected slowness is an assumption. The run demonstrates a mechanism, namely that an SLI computed from good and valid events can catch a violation that a median hides. It makes no claim about the performance of any real platform. A real validation would run against the actual runtime, over a time window, at realistic concurrency, and would be repeated.

Where this fails

The scheme above has failure modes that are properties of the approach and not of the toy implementation.

  • The contract describes the platform, not the consumer. A contract written only by the platform team encodes the platform team’s view of the task. Step 6 of the path, observing a consumer who has not seen it, is the only check against this. Without it, a contract can pass every test and still be unusable.
  • Self-service in name only. If provisioning is a script that a platform engineer runs when a request arrives, consumers experience a queue regardless of what the process document says. Measuring time from request to usable service from the consumer’s side exposes this; counting automated steps on the platform side does not.
  • Ownership fields that decay. An owner string recorded at creation time becomes wrong when a team splits or merges. Provisioning that enforces an owner pattern improves the data at the start but does nothing about later drift. Reviewing owners is a recurring operational task that must itself have an owner.
  • Guarantees without capacity. A target such as 0.999 is a promise that someone has to pay for in redundancy, staffing and on-call attention. A contract that offers a tier without the capacity to meet it produces a measured miss and a trust problem. This is the reason the critical tier goes to review.
  • Mandates hide the signal. If consumers cannot choose otherwise, usage numbers stop indicating whether the platform is good. DORA’s guidance names rigid gatekeeping as a risk that drives workarounds. Other evidence is still available: task success, time to complete the task, how often consumers leave the supported path, and what they say when asked.
  • Extension without a boundary. Adding every requested option until the platform supports everything returns the complexity to the platform team, which then carries the integration and support burden for combinations it cannot test.

A short list of questions the design should answer

For any capability, the following questions can be answered from the artifacts built in this article. If one cannot be answered, that gap is a task for the next iteration, not a detail to settle during an incident.

QuestionWhere the answer lives
Who consumes this, and what task are they trying to finish?Task list with completion conditions
What does the platform do, and what stays with the consumer?Boundary table and the contract’s ownership lists
Who is accountable for the capability after delivery?Service owner, catalog record and the Day 2 responsibilities
What does a consumer provide and receive, and what happens when the request is wrong?Contract inputs, defaults and error codes
Which requests need a person, and why?Review rules in the contract
How is the promise measured, and what counts as a valid event?SLI definition and the conformance check
How does a consumer whose need falls outside the path proceed?Documented exception process
How will we learn that the contract is wrong?Consumer walk-through, unsupported-option requests, SLI misses

The next article in the series, Linux Processes, Resources, and Failure Diagnosis, moves one layer down. A platform capability ultimately runs as processes on an operating system, and diagnosing why it failed requires understanding how those processes use CPU, memory and I/O. The contract and measurement approach in this article tells you that a promise was missed; the operating system layer is where you find out why.

References


Discover more from Insightful Data Lab

Subscribe to get the latest posts sent to your email.

Similar Posts

Questions, corrections, or additional insights?

This site uses Akismet to reduce spam. Learn how your comment data is processed.