Back to Writing
04 — Engineering
Getting Started with Go · Part 2 of 5

Getting Started with Go, Part 2: Structs, Interfaces & Errors

Cyrus Kiprop·July 20, 2026·5 min read

Part 1 left us with jobs as bare strings and a switch that guessed their priority from a prefix. That's fine for a toy, but it doesn't survive contact with a second requirement — retries, a real priority field, more than one way to process a job. This part gives the processor real types.

Structs instead of stringly-typed data

A struct is a named collection of fields — no constructors, no access modifiers, just a type declaration and a literal to build one:

go
type Job struct {
	Name     string
	Priority string
	Retries  int
}

job := Job{Name: "urgent-deploy", Priority: "HIGH", Retries: 3}

Field names starting with a capital letter are exported (visible outside the package); lowercase is package-private. That capitalization is the access modifier — there's no public/private keyword doing it separately, which trips up people coming from Java or Ruby the first time they see it.

Interfaces: implemented implicitly

Go interfaces describe behavior, not identity, and a type satisfies one just by having the right methods — there's no implements keyword, no declared relationship at all:

go
type Processor interface {
	Process(j Job) error
}

Anything with a Process(Job) error method is a Processor, full stop, even if it was written before the interface existed. That's the practical payoff: you can define a small interface next to the code that consumes it, and existing types satisfy it retroactively as long as the method shape matches. Compare that to defining a hierarchy up front and forcing every implementer to declare it — Go's version has a lot less ceremony and a lot less coupling between the interface and whoever implements it.

Composition over inheritance

Go doesn't have class inheritance at all. What it has instead is embedding — putting one struct inside another without a field name, which promotes the embedded type's fields and methods onto the outer type:

go
type Logger struct {
	Prefix string
}

func (l Logger) Log(msg string) {
	fmt.Printf("[%s] %s\n", l.Prefix, msg)
}

type Worker struct {
	Logger // embedded, not a named field
	ID int
}

w := Worker{Logger: Logger{Prefix: "worker-1"}, ID: 1}
w.Log("started") // Worker didn't define Log — Logger did

Worker didn't declare Log — it got it for free by embedding Logger. This is deliberately less powerful than inheritance: there's no method overriding, no super, no diamond-shaped ambiguity to reason about. If you want that field's behavior, you embed it; if you don't, you compose a regular named field instead. Composition is the only option, which is a large part of why Go codebases tend to have shallow, boring type hierarchies instead of deep inheritance chains.

Errors are values, not exceptions

Go has no try/catch. An error is just a value satisfying a one-method interface:

go
type error interface {
	Error() string
}

The convention — not a language rule, just an convention everyone follows — is that any function that can fail returns (result, error), and callers check the error immediately:

go
if err != nil {
    return err
}

That's the "boring" idiomatic form, and yes, you'll type it a lot. The payoff is that failure is visible in every function signature and every call site — you can't silently skip past an error the way a forgotten catch block lets you skip past an exception.

For our own errors, you build a real type implementing Error() string, which lets you attach context an inspecting caller can use:

go
type ProcessError struct {
	Job string
	Err error
}

func (e *ProcessError) Error() string {
	return fmt.Sprintf("processing %q: %v", e.Job, e.Err)
}

func (e *ProcessError) Unwrap() error {
	return e.Err
}

That Unwrap method is what makes errors.Is and errors.As work — they walk the chain of wrapped errors looking for a match, instead of you manually unwrapping nested error types by hand:

go
var procErr *ProcessError
if errors.As(err, &procErr) {
    fmt.Println("job that failed:", procErr.Job)
}

Putting it together

A Job struct, a Processor interface, and a custom error type that a caller can inspect with errors.As — this is the whole program, and it's a complete one, so Run actually executes it:

go
package main

import (
	"errors"
	"fmt"
	"strings"
)

type Job struct {
	Name     string
	Priority string
	Retries  int
}

type Processor interface {
	Process(j Job) error
}

var ErrJobFailed = errors.New("job failed")

type ProcessError struct {
	Job string
	Err error
}

func (e *ProcessError) Error() string {
	return fmt.Sprintf("processing %q: %v", e.Job, e.Err)
}

func (e *ProcessError) Unwrap() error {
	return e.Err
}

type FlakyProcessor struct{}

func (FlakyProcessor) Process(j Job) error {
	if strings.Contains(j.Name, "flaky") {
		return &ProcessError{Job: j.Name, Err: ErrJobFailed}
	}
	fmt.Printf("processed %s (priority=%s)\n", j.Name, j.Priority)
	return nil
}

func run(p Processor, jobs []Job) {
	for _, j := range jobs {
		err := p.Process(j)
		if err == nil {
			continue
		}

		var procErr *ProcessError
		if errors.As(err, &procErr) {
			fmt.Printf("retrying %s after: %v\n", procErr.Job, procErr.Err)
			continue
		}
		fmt.Println("unexpected error:", err)
	}
}

func main() {
	jobs := []Job{
		{Name: "urgent-deploy", Priority: "HIGH", Retries: 3},
		{Name: "flaky-migration", Priority: "NORMAL", Retries: 1},
		{Name: "cleanup", Priority: "LOW", Retries: 0},
	}

	run(FlakyProcessor{}, jobs)
}

run takes a Processor interface, not a concrete FlakyProcessor — that's what lets Part 3 swap in a completely different implementation (a concurrent worker pool) without touching this function's signature at all.