CRD Design and Go API Types
Table of Contents
Introduction
CRD design is where most operator complexity starts. Getting the API types right matters more than getting the reconcile logic right — type changes are hard to migrate, and a poorly modelled spec leads to a controller full of edge cases.
This article covers:
What a CRD actually is in Go
Designing the
AppStackspec and statusUsing kubebuilder markers for validation and generation
How
make manifeststurns Go structs into OpenAPI-validated CRD YAML
CRD Anatomy
A CRD is a Kubernetes API extension. When you apply a CRD to a cluster, the API server starts accepting resources of that type. A CRD defines:
API group/version/kind:
apps.htunn.io/v1alpha1/AppStackSpec schema: the fields users define (desired state)
Status schema: the fields the controller writes (observed state)
Validation: field types, required/optional, enum values, numeric ranges
Subresources: whether
statusandscaleare separate subresources
In Go, a CRD is represented by three structs:
The metav1.TypeMeta and metav1.ObjectMeta are embedded from k8s.io/apimachinery/pkg/apis/meta/v1. You don't define them — they're inherited.
Designing the AppStack API
The AppStack resource manages three Kubernetes resources for a given application:
Deployment — runs the container
Service — exposes it on a port within the cluster
HorizontalPodAutoscaler — scales replicas based on CPU (optional)
What does the user need to declare? Only what's variable across applications:
spec.image
Container image (repository + tag)
spec.port
Port the container listens on
spec.replicas
Desired replica count (overridden by HPA if autoscaling enabled)
spec.env
Environment variables
spec.autoscaling.enabled
Whether to create an HPA
spec.autoscaling.minReplicas
HPA minimum
spec.autoscaling.maxReplicas
HPA maximum
spec.autoscaling.cpuTargetPercent
HPA CPU utilization target
Everything else — label selectors, pod templates, readiness probes — the operator sets with sensible defaults.
Writing the Go Types
Open api/v1alpha1/appstack_types.go. The scaffold already has stub structs. Replace them with the full type definition:
kubebuilder Validation Markers
kubebuilder reads Go struct comments that start with // +kubebuilder: and generates corresponding OpenAPI v3 schema in the CRD YAML. This means validation runs at the API server level — invalid resources are rejected before your controller ever sees them.
Common Validation Markers
Object-Level Markers
Applied to the top-level struct:
The subresource:status marker is important. It means:
.specupdates go throughr.Update().statusupdates go throughr.Status().Update()These are separate API calls, and separate RBAC permissions
This prevents users from accidentally overwriting status when updating spec.
Print Columns
The printcolumn markers control what kubectl get appstack shows:
Result:
Status Design with Conditions
metav1.Condition is the standard Kubernetes API type for expressing resource status. Using it correctly makes your operator consistent with built-in controllers and visible to standard tooling.
For AppStack, three conditions cover the lifecycle:
Condition
True means
False means
Available
≥1 replica is ready
Deployment exists but has 0 ready replicas
Progressing
Reconcile is actively working
Reconcile is stable, no changes in progress
Degraded
An error occurred
No errors
A healthy AppStack looks like:
A deploying AppStack:
A failed AppStack:
Reason Codes
Reason must be CamelCase with no spaces. These are constants:
Generating CRD YAML
After writing the Go types:
The generated CRD YAML looks like:
Check the generated file at config/crd/bases/apps.htunn.io_appstacks.yaml to verify your markers produced the intended schema.
Install the updated CRD:
Writing a Sample Manifest
kubebuilder generates config/samples/apps_v1alpha1_appstack.yaml. Update it with a realistic sample:
Apply it:
At this point, the spec is accepted by the API server (validation passes), but no resources are created yet — that's the controller's job, covered in the next article.
Common Mistakes
Forgetting +kubebuilder:subresource:status
Without this, calling r.Status().Update() fails with a "not found" error on the status subresource. Always add this marker to your root type.
Using error in status fields
Status structs must be JSON-serializable and deepcopy-able. Use string for error messages, not error. The metav1.Condition.Message field is the right place for error descriptions.
Making required fields optional in the spec
If image is truly required to do anything, mark it required (no // +optional). Accepting a resource with an empty image and failing in the controller is harder to debug than a validation rejection at apply time.
Not using pointer types for optional numeric fields
Replicas *int32 vs Replicas int32 — the pointer form allows nil (field omitted). Non-pointer int32 defaults to 0 when not provided, which is a valid but often undesired value. Use pointer types for optional numeric fields.
Editing zz_generated.deepcopy.go
This file is regenerated on every make generate. Never edit it manually.
Last updated