Agnostic primitives with cdk8s and Crossplane v2: one composition per product
How I replaced per-backend Crossplane Compositions with a single Go orchestrator function, and the exact setup a team runs to consume PostgresInstance and ObjectBucket.
My platform catalog used to carry one Composition per product per backend. Postgres had compositions/aws.ts and compositions/cnpg.ts. Object storage had compositions/s3.ts and compositions/garage.ts. A compositionSelector matched on spec.provider and picked the right one. It looked clean on a whiteboard. It wasn't.
What broke
The first crack was duplication. Every backend file repeated the same scaffolding — composite type ref, patch sets, connection details — with only the managed resource at the bottom changing. Adding a backend to a product meant copying a file and tweaking it, then remembering to do it again for the other products.
The second crack was structural: patch-and-transform Compositions cannot nest. I wanted stacks that consume primitives — an observability stack that requests a database and a bucket, not an RDS instance and an S3 bucket. With per-backend Compositions, the stack has to know the backend to pick the Composition, which means the abstraction leaks upward. The whole point of a primitive is that the consumer doesn't know what's behind it.
The third crack was drift. The provider list existed in TypeScript (for the cdk8s XRD enums) and in Go (for anything the function side needed). They drifted silently. A provider added to the TS enum but not the Go handler surfaced only at runtime, which is the worst possible time to learn about it. Worse, the consumer repo had vendored my shared package and forked it — roughly 170k lines of duplicated code that the redesign let me delete. That deletion is the number I remember, because it was the moment the design paid off.
The fix: one thin Composition, one Go function
The replacement is ADR 007 in my repo: every product ships exactly one Composition, and it's a stub. The Composition runs in Pipeline mode with a single step that calls a central composition function, function-platform, written in Go with function-sdk-go. Backend selection is a switch provider inside a handler, not a YAML selector:
Each handler reads spec.provider and emits the matching managed resources. For provider: garage on an ObjectBucket, that's a GarageBucket plus a GarageKey with a secretTemplate that writes credentials into a <name>-creds Secret. For provider: s3, it's an s3.aws.m.upbound.io Bucket. The consumer sees neither.
The provider enum, profile defaults, and connection-key names live in one contract.json that codegens to both TypeScript and Go, with a --check gate in pre-push so drift fails the build instead of failing a reconcile.
Why Crossplane v2, and why cdk8s
I get asked why not Terraform, and why not plain Helm or Kustomize for the API definitions.
Crossplane v2 won because composition functions turn reconciliation into a real programming environment. Patching YAML with patches: - fromFieldPath: is fine until you need a switch statement, a default, or a loop — then you're in annotation gymnastics. A Go function gets typed structs, unit tests, and go test -race in CI. Version 2 specifically matters: namespaced XRs replace the old Claim model, so teams create resources directly in their own namespaces without a second API surface, and namespaced provider APIs (helm.m.crossplane.io, *.m.upbound.io) give per-team credential isolation with a <team>-<provider> ProviderConfig convention.
cdk8s won for the other half of the problem. The XRD schema is code, so it's type-checked and snapshot-tested. A broken schema change fails jest on a laptop, not kubectl apply in a cluster. The rule I enforce is that cdk8s never contains reconciliation logic — it emits the XRD and the thin Composition, nothing more. The moment cdk8s starts encoding provider branching, you've forked the control plane into a build tool. TypeScript describes APIs; Go reconciles them. Keeping that seam sharp is what made the 170k-line deletion possible.
The v2 landmines are worth naming because the docs bury them. There are no XR connection secrets — connectionSecretKeys is gone, so status is the entire contract. Pipeline-mode resources are unready unless the function says otherwise, so every handler sets readiness explicitly from the observed Ready condition. And the v2 XRD-to-CRD converter rejects additionalProperties next to properties on nested fields; keep nested objects structural or free-form, never both.
Standing it up
The local setup is two scripts. scripts/e2e-setup.sh builds a kind cluster with Crossplane, ESO, the CNPG operator, provider-helm, and the function itself — built from source and served from a local OCI registry on the kind network, because Crossplane's package cache can't use kind-loaded images:
team-setup.sh is the per-team piece: it creates the namespace, a namespaced ProviderConfig named <team>-helm, and a RoleBinding letting the provider-helm service account install charts there. Then install the product APIs:
Using a primitive
A team asks for a database with three required fields and zero provider knowledge:
unknown nodeThe answer is in status, because that's the only contract v2 leaves:
The referenced Secret is the CNPG operator's own app secret (<name>-pg-app) — username, password, dbname, host, port, uri — so applications mount the operator-native secret and the platform never handles credentials. Object storage works the same way: spec.provider: garage (the default) yields status.endpoint pointing at the in-cluster Garage S3 endpoint and status.credentialsSecretRef.name pointing at a key the Garage operator issued for exactly that bucket.
What it cost
The repo was previously TypeScript-only — cdk8s packages, jest tests, npm scripts, one toolchain that every platform engineer already had on their laptop. Introducing the orchestrator changed that: a go.mod, a Dockerfile for the function runtime image, an xpkg package build, and a second CI lane with its own build and test steps. Pre-push now runs tsc and go test -race back to back, and anyone touching the platform needs both toolchains installed and working. That sounds trivial; in practice it doubles the local environment, the CI surface, and the number of ways a "small change" can fail before lunch.
Every handler also needs tests that simulate observed state — you can't just assert the desired output, you have to feed the function a fake observed world. And the design forces discipline: the temptation to "just add a quick branch in the function" for a one-off product is constant, and every time I've allowed it, that product became the next migration.
The payoff is that adding a backend is now a case in a switch statement with a unit test, and adding a product is one XRD, one stub Composition, one handler. In the next post I'll cover what sits on top of these primitives: how the observability stack outgrew the orchestrator and moved to KRO with Argo CD doing the actual Helm work — and what broke along the way.
What Golden Paths Actually Are (and Why Most of Them Die)
I've rebuilt the same platform more than once across my career. Golden paths are what make the work transfer, not just the knowledge. Kickoff to a series on building baseline golden paths from real platform work: Crossplane XRDs, Backstage scaffolding, Argo CD at 110 apps on 10 clusters, and why the opt-out clause is the whole trick.
Productized stacks with Hiroba primitives, KRO, and Argo CD
How I build a self-service observability stack on top of Hiroba primitives using KRO ResourceGraphDefinitions and Argo CD — with the failures it took to get there.