Steven's Knowledge

Go

Simplicity as a feature -- goroutines, error handling, interfaces, and the patterns that make Go productive

Go

Go was designed at Google to solve Google's problems: large codebases, many engineers, long-lived services, fast compilation. The result is a language that is deliberately simple, sometimes frustratingly so. There are no generics-heavy abstractions (generics arrived late and are intentionally limited), no inheritance hierarchies, no operator overloading. The language fits in your head, and that is the point.

The engineers who struggle with Go are usually the ones trying to write Java or Haskell in Go syntax. The engineers who thrive are the ones who accept Go's constraints and discover that simplicity is not a limitation -- it is the feature.

Philosophy: Less Is More

Go's design principles are worth internalizing because they explain every "why doesn't Go have X?" question:

  • One way to do things. gofmt enforces a single style. There is no ternary operator. There is one loop keyword. Arguments about formatting never happen.
  • Explicit over implicit. Errors are returned as values, not thrown. Dependencies are imported explicitly. There is no hidden control flow.
  • Composition over inheritance. No classes, no inheritance. You build behavior by composing small interfaces and embedding structs.
  • Fast compilation. The language was designed so that compilation is nearly instant. This changes the development feedback loop fundamentally.

Goroutines and Channels

Goroutines are Go's concurrency primitive. They are not threads -- they are multiplexed onto OS threads by the Go runtime scheduler. You can launch millions of them.

func fetchAll(urls []string) []string {
    results := make([]string, len(urls))
    var wg sync.WaitGroup

    for i, url := range urls {
        wg.Add(1)
        go func(i int, url string) {
            defer wg.Done()
            resp, err := http.Get(url)
            if err != nil {
                results[i] = fmt.Sprintf("error: %v", err)
                return
            }
            defer resp.Body.Close()
            body, _ := io.ReadAll(resp.Body)
            results[i] = string(body)
        }(i, url)
    }

    wg.Wait()
    return results
}

Channels are the communication mechanism between goroutines. The fundamental pattern: don't communicate by sharing memory; share memory by communicating.

func pipeline(nums []int) <-chan int {
    out := make(chan int)
    go func() {
        defer close(out)
        for _, n := range nums {
            out <- n * n
        }
    }()
    return out
}

func fanOut(in <-chan int, workers int) []<-chan int {
    channels := make([]<-chan int, workers)
    for i := 0; i < workers; i++ {
        ch := make(chan int)
        channels[i] = ch
        go func() {
            defer close(ch)
            for val := range in {
                ch <- val * 2 // some processing
            }
        }()
    }
    return channels
}

Select Statement

select lets a goroutine wait on multiple channel operations. It is Go's multiplexer.

func fetchWithTimeout(url string, timeout time.Duration) (string, error) {
    ch := make(chan string, 1)
    errCh := make(chan error, 1)

    go func() {
        resp, err := http.Get(url)
        if err != nil {
            errCh <- err
            return
        }
        defer resp.Body.Close()
        body, _ := io.ReadAll(resp.Body)
        ch <- string(body)
    }()

    select {
    case result := <-ch:
        return result, nil
    case err := <-errCh:
        return "", err
    case <-time.After(timeout):
        return "", fmt.Errorf("timeout after %v", timeout)
    }
}

Error Handling

Go's error handling is verbose and explicit. That is by design. Every error is a value that must be handled at the call site.

Wrapping Errors

func readConfig(path string) (*Config, error) {
    data, err := os.ReadFile(path)
    if err != nil {
        return nil, fmt.Errorf("reading config %s: %w", path, err)
    }

    var cfg Config
    if err := json.Unmarshal(data, &cfg); err != nil {
        return nil, fmt.Errorf("parsing config %s: %w", path, err)
    }

    return &cfg, nil
}

The %w verb wraps the original error so callers can unwrap it with errors.Is and errors.As.

Sentinel Errors and Custom Types

var (
    ErrNotFound    = errors.New("not found")
    ErrUnauthorized = errors.New("unauthorized")
)

type ValidationError struct {
    Field   string
    Message string
}

func (e *ValidationError) Error() string {
    return fmt.Sprintf("validation failed on %s: %s", e.Field, e.Message)
}

// Caller can check:
// errors.Is(err, ErrNotFound)
// errors.As(err, &validationErr)

Interfaces

Go interfaces are satisfied implicitly. You never write implements. If a type has the methods, it satisfies the interface. This changes how you design -- start with the consumer, not the provider.

// Small interfaces are idiomatic. The standard library leads by example.
type Reader interface {
    Read(p []byte) (n int, err error)
}

type Writer interface {
    Write(p []byte) (n int, err error)
}

// Compose larger interfaces from small ones
type ReadWriter interface {
    Reader
    Writer
}

Accept Interfaces, Return Structs

This is Go's most important design principle for library code:

