Crossplane Composition Functions: Internal Developer Platform Guide

Crossplane Composition Functions: Internal Developer Platform Guide

Crossplane Composition Functions: Building Cloud Platforms in 2026

Imagine declaring a multi-cloud database with a single Kubernetes manifest—and having it automatically provision permissions, backups, and network policies without writing Go templates or Patch & Transform logic. That’s Crossplane Composition Functions in production today. This shift from declarative patching to functional pipelines has become the backbone of serious internal developer platforms (IDPs). In this post, we’ll dissect the gRPC-based function model, compare SDK choices, and show you how to build platform abstractions that scale across cloud providers. Whether you’re migrating from Patch & Transform or architecting your first IDP, understanding composition functions is essential for Kubernetes-native infrastructure in 2026. What this post covers: the reference architecture, function anatomy, a real CloudSQL example, SDK trade-offs, failure modes, and production deployment patterns.

Why Composition Functions Replaced Patch & Transform

Crossplane’s original composition model—Patch & Transform (P&T)—was powerful but rigid. Developers wrote CEL expressions or Go templates inline within CompositionResources, leading to massive, hard-to-test YAML files. Logic was tangled with resource declarations. Debugging meant parsing through nested substitutions and field-path errors. By Crossplane v1.14 (November 2023), the project introduced Composition Functions as a GA feature, and v1.17 made pipeline mode the default.

Functions invert the problem. Instead of patching fields into managed resources, functions run as external gRPC services, receiving the entire composition state as structured input and returning modified desired state. Each function is a discrete, testable stage in a pipeline. You can write a function once, package it as an OCI image, and reuse it across dozens of compositions. The function-patch-and-transform itself became a function (for backward compatibility), illustrating the model’s elegance.

This shift enables better separation of concerns: platform engineers own the composition functions; developers own the claims. Functions run on a schedule (default 60s timeout, configurable), are deployed to the cluster as Function resources, and communicate over Unix sockets via gRPC. The result is deterministic, debuggable, and composable platform logic.

Reference Architecture for a Functions-Based Composition

The canonical flow is straightforward but powerful:

Crossplane Composition Functions pipeline architecture — XRClaim triggers Composition, which runs function pipeline stages, each transforming state before managed resources are reconciled.

A developer applies an XRClaim (e.g., a PostgresqlInstance claim). The Crossplane controller looks up the corresponding Composite Resource (XR) definition and its active Composition. The Composition specifies mode: Pipeline, listing a sequence of functions. Each function receives a RunFunctionRequest containing:

  • observed: current state of all managed resources
  • desired: the target resource template after function processing
  • context: metadata like the claim name, target cloud region, owner references

The function processes this state and returns a modified desired state. The next function in the pipeline receives this updated state as its input. After all functions complete, the Crossplane controller reconciles the final desired state into managed resources (CloudSQL instances, IAM roles, Secrets, etc.).

The key advantage: functions are composable stages, not monolithic templates. A “fetch-secret” function can run before a “render-cloud-config” function; both remain reusable and testable in isolation.

Anatomy of a Composition Function

Under the hood, every Crossplane function is a gRPC service speaking the Crossplane-defined protocol:

Crossplane function gRPC contract showing RunFunctionRequest and RunFunctionResponse messages, with observed/desired state transformation between function stages.

The interface is minimal:

service Function {
  rpc RunFunction(RunFunctionRequest) returns (RunFunctionResponse);
}

RunFunctionRequest contains:

  • observed_composite: the current state of the XR and its managed resources
  • desired_composite: the desired state after earlier pipeline stages
  • input: function-specific configuration from the Composition manifest
  • context: claim name, target region, namespace, etc.

RunFunctionResponse returns:

  • desired_composite: updated desired state
  • results: array of log messages, warnings, errors
  • fatal: boolean to halt the pipeline on error

The Crossplane controller orchestrates the pipeline: function 1 input = claimed state; function 1 output = function 2 input. If a function sets fatal: true, the reconciliation loop pauses and retries on the next sync period.

Functions are deployed as Function resources:

apiVersion: pkg.crossplane.io/v1beta1
kind: Function
metadata:
  name: function-templating
