- August 12, 2026
- 10 min read
Building Kubernetes-Style APIs: A Practical Walkthrough
If you've spent any time with Kubernetes, you've probably absorbed its API design patterns without thinking much about them. You create a Deployment, and the cluster just... makes it happen. You don't tell Kubernetes how to roll out three replicas — you tell it what you want, and something in the background keeps nudging reality toward that goal.
That pattern — declarative desired state, continuously reconciled by a controller — is worth stealing even outside an actual Kubernetes cluster. This post walks through it end to end: an OpenAPI-first API server, a Postgres-backed store, and a reconciler — using a BackupPolicy resource as the running example.
What makes an API "Kubernetes-style"
Three ideas define the pattern:
- Declarative, not imperative. You don't say "back this up now." You say "backups should happen every 6 hours, retain 7." The system figures out the individual steps.
- Spec and status are separate.
specis what the user wants;statusis what the controller observes. Resources typically carry astatus.conditionsarray — a standard Kubernetes convention worth copying, since tooling already knows how to render it. - Reconciliation is level-triggered, not edge-triggered. The service doesn't react to "the spec changed" as a one-off event — it continuously evaluates "given the current spec, is the current state correct?" This is what makes the system self-healing: drift gets repaired on the next pass without anyone needing to notice and re-trigger anything.
OpenAPI-first, not code-first
A common pattern in these systems: an api/ directory holds hand-maintained OpenAPI YAML, a make generate step produces the Go types and client code from it, and generated *.gen.go files are never edited by hand. This matters more than it sounds — it means the wire contract is the source of truth, not a Go struct that happens to serialize a particular way. It's what lets independent tooling (a CLI, an automation collection, an integration server) build clients against the same API without guessing at the shape.
We'll do the same: the spec lives in its own file, checked into the repo, and both the server-side interface and the client are generated from it — nobody hand-writes request/response structs.
The spec file
# api/v1alpha1/openapi.yaml
openapi: 3.0.3
info:
title: Platform API
version: v1alpha1
paths:
/apis/platform.example.com/v1alpha1/backuppolicies:
get:
operationId: listBackupPolicies
parameters:
- name: labelSelector
in: query
schema: { type: string }
responses:
"200":
description: OK
content:
application/json:
schema:
$ref: "#/components/schemas/BackupPolicyList"
post:
operationId: createBackupPolicy
requestBody:
required: true
content:
application/json:
schema:
$ref: "#/components/schemas/BackupPolicy"
responses:
"201":
description: Created
content:
application/json:
schema:
$ref: "#/components/schemas/BackupPolicy"
"409":
description: Already exists
/apis/platform.example.com/v1alpha1/backuppolicies/{name}:
get:
operationId: getBackupPolicy
parameters:
- name: name
in: path
required: true
schema: { type: string }
responses:
"200":
description: OK
content:
application/json:
schema:
$ref: "#/components/schemas/BackupPolicy"
"404":
description: Not found
put:
operationId: updateBackupPolicy
parameters:
- name: name
in: path
required: true
schema: { type: string }
requestBody:
required: true
content:
application/json:
schema:
$ref: "#/components/schemas/BackupPolicy"
responses:
"200":
description: OK
content:
application/json:
schema:
$ref: "#/components/schemas/BackupPolicy"
"409":
description: resourceVersion conflict
delete:
operationId: deleteBackupPolicy
parameters:
- name: name
in: path
required: true
schema: { type: string }
responses:
"204":
description: Deleted
components:
schemas:
BackupPolicy:
type: object
required: [apiVersion, kind, metadata, spec]
properties:
apiVersion: { type: string }
kind: { type: string }
metadata:
$ref: "#/components/schemas/ObjectMeta"
spec:
$ref: "#/components/schemas/BackupPolicySpec"
status:
$ref: "#/components/schemas/BackupPolicyStatus"
BackupPolicyList:
type: object
properties:
items:
type: array
items:
$ref: "#/components/schemas/BackupPolicy"
ObjectMeta:
type: object
required: [name]
properties:
name: { type: string }
labels:
type: object
additionalProperties: { type: string }
resourceVersion: { type: string }
BackupPolicySpec:
type: object
required: [target, schedule, retention, destination]
properties:
target:
type: object
properties:
kind: { type: string }
name: { type: string }
schedule:
type: string
description: Cron expression for backup frequency
retention:
type: object
properties:
count: { type: integer, minimum: 1 }
destination:
type: object
properties:
bucket: { type: string }
BackupPolicyStatus:
type: object
properties:
lastBackupTime: { type: string, format: date-time }
lastBackupStatus: { type: string, enum: [Succeeded, Failed, Pending] }
backupsRetained: { type: integer }
conditions:
type: array
items:
type: object
properties:
type: { type: string }
status: { type: string, enum: ["True", "False", "Unknown"] }
reason: { type: string }
message: { type: string }
lastTransitionTime: { type: string, format: date-time }Generating server and client from it
oapi-codegen is the standard tool for this in Go. A config file tells it what to emit:
# api/v1alpha1/codegen-server.yaml
package: apiv1alpha1
generate:
chi-server: true
models: true
embedded-spec: true
output: internal/api/v1alpha1/server.gen.go# api/v1alpha1/codegen-client.yaml
package: apiv1alpha1client
generate:
client: true
models: true
output: client/v1alpha1/client.gen.goWired to Makefile target
.PHONY: generate
generate:
go run github.com/oapi-codegen/oapi-codegen/v2/cmd/oapi-codegen \
-config api/v1alpha1/codegen-server.yaml api/v1alpha1/openapi.yaml
go run github.com/oapi-codegen/oapi-codegen/v2/cmd/oapi-codegen \
-config api/v1alpha1/codegen-client.yaml api/v1alpha1/openapi.yamlmake generate produces two things:
1. A server interface the handlers must implement — the router, request parsing, and response marshaling are all generated, so a handler can't drift from the spec without a compile error:
// generated in internal/api/v1alpha1/server.gen.go — do not edit
type ServerInterface interface {
ListBackupPolicies(w http.ResponseWriter, r *http.Request, params ListBackupPoliciesParams)
CreateBackupPolicy(w http.ResponseWriter, r *http.Request)
GetBackupPolicy(w http.ResponseWriter, r *http.Request, name string)
UpdateBackupPolicy(w http.ResponseWriter, r *http.Request, name string)
DeleteBackupPolicy(w http.ResponseWriter, r *http.Request, name string)
}Our hand-written implementation is a thin type satisfying that interface — this is what replaces the manually wired chi.Router from earlier:
type BackupPolicyAPI struct {
db *pgxpool.Pool
}
func (a *BackupPolicyAPI) CreateBackupPolicy(w http.ResponseWriter, r *http.Request) {
var policy apiv1alpha1.BackupPolicy
if err := json.NewDecoder(r.Body).Decode(&policy); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
// ... same transactional insert + event-log write as before,
// but now `policy.Spec` and `policy.Metadata` are generated,
// typed structs instead of hand-decoded json.RawMessage.
}
func (a *BackupPolicyAPI) GetBackupPolicy(w http.ResponseWriter, r *http.Request, name string) {
var policy apiv1alpha1.BackupPolicy
err := a.db.QueryRow(r.Context(), `
SELECT spec, status, resource_version FROM objects
WHERE kind = 'BackupPolicy' AND name = $1 AND deleted_at IS NULL
`, name).Scan(&policy.Spec, &policy.Status, &policy.Metadata.ResourceVersion)
if err != nil {
http.Error(w, "not found", http.StatusNotFound)
return
}
json.NewEncoder(w).Encode(policy)
}
// Mount it:
r := chi.NewRouter()
apiv1alpha1.HandlerFromMux(&BackupPolicyAPI{db: pool}, r)2. A typed client — this is what the reconciler, the CLI, and any other integration should use instead of hand-rolling HTTP calls:
// generated in client/v1alpha1/client.gen.go — do not edit
client, err := apiv1alpha1client.NewClientWithResponses("https://platform.example.com")
if err != nil {
return err
}
resp, err := client.GetBackupPolicyWithResponse(ctx, "orders-service-backup")
if err != nil {
return err
}
if resp.StatusCode() != http.StatusOK {
return fmt.Errorf("unexpected status: %d", resp.StatusCode())
}
policy := resp.JSON200 // fully typed *BackupPolicy, no manual unmarshalingThe reconciler from earlier in this post can now use client.UpdateBackupPolicyWithResponse(...) instead of talking to the database directly — which also means the reconciler could run as a completely separate service, talking to the API over HTTP rather than sharing a database connection pool with the API server.
This is the benefit an OpenAPI-first setup gets you generally: the spec file is small enough to review in a pull request, make generate catches drift immediately (a handler that doesn't match the interface fails to compile), and every consumer — CLI, worker, third-party integrations — gets an identical, typed view of the API for free.
The resource, as a user would write it
Following typical Kubernetes-style YAML conventions (apiVersion, kind, metadata, spec) and a selector pattern:
apiVersion: platform.example.com/v1alpha1
kind: BackupPolicy
metadata:
name: orders-service-backup
labels:
team: orders
env: prod
spec:
target:
kind: Database
name: orders-db
schedule: "0 */6 * * *"
retention:
count: 7
destination:
bucket: s3://backups-prod/orders-db
status:
lastBackupTime: "2026-08-12T04:00:00Z"
lastBackupStatus: Succeeded
backupsRetained: 7
conditions:
- type: Ready
status: "True"
reason: BackupsHealthy
lastTransitionTime: "2026-08-12T04:00:12Z"A user manages this the way kubectl apply -f manages a Kubernetes resource: myctl apply -f backuppolicy.yaml, myctl get backuppolicies, myctl edit backuppolicy orders-service-backup.
The store: PostgreSQL
In systems like this, integration tests typically spin up an ephemeral Postgres instance via testcontainers — Postgres is the actual store, not a stand-in for etcd used only in tests. The schema needs to support optimistic concurrency (a resourceVersion-equivalent) and a way to stream changes to watchers like the CLI and UI:
CREATE TABLE objects (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
kind TEXT NOT NULL,
name TEXT NOT NULL,
labels JSONB NOT NULL DEFAULT '{}',
resource_version BIGINT NOT NULL DEFAULT 1,
spec JSONB NOT NULL,
status JSONB NOT NULL DEFAULT '{}',
deleted_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
UNIQUE (kind, name)
);
CREATE TABLE object_events (
id BIGSERIAL PRIMARY KEY,
object_id UUID NOT NULL REFERENCES objects(id),
event_type TEXT NOT NULL CHECK (event_type IN ('ADDED','MODIFIED','DELETED')),
resource_version BIGINT NOT NULL,
payload JSONB NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE OR REPLACE FUNCTION notify_object_event() RETURNS trigger AS $$
BEGIN
PERFORM pg_notify('object_events', NEW.id::text);
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER object_events_notify
AFTER INSERT ON object_events
FOR EACH ROW EXECUTE FUNCTION notify_object_event();labels as JSONB is what makes label-selector-style querying possible later — WHERE labels @> '{"team":"orders"}' gives you Kubernetes-style label matching without extra tables.
The Store interface
Rather than scattering SQL across HTTP handlers, define a Store interface that's the only thing allowed to know about the schema. Everything above this line — SQL, transactions, LISTEN/NOTIFY — stays behind it:
type Store interface {
Create(ctx context.Context, kind, name string, labels map[string]string, spec json.RawMessage) (*Object, error)
Get(ctx context.Context, kind, name string) (*Object, error)
List(ctx context.Context, kind string, labelSelector map[string]string) ([]*Object, error)
UpdateSpec(ctx context.Context, kind, name string, spec json.RawMessage, expectedRV int64) (*Object, error)
UpdateStatus(ctx context.Context, kind, name string, status json.RawMessage) (*Object, error)
Delete(ctx context.Context, kind, name string) error
Watch(ctx context.Context, kind string, sinceRV int64) (<-chan Event, error)
}
type Object struct {
ID string
Kind string
Name string
Labels map[string]string
ResourceVersion int64
Spec json.RawMessage
Status json.RawMessage
}
type Event struct {
Type string // ADDED, MODIFIED, DELETED
Object *Object
}The Postgres implementation is where the transactional insert-plus-event-log write from before lives — now isolated in one place instead of inline in a handler:
type pgStore struct {
db *pgxpool.Pool
}
func NewPostgresStore(db *pgxpool.Pool) Store {
return &pgStore{db: db}
}
func (s *pgStore) Create(ctx context.Context, kind, name string, labels map[string]string, spec json.RawMessage) (*Object, error) {
tx, err := s.db.Begin(ctx)
if err != nil {
return nil, err
}
defer tx.Rollback(ctx)
obj := &Object{Kind: kind, Name: name, Labels: labels, Spec: spec}
err = tx.QueryRow(ctx, `
INSERT INTO objects (kind, name, labels, spec, status)
VALUES ($1, $2, $3, $4, '{}')
RETURNING id, resource_version
`, kind, name, labels, spec).Scan(&obj.ID, &obj.ResourceVersion)
if err != nil {
return nil, err
}
_, err = tx.Exec(ctx, `
INSERT INTO object_events (object_id, event_type, resource_version, payload)
VALUES ($1, 'ADDED', $2, (SELECT to_jsonb(o) FROM objects o WHERE o.id = $1))
`, obj.ID, obj.ResourceVersion)
if err != nil {
return nil, err
}
if err := tx.Commit(ctx); err != nil {
return nil, err
}
return obj, nil
}
func (s *pgStore) UpdateSpec(ctx context.Context, kind, name string, spec json.RawMessage, expectedRV int64) (*Object, error) {
var obj Object
err := s.db.QueryRow(ctx, `
UPDATE objects
SET spec = $1, resource_version = resource_version + 1, updated_at = now()
WHERE kind = $2 AND name = $3 AND resource_version = $4 AND deleted_at IS NULL
RETURNING id, resource_version
`, spec, kind, name, expectedRV).Scan(&obj.ID, &obj.ResourceVersion)
if err != nil {
return nil, ErrResourceVersionConflict
}
return &obj, nil
}
func (s *pgStore) Watch(ctx context.Context, kind string, sinceRV int64) (<-chan Event, error) {
events := make(chan Event)
go func() {
defer close(events)
rows, _ := s.db.Query(ctx, `
SELECT event_type, payload FROM object_events
WHERE resource_version > $1 ORDER BY resource_version ASC
`, sinceRV)
for rows.Next() {
var e Event
var payload json.RawMessage
rows.Scan(&e.Type, &payload)
json.Unmarshal(payload, &e.Object)
events <- e
}
conn, _ := s.db.Acquire(ctx)
defer conn.Release()
conn.Exec(ctx, "LISTEN object_events")
for {
n, err := conn.Conn().WaitForNotification(ctx)
if err != nil {
return
}
var e Event
var payload json.RawMessage
s.db.QueryRow(ctx, `
SELECT event_type, payload FROM object_events WHERE id = $1
`, n.Payload).Scan(&e.Type, &payload)
json.Unmarshal(payload, &e.Object)
events <- e
}
}()
return events, nil
}
// Get, List, UpdateStatus, Delete follow the same shape — straight queries
// against `objects`, with UpdateStatus and Delete also writing to object_events.Nothing outside this file knows the table names, the JSONB columns, or that LISTEN/NOTIFY is involved at all.
The service layer
The Store is pure persistence — it has no opinion about cron expressions, retention counts, or what "Ready" means. That logic belongs in a BackupPolicyService, which wraps a Store and is what the HTTP handlers and the reconciler both actually talk to:
type BackupPolicyService struct {
store Store
}
func NewBackupPolicyService(store Store) *BackupPolicyService {
return &BackupPolicyService{store: store}
}
func (s *BackupPolicyService) Create(ctx context.Context, name string, labels map[string]string, spec BackupPolicySpec) (*BackupPolicy, error) {
if _, err := cron.ParseStandard(spec.Schedule); err != nil {
return nil, fmt.Errorf("invalid schedule: %w", err)
}
if spec.Retention.Count < 1 {
return nil, fmt.Errorf("retention.count must be at least 1")
}
raw, _ := json.Marshal(spec)
obj, err := s.store.Create(ctx, "BackupPolicy", name, labels, raw)
if err != nil {
return nil, err
}
return toBackupPolicy(obj), nil
}
func (s *BackupPolicyService) UpdateSpec(ctx context.Context, name string, spec BackupPolicySpec, expectedRV int64) (*BackupPolicy, error) {
if _, err := cron.ParseStandard(spec.Schedule); err != nil {
return nil, fmt.Errorf("invalid schedule: %w", err)
}
raw, _ := json.Marshal(spec)
obj, err := s.store.UpdateSpec(ctx, "BackupPolicy", name, raw, expectedRV)
if err != nil {
return nil, err
}
return toBackupPolicy(obj), nil
}
func (s *BackupPolicyService) Get(ctx context.Context, name string) (*BackupPolicy, error) {
obj, err := s.store.Get(ctx, "BackupPolicy", name)
if err != nil {
return nil, err
}
return toBackupPolicy(obj), nil
}
func (s *BackupPolicyService) Watch(ctx context.Context, sinceRV int64) (<-chan Event, error) {
return s.store.Watch(ctx, "BackupPolicy", sinceRV)
}Validation (a bad cron expression, a nonsensical retention count) is rejected here, before it ever reaches the store — the store would happily persist garbage, because persistence isn't where correctness belongs.
The API server
Handlers become thin: decode the request, call the service, encode the response. No SQL, no transactions, nothing store-specific in sight:
type API struct {
svc *BackupPolicyService
}
func (a *API) routes() chi.Router {
r := chi.NewRouter()
r.Use(jwtAuthMiddleware)
r.Route("/apis/platform.example.com/v1alpha1/backuppolicies", func(r chi.Router) {
r.Post("/", a.handleCreate)
r.Get("/{name}", a.handleGet)
r.Put("/{name}", a.handleUpdateSpec)
r.Get("/{name}/watch", a.handleWatch)
})
return r
}
func (a *API) handleCreate(w http.ResponseWriter, r *http.Request) {
var body struct {
Metadata struct {
Name string `json:"name"`
Labels map[string]string `json:"labels"`
} `json:"metadata"`
Spec BackupPolicySpec `json:"spec"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
policy, err := a.svc.Create(r.Context(), body.Metadata.Name, body.Metadata.Labels, body.Spec)
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
w.WriteHeader(http.StatusCreated)
json.NewEncoder(w).Encode(policy)
}
func (a *API) handleUpdateSpec(w http.ResponseWriter, r *http.Request) {
name := chi.URLParam(r, "name")
var body struct {
ResourceVersion int64 `json:"resourceVersion"`
Spec BackupPolicySpec `json:"spec"`
}
json.NewDecoder(r.Body).Decode(&body)
policy, err := a.svc.UpdateSpec(r.Context(), name, body.Spec, body.ResourceVersion)
if errors.Is(err, ErrResourceVersionConflict) {
http.Error(w, "resourceVersion conflict", http.StatusConflict)
return
}
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
json.NewEncoder(w).Encode(policy)
}
func (a *API) handleWatch(w http.ResponseWriter, r *http.Request) {
sinceRV, _ := strconv.ParseInt(r.URL.Query().Get("resourceVersion"), 10, 64)
flusher, _ := w.(http.Flusher)
events, err := a.svc.Watch(r.Context(), sinceRV)
if err != nil {
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
for e := range events {
json.NewEncoder(w).Encode(e)
flusher.Flush()
}
}Everything the generated ServerInterface requires is satisfied by *API — the interface enforces the shape, the service enforces the rules, and the store enforces persistence. Three layers, each replaceable independently: swap Postgres for something else without touching the service or the handlers; change a validation rule without touching SQL.
The reconciler / worker
The reconciler is just another client of the service — it doesn't reach into the store directly, and it doesn't need to run inside the API process:
type Reconciler struct {
svc *BackupPolicyService
}
func (c *Reconciler) Run(ctx context.Context) {
var lastRV int64
for {
events, err := c.svc.Watch(ctx, lastRV)
if err != nil {
time.Sleep(time.Second)
continue
}
for ev := range events {
var policy BackupPolicy
json.Unmarshal(ev.Object.Spec, &policy.Spec)
c.reconcile(ctx, ev.Object.Name, &policy)
lastRV = ev.Object.ResourceVersion
}
}
}
func (c *Reconciler) reconcile(ctx context.Context, name string, policy *BackupPolicy) {
due, err := nextScheduledBackup(policy.Spec.Schedule, policy.Status.LastBackupTime)
if err != nil {
c.setCondition(ctx, name, "Ready", "False", "InvalidSchedule")
return
}
if time.Now().Before(due) {
return
}
if err := c.runBackup(ctx, policy); err != nil {
c.setCondition(ctx, name, "Ready", "False", "BackupFailed")
return
}
c.setCondition(ctx, name, "Ready", "True", "BackupsHealthy")
}Like client-go informers, this should also run a periodic full resync on top of the watch, so a missed event or a restart during downtime doesn't leave a policy silently stuck. Because it goes through the service rather than the store, the reconciler could run in-process today and move to a fully separate binary later without a rewrite — only how it's wired up changes.
Where people go wrong
- Skipping the OpenAPI-first step. Hand-writing Go structs and letting JSON serialization be the de facto contract works until a second client (a CLI, an automation collection, an integration server) needs to talk to the same API and starts guessing at the shape.
- Editing generated code by hand. The moment someone patches a
.gen.gofile to fix a bug, the nextmake generatesilently reverts it. Fix the spec, regenerate — never the other way around. - Letting SQL leak into handlers. The moment a handler queries the database directly, you've lost the one place validation and business rules were supposed to live, and every new handler has to re-derive the rules on its own.
- Treating status like a cache the client can write to. If clients can set
statusfields directly, you've lost the single source of truth. Route status writes through the service, not the store, and don't expose a status-setting method to the user-facing API at all. - Trusting
LISTEN/NOTIFYas durable. It isn't — theobject_eventstable is the durable record;NOTIFYis only a wake-up signal for watchers already listening. - No periodic resync in the worker. Watch-only reconciliation looks correct until a connection blips and nobody notices a stuck policy.
Why this pattern generalizes
None of this machinery is Kubernetes-specific — it's a general recipe for building declarative, self-healing systems on infrastructure you already trust, like Postgres instead of etcd. Once spec/status separation, resourceVersion-based concurrency, and a durable watch log are in place, you can bolt reconcilers for anything onto the same server: provisioning, certificate rotation, backup enforcement, whatever your domain needs. The service/store split is what keeps that generic: the store never changes shape no matter how many resource types you add, and each new resource just gets its own thin service on top.
Related Posts
RAM GOPINATHAN
February 28, 2026
Automating post install configurations with cloud-init for Red Hat Enterprise Linux deployments on baremetal environments
Stop configuring servers by hand. Here's how cloud-init brings cloud-level automation to your physical infrastructure.
RAM GOPINATHAN
January 22, 2026
PODMAN TIPS: Implementing init container pattern for containers running on PODMAN
If you’ve worked with Kubernetes, you’re probably familiar with init containers—containers that run before the main application and are typically used for setup or initialization tasks. In this post,…
RAM GOPINATHAN
November 17, 2025
Using ansible and image builder to create customized RHEL system images for provisioning systems that run virtualized protection automation and control software
In my last post I covered how to perform an IPMI install using a custom ISO but did not go into too much details on how to build a customized ISO for provisioning systems that run virtualized…