// Good: accepts an interface -- any storage backend works
func NewUserService(store UserStore) *UserService {
    return &UserService{store: store}
}

type UserStore interface {
    Get(ctx context.Context, id string) (*User, error)
    Save(ctx context.Context, user *User) error
}

// The concrete implementation
type PostgresUserStore struct {
    db *sql.DB
}

func (s *PostgresUserStore) Get(ctx context.Context, id string) (*User, error) {
    // ... query db
}

func (s *PostgresUserStore) Save(ctx context.Context, user *User) error {
    // ... insert/update db
}

Struct Embedding

Go does not have inheritance, but struct embedding provides composition that feels similar:

type BaseModel struct {
    ID        string    `json:"id"`
    CreatedAt time.Time `json:"created_at"`
    UpdatedAt time.Time `json:"updated_at"`
}

type User struct {
    BaseModel
    Email string `json:"email"`
    Name  string `json:"name"`
}

// user.ID works directly -- promoted from BaseModel
// user.CreatedAt works directly

Context Propagation

Context carries deadlines, cancellation signals, and request-scoped values through the call chain. Every function that does I/O should accept context.Context as its first parameter.

func (s *Service) ProcessOrder(ctx context.Context, orderID string) error {
    ctx, cancel := context.WithTimeout(ctx, 30*time.Second)
    defer cancel()

    order, err := s.orders.Get(ctx, orderID)
    if err != nil {
        return fmt.Errorf("fetching order: %w", err)
    }

    if err := s.payments.Charge(ctx, order); err != nil {
        return fmt.Errorf("charging payment: %w", err)
    }

    return s.notifications.Send(ctx, order.UserID, "Order confirmed")
}

Graceful Shutdown

A production Go service must handle shutdown signals cleanly:

func main() {
    srv := &http.Server{Addr: ":8080", Handler: setupRoutes()}

    go func() {
        if err := srv.ListenAndServe(); err != http.ErrServerClosed {
            log.Fatalf("server error: %v", err)
        }
    }()

    quit := make(chan os.Signal, 1)
    signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
    <-quit

    log.Println("shutting down...")
    ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
    defer cancel()

    if err := srv.Shutdown(ctx); err != nil {
        log.Fatalf("forced shutdown: %v", err)
    }
    log.Println("server stopped")
}

Testing in Go

Table-Driven Tests

The idiomatic Go testing pattern. One test function covers many cases:

func TestParseAmount(t *testing.T) {
    tests := []struct {
        name    string
        input   string
        want    int64
        wantErr bool
    }{
        {"valid dollars", "$100.00", 10000, false},
        {"valid cents", "$0.50", 50, false},
        {"no dollar sign", "100.00", 0, true},
        {"negative", "-$50.00", -5000, false},
        {"empty", "", 0, true},
    }

    for _, tt := range tests {
        t.Run(tt.name, func(t *testing.T) {
            got, err := ParseAmount(tt.input)
            if (err != nil) != tt.wantErr {
                t.Errorf("ParseAmount(%q) error = %v, wantErr %v", tt.input, err, tt.wantErr)
                return
            }
            if got != tt.want {
                t.Errorf("ParseAmount(%q) = %d, want %d", tt.input, got, tt.want)
            }
        })
    }
}

Dependency Injection Without Frameworks

Go does not need a DI framework. Constructor injection with interfaces is enough:

type Server struct {
    userStore  UserStore
    mailClient MailClient
    logger     *slog.Logger
}

func NewServer(us UserStore, mc MailClient, log *slog.Logger) *Server {
    return &Server{userStore: us, mailClient: mc, logger: log}
}

// In tests, pass mocks directly:
func TestServer_CreateUser(t *testing.T) {
    mockStore := &MockUserStore{}
    mockMail := &MockMailClient{}
    srv := NewServer(mockStore, mockMail, slog.Default())
    // test srv.CreateUser(...)
}

Standard Library Highlights

Go's standard library is unusually strong. Before reaching for a third-party package, check if the standard library already solves your problem:

PackageWhat It Does
net/httpFull HTTP client and server -- production-ready without frameworks
encoding/jsonJSON marshal/unmarshal with struct tags
database/sqlDatabase-agnostic SQL interface
contextCancellation, timeouts, request-scoped values
syncMutexes, WaitGroups, Once, Map
testingBuilt-in test framework with benchmarks and fuzzing
log/slogStructured logging (Go 1.21+)
errorsError wrapping, Is, As

Go in New Zealand

Go has strong adoption at Xero (one of NZ's largest tech companies), and it is increasingly popular at startups building cloud-native services. Platform teams and infrastructure roles are the most common contexts. If you are interviewing for backend roles in NZ, Go proficiency is a genuine differentiator -- many candidates know JavaScript and Python but few can write idiomatic Go.

On this page