spec:
  package: xpkg.upbound.io/crossplane-contrib/function-go-templating:v0.1.0

The package field points to an OCI image containing the function binary and configuration. Crossplane pulls the image, spawns the function as a sidecar, and communicates over a Unix socket at /tmp/crossplane-function-<hash>.sock. This design avoids network latency and sidesteps service discovery.

Supported runtimes include:

  • Go SDK (function-go, fastest, type-safe)
  • Python SDK (function-python, good for ML/data pipelines)
  • KCL SDK (function-kcl, declarative + strong validation)
  • function-go-templating (Go text/template for simple cases, zero compilation)

The Crossplane controller enforces a 60-second timeout per function call. Longer operations should offload to async cloud jobs and poll status.

Walkthrough: Building an Internal Developer Platform XRD

Let’s build a practical example: a XPostgresInstance that developers claim, and the platform provisions CloudSQL, IAM bindings, and a connection secret.

Step 1: Define the Claim Type

apiVersion: apiextensions.crossplane.io/v1
kind: CompositeResourceDefinition
metadata:
  name: xpostgresinstances.mycompany.dev
spec:
  group: mycompany.dev
  names:
    kind: XPostgresInstance
    plural: xpostgresinstances
  claimNames:
    kind: PostgresInstance
    plural: postgresinstances
  versions:
    - name: v1
      served: true
      referenceable: true
      schema:
        openAPIV3Schema:
          type: object
          properties:
            spec:
              type: object
              properties:
                parameters:
                  type: object
                  properties:
                    region:
                      type: string
                      default: "us-central1"
                    tier:
                      type: string
                      default: "db-f1-micro"
                    databaseVersion:
                      type: string
                      default: "POSTGRES_15"
                    backupLocation:
                      type: string
                      default: "us"
                    vaultSecret:
                      type: string
                      description: "Name of Vault secret containing admin password"
                required:
                  - parameters
            status:
              type: object
              properties:
                ready:
                  type: boolean
                connectionSecretRef:
                  type: object
                  properties:
                    name:
                      type: string
                    namespace:
                      type: string

This XRD exposes three main parameters: region, instance tier, and database version. The claim status includes a reference to the provisioned connection secret.

Step 2: Write a Composition with Function Pipeline

apiVersion: apiextensions.crossplane.io/v1
kind: Composition
metadata:
  name: postgres-cloudsql
  labels:
    crossplane.io/xrd: xpostgresinstances.mycompany.dev
spec:
  compositeTypeRef:
    apiVersion: apiextensions.crossplane.io/v1
    kind: XPostgresInstance
  mode: Pipeline
  pipeline:
    - step: fetch-vault-secret
      functionRef:
        name: function-vault
      input:
        apiVersion: vault.function.crossplane.io/v1beta1
        kind: VaultLookup
        metadata:
          name: fetch-admin-password
        spec:
          secretPath: "secret/data/postgres/admin"
          secretField: "password"

    - step: render-cloudsql
      functionRef:
        name: function-go-templating
      input:
        apiVersion: templating.function.crossplane.io/v1
        kind: GoTemplate
        metadata:
          name: render-cloudsql-instance
        spec:
          source: Inline
          inline:
            template: |
              {{- $adminPassword := .context.Vault.password -}}
              apiVersion: sql.gcp.crossplane.io/v1beta1
              kind: CloudSQLInstance
              metadata:
                name: {{ .observed.composite.metadata.name }}-cloudsql
                namespace: crossplane-system
              spec:
                forProvider:
                  region: {{ .observed.composite.spec.parameters.region }}
                  databaseVersion: {{ .observed.composite.spec.parameters.databaseVersion }}
                  tier: {{ .observed.composite.spec.parameters.tier }}
                  backupConfiguration:
                    enabled: true
                    binaryLogEnabled: true
                    location: {{ .observed.composite.spec.parameters.backupLocation }}
                  settings:
                    availabilityType: REGIONAL
                    ipConfiguration:
                      ipv4Enabled: true
                      authorizedNetworks:
                        - value: "0.0.0.0/0"
                      requireSsl: true
                providerConfigRef:
                  name: gcp-provider
              status: {}
              ---
              apiVersion: sql.gcp.crossplane.io/v1beta1
              kind: User
              metadata:
                name: {{ .observed.composite.metadata.name }}-admin
              spec:
                forProvider:
                  instanceRef:
                    name: {{ .observed.composite.metadata.name }}-cloudsql
                  password:
                    secretRef:
                      name: cloudsql-admin-password
                      namespace: crossplane-system
                      key: password
                providerConfigRef:
                  name: gcp-provider

    - step: create-connection-secret
      functionRef:
        name: function-auto-ready
      input:
        apiVersion: meta.function.crossplane.io/v1
        kind: AutoReady
        metadata:
          name: mark-ready
        spec:
          managedResources:
            - apiVersion: sql.gcp.crossplane.io/v1beta1
              kind: CloudSQLInstance
            - apiVersion: sql.gcp.crossplane.io/v1beta1
              kind: User

