Skip to content

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

ConsumersPattern ComplexityRecommendation
1-2AnyDirect 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.
AnyRapidly changing Azure requirementsDirect 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.

bash
helm template my-release ./charts/azure-base -f values-dev.yaml \
  | grep "^kind:" | sort | uniq -c

For azure-base, this gives:

ResourceVaries By Consumer?What Varies?
ResourceGroupYesname, location
VirtualNetworkYesaddressSpace
SubnetYesaddressPrefixes, serviceEndpoints
SecurityGroupYesrules
SubnetNSGAssociationNoalways same pattern
Account (Storage)YesreplicationType, containers
ContainerYescount, names
VaultYesskuName, purgeProtection

Step 2: Design the XRD schema

The XRD defines what consumers can configure. Everything else becomes an implementation detail hidden inside the Composition.

yaml
# 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
      - logs

WARNING

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

yaml
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
                    - networkSize

Step 4: Write the Composition

Map abstract parameters to concrete MR specs:

yaml
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 resources

Step 5: Deploy platform resources separately

platform/
├── xrds/
│   └── xazurebase.yaml
└── compositions/
    └── azure-base.yaml
bash
# 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):

yaml
# templates/vnet.yaml
apiVersion: network.azure.upbound.io/v1beta1
kind: VirtualNetwork
spec:
  forProvider:
    location: {{ .Values.location }}
    addressSpace:
      - {{ .Values.network.vnet.addressSpace }}

After (Composition-backed):

yaml
# 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:

yaml
network:
  vnet:
    addressSpace: "10.10.0.0/16"
  subnets:
    app:
      addressPrefix: "10.10.1.0/24"
storage:
  accountReplicationType: LRS
keyvault:
  skuName: standard

After:

yaml
networkSize: small
storageRedundancy: local
keyvaultTier: standard
containers:
  - data

What You Lose When You Abstract

Be honest about the costs:

AspectDirect MRsCompositions
Debuggingkubectl describe virtualnetwork shows everythingMust trace: Claim → XR → Composition → MR
VisibilityAll Azure fields visible in templatesAzure fields hidden inside Composition
FlexibilityCan tweak any field per consumerConsumers limited to XRD schema
Change speedChange template, helm upgradeChange Composition, test, deploy platform, then upgrade consumers
OwnershipChart owner controls everythingPlatform team owns Compositions, consumers own Claims
Learning curveHelm + provider CRDsHelm + 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.

Released under the MIT License.