Golden Paths and Service Contracts

Suppose a platform team has already decided where services will run. The previous article in this series, Choosing Kubernetes, Managed Services, or Simpler Runtime Options, compared runtimes using workload needs, operational burden, and cost. This article assumes you took away that comparison method and that one or more runtimes now exist. It does not repeat it. The question here comes after the decision: how does a product team actually get a service onto that runtime, and what exactly is the platform promising when it says a way of doing so is supported?

Without an explicit answer, the promise lives in people’s heads. One engineer remembers that a certain language version is fine, another believes the metrics endpoint is optional, and a third copies a repository that was correct two years ago. A golden path is the platform’s attempt to replace that folklore with a supported route. A service contract is the written form of what that route guarantees and what it asks in return. This article builds both: the journey a consumer walks, the template that starts it, the options the platform supports, the way exceptions are handled, and the tests that keep the contract true as it changes.

All examples are illustrative. The scenario teams, option names, and numbers are invented for this article, and the script was written for it and its output was produced by running it locally with Python 3.12 and no third-party packages. The script demonstrates mechanisms. Neither it nor its output is a measurement of any real platform.

What a golden path is, and what a contract adds

Spotify describes its Golden Path as the opinionated and supported way to build something, with engineers free to leave it and do their own thing but without the same support (Spotify Engineering). The CNCF platforms white paper lists golden path templates among platform capabilities and describes them as templated compositions of integrated code and capabilities, bundling an initial project template, documentation, and reusable workflows (CNCF TAG App Delivery). The same paper recommends building the thinnest viable platform layer over implementations from managed providers, a scoping idea covered in thinnest viable platform.

Those descriptions say what a path is made of. They leave open how a consumer knows what is promised. A path with a template and a wiki page promises something, but the promise is vague: does supported mean the platform team will answer a question within a day, patch the base image within a week, or merely that a template exists? A service contract states, for one kind of service, four things:

  • The consumer’s interface. What the consumer declares or chooses: name, owner, runtime, tier, region, and which of these have restricted value sets.
  • The platform’s guarantees. What the consumer receives in return, such as a pipeline, a runtime, telemetry collection, and the support route.
  • The consumer’s obligations. What the service itself must do for the platform to keep its side, for example expose a health endpoint and shut down gracefully on a termination signal.
  • The change policy. How the contract changes, how much notice precedes a removal, and how a service departs from it.

Contract is a design choice of this article, not a standard term with a fixed schema. The shape above is one reasonable decomposition. What matters is that each statement can be checked: by a person reading it, by a validator reading a manifest, or by a test exercising behavior. A statement that nobody can check is an aspiration, and aspirations decay quietly.

The contract also fits the idea of platform as a product. A product has users, a defined offer, and a way to find out whether the offer works. DORA’s platform engineering guidance recommends treating developers as customers, mapping critical user journeys, and assigning product management, and it lists pitfalls such as building without user research, rigid top-down governance, and releasing everything at once (DORA). The contract is where the product’s offer becomes concrete enough to be tested against those users.

The consumer journey

The journey comes first because a contract written from the platform’s side tends to describe components, while a consumer experiences a sequence of tasks. Walking the sequence exposes where the contract must say something. The table uses a hypothetical product team, here called the orders team, that must stand up a new internal service. It is a composite invented for this article and not a report of any real team.

StageThe consumer’s questionWhat the contract must stateEvidence the platform can collect
DiscoverIs there a supported way to build this kind of service, and is it right for me?The service kinds the path covers and the ones it does notWhether people find the path unaided; searches and questions that end with no match
StartHow do I get a working skeleton?The template, its inputs, and what it producesTemplate runs started, completed, abandoned, and the step where abandoned runs stopped
ConfigureWhich choices am I allowed to make?Supported options, deprecated options, and the rule for anything elseRejected inputs, and which options are chosen
ShipWhat happens between my commit and production?The pipeline, its mandatory checks, and what a failure message meansPipeline pass and failure causes; whether a failure message led to a fix without a ticket
OperateWhat do I get for free, and what am I responsible for?Telemetry provided, the on-call and escalation route, and the consumer’s obligationsWhether required endpoints respond; support requests by category
ChangeWhat changes under me, and when?The versioning and deprecation policyServices pinned to old contract versions; notice given versus migration completed
LeaveWhat if my service does not fit, or I stop needing it?The exception process and the retirement processExceptions open, expiring, and expired; retirement requests

