Skip to main content

Go Code Style Guide

This guide covers Go code conventions used across the Michelangelo codebase. It complements the error handling guide with broader patterns: package structure, interface design, logging, and test organization.


Package Naming and Structure

Naming

  • Package names are lowercase, single-word, no underscores: backends, scheduler, ingester.
  • Package names should match the directory name. The directory go/components/inferenceserver/backends/ contains package backends.
  • Avoid stutter: a Backend type in package backends is referenced as backends.Backend — not backends.BackendInterface.
  • Test files for package foo use package foo (white-box tests) or package foo_test (black-box tests). Do not mix both styles in the same directory without good reason.

Directory Layout

go/
├── cmd/ # Entry points — one binary per subdirectory
│ ├── apiserver/
│ ├── controllermgr/
│ └── worker/
└── components/ # Reusable packages
├── inferenceserver/
│ └── backends/ # Interface + registry + implementations
├── scheduler/
└── jobs/
  • cmd/ packages should be thin: they wire dependencies and call into components/.
  • Business logic belongs in components/, not in cmd/.
  • Generated code lives in go/gen/ — never edit files there.

Interface Design

The codebase uses interfaces at subsystem boundaries to make components testable and extensible. Follow these patterns when defining a new interface.

Define interfaces where they are used, not where they are implemented

// ✅ Good: interface defined in the consumer package
// go/components/scheduler/scheduler.go
package scheduler

// JobQueue is the interface the scheduler depends on.
// Implementations live elsewhere (e.g., kueue/, volcano/).
type JobQueue interface {
Enqueue(ctx context.Context, job *Job) error
Dequeue(ctx context.Context) (*Job, error)
}
// ❌ Bad: defining the interface in the implementation package
// go/components/kueue/interface.go
package kueue

type KueueJobQueue interface { ... } // Consumer imports kueue — wrong direction

Keep interfaces small

Prefer small, focused interfaces over large ones. A type that satisfies a small interface is easier to mock and test.

// ✅ Good: single responsibility
type ModelConfigProvider interface {
GetModelConfig(ctx context.Context, name, namespace string) (*ModelConfig, error)
UpdateModelConfig(ctx context.Context, config *ModelConfig) error
}

// ❌ Bad: unrelated concerns bundled
type ModelManager interface {
GetModelConfig(ctx context.Context, ...) (*ModelConfig, error)
UpdateModelConfig(ctx context.Context, ...) error
DeleteServer(ctx context.Context, ...) error // unrelated
IsHealthy(ctx context.Context, ...) (bool, error) // unrelated
}

Document idempotency requirements

Methods that are called by controllers are typically called repeatedly. Document this explicitly:

// Backend abstracts inference server provisioning for different frameworks (Triton, vLLM, etc.).
// All methods must be idempotent.
type Backend interface {
// CreateServer provisions infrastructure and returns the current state.
// Safe to call multiple times — must be a no-op if the server already exists.
CreateServer(ctx context.Context, logger *zap.Logger, kubeClient client.Client, inferenceServer *v2pb.InferenceServer) (*ServerStatus, error)
...
}

Registry pattern for extensible sets

When a subsystem supports multiple implementations (backends, schedulers), use a registry:

type Registry struct {
mu sync.RWMutex
backends map[v2pb.BackendType]Backend
}

func (r *Registry) Register(backendType v2pb.BackendType, backend Backend) {
r.mu.Lock()
defer r.mu.Unlock()
r.backends[backendType] = backend
}

func (r *Registry) GetBackend(backendType v2pb.BackendType) (Backend, error) {
r.mu.RLock()
defer r.mu.RUnlock()
b, ok := r.backends[backendType]
if !ok {
return nil, fmt.Errorf("backend %q not registered", backendType)
}
return b, nil
}

Logging Conventions

Michelangelo uses two loggers depending on the context:

LoggerPackageUsed in
go.uber.org/zap*zap.LoggerMost components — direct zap.Logger field in structs
sigs.k8s.io/controller-runtime/pkg/loglogr.LoggerKubernetes controllers (controller-runtime convention)

Zap usage

Pass the logger as a parameter (do not use a global logger):

// ✅ Good: logger as parameter
func (b *TritonBackend) CreateServer(ctx context.Context, logger *zap.Logger, ...) error {
logger.Info("creating triton server",
zap.String("name", inferenceServer.Name),
zap.String("namespace", inferenceServer.Namespace))
...
}

// ❌ Bad: global logger
func (b *TritonBackend) CreateServer(ctx context.Context, ...) error {
zap.L().Info("creating triton server") // global — untestable and not contextual
...
}

Field names: use consistent key names across the codebase.

FieldZap key
Errorzap.Error(err)
Kubernetes resource namezap.String("name", ...)
Kubernetes namespacezap.String("namespace", ...)
Operationzap.String("operation", ...)

controller-runtime logr usage

Controllers use log.FromContext(ctx) to get a logger with request context already attached:

func (r *Reconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) {
logger := log.FromContext(ctx).WithValues("resource", req.NamespacedName)

if err := r.doSomething(ctx); err != nil {
logger.Error(err, "failed to do something",
"operation", "do_something",
"namespace", req.Namespace,
"name", req.Name)
return ctrl.Result{}, err
}
return ctrl.Result{}, nil
}

See Error Handling for the required log-and-return pattern in controllers.

Log levels

LevelWhen to use
InfoNormal operations: resource created, task started, status updated
ErrorActionable failures: operation failed and needs investigation
DebugHigh-frequency detail useful only during active debugging — off by default

Do not use Warn. Use Info for expected transient conditions (rate limits, retries) and Error for unexpected failures.


TODO Comments

All TODO comments must reference a GitHub issue. The CI TODO check enforces this:

// ✅ Passes CI
// TODO(#456): switch to batch API once available

// ❌ Fails CI — golangci-lint godox check
// TODO: switch to batch API
// TODO - handle this edge case

To add a TODO:

  1. Create a GitHub issue describing the work.
  2. Reference it: TODO(#<issue-number>): brief description.

Test File Organization

File naming

scheduler.go           → scheduler_test.go
registry.go → registry_test.go

Place test files in the same directory as the code under test. Use the same package name (package scheduler) for white-box tests that access unexported identifiers, or package scheduler_test for black-box tests.

Test function naming

// Pattern: Test<Type>_<Method>_<Scenario>
func TestRegistry_GetBackend_NotFound(t *testing.T) { ... }
func TestReconciler_Reconcile_ErrorOnGet(t *testing.T) { ... }

Mock generation

Mocks are generated with mamockgen (a wrapper around mockgen). Add the generate directive at the top of the interface file:

//go:generate mamockgen Backend

Then run:

cd go && go generate ./components/inferenceserver/backends/...

Generated mocks land in <package>/backendsmocks/ (package name + mocks suffix). Import them in tests:

import "github.com/michelangelo-ai/michelangelo/go/components/inferenceserver/backends/backendsmocks"

See Using Go Mocks in Unit Tests for full mock usage patterns.

Table-driven tests

Prefer table-driven tests for functions with multiple input/output cases:

func TestValidateModelName(t *testing.T) {
tests := []struct {
name string
input string
wantErr bool
}{
{name: "valid", input: "fraud-detector", wantErr: false},
{name: "empty", input: "", wantErr: true},
{name: "too long", input: strings.Repeat("a", 256), wantErr: true},
}

for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
err := validateModelName(tc.input)
if (err != nil) != tc.wantErr {
t.Errorf("validateModelName(%q) error = %v, wantErr %v", tc.input, err, tc.wantErr)
}
})
}
}