Go Style Guide
Effective Go standards for Fulcrum backend services.
Overview
This guide summarizes key rules from Effective Go and Fulcrum-specific conventions for writing idiomatic Go code.
Formatting
| Rule | Standard |
|---|---|
| Formatter | gofmt (mandatory) |
| Indentation | Tabs |
| Line Length | No strict limit |
All Go code must be formatted with gofmt. This is non-negotiable.
Naming
General Rules
- Use
MixedCapsormixedCapsfor multi-word names - Do not use underscores in names
- Exported names start with uppercase (public)
- Unexported names start with lowercase (private)
Specific Conventions
| Type | Convention | Example |
|---|---|---|
| Package | Short, lowercase, single-word | policyengine |
| Getter | No Get prefix |
Owner() not GetOwner() |
| Interface | Method name + -er suffix |
Evaluator, Publisher |
Fulcrum Conventions
// Context first in function signatures
func CreateEnvelope(ctx context.Context, tenantID string, req *CreateRequest) (*Envelope, error)
// Error wrapping with context
if err := store.Save(ctx, envelope); err != nil {
return nil, fmt.Errorf("save envelope: %w", err)
}
// Interface suffix: -er
type PolicyEvaluator interface {
Evaluate(ctx context.Context, req *EvalRequest) (*EvalResult, error)
}
Control Structures
If Statements
// No parentheses, mandatory braces, initialization allowed
if err := file.Chmod(0664); err != nil {
return fmt.Errorf("chmod: %w", err)
}
For Loops
// Go's only looping construct
for i := 0; i < 10; i++ { } // Traditional
for condition { } // While-style
for { } // Infinite
for key, value := range items { } // Range iteration
Switch Statements
// No fall-through by default (use fallthrough explicitly)
switch status {
case StatusPending:
// Handle pending
case StatusActive:
// Handle active
default:
return fmt.Errorf("unknown status: %s", status)
}
// Expression-less switch for cleaner if-else chains
switch {
case cost > 100:
return ErrBudgetExceeded
case cost > 50:
return ErrBudgetWarning
default:
return nil
}
Functions
Multiple Returns
// Standard pattern: value + error
func GetPolicy(ctx context.Context, id string) (*Policy, error) {
policy, err := store.Get(ctx, id)
if err != nil {
return nil, fmt.Errorf("get policy %s: %w", id, err)
}
return policy, nil
}
Defer
// Use for cleanup (runs when function returns)
func ReadFile(path string) ([]byte, error) {
f, err := os.Open(path)
if err != nil {
return nil, err
}
defer f.Close()
return io.ReadAll(f)
}
Data Structures
new vs make
| Function | Purpose | Returns |
|---|---|---|
new(T) |
Allocates zeroed memory | *T (pointer) |
make(T, ...) |
Initializes slices, maps, channels | T (value) |
// new: allocates and zeros
p := new(Policy) // *Policy pointing to zeroed Policy
// make: initializes
s := make([]Policy, 0, 10) // Empty slice, capacity 10
m := make(map[string]*Policy) // Initialized map
ch := make(chan Event, 100) // Buffered channel
Slices and Maps
// Prefer slices over arrays
policies := make([]*Policy, 0)
// Map existence check with comma-ok
value, ok := myMap[key]
if !ok {
return nil, ErrNotFound
}
Interfaces
Implicit Implementation
// No "implements" keyword needed
type PolicyEvaluator interface {
Evaluate(ctx context.Context, req *EvalRequest) (*EvalResult, error)
}
// InMemoryEvaluator implements PolicyEvaluator by having the method
type InMemoryEvaluator struct {
policies []*Policy
}
func (e *InMemoryEvaluator) Evaluate(ctx context.Context, req *EvalRequest) (*EvalResult, error) {
// Implementation
}
Prefer Small Interfaces
// Good: Single-method interface
type Evaluator interface {
Evaluate(ctx context.Context, req *EvalRequest) (*EvalResult, error)
}
// Good: Compose small interfaces
type PolicyService interface {
Evaluator
PolicyReader
PolicyWriter
}
Concurrency
Core Philosophy
Do not communicate by sharing memory; instead, share memory by communicating.
Goroutines
// Lightweight concurrent function
go processEnvelope(ctx, envelope)
// With error handling via channels
errCh := make(chan error, 1)
go func() {
errCh <- processEnvelope(ctx, envelope)
}()
Channels
// Create typed channels
events := make(chan Event, 100) // Buffered channel
// Send and receive
events <- event // Send
event := <-events // Receive
// Select for multiple channels
select {
case event := <-events:
handle(event)
case <-ctx.Done():
return ctx.Err()
}
Error Handling
Explicit Handling Required
// NEVER discard errors
result, _ := doThing() // BAD
// ALWAYS handle or propagate
result, err := doThing()
if err != nil {
return fmt.Errorf("do thing: %w", err)
}
Error Wrapping
// Wrap errors with context
if err := validate(req); err != nil {
return fmt.Errorf("validate request: %w", err)
}
// Check wrapped errors
if errors.Is(err, ErrNotFound) {
return nil, status.Error(codes.NotFound, "policy not found")
}
Panic
// Only for truly unrecoverable situations
// Libraries should NEVER panic
func mustParse(s string) Config {
cfg, err := Parse(s)
if err != nil {
panic(fmt.Sprintf("parse config: %v", err))
}
return cfg
}
Fulcrum-Specific Patterns
Context Usage
// Always pass context first
func (s *Service) CreateEnvelope(ctx context.Context, req *CreateRequest) (*Envelope, error) {
// Use context for cancellation and tenant info
tenantID := tenant.FromContext(ctx)
// Propagate context to all downstream calls
return s.store.Create(ctx, tenantID, envelope)
}
Table-Driven Tests
func TestEvaluate(t *testing.T) {
tests := []struct {
name string
input *EvalRequest
want Decision
wantErr bool
}{
{
name: "allows safe request",
input: &EvalRequest{Tool: "read_file"},
want: DecisionAllow,
},
{
name: "denies bash",
input: &EvalRequest{Tool: "bash"},
want: DecisionDeny,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := evaluator.Evaluate(ctx, tt.input)
if (err != nil) != tt.wantErr {
t.Errorf("error = %v, wantErr %v", err, tt.wantErr)
return
}
if got.Decision != tt.want {
t.Errorf("decision = %v, want %v", got.Decision, tt.want)
}
})
}
}
See Also
- Effective Go - Official Go style guide
- Go Code Review Comments - Common review feedback
- Architecture Decisions - Fulcrum ADRs
Document Version: 1.0 Last Updated: January 20, 2026