A CustomResourceDefinition (CRD) adds a resource type: kubectl 5,150 , RBAC and watches treat it like a built-in kind 14,561 , and the API server enforces its OpenAPI v3 schema on every write. BookNest's promotions are an example:
apiVersion: apiextensions.k8s.io/v1
kind: CustomResourceDefinition
metadata: { name: promotions.booknest.example.com }
spec:
group: booknest.example.com
scope: Namespaced
names: { kind: Promotion, plural: promotions, singular: promotion, shortNames: [promo] }
versions:
- name: v1
served: true
storage: true
additionalPrinterColumns:
- { name: Book, type: integer, jsonPath: .spec.bookId }
- { name: Percent, type: integer, jsonPath: .spec.percentOff }
- { name: Until, type: string, jsonPath: .spec.until }
schema:
openAPIV3Schema:
type: object
properties:
spec:
type: object
required: [bookId, percentOff, until]
properties:
bookId: { type: integer, minimum: 1 }
percentOff: { type: integer, minimum: 5, maximum: 50 }
until: { type: string, format: date }kubectl apply -f k8s/promotion-crd.yaml >/dev/null
P='{"apiVersion": "booknest.example.com/v1", "kind": "Promotion", "metadata": {"name": "%s"},'
P+=' "spec": {"bookId": %s, "percentOff": %s, "until": "%s"}}'
printf "$P" autumn-cooking 3 20 2026-10-31 | kubectl apply -f -
kubectl get promo
printf "$P" too-generous 0 90 soon | kubectl create -f - 2>&1 | sed 's/ in body//'Output
promotion.booknest.example.com/autumn-cooking created NAME BOOK PERCENT UNTIL autumn-cooking 3 20 2026-10-31 The Promotion "too-generous" is invalid: * spec.bookId: Invalid value: 0: spec.bookId should be greater than or equal to 1 * spec.percentOff: Invalid value: 90: spec.percentOff should be less than or equal to 50 * spec.until: Invalid value: "soon": spec.until must be of type date: "soon"
The API server rejected all three mistakes before storing anything; rules that span fields go in x-kubernetes-validations as CEL expressions. Still, a Promotion is inert data: nothing changes a price until a controller watches these objects and acts on them.