When to Abstract: The Graduation Checklist
This page defines the exact conditions and steps for moving from direct Managed Resources (what this project does) to Crossplane Compositions, XRDs, and Claims.
This is not a philosophical discussion. It is a checklist with concrete triggers.
The Two Stages
Stage 1 is not a stepping stone you rush through. It is a valid production architecture. Stage 2 is only worth the investment when specific conditions are met.
The Graduation Triggers
You should move to Compositions when ALL FIVE of these are true simultaneously:
Trigger 1: Multiple consumers of the same pattern
What this means
You have 3 or more teams (or projects) that need the same infrastructure shape. For example, three different microservices each need "a VNet with 2 subnets, an NSG, and a Storage Account." They currently each maintain their own copy of the Helm chart with minor variations.
Symptom: You are copy-pasting charts and making small changes. Bug fixes need to be applied to 3+ copies.
Threshold: 3+ consumers of the same pattern.
Trigger 2: Consumers should not see Azure details
What this means
The people deploying infrastructure should not need to know that a VNet requires addressSpace as an array, that a Subnet needs serviceEndpoints, or that an NSG rule has a priority field. They should say "give me a network with size=large" and the platform handles the rest.
Symptom: Non-platform engineers are making mistakes in Azure resource specs, or asking questions about provider CRD fields they shouldn't need to understand.
Threshold: You have a clear consumer/provider boundary between teams.
Trigger 3: You need to change Azure wiring without touching consumers
What this means
You want to add a NAT Gateway to every VNet, or switch from NSG inline rules to separate SecurityRule resources, or add a Private DNS Zone. With direct MRs, this means updating every consumer's chart. With Compositions, you update the Composition once and all consumers get the change automatically.
Symptom: A platform-level change requires PRs to 3+ consumer repositories.
Threshold: You've had this need at least twice.
Trigger 4: You have a dedicated platform team
What this means
Someone (or a team) owns the Composition lifecycle: writing XRDs, writing and testing Compositions, versioning them, and supporting consumers. This is not a part-time job. Compositions have their own bugs, their own testing requirements, and their own upgrade paths.
Symptom: You have a team with "platform" in their name, or a person whose primary responsibility is Crossplane infrastructure.
Threshold: At least one person dedicated to platform work.
Trigger 5: You understand the MRs you are abstracting
What this means
You have run the direct MR approach in production. You know which fields vary between environments, which fields are always the same, which cross-resource references are needed, and what the common failure modes are. You cannot write a good abstraction without this operational knowledge.
Symptom: You can write the forProvider spec for every resource from memory.
Threshold: You have operated the MRs for at least one full release cycle.
The Decision Matrix
| Consumers | Pattern Complexity | Recommendation |
|---|---|---|
| 1-2 | Any | Direct MRs. No abstraction needed. |
| 3+ | Simple (< 5 resources) | Shared Helm chart with value overrides. Still no Compositions. |
| 3+ | Complex (5+ resources with wiring) | Compositions justified. Proceed to migration steps. |
| Any | Rapidly changing Azure requirements | Direct MRs. Compositions add friction to change. |
Migration Steps: Direct MRs to Compositions
If all five triggers are met, here is the step-by-step migration path.
Step 1: Inventory your MRs
List every Managed Resource your chart renders and identify which fields vary between consumers.
helm template my-release ./charts/azure-base -f values-dev.yaml \
| grep "^kind:" | sort | uniq -cFor azure-base, this gives:
| Resource | Varies By Consumer? | What Varies? |
|---|---|---|
| ResourceGroup | Yes | name, location |
| VirtualNetwork | Yes | addressSpace |
| Subnet | Yes | addressPrefixes, serviceEndpoints |
| SecurityGroup | Yes | rules |
| SubnetNSGAssociation | No | always same pattern |
| Account (Storage) | Yes | replicationType, containers |
| Container | Yes | count, names |
| Vault | Yes | skuName, purgeProtection |
Step 2: Design the XRD schema
The XRD defines what consumers can configure. Everything else becomes an implementation detail hidden inside the Composition.
# What consumers see:
spec:
parameters:
location: eastus
networkSize: small | medium | large # Abstraction over CIDR math
storageRedundancy: local | zone | geo # Abstraction over LRS/ZRS/GRS
keyvaultTier: standard | premium
containers:
- data
- logsWARNING
Do not expose every forProvider field in the XRD. The whole point of abstracting is to reduce the surface area. If your XRD has the same number of fields as the raw MRs, you have not abstracted anything.
Step 3: Write the XRD
apiVersion: apiextensions.crossplane.io/v1
kind: CompositeResourceDefinition
metadata:
name: xazurebases.platform.example.org
spec:
group: platform.example.org
names:
kind: XAzureBase
plural: xazurebases
claimNames:
kind: AzureBase
plural: azurebases
versions:
- name: v1alpha1
served: true
referenceable: true
schema:
openAPIV3Schema:
type: object
properties:
spec:
type: object
properties:
parameters:
type: object
properties:
location:
type: string
enum: [eastus, westeurope, southeastasia]
networkSize:
type: string
enum: [small, medium, large]
storageRedundancy:
type: string
enum: [local, zone, geo]
keyvaultTier:
type: string
enum: [standard, premium]
containers:
type: array
items:
type: string
required:
- location
- networkSizeStep 4: Write the Composition
Map abstract parameters to concrete MR specs:
apiVersion: apiextensions.crossplane.io/v1
kind: Composition
metadata:
name: azure-base-composition
spec:
compositeTypeRef:
apiVersion: platform.example.org/v1alpha1
kind: XAzureBase
resources:
- name: resource-group
base:
apiVersion: azure.upbound.io/v1beta1
kind: ResourceGroup
spec:
forProvider:
location: ""
patches:
- type: FromCompositeFieldPath
fromFieldPath: spec.parameters.location
toFieldPath: spec.forProvider.location
- name: vnet
base:
apiVersion: network.azure.upbound.io/v1beta1
kind: VirtualNetwork
spec:
forProvider:
location: ""
addressSpace: []
patches:
- type: FromCompositeFieldPath
fromFieldPath: spec.parameters.location
toFieldPath: spec.forProvider.location
# Map networkSize to actual CIDR
- type: FromCompositeFieldPath
fromFieldPath: spec.parameters.networkSize
toFieldPath: spec.forProvider.addressSpace[0]
transforms:
- type: map
map:
small: "10.0.0.0/20"
medium: "10.0.0.0/16"
large: "10.0.0.0/12"
# ... more resourcesStep 5: Deploy platform resources separately
platform/
├── xrds/
│ └── xazurebase.yaml
└── compositions/
└── azure-base.yaml# Applied by platform team, NOT inside any Helm chart
kubectl apply -f platform/xrds/
kubectl apply -f platform/compositions/Step 6: Update Helm chart to render Claims instead of MRs
Before (current):
# templates/vnet.yaml
apiVersion: network.azure.upbound.io/v1beta1
kind: VirtualNetwork
spec:
forProvider:
location: {{ .Values.location }}
addressSpace:
- {{ .Values.network.vnet.addressSpace }}After (Composition-backed):
# templates/azure-base-claim.yaml
apiVersion: platform.example.org/v1alpha1
kind: AzureBase
metadata:
name: {{ .Values.project }}-{{ .Values.environment }}
namespace: {{ .Release.Namespace }}
spec:
parameters:
location: {{ .Values.location }}
networkSize: {{ .Values.networkSize }}
storageRedundancy: {{ .Values.storageRedundancy }}
keyvaultTier: {{ .Values.keyvaultTier }}
containers:
{{- range .Values.containers }}
- {{ . }}
{{- end }}The chart goes from 7 template files to 1. The complexity moves to the Composition.
Step 7: Simplify values files
Before:
network:
vnet:
addressSpace: "10.10.0.0/16"
subnets:
app:
addressPrefix: "10.10.1.0/24"
storage:
accountReplicationType: LRS
keyvault:
skuName: standardAfter:
networkSize: small
storageRedundancy: local
keyvaultTier: standard
containers:
- dataWhat You Lose When You Abstract
Be honest about the costs:
| Aspect | Direct MRs | Compositions |
|---|---|---|
| Debugging | kubectl describe virtualnetwork shows everything | Must trace: Claim → XR → Composition → MR |
| Visibility | All Azure fields visible in templates | Azure fields hidden inside Composition |
| Flexibility | Can tweak any field per consumer | Consumers limited to XRD schema |
| Change speed | Change template, helm upgrade | Change Composition, test, deploy platform, then upgrade consumers |
| Ownership | Chart owner controls everything | Platform team owns Compositions, consumers own Claims |
| Learning curve | Helm + provider CRDs | Helm + XRDs + Compositions + patching + provider CRDs |
Signals That You Abstracted Too Early
- Your Composition has the same number of patches as the MRs have fields
- You have exactly one consumer of the Composition
- The platform team is also the only consumer team
- You spend more time debugging Composition patches than Azure issues
- Consumers regularly ask for new fields to be exposed in the XRD
- You find yourself adding
passthroughFields:to proxy raw MR fields
If you see these signals, consider reverting to direct MRs. There is no shame in removing an abstraction that doesn't earn its keep.