Three observations make the table useful rather than decorative. First, each row ends in something observable. DORA’s research summary singles out clear feedback on the outcome of tasks as a platform capability, so the third column asks what the platform can show a person when the task fails, not just when it succeeds. A rejected input that says only “invalid configuration” leaves the consumer with a ticket to file, which defeats self-service. Second, the journey is longer than the first deployment. Most of the contract’s value, and most of its cost, sits in Operate and Change, because a service lives far longer than the afternoon it was created. Third, the Leave row matters even for a path nobody wants to leave. Every platform meets a service that does not fit, and the only choice is whether the exit is designed or improvised.

The journey also tells you what to measure first. Task success and time in the Start and Ship stages, rejection reasons in Configure, and support volume in Operate are all observable without a survey. Interpreting those numbers, including the limits of causal claims about adoption, is the subject of a later article, Measuring Platform Adoption and Developer Friction. For this article, the point is only that each stage should name its evidence before the contract is written, so that later you can tell whether the contract helped.

Service templates

What a template is for

A template generates the starting point of a service so that the first working version already satisfies the contract. Scaffolding describes generating initial project material from a prepared skeleton and parameters. In Backstage, a software template is a YAML definition with parameters that follow JSON Schema, an ordered list of steps that each invoke an action, and optional outputs shown to the user; the documented fetch:template action renders template files with the supplied values and publish actions push the result to a repository (Backstage documentation). Other tools do similar work. This article uses the Backstage model as a concrete reference without assuming you run it, because the design questions are the same regardless of product.

The template’s job in the contract is narrower than it first appears. It does not make the service good. It makes the service start in a state where every checkable claim in the contract is already true: required fields present, runtime inside the supported set, required endpoints implemented, the pipeline referenced rather than hand-written. The contract says what a service must be. The template is the cheapest way to begin there.

Generated copy or referenced dependency

Everything in a template is either copied into the new repository or referenced from somewhere the platform maintains. The choice decides how fixes reach consumers. The pipeline template entry separates three intentions: a convenience that teams copy and edit, a standard that is referenced and versioned, and a control that is enforced. Copied content diverges, because the consumer owns it from the first commit; a security fix then needs a patch for every copy. Referenced content stays maintainable but becomes an interface with consumers, so it needs a version, an owner, tests, and a deprecation path.

A practical split follows from that. Copy what consumers legitimately adapt, such as business logic scaffolding and service-specific configuration. Reference what must stay uniform and fixable, such as the delivery workflow and the base runtime image. Copied files can still carry a template version identifier so the platform can tell which services were generated from which revision and can offer an update later. Whether the platform runs an updater, or leaves updates to each team, is a support decision the contract should state explicitly. Scaffolding does not by itself maintain what it generated.

Inputs, outputs, and the registration of ownership

The template’s parameters are the first visible surface of the contract. Parameters that map to restricted value sets, such as runtime or tier, should be selection lists generated from the contract’s supported options instead of free text, so that the form and the validator cannot drift apart. The required fields in the contract should be required in the form. Because Backstage validates parameters with JSON Schema, the supported sets can be expressed once as schema constraints and reused in other checks.

The template should also register the new service’s owner at creation. Ownership is the contract field most likely to be missing later, and it is cheapest to capture when the creator is present and the service has no history. Registration creates a record but does not prove the owner’s claim is true; the service owner role needs a usable mandate, not only a name. The next article, Developer Portals, Catalogs, and Provisioning Workflows, covers how that metadata is kept trustworthy after creation.

Testing the template against the contract

A template is code that produces code, so it needs a test whose subject is its output. The simplest useful test renders the template with representative inputs and validates the result against the contract. If the contract changes in a way that invalidates what the template produces, this test fails in the template’s repository before any consumer is affected. The script later in this article does exactly this in its first check: it renders a manifest from a stand-in template function and validates it.

