Ruslan Akchurin · Sydney
AboutWriting

Resolve by contract

Safer cross-stack dependencies.

"The connections between modules are the assumptions which the modules make about each other."

David L. Parnas, "Information Distribution Aspects of Design Methodology" · 1971

Contracts make cross-tier assumptions reviewable: a consumer names a stable producer namespace, a key inside that namespace, the context it is resolving in, and the type it expects back.

That changes a dependency from "reach into this output path" to "ask this producer for this named capability here." Without that step, a workload starts to depend on names that were : vpc_id, private_subnet_ids, a resource URN, or a module path that was never meant to become public interface. The producer namespace can still resolve to a physical stack, catalogue entry, or output source; consumers should not have to know which one.

Address, context, source

The release or apply boundary selects context: staging, prod, and, in larger systems, region or account. A consumer should not reach sideways into another environment to choose it. The same producer, key, and type can resolve in one context and fail in another.

The producer is often represented by a stack because stacks are what the tooling discovers and applies. In the contract, the producer owns the published values; where those values are stored is an implementation detail.

Dependency by namespace and key

A dependency names the producer and key the consumer needs in the current context. That makes the dependency addressable without making the implementation public.

The namespace gives tooling somewhere stable to look, and the key names the capability being requested. The workload depends on environment/network.primary; the resolver can find the current backing value without exposing the producer's internal shape.

The contract keeps the producer's private shape out of the consumer:

hidden reference:
  reads: env-prod.vpc_id
  assumes: output name, stack spelling,
           subnet semantics

contracted dependency:
  requires: environment/network.primary
  expects: NetworkRef
  resolves in: current context
consumer: workload-api
requires: environment/network.primary
expects: NetworkRef

staging -> {
  vpcId: "vpc-123",
  privateSubnetIds: ["subnet-a", "subnet-b"],
}
prod    -> {
  vpcId: "vpc-789",
  privateSubnetIds: ["subnet-x", "subnet-y"],
}
Resolution stops the apply The same consumer requirement evaluated in each context. In staging it resolves and the apply proceeds, drawn with a filled arrowhead. In prod the key is missing and the apply stops, drawn with the accent stop bar. CONTEXTUAL RESOLUTION consumer · workload/api requires: environment/network.primary expects: NetworkRef in staging resolves apply proceeds in prod missing key apply stops
Resolution stops the apply

NetworkRef matters because it names the minimum usable capability and its invariants: the network identity, the attachable subnets, and the context in which those values are valid. A raw string array cannot say whether those subnets carry the expected reachability class, whether they're routable from the workload, or whether they were published by the producer with to that network (the host project in shared VPC, not a consumer that can read the IDs).

Resolution needs type

Resolution is the act of taking the contract address, current context, and expected type, then producing the value that satisfies them. A strict resolver also needs an explicit error when the value cannot be produced.

A producer and key without a type are only an address. The contract also needs the expected shape of the resolved value, because names alone create accidental compatibility.

For infrastructure, type does not have to mean a programming-language type. It can be a logical infrastructure type: network reference, database reference, cluster identity, endpoint.

The type carries the semantic promise. The consumer asks for the capability those names represent; subnet IDs and role names are only the backing values.

The resolved value must be usable as the thing the consumer asked for. A string is not automatically a network, and a name is not automatically an identity. Type is what stops unrelated values from becoming compatible because their names happen to line up.

The failure to prevent is not exotic: an internet-facing subnet list can satisfy an internal subnet key if both are only string arrays, and a role name can look like identity while carrying the wrong trust assumption.

The consumer selects the contract address, not the concrete backing value. Successful resolution returns the value bound to that address in the current context. Resolver scope, overrides, and precedence rules are policy around the same base contract: producer, key, context, and expected type.

Failure is part of the contract

A contract has to make invalid composition fail visibly. Every failure category stops the apply path before infrastructure changes.

The failures should be specific enough to fix:

  • unknown producer: environment is not published in this context
  • missing key: network.primary exists in staging but not prod
  • wrong type: the value resolves, but not as NetworkRef
  • ambiguous binding: more than one binding satisfies environment/network.primary in prod
  • cycle: the producer cannot publish because it depends on the consumer

Silent failure is worse than a broken dependency because it can produce the wrong infrastructure while still looking composed. The system must fail rather than guess; the alternative is stack boundaries shaped by whichever change applied most recently.

Resolution belongs where the release path can enforce it: before preview in CI, during a generation step, or inside the IaC program itself. Wherever it runs, invalid composition has to fail before infrastructure is changed, and the failure has to name the producer, key, context, and expected type that could not be satisfied.

Permission rules, versioning, and migration order stay downstream of the contract. The next part moves from contract shape to membership: where the resources and relationships that publish or consume the contract should live.

In the companion repoGitHublib/ (opens in new tab)