Composition of Compositions: A Practical Tutorial
Building Higher-Level Service Abstractions with Crossplane v2
This tutorial demonstrates how to create composite services using Crossplane's powerful "composition of compositions" pattern. We'll use the WhoAmIService template as a real-world example that combines an application deployment with DNS configuration into a single, easy-to-consume service.
Table of Contents
- Introduction
- The Restaurant Analogy
- Architecture Overview
- Implementation Walkthrough
- Creating the Parent Composition
- Handling Status Updates
- Common Pitfalls and Solutions
- Testing Your Composition
- Production Considerations
Introduction
Composition of compositions is a design pattern where a Crossplane Composite Resource (XR) creates other XRs, rather than just managed resources. This enables:
- Service Bundling: Combine multiple infrastructure components into cohesive services
- Abstraction Layers: Hide complexity from end users while maintaining flexibility
- Reusability: Leverage existing compositions as building blocks
- Separation of Concerns: Different teams can manage different layers
Why This Matters
Instead of requiring users to:
- Deploy an application
- Configure DNS separately
- Set up monitoring
- Configure backups
You can provide a single resource that handles everything:
apiVersion: openportal.dev/v1alpha1
kind: WhoAmIService
metadata:
name: my-app
namespace: demo
spec:
name: my-app # That's all you need!
The Restaurant Analogy
To understand composition of compositions, let's use our restaurant analogy:
Traditional Approach (Direct Composition)
- Customer orders individual items: "I'll have a burger, fries, and a drink"
- Kitchen prepares each item separately
- Result: Customer manages multiple orders
Composition of Compositions
- Customer orders a combo meal: "I'll have the Burger Combo #3"
- Combo Menu (Parent XR) defines: Burger + Fries + Drink
- Individual Menus (Child XRs) handle each component
- Result: Customer gets a complete meal with one order
In our WhoAmIService example:
- WhoAmIService = Combo Meal (Parent XR)
- WhoAmIApp = Burger (Child XR #1)
- CloudflareDNSRecord = Drink (Child XR #2)
Architecture Overview
Component Hierarchy
-
WhoAmIService (Parent XR)
- Orchestrates the overall service
- Manages relationships between components
- Provides unified status reporting
-
WhoAmIApp (Child XR)
- Handles application deployment
- Creates Kubernetes resources (Deployment, Service, Ingress)
- Reports application readiness
-
CloudflareDNSRecord (Child XR)
- Manages DNS configuration
- Creates DNSEndpoint for External-DNS
- Handles DNS provider integration
Implementation Walkthrough
Step 1: Define the Parent XRD
First, create the XRD for your parent composition:
# template-whoami-service/configuration/xrd.yaml
apiVersion: apiextensions.crossplane.io/v2
kind: CompositeResourceDefinition
metadata:
name: whoamiservices.openportal.dev
spec:
scope: Namespaced # Crossplane v2 - XRs can be namespaced
group: openportal.dev
names:
kind: WhoAmIService
plural: whoamiservices
defaultCompositionRef:
name: whoamiservice # Simple name, no domain suffix in v2
versions:
- name: v1alpha1
served: true
referenceable: true
schema:
openAPIV3Schema:
type: object
properties:
spec:
type: object
properties:
name:
type: string
description: "Service name (used for app and DNS)"
pattern: "^[a-z0-9]([-a-z0-9]*[a-z0-9])?$"
minLength: 1
maxLength: 63
required:
- name
status:
type: object
properties:
appReady:
type: boolean
dnsReady:
type: boolean
domain:
type: string
ipAddress:
type: string
Step 2: Create the Parent Composition
The parent composition creates child XRs using go-templating:
# template-whoami-service/configuration/composition.yaml
apiVersion: apiextensions.crossplane.io/v1
kind: Composition
metadata:
name: whoamiservice
spec:
compositeTypeRef:
apiVersion: openportal.dev/v1alpha1
kind: WhoAmIService
mode: Pipeline
pipeline:
# Step 1: Load environment configuration
- step: load-environment
functionRef:
name: function-environment-configs
input:
apiVersion: environmentconfigs.fn.crossplane.io/v1beta1
kind: Input
spec:
environmentConfigs:
- type: Reference
ref:
name: dns-config
# Step 2: Create the WhoAmIApp XR
- step: create-app
functionRef:
name: function-go-templating
input:
apiVersion: gotemplating.fn.crossplane.io/v1beta1
kind: GoTemplate
source: Inline
inline:
template: |
{{- $name := .observed.composite.resource.spec.name }}
{{- $xrName := .observed.composite.resource.metadata.name }}
{{- $xrNamespace := .observed.composite.resource.metadata.namespace }}
apiVersion: openportal.dev/v1alpha1
kind: WhoAmIApp
metadata:
name: {{ $xrName }}-app
namespace: {{ $xrNamespace }}
annotations:
# This annotation is REQUIRED for go-templating to track the resource
gotemplating.fn.crossplane.io/composition-resource-name: whoami-app
# Wait for this resource to be ready before proceeding
gotemplating.fn.crossplane.io/ready: "True"
spec:
name: {{ $name }}
replicas: 1 # Fixed value for simplicity
image: "traefik/whoami:v1.10.1" # Fixed demo image
Creating the Parent Composition
Key Concepts for Child XR Creation
When creating child XRs from a parent composition, remember these critical points:
1. No compositionRef in spec
# ❌ WRONG - Don't do this
spec:
compositionRef:
name: whoamiapp
name: my-app
# ✅ CORRECT - Let XRD default handle it
spec:
name: my-app
2. Required Annotations for go-templating
metadata:
annotations:
# This tells go-templating to track this resource
gotemplating.fn.crossplane.io/composition-resource-name: unique-name
# Optional: Wait for readiness
gotemplating.fn.crossplane.io/ready: "True"
3. Namespace Handling in v2
# Always include namespace for namespaced XRs
metadata:
name: {{ $xrName }}-app
namespace: {{ $xrNamespace }} # Critical in v2!
Creating Multiple Child XRs
Here's how to create both application and DNS resources:
# Step 3: Wait for app readiness
- step: wait-app-ready
functionRef:
name: function-auto-ready
# Step 4: Create DNS record with app information
- step: create-dns
functionRef:
name: function-go-templating
input:
apiVersion: gotemplating.fn.crossplane.io/v1beta1
kind: GoTemplate
source: Inline
inline:
template: |
{{- $name := .observed.composite.resource.spec.name }}
{{- $xrName := .observed.composite.resource.metadata.name }}
{{- $xrNamespace := .observed.composite.resource.metadata.namespace }}
# Extract IP from the app's ingress (if available)
{{- $ingressIP := "" }}
{{- range .observed.resources }}
{{- if eq .resource.kind "Object" }}
{{- if .resource.spec.forProvider.manifest }}
{{- if eq .resource.spec.forProvider.manifest.kind "Ingress" }}
{{- if .resource.status.atProvider.manifest.status.loadBalancer.ingress }}
{{- $ingressIP = (index .resource.status.atProvider.manifest.status.loadBalancer.ingress 0).ip | default "" }}
{{- end }}
{{- end }}
{{- end }}
{{- end }}
{{- end }}
# Default to placeholder if IP not yet available
{{- if not $ingressIP }}
{{- $ingressIP = "192.168.1.100" }}
{{- end }}
apiVersion: openportal.dev/v1alpha1
kind: CloudflareDNSRecord
metadata:
name: {{ $xrName }}-dns
namespace: {{ $xrNamespace }}
annotations:
gotemplating.fn.crossplane.io/composition-resource-name: cloudflare-dns
spec:
type: A
name: {{ $name }}
value: {{ $ingressIP | quote }}
# Uses defaults: ttl: 1 (automatic), proxied: false, zone: "openportal-zone"
Handling Status Updates
The Correct Way to Update Composite Status
In Crossplane v2 with go-templating, update status by outputting the XR type with status fields:
# Step 5: Update composite status
- step: update-status
functionRef:
name: function-go-templating
input:
apiVersion: gotemplating.fn.crossplane.io/v1beta1
kind: GoTemplate
source: Inline
inline:
template: |
{{- $name := .observed.composite.resource.spec.name }}
{{- $domain := printf "%s.%s" $name "example.com" }}
# Extract readiness from child XRs
{{- $appReady := false }}
{{- $dnsReady := false }}
{{- range .observed.resources }}
{{- if eq .resource.kind "WhoAmIApp" }}
{{- range .resource.status.conditions }}
{{- if and (eq .type "Ready") (eq .status "True") }}
{{- $appReady = true }}
{{- end }}
{{- end }}
{{- end }}
{{- if eq .resource.kind "CloudflareDNSRecord" }}
{{- range .resource.status.conditions }}
{{- if and (eq .type "Ready") (eq .status "True") }}
{{- $dnsReady = true }}
{{- end }}
{{- end }}
{{- end }}
{{- end }}
# Output the XR type with status (NOT CompositeResourceStatus!)
apiVersion: openportal.dev/v1alpha1
kind: WhoAmIService
status:
appReady: {{ $appReady }}
dnsReady: {{ $dnsReady }}
domain: {{ $domain | quote }}
Common Status Update Mistakes
# ❌ WRONG - CompositeResourceStatus doesn't exist
apiVersion: meta.crossplane.io/v1alpha1
kind: CompositeResourceStatus
status:
ready: true
# ❌ WRONG - Missing required annotation
apiVersion: meta.crossplane.io/v1alpha1
kind: CompositeResourceStatus
metadata:
annotations:
gotemplating.fn.crossplane.io/composition-resource-name: status
status:
ready: true
# ✅ CORRECT - Output the XR type directly
apiVersion: openportal.dev/v1alpha1
kind: WhoAmIService
status:
ready: true
Common Pitfalls and Solutions
Pitfall 1: Wrong API Versions
Problem: Using incorrect API versions for XRs
# Wrong
apiVersion: platform.io/v1alpha1 # Old or incorrect group
Solution: Use the correct group from your XRD
# Correct
apiVersion: openportal.dev/v1alpha1
Pitfall 2: Missing Namespace
Problem: Forgetting namespace for namespaced XRs
# Wrong
metadata:
name: my-resource
# Missing namespace!
Solution: Always include namespace for v2 namespaced XRs
# Correct
metadata:
name: my-resource
namespace: {{ $xrNamespace }}
Pitfall 3: Incorrect Composition Names in XRDs
Problem: Using v1-style composition names with domain suffixes
# Wrong (v1 style)
defaultCompositionRef:
name: whoamiapp.openportal.dev
Solution: Use simple names in v2
# Correct (v2 style)
defaultCompositionRef:
name: whoamiapp
Pitfall 4: Resource Tracking Issues
Problem: go-templating can't track resources without proper annotations
# Missing required annotation
metadata:
name: my-resource
Solution: Add composition-resource-name annotation
metadata:
name: my-resource
annotations:
gotemplating.fn.crossplane.io/composition-resource-name: unique-id
Testing Your Composition
1. Deploy the Parent XRD and Composition
# Apply XRD
kubectl apply -f template-whoami-service/configuration/xrd.yaml
# Apply Composition
kubectl apply -f template-whoami-service/configuration/composition.yaml
2. Create a Test Instance
# test-whoami-service.yaml
apiVersion: openportal.dev/v1alpha1
kind: WhoAmIService
metadata:
name: test-app
namespace: demo
spec:
name: test-app
replicas: 2
kubectl apply -f test-whoami-service.yaml
3. Verify Child XR Creation
# Check parent XR
kubectl get whoamiservices -n demo
# Check child XRs
kubectl get whoamiapps,cloudflarednsrecords -n demo
# Check actual resources
kubectl get pods,services,ingresses -n demo
4. Debug Issues
# Check parent XR status
kubectl describe whoamiservices test-app -n demo
# Check composition reconciliation
kubectl get compositions whoamiservice -o yaml | grep -A 20 "status:"
# Check function pipeline logs
kubectl logs -n crossplane-system deployment/crossplane -f | grep whoamiservice
Production Considerations
1. Version Management
Use semantic versioning for your compositions:
# Tag releases properly
git tag -a v1.0.0 -m "Initial release"
git tag -a v1.1.0 -m "Add monitoring support"
git tag -a v1.1.1 -m "Fix DNS status update"
2. Composition Revisions
Control composition updates:
spec:
compositionUpdatePolicy: Manual # Don't auto-update in production
compositionRevisionRef:
name: whoamiservice-v1-0-0
3. Resource Limits
Set appropriate limits for child XRs:
spec:
resourceLimits:
cpu: "1000m"
memory: "1Gi"
4. Monitoring and Observability
Add labels for tracking:
metadata:
labels:
service-tier: frontend
managed-by: platform-team
cost-center: engineering
5. Rollback Strategy
Keep previous composition revisions:
# List composition revisions
kubectl get compositionrevisions | grep whoamiservice
# Rollback if needed
kubectl patch whoamiservices test-app -n demo --type=merge \
-p '{"spec":{"compositionRevisionRef":{"name":"whoamiservice-previous"}}}'
Advanced Patterns
Pattern 1: Conditional Resource Creation
Create resources based on spec fields:
template: |
{{- if .observed.composite.resource.spec.enableMonitoring }}
apiVersion: monitoring.io/v1
kind: ServiceMonitor
metadata:
name: {{ $xrName }}-monitor
spec:
selector:
app: {{ $name }}
{{- end }}
Pattern 2: Resource Ordering with Dependencies
Use gotemplating.fn.crossplane.io/ready to control ordering:
# First XR - will be created and wait for ready
annotations:
gotemplating.fn.crossplane.io/composition-resource-name: database
gotemplating.fn.crossplane.io/ready: "True"
# Second XR - created after database is ready
annotations:
gotemplating.fn.crossplane.io/composition-resource-name: application
Pattern 3: Data Passing Between XRs
Extract data from one XR to use in another:
# Extract from first XR
{{- $dbHost := "" }}
{{- range .observed.resources }}
{{- if eq .resource.metadata.name "database" }}
{{- $dbHost = .resource.status.endpoint }}
{{- end }}
{{- end }}
# Use in second XR
spec:
databaseHost: {{ $dbHost }}
Troubleshooting Guide
Issue: "Composition not found"
Symptom: cannot fetch Composition: Composition.apiextensions.crossplane.io "xxx.domain.com" not found
Solution: Check XRD defaultCompositionRef uses v2 naming (no domain suffix)
Issue: "field not declared in schema"
Symptom: .spec.compositionRef: field not declared in schema
Solution: Remove compositionRef from child XR specs
Issue: "cannot apply composed resource"
Symptom: Resources not being created or updated
Solution: Add required go-templating annotations
Issue: Status not updating
Symptom: Parent XR status remains empty
Solution: Output XR type with status, not CompositeResourceStatus
Summary
Composition of compositions enables powerful service abstractions in Crossplane v2. Key takeaways:
- Parent XRs create Child XRs - Build hierarchical service definitions
- Use XRD defaults - Don't specify compositionRef in child XR specs
- Include namespaces - Critical for v2 namespaced resources
- Track resources properly - Use go-templating annotations
- Update status correctly - Output XR type with status fields
This pattern transforms infrastructure complexity into simple, consumable services that developers love!
Next Steps
- Explore the WhoAmIService source code
- Read about Crossplane Composition Functions
- Learn about Environment Configurations
- Join the Crossplane Slack community
Last updated: September 2025 | Based on Crossplane v2.0 and the WhoAmIService v1.0.4 template