Two limits are worth stating. The test covers the generated manifest and files, not the behavior of a deployed service, so a template that declares a health endpoint can still generate code that does not implement it. And a template tested only with default inputs can hide failures for other supported combinations, so the test should iterate over the supported option matrix when that matrix is small enough, or sample it deliberately when it is not.

Writing the contract down

The contract has a human-readable form and a machine-readable form, and the two should be generated from or checked against one source. The human-readable form explains the reasons, the support promise, and the obligations. The machine-readable form lets a validator, a template form, and a pipeline gate share one definition of supported. Here is a deliberately small machine-readable core, followed by the validator that reads it. Both are original to this article.

The script below contains the contract data, a validator, a stand-in template, a change classifier, and a consumer check. The sections that follow explain each part and show the output of one run. The date is fixed in the script so that its output does not change from day to day.

"""Illustrative golden-path contract tooling (author-written example, stdlib only)."""
import json, datetime, copy

TODAY = datetime.date(2024, 8, 8)  # fixed so the output is reproducible

CONTRACT_V1 = {
    "contract_version": "1.2.0",
    "options": {
        "runtime":  {"supported": ["python3.11", "python3.12", "node20"], "deprecated": ["python3.10"]},
        "tier":     {"supported": ["standard", "critical"]},
        "region":   {"supported": ["region-a", "region-b"]},
    },
    "required_fields": ["name", "owner", "runtime", "tier", "region"],
    "guarantees_required": ["health_endpoint", "metrics_endpoint", "graceful_shutdown"],
}

def validate(service, contract, today=TODAY):
    errors, warnings = [], []
    exc = {e["field"]: e for e in service.get("exceptions", [])}
    for f in contract["required_fields"]:
        if f not in service:
            errors.append(f"missing required field: {f}")
    for field, spec in contract["options"].items():
        if field not in service:
            continue
        value = service[field]
        if value in spec["supported"]:
            continue
        if value in spec.get("deprecated", []):
            warnings.append(f"{field}={value} is deprecated")
            continue
        e = exc.get(field)
        if e is None:
            errors.append(f"{field}={value} is outside the supported set and has no exception")
        elif datetime.date.fromisoformat(e["expires"]) < today:
            errors.append(f"exception for {field} expired on {e['expires']}")
        elif not e.get("approved_by"):
            errors.append(f"exception for {field} has no approver")
        else:
            warnings.append(f"{field}={value} runs under exception until {e['expires']} (approved_by={e['approved_by']})")
    for g in contract["guarantees_required"]:
        if g not in service.get("guarantees", []):
            errors.append(f"required guarantee not declared: {g}")
    return errors, warnings

def render_template(name, owner, runtime="python3.12", tier="standard", region="region-a"):
    """Stand-in for a service template: produces the manifest a new service starts with."""
    return {"name": name, "owner": owner, "runtime": runtime, "tier": tier, "region": region,
            "guarantees": ["health_endpoint", "metrics_endpoint", "graceful_shutdown"]}

def classify_change(old, new):
    """Classify contract changes from the consumer's point of view."""
    out = []
    for f in new["required_fields"]:
        if f not in old["required_fields"]:
            out.append(("breaking", f"new required field '{f}'"))
    for f in old["required_fields"]:
        if f not in new["required_fields"]:
            out.append(("compatible", f"field '{f}' no longer required"))
    for field, spec in old["options"].items():
        nspec = new["options"].get(field)
        if nspec is None:
            out.append(("breaking", f"option '{field}' removed")); continue
        for v in spec["supported"]:
            if v not in nspec["supported"]:
                out.append(("breaking", f"{field}={v} no longer supported"))
        for v in nspec["supported"]:
            if v not in spec["supported"]:
                out.append(("additive", f"{field}={v} added"))
    for g in new["guarantees_required"]:
        if g not in old["guarantees_required"]:
            out.append(("breaking", f"new required guarantee '{g}'"))
    return out

# Consumer expectations: what each existing service actually relies on.
CONSUMERS = [
    {"name": "orders-api",   "uses": {"runtime": "python3.11", "tier": "critical", "region": "region-a"}},
    {"name": "reports-job",  "uses": {"runtime": "python3.10", "tier": "standard", "region": "region-b"}},
    {"name": "web-bff",      "uses": {"runtime": "node20",     "tier": "standard", "region": "region-a"}},
]

