Idiomatic Go patterns and conventions used across the OCM monorepo.
The dominant constructor pattern. Define an option type as a function that mutates a config struct, apply via variadic New*().
type Option func(*Options)
func WithScheme(scheme *runtime.Scheme) Option {
return func(o *Options) {
o.Scheme = scheme
}
}
func New(opts ...Option) (*Thing, error) {
o := &Options{}
for _, opt := range opts {
opt(o)
}
if o.Scheme == nil {
o.Scheme = DefaultScheme
}
// ...
}Nil fields are always checked after applying options and replaced with sensible defaults.
Used when no configuration variability is needed. New*() returns a pointer to the struct.
func NewManager(ctx context.Context) *Manager {
return &Manager{
Registry: NewRegistry(ctx),
}
}String-based enums use a named type with a const block:
type Policy string
const (
PolicyAllow Policy = "Allow"
PolicyDeny Policy = "Deny"
)Validation happens at the boundary — custom flag types validate on Set(), kubebuilder markers validate on admission.
Numeric enums use iota:
type CopyMode int
const (
CopyModeLocal CopyMode = iota
CopyModeAll
)Each typed package defines its type name and version as constants:
const (
Type = "example.config"
Version = "v1"
)These are passed to runtime.NewVersionedType(Type, Version) during scheme registration.
Interfaces are kept small — typically 1–3 methods. Larger interfaces are composed via embedding.
type Signer interface {
Sign(ctx context.Context, ...) (Signature, error)
}
type Verifier interface {
Verify(ctx context.Context, ...) error
}
type Handler interface {
Signer
Verifier
}Modules may extend base interfaces from other modules with domain-specific methods:
type ExtendedRepository interface {
repository.Repository
repository.HealthCheckable
DigestProcessor
}Optional behaviors are expressed as small single-method interfaces that a type may or may not implement. Consumers use type assertions to discover capabilities at runtime:
type SizeAware interface {
Size() (int64, error)
}
type DigestAware interface {
Digest() (string, error)
}
// Consumer checks at runtime:
if sa, ok := blob.(SizeAware); ok {
size, _ := sa.Size()
// use size
}This pattern avoids bloating the primary interface with optional concerns.
Structs use function fields for lifecycle extensibility. Callbacks follow the On<Event> naming convention:
type Callbacks struct {
OnStart func(ctx context.Context, obj *Thing) error
OnEnd func(ctx context.Context, obj *Thing, err error) error
}Unexported structs adapt between interface boundaries (e.g., external plugin contracts to internal interfaces). The wrapper holds the external dependency and translates method signatures:
type pluginConverter struct {
external ExternalContract
scheme *runtime.Scheme
}
var _ InternalInterface = (*pluginConverter)(nil)Package-level, Err prefix, errors.New():
var ErrNotFound = errors.New("not found")Wrap with fmt.Errorf and %w. Message describes the failed operation:
return fmt.Errorf("unable to open file: %w", err)
return fmt.Errorf("failed to resolve version: %w", err)Combine multiple errors from cleanup paths with errors.Join():
func doWork() (err error) {
r, err := open()
if err != nil {
return err
}
defer func() { err = errors.Join(err, r.Close()) }()
// ...
}Also used inline:
return errors.Join(ErrUnknown, fmt.Errorf("operation failed: %w", err))Domain-specific error types carry structured context and enable errors.As() matching:
type NotReadyError struct {
ObjectName string
}
func (e *NotReadyError) Error() string {
return fmt.Sprintf("object %s is not ready", e.ObjectName)
}Always errors.Is() for sentinels, errors.As() for typed errors:
if errors.Is(err, ErrNotFound) {
// handle
}Reconcilers wrap non-retriable errors with reconcile.TerminalError() to prevent requeue:
return reconcile.TerminalError(fmt.Errorf("invalid config: %w", err))context.Context is always the first parameter in public API methods. No exceptions.
func (r *Repo) Get(ctx context.Context, name string) (*Thing, error)In new test code, prefer t.Context() (bindings/CLI) or ctx SpecContext (controller/Ginkgo) over context.Background() or context.TODO(). Legacy tests still use context.Background() — migrate when touching those files.
For registries and caches with many readers, few writers.
For exclusive access. Always defer Unlock() immediately after Lock().
mu.Lock()
defer mu.Unlock()For parallel operations with error aggregation:
g, ctx := errgroup.WithContext(ctx)
g.Go(func() error { /* ... */ })
if err := g.Wait(); err != nil {
return err
}For lock-free concurrent access in hot paths (e.g., done-tracking maps).
Wraps critical sections in closures to centralize lock management:
func (g *SyncedDirectedAcyclicGraph[T]) WithReadLock(fn func(d *dag.DirectedAcyclicGraph[T]) error) error {
g.dagMu.RLock()
defer g.dagMu.RUnlock()
return fn(g.dag)
}Defers expensive setup (environment creation, client initialization) to first use:
var getEnv = sync.OnceValues(func() (*Environment, error) {
return createExpensiveEnvironment()
})Capture final error state for logging or metrics:
func (r *Repo) Do(ctx context.Context) (err error) {
done := log.Operation(ctx, "doing thing")
defer func() { done(err) }()
// ...
}Wrap cleanup functions as io.Closer:
type closerFunc func() error
func (f closerFunc) Close() error { return f() }var _ io.Closer = (*MyType)(nil)Types that need to accept multiple input formats use the type-alias trick to avoid infinite recursion:
func (c *Consumer) UnmarshalJSON(data []byte) error {
type Alias Consumer
alias := &Alias{}
if err := json.Unmarshal(data, alias); err == nil {
*c = Consumer(*alias)
return nil
}
// try legacy format...
}Some types accept both array and single-object forms, trying each format and falling back:
func (c *Spec) UnmarshalJSON(data []byte) error {
var items []Item
if err := json.Unmarshal(data, &items); err == nil {
c.Items = items
return nil
}
var single Item
if err := json.Unmarshal(data, &single); err == nil {
c.Items = []Item{single}
return nil
}
return fmt.Errorf("unable to unmarshal spec")
}JSON schemas for validation are embedded in production code via //go:embed:
//go:embed schemas/Config.schema.json
var configSchema []byteGenerated by jsonschemagen into zz_generated.ocm_jsonschema.go files.
- Pointer receivers for methods that mutate state or implement
UnmarshalJSON. - Value receivers for pure queries like
String(),IsZero(),Equal().
func (t Type) String() string { /* value - no mutation */ }
func (t *Type) UnmarshalJSON([]byte) error { /* pointer - mutates */ }Most structs use pointer receivers throughout. Value receivers are reserved for small value types.
Used sparingly — primarily in DAG processing and generic utility functions.
func Process[K cmp.Ordered, V any](ctx context.Context, graph *Graph[K]) errorController utilities use generics with pointer-type constraints for K8s objects:
func GetReadyObject[T any, P ObjectPointer[T]](ctx context.Context, c client.Reader, key client.ObjectKey) (P, error)Go range-over-func iterators for lazy traversal without materializing collections:
func (r *Scheme) GetTypesIter() iter.Seq2[Type, iter.Seq[Type]] {
return func(yield func(Type, iter.Seq[Type]) bool) {
r.mu.RLock()
defer r.mu.RUnlock()
for typ := range r.defaults.Iter() {
if !yield(typ, r.AliasesIter(typ)) {
return
}
}
}
}Used alongside materialized methods (e.g., GetTypes()) for flexibility.
The runtime type system is the foundation of OCM. It lives in bindings/go/runtime/ and provides the mechanism by which all typed objects are identified, registered, serialized, deserialized, and converted.
A name + optional version pair:
type Type struct {
Version string
Name string
}Helpers:
runtime.NewVersionedType("file", "v1") // Type{Name: "file", Version: "v1"}
runtime.NewUnversionedType("file") // Type{Name: "file", Version: ""}Type.String() returns "file" (unversioned) or "file/v1" (versioned). JSON marshals to the same string representation. UnmarshalJSON accepts both "file/v1" and {"type": "file/v1"} as input.
Every object in the type system must implement:
type Typed interface {
GetType() Type
SetType(Type)
DeepCopyTyped() Typed
}GetType()/SetType()— auto-generated via// +ocm:typegen=trueDeepCopyTyped()— auto-generated via// +k8s:deepcopy-gen:interfaces=ocm.software/open-component-model/bindings/go/runtime.Typed
A concrete implementation:
// +k8s:deepcopy-gen:interfaces=ocm.software/open-component-model/bindings/go/runtime.Typed
// +k8s:deepcopy-gen=true
// +ocm:typegen=true
type Config struct {
Type runtime.Type `json:"type"`
Path string `json:"path"`
}The generated code produces zz_generated.ocm_type.go (GetType/SetType) and zz_generated.deepcopy.go (DeepCopy/DeepCopyTyped).
Thread-safe registry mapping Type ↔ reflect.Type. Central mechanism for creating, resolving, decoding, and converting typed objects.
Internal structure:
type Scheme struct {
mu sync.RWMutex
allowUnknown bool // if true, unknown types resolve to Raw{}
defaults *bimap.Map[Type, reflect.Type] // bidirectional 1:1 mapping
aliases map[Type]Type // alias → default type
instances map[reflect.Type]Typed // prototype instances for DeepCopy
}The defaults bimap provides O(1) lookup in both directions. Aliases map alternative Type values to the canonical default.
scheme := runtime.NewScheme()
scheme := runtime.NewScheme(runtime.WithAllowUnknown()) // unknown types resolve to Raw{}MustRegisterWithAlias — primary registration method. First Type is the default, rest are aliases:
scheme.MustRegisterWithAlias(&v1.Config{},
runtime.NewVersionedType("config", "v1"), // default
runtime.NewUnversionedType("config"), // alias
)What happens internally:
defaultsbimap stores:Type{Name:"config", Version:"v1"}↔reflect.TypeOf(&v1.Config{})aliasesstores:Type{Name:"config", Version:""}→Type{Name:"config", Version:"v1"}instancesstores:reflect.TypeOf(&v1.Config{})→&v1.Config{}(the prototype, viaDeepCopyTyped())
Panics if a Type or reflect.Type is already registered. Use RegisterWithAlias for the error-returning variant.
MustRegister — derives the type name from the Go struct name:
scheme.MustRegister(&Config{}, "v1")
// Registers as Type{Name: "Config", Version: "v1"}RegisterScheme — merges all types from another Scheme:
scheme.RegisterScheme(otherScheme)obj, err := scheme.NewObject(runtime.NewVersionedType("config", "v1"))Resolution path:
- Look up in
defaultsbimap → found? Use that reflect.Type - Not found → look up in
aliases→ get the default Type → look up indefaults - Get the prototype from
instances[reflect.Type] - Call
prototype.DeepCopyTyped()to get a fresh instance - Call
obj.SetType(requestedType)— sets the Type to what was requested (including aliases) - Return the instance
If the Type is not found and allowUnknown is false, returns an error. If allowUnknown is true, returns &Raw{}.
err := scheme.Decode(reader, into)Reads YAML/JSON from io.Reader, unmarshals into the target. If the target already has a non-empty Type before decoding, validates that the decoded Type matches.
Convert() handles four cases:
| From | To | What Happens |
|---|---|---|
*Raw |
*Raw |
Deep copy |
*Raw |
Typed struct | json.Unmarshal(raw.Data, into) |
| Typed struct | *Raw |
json.Marshal(from) → canonicalize → store in Raw.Data |
| Typed struct | Typed struct | DeepCopyTyped() + reflection assignment |
Wraps unknown or extensible types. Holds the Type and canonical JSON bytes without interpreting them:
type Raw struct {
Type `json:"type"`
Data []byte `json:"-"`
}UnmarshalJSON: extracts"type", stores full JSON inData(canonicalized)MarshalJSON: returnsDatadirectly
Use case — fields that can hold any typed object:
type Resolver struct {
Repository *runtime.Raw `json:"repository"`
}Convert to concrete type via scheme.Convert(&raw, &concreteType).
A map[string]string that uniquely identifies resources. Implements Typed (GetType/SetType via the "type" key). Provides CanonicalHashV1() (FNV64 of sorted key=value pairs) and stable String().
Reserved keys: type, hostname, scheme, path, port.
var Scheme = runtime.NewScheme()
func init() {
Scheme.MustRegisterWithAlias(&v1.Config{},
runtime.NewVersionedType(Type, Version),
runtime.NewUnversionedType(Type),
)
}Expose a MustAddToScheme function so external packages can pull in your types:
func MustAddToScheme(scheme *runtime.Scheme) {
scheme.MustRegisterWithAlias(&v1.Config{},
runtime.NewVersionedType(Type, Version),
runtime.NewUnversionedType(Type),
)
}Merge multiple sub-schemes into a parent:
func Register(scheme *runtime.Scheme) error {
return scheme.RegisterSchemes(
specv1.Scheme,
configv1.Scheme,
)
}Or consumer-side via init():
var DefaultScheme = runtime.NewScheme()
func init() {
pkg1.MustAddToScheme(DefaultScheme)
pkg2.MustAddToScheme(DefaultScheme)
}JSON → Raw → Typed → modify → Raw → JSON:
data := bytes.NewReader([]byte(`{"type": "config/v1", "host": "localhost"}`))
raw := &runtime.Raw{}
if err := scheme.Decode(data, raw); err != nil {
return err
}
cfg := &v1.Config{}
if err := scheme.Convert(raw, cfg); err != nil {
return err
}
cfg.Host = "example.com"
out := &runtime.Raw{}
if err := scheme.Convert(cfg, out); err != nil {
return err
}
output, err := json.Marshal(out)When the concrete type is unknown at compile time, use NewObject for dispatch:
obj, err := scheme.NewObject(raw.Type)
if err != nil {
return err
}
if err := scheme.Convert(raw, obj); err != nil {
return err
}Forgetting to register a type. Convert() and NewObject() return errors for unregistered types. Always register in init() or via MustAddToScheme.
Registering the same type twice. MustRegisterWithAlias panics if the Type value or Go type is already registered. Use one call with multiple aliases:
// Wrong — panics on second call (same Go type registered twice)
scheme.MustRegisterWithAlias(&Config{}, runtime.NewVersionedType("config", "v1"))
scheme.MustRegisterWithAlias(&Config{}, runtime.NewUnversionedType("config"))
// Correct — one call, default plus aliases
scheme.MustRegisterWithAlias(&Config{},
runtime.NewVersionedType("config", "v1"),
runtime.NewUnversionedType("config"),
)Note: different struct versions (e.g. v1.Config and v2.Config) are separate Go types and are registered independently.
DeepCopyTyped returning self. The Scheme stores prototypes and clones them via DeepCopyTyped(). Returning the same pointer causes shared mutable state:
// Wrong
func (m *MyType) DeepCopyTyped() runtime.Typed { return m }
// Correct
func (m *MyType) DeepCopyTyped() runtime.Typed {
c := *m
return &c
}Alias resolution preserves the requested Type. NewObject() calls SetType() with the Type you asked for, not the canonical default. If you look up an unversioned alias, GetType() returns the unversioned form.
Decode validates pre-set types. If the target already has a non-empty Type before Decode() and the JSON contains a different type, Decode returns an error.
Implementation details live under internal/. Public interfaces are defined in the parent package.
| Pattern | Purpose |
|---|---|
zz_generated.*.go |
Generated code — never edit |
doc.go |
Package documentation |
interface.go |
Interface definitions |
*_options.go |
Functional options |
suite_test.go |
Ginkgo test suite setup |
Packages that self-register types via init() are imported with blank identifiers where their types are needed:
import _ "ocm.software/open-component-model/bindings/go/access/localblob/v1"Enforced by gci. Four groups separated by blank lines:
- Standard library
- Blank imports (
_ "embed") - Third-party packages
- OCM modules (
ocm.software/open-component-model/...)
import (
"context"
"fmt"
_ "embed"
"github.com/spf13/cobra"
"ocm.software/open-component-model/bindings/go/runtime"
)| Area | Framework | Assertion Style |
|---|---|---|
bindings/go/ |
testify | require.New(t) |
cli/ |
testify + test.OCM() helper |
require.New(t) |
kubernetes/controller/ |
Ginkgo v2 + Gomega | Expect().To() |
var _ = Describe("Controller", func() {
BeforeEach(func() { /* setup */ })
It("does something", func(ctx SpecContext) {
By("step description")
// ...
})
})Async assertions use Eventually with context:
Eventually(func(ctx context.Context) error {
return k8sClient.Get(ctx, key, obj)
}, "15s").WithContext(ctx).Should(Succeed())//go:embed testdatafor static fixturest.TempDir()/GinkgoT().TempDir()for ephemeral data
Every command is a New() function returning *cobra.Command. Parent commands return cmd.Help() from RunE. Subcommands are added via cmd.AddCommand().
Dependencies are injected via context during PersistentPreRunE and retrieved with typed accessors:
ocmctx.FromContext(cmd.Context()).PluginManager()Flags implement pflag.Value for validation at set-time. Reusable flag types (enum, file) enforce constraints and generate help text automatically:
enum.VarP(cmd.Flags(), "output", "o", []string{"json", "yaml", "ndjson"}, "output format")File flags validate existence on Set() and expose Open() / Exists() helpers.
Pluggable renderer system supporting JSON, YAML, NDJSON, Tree, and Table output via a format enum. Two render modes: static (one-time) and live (terminal refresh with ANSI control sequences). Pager integration for large output.
All reconcilers embed a shared base reconciler providing ctrl.Client, runtime.Scheme, and record.EventRecorder.
Named error return enables deferred status updates that capture the final error state:
func (r *Reconciler) Reconcile(ctx context.Context, req ctrl.Request) (_ ctrl.Result, err error) {
patchHelper := patch.NewSerialPatcher(obj, r.Client)
defer func(ctx context.Context) {
err = errors.Join(err, status.UpdateStatus(ctx, patchHelper, obj, r.EventRecorder, obj.GetRequeueAfter(), err))
}(ctx)
// ...
}Helpers abstract fluxcd condition management. Condition helpers combine condition mutation with event recording in a single call:
status.MarkReady(recorder, obj, "reconciled")
status.MarkNotReady(recorder, obj, reason, message)
status.MarkAsStalled(recorder, obj, reason, message)The deferred UpdateStatus observes reconciliation state — setting ProgressingWithRetryReason on errors during reconciliation and mutating ObservedGeneration only when ready.
All controllers use GenerationChangedPredicate to only reconcile on spec changes, filtering out status-only updates:
For(&v1alpha1.Component{}, builder.WithPredicates(predicate.GenerationChangedPredicate{}))Field indexes are registered at controller setup for efficient cross-resource lookups. Watch handlers use handler.EnqueueRequestsFromMapFunc with client.MatchingFields{} to find related objects:
Watches(&v1alpha1.Repository{},
handler.EnqueueRequestsFromMapFunc(func(ctx context.Context, obj client.Object) []reconcile.Request {
list := &v1alpha1.ComponentList{}
r.List(ctx, list, client.MatchingFields{fieldName: obj.GetName()})
// build and return requests...
}))Deletion is guarded by finalizers. reconcileDelete checks for dependent resources before removing the finalizer. Adding a finalizer triggers an immediate requeue. Multiple finalizers may be used in sequence to enforce cleanup ordering.
The deployer uses SSA (client.Apply with client.ForceOwnership) and ApplySet (KEP-3659) for resource lifecycle management. The workflow is: Project (compute scope) → Apply (SSA all resources) → Prune (delete orphans matching the ApplySet label).
Async resolution uses a worker pool with an expirable LRU cache. The Load(key, fallbackFunc) pattern checks cache first, then dispatches work. Multiple requesters can subscribe to the same in-progress resolution:
result, err := cache.Load(key, func() (V, error) {
return expensiveOperation()
})Controller references are set on dynamically deployed objects for garbage collection. Dynamic informers use EnqueueRequestForOwner with OnlyControllerOwner() to watch owned resources.
Defined with kubebuilder markers. List types and scheme registration happen in init().
For watching arbitrary GVKs at runtime, a custom informer manager maintains metadata-only caches (PartialObjectMetadata). Register/unregister via channels. Implements manager.Runnable for controller-runtime integration. A transformer strips objects to metadata-only to reduce memory.
| Area | Logger |
|---|---|
bindings/go/ |
log/slog via slogcontext |
cli/ |
log/slog with JSON/text format flag |
kubernetes/controller/ |
logr via controller-runtime zap |
Three generators, triggered by markers:
| Generator | Marker | Output |
|---|---|---|
| ocmtypegen | // +ocm:typegen=true |
zz_generated.ocm_type.go |
| jsonschemagen | // +ocm:jsonschema-gen=true |
zz_generated.ocm_jsonschema.go |
| deepcopy-gen | // +k8s:deepcopy-gen=true |
zz_generated.deepcopy.go |
All generated files carry //go:build !ignore_autogenerated. Run task generate after adding or modifying markers.