Survey any platform engineering team about their recent work, and you will likely find a Kubernetes controller that orchestrates multiple objects in sequence and shuttles values between them. The logic is straightforward—no domain-specific behavior, just plumbing. A Deployment paired with a Service, connected to a ConfigMap that reads the Service's hostname from its status field. Building the first such controller is manageable; by the tenth iteration, each brings its own reconciliation bugs, maintenance overhead, and on-call responsibilities. The pattern repeats across thousands of organizations, yet each team implements it independently.

The missing recipe

Kubernetes provides the building blocks: Custom Resource Definitions for new types, controllers to implement their behavior, server-side apply for field ownership tracking, CEL for expression evaluation, and OpenAPI schemas for type safety. What the platform lacks is a declarative way to express how multiple resources relate to one another. Nothing in Kubernetes states that several objects form a logical unit, or that a field in one resource depends on another's status and must wait for it to be ready.

Every tool that manages resource composition solves this gap independently. Helm handles it at template render time, and Helm 4 added server-side apply and status waiting. Argo CD implements sync waves, hooks, and health checks because manifests alone cannot express ordering or completion criteria. Crossplane offers compositions and functions, with version 2 now composing native Kubernetes resources directly. Operators written in Go handle it by hand. Stripped to their essence, all these solutions share the same core: a collection of objects, dependencies between them, values flowing along the edges, and continuous reconciliation to keep the cluster aligned with intent. Each recent tool addition reaches for a capability the underlying platform lacks: sequencing, readiness detection, per-field ownership, or in-cluster expression evaluation.

Kubernetes has the ingredients. What Kubernetes does not provide is the recipe. Nothing says that several resources form one thing, or that a value in one comes from the status of another and must wait for it.

If nearly every platform rebuilds the same piece, the logical question emerges: should this capability be shared? That reasoning led kro, the Kube Resource Orchestrator, to move into Kubernetes SIG Cloud Provider in September 2025 as a foundational primitive for other tools to build upon, rather than as another end-to-end platform.

Decomposing the controller pattern

Kro began by analyzing hand-written controllers and extracting what varies from what stays constant. The API mechanics and lifecycle management are identical across all controllers; only the wiring logic differs. The ResourceGraphDefinition captures this wiring as declarative data, allowing a single controller to interpret every definition. By connecting CRDs, OpenAPI, CEL, and server-side apply, kro provides the missing orchestration layer.

A ResourceGraphDefinition contains two distinct parts:

  1. The composition—the template containing resources and CEL expressions that reference between them
  2. The API—a schema, the generated CRD, and the instances that application teams create
Diagram showing parts of the glue controllers that are shared, vs those that differ.

Composition through inferred dependencies

Kro derives the dependency graph directly from CEL expression references, eliminating the need for explicit ordering declarations. Type-checking happens against OpenAPI schemas before any instance is created. When a ConfigMap needs the Service's hostname, the template simply references that field. This reference itself becomes the ordering rule: because the ConfigMap reads from the Service, the Service must be created first. No separate ordering specification exists to fall out of sync with the actual dependencies. An inferred order cannot drift because it is derived from the references themselves rather than maintained as separate metadata. Wherever order is declared independently of the reference that requires it, two sources of truth emerge, creating opportunities for inconsistency after any rename or reordering.

The API layer

Platform teams author the ResourceGraphDefinition while application teams interact with a standard Kubernetes API, complete with kubectl, RBAC, and GitOps integration. Ten separate glue controllers become ten definitions executed by a single controller. Since wiring becomes data, creating new APIs carries minimal cost—but that affordability comes with a responsibility. Application teams point their manifests, access controls, and deployment pipelines at these APIs, and removing a field later breaks things the platform team cannot observe. Kro prevents ResourceGraphDefinition schema changes that would break existing instances unless the author explicitly permits it. The trade-off shifts the cost from implementation to ownership: ten codebases disappear, but ten APIs remain. Teams should measure success by counting APIs, not controllers.

Composition without the API wrapper

Once composition became inexpensive to implement, additional use cases emerged that did not require their own dedicated API. Examples include a default-deny NetworkPolicy applied to every labeled Namespace, multiple labeled Services aggregated into a single Ingress, or a DNS stack installed once and kept healthy. These do not respond to explicit requests; instead, they react to existing cluster state. They are one-off compositions that do not need a repeatable API surface.

Kro 0.10 introduces a primitive for this scenario. A Graph represents the composition piece in isolation—namespaced, with no schema, no CRD, and no instances. Here is the Ingress example in abbreviated form:

apiVersion: kro.run/v1alpha1
kind: Graph
spec:
  nodes:
    - id: services
      ref:                          # read only, never owned
        kind: Service
        metadata:
          selector: {matchLabels: {expose: "true"}}
    - id: ingress
      template:                     # created and owned
        kind: Ingress
        spec:
          rules: >
            ${services.map(svc, {"host": svc.metadata.name + ".example.com", ...})}

No one explicitly creates instances. The Services already exist in the cluster, and the Graph follows them: label a Service and a rule appears in the Ingress; remove the label and the rule disappears.

The composition primitive

Architectural diagram comparing kro 0.9 and kro 0.10

Of the two halves, only one represents genuinely new functionality. The API half is simply a CRD, a primitive Kubernetes already possesses. ResourceGraphDefinitions make CRDs cheaper to create, but the primitive existed before. The composition half is what Kubernetes lacked and what every tool built independently. Graph provides that half standalone, and its existence reveals what was previously obscured: the API was never the fundamental input, merely one possible trigger. A composition reconciles against cluster state, and a new custom resource instance is just one of many state changes it can respond to. Regardless of the trigger, the underlying work remains constant: objects, references between them, and continuous declarative reconciliation keeping them synchronized. That is the primitive. A ResourceGraphDefinition is the same primitive with a CRD layer in front.

Strip the trigger from anything that creates objects, copies values between them, and keeps checking, and what is left is a composition.

This pattern generalizes widely. An operator serving a single custom resource is a composition with an API in front. A pipeline stage that applies manifests in sequence and waits for them is a composition with a commit as the trigger, minus the continuous loop. A runbook that provisions a Namespace, its quotas, and its role bindings is a composition with a ticket as the trigger. Viewed this way, the question shifts from which tool to adopt to where composition is already occurring and what mechanism is performing it.

Composition hiding in plain sight

Kubernetes platforms already contain numerous resource graphs; they simply are not written as such. Naming conventions allow two manifests to agree on a string. Sync waves or hooks encode dependency graphs as step sequences. Scripts copy status fields from one object to another. Each exists because the underlying tool cannot express a dependency between objects or cannot observe current cluster state, so the dependency lives elsewhere—in a string, a step number, or a script. A composition tool brings it back as a reference between the objects themselves. In kro, that reference becomes a CEL expression continuously watched and evaluated within a graph.

Diagram comparing indirect resource dependency methods with direct, continuous object references.

A pattern Kubernetes has followed before

Kubernetes has evolved this way previously. Rolling updates began as a kubectl subcommand until the Deployment controller absorbed them, simplifying everything built on top. Composition appears ready for the same transition. Kro, a Kubernetes SIG Cloud Provider subproject available on GitHub, demonstrates what changes when composition machinery becomes reusable and relationships become declarative. The question extends beyond any single project: if composition is what nearly every platform needs, should every platform be forced to build it independently? Composition may represent the primitive Kubernetes is missing.