def consumer_check(contract, consumers):
    rows = []
    for c in consumers:
        problems = []
        for field, val in c["uses"].items():
            spec = contract["options"].get(field)
            if spec is None:
                problems.append(f"{field}: option removed")
            elif val in spec["supported"]:
                pass
            elif val in spec.get("deprecated", []):
                problems.append(f"{field}={val}: deprecated only")
            else:
                problems.append(f"{field}={val}: unsupported")
        rows.append((c["name"], problems))
    return rows

if __name__ == "__main__":
    print("== 1. Template output conformance")
    s = render_template("billing-svc", "team-billing")
    print(validate(s, CONTRACT_V1))

    print("\n== 2. Validation cases")
    cases = {
      "unsupported runtime, no exception": dict(render_template("a","t"), runtime="go1.22"),
      "deprecated runtime": dict(render_template("b","t"), runtime="python3.10"),
      "runtime under valid exception": dict(render_template("c","t"), runtime="go1.22",
          exceptions=[{"field":"runtime","approved_by":"platform-review","expires":"2024-12-31"}]),
      "expired exception": dict(render_template("d","t"), runtime="go1.22",
          exceptions=[{"field":"runtime","approved_by":"platform-review","expires":"2024-06-30"}]),
      "exception without approver": dict(render_template("e","t"), runtime="go1.22",
          exceptions=[{"field":"runtime","expires":"2024-12-31"}]),
      "missing guarantee and owner": {k:v for k,v in dict(render_template("f","t"), guarantees=["health_endpoint"]).items() if k!="owner"},
    }
    for k, svc in cases.items():
        e, w = validate(svc, CONTRACT_V1)
        print(f"- {k}\n    errors={e}\n    warnings={w}")

    print("\n== 3. Candidate contract v2: move python3.11 to deprecated, add java21, add a required field and a required guarantee")
    v2 = copy.deepcopy(CONTRACT_V1)
    v2["contract_version"] = "2.0.0"
    v2["required_fields"].append("cost_center")
    v2["options"]["runtime"]["supported"].append("java21")
    v2["options"]["runtime"]["supported"].remove("python3.11")
    v2["options"]["runtime"]["deprecated"] = ["python3.11"]
    v2["guarantees_required"].append("structured_logs")
    for kind, msg in classify_change(CONTRACT_V1, v2):
        print(f"- {kind}: {msg}")

    print("\n== 4. Consumer-driven check against v1 and v2")
    for label, c in (("v1", CONTRACT_V1), ("v2", v2)):
        print(label)
        for name, problems in consumer_check(c, CONSUMERS):
            print("  ", name, "OK" if not problems else problems)

Read the validator before the output. It returns two lists. An error stops the pipeline or the creation flow. A warning lets the work proceed and records something a person should know. Which findings are errors and which are warnings is itself a contract decision, because an error is a promise to reject and a warning is a promise only to tell.

The change classifier and consumer check are separate from the validator on purpose. The validator asks whether one service conforms to one contract version. The classifier asks what a new contract version does to a class of services. The consumer check asks whether specific, known services still conform. Those are three different questions, and the compatibility section returns to them.

Supported options

Supported, deprecated, and unsupported

The contract in the script gives each option field two sets: supported values and deprecated values. Anything in neither set is unsupported. The three-way split matters because the platform has three different things to say. Supported means the platform tests, documents, and operates it. Deprecated means it still works and the platform still helps, but the consumer should plan to move; deprecation is a warning with a replacement and a timeline, and removal is a separate, later, breaking change. Unsupported means the platform makes no promise, which does not mean forbidden. The Spotify account and the CNCF paper both describe room for teams to work off the path, at the price of different support.

A common mistake is to publish only the supported list. A consumer holding a deprecated option then sees an error where the platform meant a warning, and the migration conversation starts as a conflict. Publishing the deprecated set with dates lets the validator say what is true.

What each supported option costs

Every supported combination multiplies work. A runtime version needs a base image, security patching, a pipeline path, template variants, documentation, and a person who can diagnose its failures at three in the morning. With three supported runtime versions, two regions, and two tiers, the script’s contract already allows twelve combinations. Not every combination needs its own testing, because many of the dimensions are independent, but the platform has to know which ones are independent and which interact. The costs fall on the platform team first and the consumers second, and they scale with the number of supported options rather than the number of services.