This pipeline has three stages:

  1. fetch-vault-secret: Retrieve the admin password from a Vault instance outside Kubernetes.
  2. render-cloudsql: Use Go templating to materialize CloudSQL instance and user manifests based on the claim spec.
  3. create-connection-secret: Mark the composition ready once both managed resources are ready.

Each function is independent, testable, and reusable across multiple compositions.

Step 3: Developer Experience

A developer creates a claim:

apiVersion: mycompany.dev/v1
kind: PostgresInstance
metadata:
  name: my-analytics-db
  namespace: data-engineering
spec:
  parameters:
    region: "us-west1"
    tier: "db-custom-4-16384"
    databaseVersion: "POSTGRES_15"
    backupLocation: "us-west1"
    vaultSecret: "secret/data/postgres/admin"

Within seconds, the Crossplane controller:
1. Recognizes the claim matches XPostgresInstance.
2. Instantiates the Composition and pipeline.
3. Runs each function in sequence.
4. Reconciles the final desired state (CloudSQL instance, user, and connection secret).
5. Updates the claim status with connection details.

The developer never touches GCP console, writes Terraform, or manages IAM roles. The platform owns complexity; developers own simplicity.

Developer self-service flow — claim is applied, Composition pipeline runs, managed resources are provisioned, and status is propagated back to the claim, closing the feedback loop.

Function SDKs Compared: Go vs Python vs KCL vs Templating

Choosing a runtime involves trade-offs. Crossplane provides multiple SDKs to match different use cases:

Go SDK (function-go)

Best for: High performance, type safety, complex logic.

package main

import (
    "context"
    fnv1 "github.com/crossplane/function-sdk-go/proto/v1"
    "google.golang.org/grpc"
)

func (s *Server) RunFunction(ctx context.Context, req *fnv1.RunFunctionRequest) (*fnv1.RunFunctionResponse, error) {
    // Type-safe access to observed and desired state
    desired := req.DesiredComposite
    observed := req.ObservedComposite

    // Mutate desired state
    // ...

    return &fnv1.RunFunctionResponse{
        DesiredComposite: desired,
        Results: []*fnv1.Result{
            {
                Severity: fnv1.Severity_SEVERITY_NORMAL,
                Message:  "Successfully processed composition",
            },
        },
    }, nil
}

Go SDK advantages:
– Compile-time type checking.
– Minimal reflection overhead; latency typically <50ms per function call.
– Access to rich ecosystem (AWS SDK, GCP client libraries).
– Native support for complex logic (loops, conditionals, error handling).

Go SDK disadvantages:
– Requires compilation and container build.
– Larger binary size (~30-50MB compressed).
– Steeper learning curve for platform teams without Go experience.

Python SDK (function-python)

Best for: Data transformation, ML-driven logic, rapid prototyping.

import json
from function.proto import v1pb2 as fnv1

def run_function(request: fnv1.RunFunctionRequest) -> fnv1.RunFunctionResponse:
    desired = request.desired_composite
    observed = request.observed_composite

    # Mutation logic
    # ...

    return fnv1.RunFunctionResponse(
        desired_composite=desired,
        results=[
            fnv1.Result(
                severity=fnv1.Severity.SEVERITY_NORMAL,
                message="Processed composition"
            )
        ]
    )

Python advantages:
– Easy to learn; familiar to DevOps teams.
– Strong for data pipelines, parsing, and transformation.
– Rapid iteration; minimal compilation.

