Simple guides introducing Fabrica with hands-on examples.
Hub/spoke API versioning with automatic conversion, storage stability, and smooth client migrations.
Fabrica implements Kubebuilder-style hub/spoke versioning to provide stable APIs while allowing evolution of your resource schemas over time. This allows you to:
┌─────────────────────────────────────────┐
│ Client Request │
│ (apiVersion: infra.example.io/v1beta1)│
└──────────────────┬──────────────────────┘
│
▼
┌─────────────────┐
│ Version Middleware│
│ (negotiation) │
└────────┬──────────┘
│
▼ Convert to Hub
┌────────────────┐
│ Hub (v1) │ ◄── Storage always uses this
│ Storage Version │
└────────┬────────┘
│
▼ Convert to Requested Spoke
┌─────────────────┐
│ Spoke (v1beta1) │
│ Response │
└──────────────────┘
v1). All resources are stored in this format. All internal operations use the hub version.v1alpha1, v1beta1, v1, etc.). Clients request any spoke version and the server automatically converts.Your internal storage format (the “hub”) remains stable while external APIs (the “spokes”) evolve independently. This allows you to:
Clients can pin to a specific API version and continue working even as you add new features to newer versions.
Breaking changes to your types can be introduced in a new spoke version while the hub remains unchanged.
The apis.yaml file defines your API groups and versions:
groups:
- name: infra.example.io # API group name
storageVersion: v1 # Hub version (used for storage)
versions: # Spoke versions (external APIs)
- v1alpha1 # Alpha version (unstable)
- v1beta1 # Beta version (semi-stable)
- v1 # Stable version
imports: # Optional: import external types
- module: github.com/org/pkg
tag: v1.0.0
packages:
- path: api/types
expose:
- kind: MyResource
specFrom: pkg.MyResourceSpec
statusFrom: pkg.MyResourceStatus
v1alpha1, v1alpha2: Alpha versions. Unstable, may change without notice. Resources can be added freely.v1beta1, v1beta2: Beta versions. Semi-stable, breaking changes announced in advance. Resources can be added freely.v1, v2: Stable versions. Changes follow semantic versioning. Adding new resources requires --force flag.By default, Fabrica generates resources with a single version (v1) that acts as both hub and spoke. To enable multi-version support:
Step 1: Add the version to your project
# Add an alpha version (for experimentation)
fabrica add version v2alpha1
# Or add a stable version (requires --force)
fabrica add version v2 --force
This command:
apis.yaml with the new versionapis/<group>/<version>/)Step 2: Add or modify resources in the new version
# Add a new resource to the alpha version
fabrica add resource Feature --version v2alpha1
# Modify existing resources in the version directory
# Edit apis/<group>/v2alpha1/device_types.go
Important: You must use fabrica add version before adding resources to a new version. If you try to add a resource to a non-existent version:
fabrica add resource Device --version v2
# Error: version v2 not found in apis.yaml (available: [v1])
#
# To add a new version, run: fabrica add version v2
Step 3: Generate handlers and conversions
fabrica generate
This generates:
apis/<group>/v1/types_generated.go (hub)apis/<group>/v2alpha1/types_generated.go (spoke)Versioning is driven by apis.yaml plus CLI scaffolding. No manual registration is required.
Single version (default):
1) fabrica init myapi --group example.fabrica.dev --versions v1
2) fabrica add resource Device --version v1
3) Edit apis/example.fabrica.dev/v1/device_types.go
4) fabrica generate
Add another version (spoke):
1) fabrica add version v2beta1 (copies types into apis/example.fabrica.dev/v2beta1/ and updates apis.yaml)
2) Update the v2beta1 types as needed
3) Add converters under apis/example.fabrica.dev/v2beta1/converter.go
4) fabrica generate
With versioning enabled, Fabrica generates:
apis/
└── infra.example.io/
├── v1/ # Hub (storage version)
│ ├── types_generated.go # Flattened Device type
│ └── register_generated.go
├── v1beta1/ # Spoke (external version)
│ ├── types_generated.go # Flattened Device type
│ └── conversions_generated.go # Conversion to/from hub
└── v1alpha1/ # Spoke (external version)
├── types_generated.go
└── conversions_generated.go
apis/infra.example.io/v1/types_generated.go)package v1
type Device struct {
APIVersion string `json:"apiVersion"` // "infra.example.io/v1"
Kind string `json:"kind"` // "Device"
Metadata Metadata `json:"metadata"`
Spec DeviceSpec `json:"spec"`
Status DeviceStatus `json:"status,omitempty"`
}
// IsHub marks this as the hub version
func (Device) IsHub() {}
apis/infra.example.io/v1beta1/types_generated.go)package v1beta1
import v1 "yourmodule/apis/infra.example.io/v1"
type Device struct {
APIVersion string `json:"apiVersion"` // "infra.example.io/v1beta1"
Kind string `json:"kind"`
Metadata Metadata `json:"metadata"`
Spec DeviceSpec `json:"spec"`
Status DeviceStatus `json:"status,omitempty"`
}
// ConvertTo converts this spoke to the hub
func (src *Device) ConvertTo(dstRaw interface{}) error {
dst := dstRaw.(*v1.Device)
// Field-by-field conversion logic...
return nil
}
// ConvertFrom converts from the hub to this spoke
func (dst *Device) ConvertFrom(srcRaw interface{}) error {
src := srcRaw.(*v1.Device)
// Field-by-field conversion logic...
return nil
}
v1 (stable/hub):
// apis/example.fabrica.dev/v1/device_types.go
package v1
type Device struct {
APIVersion string `json:"apiVersion"`
Kind string `json:"kind"`
Metadata Metadata `json:"metadata"`
Spec DeviceSpec `json:"spec"`
Status DeviceStatus `json:"status,omitempty"`
}
type DeviceSpec struct {
Name string `json:"name"`
Location string `json:"location"`
Username string `json:"username"` // Flat auth
Password string `json:"password"`
}
v2beta1 (spoke with structured auth):
// apis/example.fabrica.dev/v2beta1/device_types.go
package v2beta1
type Device struct {
APIVersion string `json:"apiVersion"`
Kind string `json:"kind"`
Metadata Metadata `json:"metadata"`
Spec DeviceSpec `json:"spec"`
Status DeviceStatus `json:"status,omitempty"`
}
type DeviceSpec struct {
Name string `json:"name"`
Location string `json:"location"`
Auth AuthConfig `json:"auth"` // Structured auth
}
type AuthConfig struct {
Type string `json:"type"` // "basic", "oauth", "cert"
Username string `json:"username,omitempty"`
Password string `json:"password,omitempty"`
Token string `json:"token,omitempty"`
}
// apis/example.fabrica.dev/v2beta1/converter.go
package v2beta1
import (
v1 "yourmodule/apis/example.fabrica.dev/v1"
)
// ConvertTo converts v2beta1 Device to v1 (hub)
func (src *Device) ConvertTo(dstRaw interface{}) error {
dst := dstRaw.(*v1.Device)
// Copy flattened envelope fields
dst.APIVersion = src.APIVersion
dst.Kind = src.Kind
dst.Metadata = src.Metadata
// Standard field copy
dst.Spec.Name = src.Spec.Name
dst.Spec.Location = src.Spec.Location
// Custom transformation: v2beta1 structured auth → v1 flat auth
if src.Spec.Auth.Type == "basic" {
dst.Spec.Username = src.Spec.Auth.Username
dst.Spec.Password = src.Spec.Auth.Password
} else {
log.Warn("Non-basic auth will be lost in v1 conversion")
}
dst.Status = src.Status
return nil
}
// ConvertFrom converts v1 (hub) Device to v2beta1
func (dst *Device) ConvertFrom(srcRaw interface{}) error {
src := srcRaw.(*v1.Device)
// Copy flattened envelope fields
dst.APIVersion = src.APIVersion
dst.Kind = src.Kind
dst.Metadata = src.Metadata
// Standard field copy
dst.Spec.Name = src.Spec.Name
dst.Spec.Location = src.Spec.Location
// Custom transformation: v1 flat auth → v2beta1 structured auth
dst.Spec.Auth = AuthConfig{
Type: "basic",
Username: src.Spec.Username,
Password: src.Spec.Password,
}
dst.Status = src.Status
return nil
}
No manual registration is needed; the generator wires converters automatically:
1) Add your converter next to the versioned types (e.g., apis/example.fabrica.dev/v2beta1/converter.go).
2) Export a constructor: func NewDeviceConverter() versioning.VersionConverter { return &DeviceConverter{} }.
3) Run fabrica generate; the generator discovers and registers converters via apis.yaml.
Clients can request a specific version using the apiVersion field in the request body, an explicit versioned URL, or the Accept header.
Precedence (highest to lowest):
apiVersion in request body (POST/PUT/PATCH)/apis/<group>/<version>/...)Accept header (application/json;version=v1beta1)curl -X POST http://localhost:8080/devices \
-H "Content-Type: application/json" \
-d '{
"apiVersion": "infra.example.io/v1beta1",
"kind": "Device",
"metadata": {"name": "device-01"},
"spec": { ... }
}'
curl -X GET http://localhost:8080/devices/device-01 \
-H "Accept: application/json; api-version=infra.example.io/v1beta1"
If no version is specified, the server returns the preferred version (typically the storage version). If a requested version is not registered in apis.yaml, the server responds with 406 Not Acceptable.
Request Flow:
apiVersion: infra.example.io/v1beta1v1beta1.Device typev1.Device (hub) via ConvertTo()Response Flow:
v1.Device (hub)v1beta1.Device via ConvertFrom()v1beta1# Request v1
curl -H "Accept: application/json;version=v1" \
http://localhost:8080/devices/dev-123
# Request v2beta1
curl -H "Accept: application/json;version=v2beta1" \
http://localhost:8080/devices/dev-123
# Request default version (omit version)
curl http://localhost:8080/devices/dev-123
For complex negotiation strategies, you can implement custom middleware in your project:
// Custom version negotiation middleware
func VersionNegotiation(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Check client header, query param, or other criteria
version := r.Header.Get("X-API-Version")
if version == "" {
version = r.URL.Query().Get("version")
}
if version == "" {
version = "v1" // default
}
// Store in context
ctx := context.WithValue(r.Context(), "version", version)
next.ServeHTTP(w, r.WithContext(ctx))
})
}
Day 1: Launch v2, deprecate v1
Day 30: Remove v1
Pros: Simple Cons: Breaks clients, forces immediate migration
Day 1: Launch v2beta1 for testing
Day 30: Promote to v2 stable
Day 60: Mark v1 as deprecated
Day 180: Remove v1 support
Pros: Smooth transition, no breakage Cons: More work, temporary maintenance burden
Support v1, v2, v3 indefinitely
Pros: Maximum compatibility Cons: Significant maintenance burden
Phase 1: Launch Beta (Day 1)
fabrica add version v2beta1
# Edit apis/example.fabrica.dev/v2beta1/*_types.go and converter
fabrica generate
Phase 2: Promote to Stable (Day 30)
# Update converters if needed
fabrica generate
Phase 3: Deprecate Old Version (Day 60)
# Communication to clients: "v1 deprecated; migrate to v2 by Day 180"
Phase 4: Remove Old Version (Day 180)
# Remove v1 from apis.yaml
# Update all links and documentation
fabrica generate
When making breaking changes to your types, you have two options:
v1) unchangedv2beta1) with the breaking changeExample: Renaming a field
# apis.yaml
groups:
- name: infra.example.io
storageVersion: v1 # Hub stays unchanged
versions:
- v1alpha1 # Old version
- v1beta1 # Current version
- v2beta1 # New version with breaking change
- v1 # Stable
Field renaming conversion:
// apis/example.fabrica.dev/v2beta1/converter.go
func (src *Device) ConvertTo(dstRaw interface{}) error {
dst := dstRaw.(*v1.Device)
// Custom transformation: v2beta1 "hostname" → v1 "ipAddress"
dst.Spec.IPAddress = src.Spec.Hostname
return nil
}
When the hub itself needs to change (rare), and you want to remove deprecated fields:
v2)v1 to v2v2Note: This is complex and should be avoided in most cases. Prefer keeping the hub stable and using spokes.
DO:
✅ Use semantic versioning (v1, v2, v3)
✅ Mark stability (alpha, beta, stable)
✅ Provide bidirectional conversion
✅ Document breaking changes in release notes
✅ Give deprecation warnings to clients
✅ Test all conversion paths thoroughly
DON’T:
❌ Use arbitrary version strings
❌ Break existing versions without warning
❌ Skip alpha/beta for major changes
❌ Remove versions without deprecation period
❌ Forget to update converters when changing fields
DO:
✅ Handle all field mappings
✅ Document lossy conversions (data loss warnings)
✅ Provide sensible default values for missing fields
✅ Test all conversion paths (v1→v2 and v2→v1)
✅ Log conversion warnings for complex transformations
Example:
func (src *Device) ConvertTo(dstRaw interface{}) error {
dst := dstRaw.(*v1.Device)
// Safe field copy
dst.Spec.Name = src.Spec.Name
// Lossy conversion with warning
if src.Spec.Auth.Type != "basic" {
log.Warn("Non-basic auth will be lost in v1 conversion")
}
// Provide default value
if src.Spec.Timeout == 0 {
dst.Spec.Timeout = 30 * time.Second
} else {
dst.Spec.Timeout = src.Spec.Timeout
}
return nil
}
DO:
✅ Announce deprecation in release notes
✅ Provide migration guides with examples
✅ Support multiple versions during transition (3-6 months minimum)
✅ Include links to migration documentation in error messages
✅ Monitor version usage metrics
Example deprecation notice:
## Deprecation Notice (v1.5.0)
The Device API v1 is deprecated and will be removed in v2.0 (Q4 2025).
**Action required:** Migrate to Device API v2 by October 31, 2025.
See [Migration Guide](https://docs.example.io/migration) for step-by-step instructions.
Cause: Client requested a version not in the apis.yaml versions list, or
the version registry was not generated/imported in the server.
Solution: Add the version to apis.yaml or update the client to use a supported version.
If the registry is missing, run fabrica generate and ensure
_ "<module>/pkg/apiversion" is imported in cmd/server/main.go.
# apis.yaml
groups:
- name: infra.example.io
versions:
- v1 # Add missing version here
- v2beta1
Cause: Field mismatch between hub and spoke (e.g., renamed field without conversion logic).
Solution: Implement custom conversion logic in the generated ConvertTo/ConvertFrom functions.
func (src *Device) ConvertTo(dstRaw interface{}) error {
dst := dstRaw.(*v1.Device)
// Ensure all fields are mapped
dst.Spec.NewFieldName = src.Spec.OldFieldName
return nil
}
Cause: Spoke type is missing the conversion handler.
Solution: Ensure the converter is in the correct package and is exported.
// apis/example.fabrica.dev/v2beta1/converter.go
func (dst *Device) ConvertFrom(srcRaw interface{}) error {
src := srcRaw.(*v1.Device)
// Implement reverse conversion...
return nil
}
Cause: Conversion middleware not configured or client not specifying version.
Solution: Ensure:
fabrica generate)apiVersion in request