This is why the supported list should be treated as a budget. The decision rule used in the previous article to compare runtimes, workload fit against operational burden, applies again here. Add a supported option when enough services need it, the platform can operate it, and the maintenance commitment is accepted by a named owner. Without that, prefer the exception route described below. The reverse also holds: a supported option that nobody uses and that costs support effort should enter the deprecation process.

Constraints are not automatically good. The enabling constraints entry draws on DORA’s warning against standards imposed without collaboration and against one solution applied to incompatible workloads. The test for a restriction is the outcome it protects. A runtime allowlist that protects patchability is easy to defend. A rule that forces every service onto one database because that is simpler for the platform team, regardless of workload, needs its justification examined, since the cost shifts to consumers.

Mandatory controls and optional implementations

Separating policy from implementation avoids a frequent confusion. Some requirements are mandatory regardless of how the service is built: every production service has an owner, emits metrics, and goes through the pipeline’s scans. How the service meets the requirement can sometimes be left open. Offering several implementations of a mandatory control is a legitimate design, and so is requiring exactly one. The golden path entry puts this plainly: organizations may require particular controls even when the implementation path is optional. In the contract, mark each requirement as mandatory or optional and, for mandatory ones, say whether alternatives are acceptable and who decides.

Exception handling

An exception is a record, not a conversation

Sooner or later a service needs something outside the supported set. The contract’s job is to make that case ordinary. The validator in the script treats an exception as a structured record attached to the service: the field it concerns, the approver, and an expiry date. Its behavior in the relevant cases appears in section 2 of the output below, copied from a run of the script. The same output also contains sections 3 and 4, which the compatibility section uses.

== 1. Template output conformance
([], [])

== 2. Validation cases
- unsupported runtime, no exception
    errors=['runtime=go1.22 is outside the supported set and has no exception']
    warnings=[]
- deprecated runtime
    errors=[]
    warnings=['runtime=python3.10 is deprecated']
- runtime under valid exception
    errors=[]
    warnings=['runtime=go1.22 runs under exception until 2024-12-31 (approved_by=platform-review)']
- expired exception
    errors=['exception for runtime expired on 2024-06-30']
    warnings=[]
- exception without approver
    errors=['exception for runtime has no approver']
    warnings=[]
- missing guarantee and owner
    errors=['missing required field: owner', 'required guarantee not declared: metrics_endpoint', 'required guarantee not declared: graceful_shutdown']
    warnings=[]

== 3. Candidate contract v2: move python3.11 to deprecated, add java21, add a required field and a required guarantee
- breaking: new required field 'cost_center'
- breaking: runtime=python3.11 no longer supported
- additive: runtime=java21 added
- breaking: new required guarantee 'structured_logs'

== 4. Consumer-driven check against v1 and v2
v1
   orders-api OK
   reports-job ['runtime=python3.10: deprecated only']
   web-bff OK
v2
   orders-api ['runtime=python3.11: deprecated only']
   reports-job ['runtime=python3.10: unsupported']
   web-bff OK

Read section 2 of the output as a specification of the exception process. A value outside the supported set with no exception is an error. The same value under a valid exception is allowed, with a warning that names the approver and the expiry so that the exception stays visible in every pipeline run. An expired exception is an error again, which is the property that stops exceptions from becoming permanent by neglect. An exception with no approver is an error, because an exception is a decision someone made, and a field left blank is not a decision.

The script checks the presence of an approver, not the approver’s authority. Whether the named person is allowed to approve that kind of exception is a policy lookup the real system would need, and nothing in this example performs it. A claim by the consumer that the service meets a control is likewise not approval. The enabling constraints entry makes the same point: someone has to validate control equivalence and accept the compatibility, cost, and support obligations.

What an exception record should contain

The minimum is the scope (which field and which service), the reason, an approver, an owner on the consumer side, the expiry or exit condition, and the support that remains. The last item is the one most often omitted. If a service runs a runtime the platform does not support, the contract should say whether the platform will still answer questions about the pipeline, whether security patches for that runtime are the consumer’s responsibility, and who gets paged when the platform’s shared components change in a way that breaks the unsupported setup. The golden path entry states the obligation: an agreement about the work outside the path should say which controls still apply and who handles failures.