Python disadvantages:
– Slower startup; cold-start latency can exceed 500ms.
– Larger memory footprint.
– GIL contention under high concurrency.

KCL SDK (function-kcl)

Best for: Declarative validation, schema enforcement, policy-as-code.

import k8s
import crossplane.pkg

_adminPassword = context.vault_secret.password

cloudsql_instance = k8s.core.v1.ObjectMeta {
    name = context.claim_name + "-cloudsql"
    namespace = "crossplane-system"
}

resources = [
    {
        apiVersion = "sql.gcp.crossplane.io/v1beta1"
        kind = "CloudSQLInstance"
        metadata = cloudsql_instance
        spec = {
            forProvider = {
                region = context.region
                databaseVersion = context.databaseVersion
                tier = context.tier
            }
        }
    }
]

KCL advantages:
– Declarative, human-readable; reduces imperative bugs.
– Built-in validation and type checking.
– Policy enforcement (e.g., “all CloudSQL instances must have backups enabled”).
– Fast execution (~100ms).

KCL disadvantages:
– Smaller ecosystem; fewer third-party integrations.
– Steeper learning curve for teams unfamiliar with KCL syntax.

function-go-templating

Best for: Simple cases, zero compilation, Go text/template syntax.

{{ $region := .observed.composite.spec.parameters.region }}
apiVersion: sql.gcp.crossplane.io/v1beta1
kind: CloudSQLInstance
metadata:
  name: {{ .observed.composite.metadata.name }}-cloudsql
spec:
  forProvider:
    region: {{ $region }}
    tier: db-f1-micro

Templating advantages:
– No compilation; instant feedback.
– Familiar Go template syntax.
– Ideal for simple field substitution.

Templating disadvantages:
– No type safety; errors emerge at runtime.
– Limited to Go template semantics; no loops or conditionals.
– Scales poorly for complex multi-resource orchestration.

Comparison of SDK ergonomics, performance, type safety, and ecosystem maturity across Go, Python, KCL, and function-go-templating.

Trade-offs and Failure Modes

While Composition Functions unlock powerful capabilities, they introduce operational complexity:

Reconciliation Latency

Each function call adds latency to the reconciliation loop. A typical composition without functions reconciles in 50-100ms. Adding three functions (vault lookup, template rendering, status check) can push latency to 250-400ms, especially under high load. Vault lookups and external API calls compound the cost. For time-sensitive applications (auto-scaling, active-passive failover), this added latency matters.

Mitigation: Use function caching, run functions on dedicated node pools with high memory, and avoid blocking I/O in the function hot path.

Debugging Across Function Boundaries

When a composition fails, the error may originate in function A, propagate as corruption through function B, and surface as a constraint violation in function C. Stack traces span multiple gRPC boundaries. Tools like crossplane render help, but tracing a failure across the entire pipeline requires logging discipline.

Mitigation: Instrument each function with structured logging; include function name, input hash, and result status in logs. Adopt distributed tracing (e.g., OpenTelemetry) early.

Package Supply Chain

Functions are OCI images pulled from registries. A compromised function image can inject malicious resources into the cluster. Pinning image digests (SHA256) instead of tags is mandatory. Scanning images for vulnerabilities before promotion to production is essential.

Mitigation: Use image signatures (Cosign), enforce admission policies (Kyverno, OPA/Gatekeeper), and scan all function images with a SBOM tool.

Cold Start Overhead

Python and larger Go binaries suffer cold-start latency (first invocation after a controller restart can be 500ms+). Container runtimes and image caching mitigate this, but it’s a real cost in high-churn environments.

Mitigation: Use function-go or function-go-templating for latency-sensitive compositions. Pre-pull function images on nodes. Monitor crossplane_function_run_duration_seconds to identify outliers.

Reconciliation latency comparison: baseline ~50ms without functions vs 250ms+ with a 3-function pipeline, showing latency breakdown per stage.

Production Recommendations

1. Pin Function Digests

Never use image tags; always pin by SHA256 digest:

spec:
  package: xpkg.upbound.io/crossplane-contrib/function-go-templating@sha256:a3f9b8c1d2e3f4a5b6c7d8e9f0a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7

