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.
gofmtenforces 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 directlyContext 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:
| Package | What It Does |
|---|---|
net/http | Full HTTP client and server -- production-ready without frameworks |
encoding/json | JSON marshal/unmarshal with struct tags |
database/sql | Database-agnostic SQL interface |
context | Cancellation, timeouts, request-scoped values |
sync | Mutexes, WaitGroups, Once, Map |
testing | Built-in test framework with benchmarks and fuzzing |
log/slog | Structured logging (Go 1.21+) |
errors | Error 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.