Two kinds of exception deserve different handling. A temporary exception has an owner, a scope, and an exit condition or expiry, such as a service that keeps an older runtime until a dependency upgrade lands. A standing exception is an alternative approved for the long term, so it needs a maintained support agreement and a review schedule instead of an expiry that someone will extend by reflex. If many services request the same exception, the exception is evidence about the supported set, and the platform should consider promoting it, or deliberately declining and explaining why.

How exceptions fail

  • Silent bypass. A consumer who finds the exception process slower than copying the template and editing it removes a check without telling anyone. The validator catches only what it validates, so the bypass is visible only if the check runs on a path the consumer cannot skip. A control must be enforced where the protected resource is, for example at the pipeline step that grants production credentials, not only in documentation. Detection alone is not prevention.
  • Exception sprawl. Exceptions that never expire accumulate until the exception list is larger than the supported list. The expiry rule above exists to force a decision.
  • Approval by assertion. The record names an approver who never reviewed anything. Auditing a sample of approvals against what the approver actually saw is the check, and no field in a manifest can substitute for it.
  • Departure read as defiance. The golden path entry cautions that a departure may reveal a requirement the supported path does not meet, or may reflect unfamiliarity, switching costs, or a temporary constraint. Asking why before deciding is cheaper than assuming.

Compatibility and contract testing

Three directions of compatibility

A contract can break in three directions, and each needs its own check.

  • Template to contract. The template’s output no longer conforms to the current contract. Test: render the template and validate, as the script does first.
  • Contract change to existing consumers. A new contract version rejects services that conformed to the old one. Test: classify the change and run the validator over real service manifests.
  • Platform to contract. The platform does not actually provide a guarantee the contract states, such as a metrics collector that stops scraping an endpoint. Test: behavioral checks in the platform’s own pipeline, outside the manifest.

The first two are cheap because they operate on declarations. The third is where most real failures hide, and no manifest check reaches it.

Classifying a contract change

What counts as breaking depends on the supported interface and what consumers depend on. The breaking change entry lists the usual cases: adding a required input breaks callers that omit it, removing a value breaks users of it, tightening validation breaks callers whose input was previously accepted, and adding an enum value breaks consumers that handle the old set exhaustively. Applied to the contract, a new required field, a removed option value, or a new required guarantee all break existing services. A newly added option value is additive for services that set a field, but it can still surprise any tooling that assumes the old set is complete.

Section 3 of the script’s output classifies a hypothetical version 2 of the contract that moves one runtime to deprecated, adds another runtime, adds a required field, and adds a required guarantee. Three of the four changes are breaking, one is additive. Note that the classifier treats moving a value from supported to deprecated as “no longer supported”. That is a deliberate simplification in the script: a service that declares a deprecated value still receives a warning, not a rejection, so in practice that change is gentler than the classifier’s wording suggests. The consumer check in the next block shows the effect on actual services.

Version numbers should communicate this. Semantic Versioning increments the major version for incompatible changes, the minor version for backward-compatible additions, and the patch version for compatible fixes, and it requires a declared public API for “incompatible” to have meaning. The contract is that declaration. The script’s candidate is labeled 2.0.0 because it contains breaking changes. Adopting SemVer for a contract is a convention you choose and publish; it does not by itself guarantee that every change is classified correctly, which is why the classifier and consumer checks exist.

Consumer-driven checks

Martin Fowler’s account of consumer-driven contracts distinguishes a provider’s single, authoritative contract describing everything it offers from consumer contracts that are multiple, non-authoritative subsets, each capturing what one consumer relies on; the consumer-driven contract is the union of those active expectations (Martin Fowler). The same article advocates being conservative in what is sent and liberal in what is received, so that providers can add optional fields without breaking consumers. Pact implements the idea for HTTP and message interactions: the contract is generated by consumer tests against a mock provider, and the provider then verifies its real behavior against that generated file (Pact documentation).