This ensures reproducible function execution across restarts and clusters.

2. Dedicated Node Pool for Functions

Crossplane controllers and functions should not contend for CPU. Provision a dedicated node pool for crossplane-system namespace:

nodeSelector:
  workload: crossplane-functions
tolerations:
  - key: crossplane.io/function
    operator: Equal
    value: "true"
    effect: NoSchedule

3. Monitor Function Latency

Crossplane exports Prometheus metrics:

crossplane_function_run_duration_seconds{function="function-go-templating"} 0.145
crossplane_function_call_count{function="function-go-templating"} 42

Alert if any function consistently exceeds 1 second. Use Prometheus histograms to track p50, p95, p99 latencies.

4. Validate Before Applying

Use crossplane render to execute the composition locally and inspect the generated resources:

crossplane render xpostgresinstances.mycompany.dev \
  --claim-file my-claim.yaml \
  --composition-file my-composition.yaml

This catches logic errors before deployment.

5. Composition Revisions

Never modify a Composition in place. Use composition revisions to safely roll out changes:

apiVersion: apiextensions.crossplane.io/v1
kind: Composition
metadata:
  name: postgres-cloudsql
spec:
  compositeTypeRef:
    apiVersion: apiextensions.crossplane.io/v1
    kind: XPostgresInstance
  publishedVersion: "1.0.0"
  mode: Pipeline
  # ...

Increment the version, deploy the new Composition alongside the old one, and gradually migrate claims via compositionSelector labels.

6. Function Health Checks

Deploy function health check sidecar:

apiVersion: v1
kind: Pod
metadata:
  name: function-go-templating
spec:
  containers:
    - name: function
      image: xpkg.upbound.io/crossplane-contrib/function-go-templating@sha256:...
      livenessProbe:
        grpc:
          port: 9443
        initialDelaySeconds: 10
        periodSeconds: 30

gRPC liveness probes ensure stuck functions are restarted.

FAQ

Q: Are Composition Functions production ready?

A: Yes. Composition Functions reached GA in v1.14 (November 2023) and are battle-tested in production IDPs at scale. However, they add operational complexity; teams should be comfortable with gRPC debugging and image supply chain management.

Q: Do I need Composition Functions or can I still use Patch & Transform?

A: Patch & Transform still works (it’s implemented as a built-in function now). Use P&T for simple compositions; migrate to functions when logic becomes unwieldy, testability matters, or you need code reuse across multiple compositions. Functions are worth the investment if your IDP scales to 20+ claim types.

Q: How do I debug a failing function?

A: Enable debug logging on the Crossplane controller: --debug flag. Inspect function logs with kubectl logs -f deployment/crossplane -c crossplane -n crossplane-system. Use crossplane render to test the composition locally. Add structured logging (Zap, Slog) to the function itself, including request/response hashes.

Q: What’s the latency cost?

A: A single function call adds 50-200ms under normal load; each additional function multiplies this. Vault lookups and external API calls can add 500ms+. For most workloads, this is acceptable; provision headroom in your reconciliation SLAs.

Q: Crossplane vs Terraform vs Pulumi for IDPs?

A: Crossplane is Kubernetes-native; resources are CRDs, not state files. Terraform is cloud-agnostic and mature; Pulumi is programmatic. Choose Crossplane if your team is invested in Kubernetes; choose Terraform for multi-cloud with less k8s friction; choose Pulumi for teams that prefer Python/Go over HCL. Crossplane’s function model is closest to Pulumi’s multi-language SDK ecosystem.

Q: Can I write functions in TypeScript?

A: Not yet. The SDK ecosystem covers Go, Python, and KCL. TypeScript support is on the roadmap but requires a JavaScript runtime sidecar and adds startup overhead. For now, Go or Python are the practical choices.

Further Reading

For deeper dives into Crossplane, check out our companion posts on internal developer platforms and service mesh observability. If you’re building real-time systems with Crossplane, see our tutorial on MQTT clusters in Kubernetes.

External references:


Written by the iotdigitaltwinplm.com platform-engineering desk. Last updated 2026-04-24.

Comments

No comments yet. Why don’t you start the discussion?

Leave a Reply

Your email address will not be published. Required fields are marked *