The platform’s situation has the same shape with different artifacts. The provider is the platform’s contract. The consumers are the existing services. A consumer’s expectation is the part of the contract it actually uses: its runtime, tier, and region. The consumer_check function in the script evaluates a candidate contract against those declared uses. This is an analogy to consumer-driven contract testing, not an implementation of Pact. Pact tests request and response interactions between running components. The script checks declared option values against sets, which is much weaker, and it does not exercise any behavior.

The output is still informative. Against version 1, one illustrative consumer, reports-job, already uses a deprecated runtime and receives a warning-level finding. Against version 2, two services are affected: orders-api now uses a runtime that has become deprecated, reports-job uses one that is no longer supported at all, and web-bff is unaffected. The consequence for planning is concrete. Before publishing version 2, the platform knows which teams to contact, how many are blocked outright, and how many only need to plan. It also knows that the proposed removal would hit reports-job twice, since that service was already behind. Without the consumer check, the platform would find this out from the pipeline failure.

The check has two limits to keep in view. It sees only the consumers that publish their uses. A service whose manifest is missing, stale, or wrong is invisible, so the check is only as good as the coverage of the catalog or manifest set it runs over. And it examines what consumers declare, while Fowler’s tolerant-reader point concerns what they depend on in practice; a consumer that relies on an unspecified behavior will not appear. The first limit is addressed by coverage measurement. The second is the reason real contract tests exercise behavior.

Moving consumers without a flag day

When a change is breaking, the usual path is the expand and contract pattern: add the new form while keeping the old one, migrate consumers, and remove the old form when supported consumers no longer depend on it and the rollback window has closed. For the contract, expansion means accepting both the old and the new runtime during an overlap, publishing the deprecation with a date, and giving consumers a tool to see their status. SemVer’s deprecation rule is similar in spirit for libraries: deprecate in a minor release and keep the functionality for at least one minor release before removing it in a major one (Semantic Versioning 2.0.0). Platforms have no single standard window. The length depends on how long consumers need to schedule work, which you can estimate from the consumer check and revisit with real migration data. Retirement is covered end to end in Retiring Platform Capabilities Without Breaking Consumers.

Where this design fails

A written contract does not make a platform good. Several failure modes are worth testing for before relying on it.

  • The contract describes an ideal nobody meets. If the guarantees section lists support that the team cannot staff, consumers learn to ignore it. Write the promise the team can keep, and measure whether it is kept.
  • Validation without enforcement. A validator in a pipeline step that a consumer can edit is advice. Decide which checks are controls, and place those where the protected resource can reject a bypass.
  • Template drift. Services generated at different times conform to different contract versions. The template version identifier and the consumer check together show how far behind the fleet is.
  • Too many parameters. If the template exposes dozens of inputs to avoid saying no, it has become a configuration language with its own testing burden. Parameterize differences whose behavior you can define and test.
  • A single path for incompatible workloads. If the supported set cannot express a legitimate need and every such team requests an exception, the contract is wrong and the exceptions are the evidence.
  • Adoption read from compliance. If use of the path is mandated, high usage says little about whether it helps. Task outcomes and the reasons teams choose alternatives remain the better evidence.

A build order

The sequence below is a proposal, not a standard procedure. It orders the work so that each step produces something checkable.

  1. Pick one service kind that many teams build, and write the consumer journey for it with an observable signal for each stage.
  2. Write the contract for that kind: interface, guarantees, obligations, and change policy. Mark each requirement mandatory or optional.
  3. Define the supported, deprecated, and unsupported option sets, and name the owner who accepts the maintenance commitment for each supported option.
  4. Build the template so its output conforms, with the form generated from the supported sets, and add a test that renders it and validates the result.
  5. Add the validator to the pipeline at the step that protects production, and define the exception record, including the support that remains and the expiry rule.
  6. Before every contract change, classify it and run the consumer check over all registered services. Publish deprecations with dates and give affected owners their status.
  7. Add behavioral checks for each guarantee the platform makes, so that the platform-to-contract direction is covered as well.

A golden path is a promise about a route. The explicit contract makes the promise precise enough to test: each statement has a checker, each exception has an owner and an end date, and each change is classified before consumers meet it. The templates and the validator are small pieces of software. The harder part is the agreement they encode, and the support commitment behind it.

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.