Go was built at Google by Robert Griesemer, Rob Pike, and Ken Thompson to be simple enough to learn in weeks and fast enough to run the internet’s infrastructure — Docker, Kubernetes, and Terraform are all written in it. It has 25 keywords, compiles to a single binary, and treats concurrency as a first-class feature instead of an afterthought.

This guide takes you from go run to a working project: TaskTrail, a task-tracking service with a REST API, JWT authentication, PostgreSQL storage, and a CLI client, all sharing one domain model. Every section adds a concept. The final part connects them into one project — nothing here is a disconnected snippet.

What you will have by the end: a solid grip on Go’s type system, error handling, and interfaces, comfort with goroutines and channels for real concurrency, and a production-shaped Go service — REST API, database layer, CLI, and Docker deployment — tested with the standard library.

Two things before you start:

  • No prior Go experience needed, though some programming background helps. If you know a language with explicit types (Java, C++, TypeScript), the syntax will feel familiar fast.
  • Full source code for the capstone project is on GitHub: github.com/kemalcodes/go-tutorial.

Part 1: Foundations

Why Go

Go trades expressiveness for simplicity on purpose. There is no inheritance, no exceptions, and — until 2022 — no generics. The language designers believed a smaller language is a more maintainable one: any developer can read any Go codebase, because there is only one way to write most things.

package main

import "fmt"

func main() {
    fmt.Println("Hello, World!")
}

That compiles to a single, dependency-free binary in about a second. No virtual machine, no interpreter — copy the binary to a server and run it. This is why Go dominates infrastructure tooling: Kubernetes, Docker, Terraform, and Hugo (which builds this very blog) are all Go binaries.

GoRustPythonJava
Memory managementGarbage collectedOwnership (no GC)Garbage collectedGarbage collected (JVM)
ConcurrencyGoroutines (built-in)async/await + ownershipasyncio (limited)Threads / virtual threads
Compile speedVery fastSlowN/A (interpreted)Moderate
Learning curveEasyHardVery easyModerate
Best forAPIs, CLIs, infrastructureSystems, performance-criticalData, AI, scriptingEnterprise, Android

Choose Go when you’re building a server, a CLI tool, or anything that needs to deploy as one small, fast binary. Choose Rust when you need to squeeze out the last percent of performance or work without a garbage collector. The two pair well — Go for the application layer, Rust for the performance-critical pieces underneath.

The language shows up wherever “deploy one small binary, handle a lot of concurrent connections” is the job: Uber runs high-throughput microservices in it, Cloudflare uses it for edge and network infrastructure, and Dropbox migrated performance-critical systems off Python and onto Go specifically for the speed and deployment simplicity. A Go binary in Docker can land under 15MB — the entire runtime is compiled in, there’s no separate interpreter or virtual machine image to ship alongside it. Demand tracks the adoption: Go sits among the higher-paid mainstream languages in salary surveys, and a majority of working Go developers report building CLI tools as part of their day-to-day work — a direct consequence of “compiles to one dependency-free binary” being the exact property a command-line tool needs.

Setup

# macOS
brew install go

# verify
go version

Every Go project is a module. Create one, then write your first program:

mkdir hello-go && cd hello-go
go mod init hello-go
// main.go
package main

import "fmt"

func main() {
    fmt.Println("Hello, World!")
}
go run main.go       # compile + run in one step, no binary left behind
go build -o hello .  # compile to a standalone binary
./hello

Four commands you’ll use constantly: go run for iterating, go build for producing a binary, go mod tidy for syncing dependencies, and go fmt ./... for formatting — Go ships one official style, so there’s never a tabs-vs-spaces argument in a Go codebase.

Two rules the compiler enforces, not just suggests: unused imports and unused variables are compile errors. This keeps every Go file free of dead weight, but it does mean code you’re mid-editing won’t build until it’s clean.

Variables, Types, and Constants

:= declares and infers a type in one step — the idiomatic way to declare variables inside a function:

name := "Alex"    // string
age := 25         // int
height := 1.75    // float64

var is for package-level variables (:= doesn’t work outside a function) or when you want to be explicit about the type without a value yet:

var appName = "MyApp"   // package-level, must use var

func main() {
    var count int   // zero value: 0
}

This points at one of Go’s most useful design decisions: every type has a zero value, so an uninitialized variable is never garbage — it’s 0 for numbers, false for bools, "" for strings, nil for pointers, slices, and maps. A struct’s zero value has every field set to its own zero value, so a struct is always in a valid, if empty, state.

Go never converts types implicitly — float64(intValue) is required even for something as simple as mixing an int and a float64 in one expression. This is deliberate: silent numeric conversion is a common bug source in other languages, and Go makes you write the conversion where it happens.

const maxRetries = 3   // compile-time constant

const (
    Sunday = iota   // 0
    Monday          // 1 — iota auto-increments
    Tuesday         // 2
)

iota replaces the enum keyword Go doesn’t have — it starts at 0 in a const block and increments by one per line, and combined with bit-shifting it is the standard way to build flag-style constants:

const (
    _  = iota             // skip 0
    KB = 1 << (10 * iota) // 1 << 10 = 1024
    MB                    // 1 << 20 = 1,048,576
    GB                    // 1 << 30 = 1,073,741,824
)

Two more built-in types round out the basics: byte is an alias for uint8 (ASCII text), and rune is an alias for int32 (a single Unicode code point). This matters the moment you iterate a string — for i, ch := range word gives you runes, not bytes, so range over "日本語" correctly stops on each character instead of splitting multi-byte UTF-8 sequences in half.

Every type has a full zero-value table worth memorizing, since it explains why Go code so rarely null-checks primitives:

TypeZero value
int, float640, 0.0
boolfalse
string""
Pointers, slices, maps, channelsnil

Type conversion has one gotcha that catches everyone once: string(65) does not produce "65" — it converts the number to the Unicode code point it represents, giving you "A". To turn a number into its decimal string, use fmt.Sprintf("%d", n) or the strconv package:

n, err := strconv.Atoi("42")     // string to int, returns an error for bad input
s := strconv.Itoa(100)            // int to string — "100"
f, err := strconv.ParseFloat("3.14", 64)

strconv.Atoi (and its siblings) returning (value, error) instead of panicking on bad input is the same two-value pattern you’ll see everywhere in Go — covered next.

Functions and Error Handling

A function that returns two values is completely ordinary in Go — it’s the pattern almost every standard library function follows:

func divide(a, b int) (int, int) {
    return a / b, a % b
}

quotient, remainder := divide(17, 5)

This is the foundation of Go’s error handling. There are no exceptions — a function that can fail returns an error as its last value, and the caller checks it immediately:

func divide(a, b float64) (float64, error) {
    if b == 0 {
        return 0, errors.New("cannot divide by zero")
    }
    return a / b, nil
}

result, err := divide(10, 0)
if err != nil {
    fmt.Println("Error:", err)
    return
}

if err != nil is the single most common code pattern you will type in Go. It looks repetitive coming from exception-based languages, but the payoff is that every place a function can fail is visible at the call site — nothing is hidden behind an invisible throw that could surface three stack frames up. Use errors.New() for a fixed message, fmt.Errorf() when you need to interpolate a value.

defer schedules a call to run when the enclosing function returns, regardless of how it returns — the standard use is guaranteeing cleanup right next to the line that acquired the resource:

file, err := os.Open(path)
if err != nil {
    return err
}
defer file.Close()   // guaranteed to run, even on an early return below

Multiple defers run in reverse order (last deferred, first executed) — useful to remember when a function defers several cleanup steps that depend on each other.

Default parameters don’t exist in Go, but variadic parameters cover the common case of “any number of arguments”:

func sum(numbers ...int) int {
    total := 0
    for _, n := range numbers {
        total += n
    }
    return total
}

sum(1, 2, 3)          // 6
sum(existingSlice...) // spread a slice into variadic args

Return values can be named in the function signature, which both documents what each one means and lets you return bare (a “naked return”) to send back whatever those named variables currently hold:

func circleStats(radius float64) (area, perimeter float64) {
    area = math.Pi * radius * radius
    perimeter = 2 * math.Pi * radius
    return   // returns area and perimeter by name
}

Named returns pay for themselves as documentation on short functions; for anything longer than a handful of lines, an explicit return area, perimeter is easier to follow than tracking naked returns back to where the named variables were last assigned.

Control Flow

Go has exactly one loop keyword — for — and it covers for, while, and do-while from other languages depending on how you write it:

for i := 0; i < 5; i++ { }   // classic C-style
for count > 0 { count-- }     // while-style — drop init/post
for { break }                  // infinite — drop everything, use break

for range iterates collections and, since Go 1.22, plain integers:

for i, fruit := range []string{"apple", "banana"} { }   // index + value
for i := range 5 { }                                      // 0..4, no slice needed

if/else needs no parentheses around the condition, and — like switch — supports a short statement before the condition, scoped to just that block. This is the idiom behind almost every error check you’ll write:

if err := doSomething(); err != nil {
    return err
}
// err is not in scope here

switch has no fall-through by default (the opposite of C and Java), which removes an entire category of forgotten-break bugs, and it works without a condition at all as a cleaner alternative to a long if/else if chain:

switch {
case score >= 90:
    return "A"
case score >= 80:
    return "B"
default:
    return "F"
}

A type switch checks the dynamic type stored in an interface value — you’ll use this constantly once interfaces enter the picture in the next section:

switch v := value.(type) {
case int:
    fmt.Println("int:", v)
case string:
    fmt.Println("string:", v)
}

Inside each case, v has the matching concrete type — no manual assertion needed. Unlike C or Java, Go switch does not fall through by default; if you genuinely need the old fall-through behavior, opt in explicitly with fallthrough:

switch num {
case 1:
    fmt.Println("One")
    fallthrough   // explicitly continue into the next case
case 2:
    fmt.Println("Two")
}

Labels let break and continue target an outer loop instead of the innermost one — the one case where Go’s minimalism still gives you an escape hatch for nested loops:

outer:
for i := 1; i <= 9; i++ {
    for j := 1; j <= 9; j++ {
        if i+j == 10 {
            break outer   // exits BOTH loops, not just the inner one
        }
    }
}

Arrays, Slices, and Maps

Arrays have a fixed size baked into their type ([5]int and [3]int are different types) — in practice you will almost always reach for a slice instead, Go’s dynamic, growable array:

names := []string{"Alex", "Sam"}      // slice literal
scores := make([]int, 0, 10)          // length 0, capacity 10 — avoids reallocation
names = append(names, "Jordan")        // always reassign — append may return a new backing array

A slice is really three fields under the hood — a pointer to a backing array, a length, and a capacity. That has a sharp edge worth knowing early: slicing an existing slice (sub := original[1:3]) shares the same backing array, so writing through sub mutates original. Use copy() when you need an independent slice.

numbers := []int{0, 1, 2, 3, 4, 5}
fmt.Println(numbers[2:5])    // [2 3 4] — end is exclusive
fmt.Println(numbers[:3])     // [0 1 2]

Maps are Go’s key-value collection. Always initialize with make or a literal — writing to a nil map panics:

ages := map[string]int{"Alex": 25, "Sam": 30}
ages["Jordan"] = 22

age, ok := ages["Taylor"]   // the two-value form — ok is false if the key is missing

That value, ok := map[key] idiom matters because reading a missing key silently returns the zero value — without checking ok, you cannot tell “key missing” from “key present with a zero value.” Map iteration order is randomized on every run by design, so sort the keys yourself if you need deterministic output. Writing to an uninitialized (nil) map panics at runtime — make() or a literal is not optional:

var m map[string]int
m["key"] = 1   // panic: assignment to entry in nil map

append’s capacity growth is worth understanding once, because it explains a class of subtle bugs. When a slice’s length hits its capacity, the next append allocates a new, larger backing array (roughly doubling) and copies everything over — which is exactly why you always reassign the result: numbers = append(numbers, x), never a bare append(numbers, x) that discards the possibly-reallocated slice.

The sharper edge is that two slices can alias the same backing array. Slicing an existing slice (sub := original[1:3]) does not copy — it’s a new header pointing at the same memory, so writing through sub is visible in original:

original := []int{1, 2, 3, 4, 5}
sub := original[1:3]      // [2, 3] — shares original's backing array
sub[0] = 99
fmt.Println(original)     // [1 99 3 4 5] — mutated!

Use copy(dst, src) when you need an independent slice instead of an aliased view:

dst := make([]int, len(src))
copy(dst, src)   // dst and src no longer share memory

Go has no built-in remove — deleting an element is append plus a slice expression, which reads odd the first time but is the idiomatic form:

numbers = append(numbers[:index], numbers[index+1:]...)

Part 2: Types and Interfaces

Structs, Methods, and Composition

Go has no classes. A struct groups data; a method is a function with a receiver that attaches behavior to a type:

type Rectangle struct {
    Width, Height float64
}

func (r Rectangle) Area() float64 {
    return r.Width * r.Height
}

rect := Rectangle{Width: 10, Height: 5}
fmt.Println(rect.Area())

The receiver — (r Rectangle) — decides whether the method sees a copy or the original. A value receiver gets a copy (read-only, effectively); a pointer receiver can mutate the caller’s struct:

func (c *Counter) Increment() { c.Value++ }   // must be pointer to mutate
func (c Counter) Current() int { return c.Value }  // read-only, value is fine

Rule of thumb: if any method on a type needs a pointer receiver, make them all pointer receivers for consistency. Go has no constructors — the convention is a plain function prefixed New, returning a pointer:

func NewUser(name, email string) *User {
    return &User{Name: name, Email: email}
}

Instead of inheritance, Go uses composition via embedding — one struct contains another, and the outer struct gets the inner struct’s fields and methods “promoted” as if they were its own:

type Address struct{ City string }
func (a Address) FullAddress() string { return a.City }

type Employee struct {
    Name string
    Address   // embedded, no field name
}

emp := Employee{Name: "Alex", Address: Address{City: "Berlin"}}
emp.City          // promoted field
emp.FullAddress()  // promoted method

Employee has an Address, it is not an Address — composition, not inheritance. Struct tags (`json:"name"`) attach metadata other packages read via reflection — you’ll use these on nearly every struct that crosses a JSON boundary, including json:"-" to exclude a field entirely (the standard way to keep a password hash out of an API response) and json:"name,omitempty" to drop empty fields.

Two embedding pitfalls worth knowing before they surprise you. First, if two embedded structs both have a field with the same name, accessing it through the outer struct is a compile error — you must qualify it (c.A.Name) until you resolve the ambiguity. Second, structs are only comparable with == if every field is comparable — a struct containing a slice or a map cannot be compared directly:

type Data struct{ Values []int }
a := Data{Values: []int{1, 2}}
b := Data{Values: []int{1, 2}}
// a == b   // compile error: struct containing []int cannot be compared

For quick, throwaway grouping you don’t want a named type for, Go allows anonymous structs:

point := struct{ X, Y int }{X: 10, Y: 20}

And implementing String() string on a type — satisfying the standard library’s fmt.Stringer interface — controls exactly how fmt.Println and %v render it, which is the idiomatic way to make your own types print nicely without a wrapper function:

func (c Color) String() string {
    return fmt.Sprintf("rgb(%d, %d, %d)", c.R, c.G, c.B)
}
fmt.Println(red)   // rgb(255, 0, 0) — Println calls String() automatically

Interfaces and Polymorphism

Interfaces in Go are implicitly satisfied — there is no implements keyword. If a type has the right methods, it automatically satisfies the interface, even one defined in a completely different package after the type already existed:

type Shape interface {
    Area() float64
}

type Circle struct{ Radius float64 }
func (c Circle) Area() float64 { return math.Pi * c.Radius * c.Radius }

func printArea(s Shape) { fmt.Println(s.Area()) }
printArea(Circle{Radius: 5})   // Circle satisfies Shape without saying so

This is why small interfaces dominate idiomatic Go — a Go proverb states it directly: “the bigger the interface, the weaker the abstraction.” The standard library’s io.Reader and io.Writer are one method each, and their power comes from composability: io.ReadWriteCloser is just Reader + Writer + Closer combined.

A type assertion extracts the concrete type back out of an interface value. Always use the two-value form to avoid a panic when the type doesn’t match:

str, ok := value.(string)
if !ok {
    // not a string — handled safely
}

The design principle that ties this together, quoted in every Go style guide: accept interfaces, return structs. A function that accepts io.Reader works with a file, a network connection, or an in-memory buffer without caring which — and a function that returns a concrete *User gives the caller the full type with all its methods, rather than hiding it behind an interface.

Interfaces compose the same way structs do — embed one interface inside another to build a bigger contract from small pieces, exactly how the standard library builds io.ReadWriteCloser out of Reader + Writer + Closer:

type ReadWriter interface {
    Reader
    Writer
}

The empty interface (interface{}, or any since Go 1.18) has zero methods, so every type satisfies it — useful for “accept literally anything” functions, but it turns off compile-time type checking, so reach for a specific interface or a generic function (Part 4) before falling back to any. Implementing the three-method sort.Interface (Len, Less, Swap) makes any collection sortable with sort.Sort:

type ByName []Employee
func (b ByName) Len() int           { return len(b) }
func (b ByName) Less(i, j int) bool { return b[i].Name < b[j].Name }
func (b ByName) Swap(i, j int)      { b[i], b[j] = b[j], b[i] }

sort.Sort(ByName(employees))

For one-off sorts, sort.Slice(employees, func(i, j int) bool { return employees[i].Salary > employees[j].Salary }) skips defining a named type entirely.

Two mistakes show up constantly in interface-heavy Go code. The first is defining interfaces too large — a Go proverb again: “the bigger the interface, the weaker the abstraction.” A 15-method Service interface is hard to implement and hard to mock; splitting it into UserGetter, UserCreator, and so on lets callers depend on only what they use. The second is returning an interface from a constructor instead of a concrete type — func NewLogger() Logger throws away the caller’s ability to see the full type, where func NewFileLogger() *FileLogger does not; save the interface for the parameter side of a function signature, not the return side.

Pointers

& takes the address of a value; * dereferences a pointer back to its value. Go pointers are deliberately simpler than C’s — there is no pointer arithmetic, so you cannot walk memory by incrementing a pointer, which removes an entire class of memory-corruption bugs:

name := "Alex"
ptr := &name        // *string
fmt.Println(*ptr)   // "Alex"

The practical reason to reach for a pointer: a function receiving a value gets a copy, so changes inside it never touch the caller’s data. A function receiving a pointer modifies the original:

func celebrateBirthday(u *User) { u.Age++ }   // mutates the caller's User

Structs auto-dereference for field access (u.Age, not (*u).Age), which is why pointer-heavy Go code still reads cleanly. A nil pointer dereference panics at runtime — always check for nil before using a pointer that might not be set, especially in linked structures like trees or lists where a “no child” case is represented as nil.

One thing that surprises newcomers from C: returning a pointer to a local variable is completely safe in Go. The compiler’s escape analysis detects the reference escaping the function and allocates that variable on the heap instead of the stack automatically — you don’t manage this yourself.

new(T) allocates zeroed memory for a type and returns a pointer to it — new(int) behaves like &someZeroInt. In practice, &Type{} is far more common than new(Type) for structs because it lets you set fields inline; new shows up mostly for basic types where there’s nothing to initialize.

If you’ve also looked at Rust, the comparison is a useful anchor for what Go’s simplicity trades away:

GoRust
Syntax*T pointer, &x address&T reference, &mut T mutable reference
Null safetynil pointers (runtime panic)No null — Option<T> instead
Multiple mutable referencesAllowed, programmer’s responsibilityRejected at compile time by the borrow checker
Memory managementGarbage collectedOwnership, no GC

Go hands you the pointer and trusts you; Rust’s compiler enforces the discipline for you at the cost of a steeper learning curve. Neither allows raw pointer arithmetic in safe code.

Project Structure

Go doesn’t mandate a layout, but the ecosystem converged on one. For anything past a single-file script:

my-api/
  cmd/api/main.go        # entry point — thin, just wires dependencies together
  internal/
    task/
      model.go            # struct definitions
      repository.go       # database access, behind an interface
      service.go           # business logic, depends on the interface not the implementation
      handler.go            # HTTP layer, translates requests to service calls

internal/ is enforced by the Go compiler itself — packages outside your module literally cannot import from it, which is a stronger guarantee than a naming convention. cmd/ holds only entry points; if your project produces two binaries (an API server and a background worker), each gets its own subdirectory.

The pattern that makes this layered structure worth the extra files is dependency injection without a framework: the repository is defined as an interface, the service depends on that interface (not a concrete database type), and main.go wires the real implementation in at startup.

userRepo := NewMemoryRepository()      // or NewPostgresRepository(db)
userService := NewService(userRepo)    // depends on the interface
userHandler := NewHandler(userService)

Swap NewMemoryRepository() for a real database later, and the service and handler code never changes — and in tests, you swap in a fake repository with zero mocking framework required.

Package names carry more weight than file names in Go: keep them short, lowercase, one word, and named for what the package does, not what it containsuser and auth, never utils, helpers, or models. A vague catch-all package is the single most common structural smell in Go codebases that grew without discipline.

Configuration is typically its own small package, reading from environment variables with sane fallbacks so the same binary runs unmodified in dev and production:

func Load() (*Config, error) {
    port, err := strconv.Atoi(getEnv("PORT", "8080"))
    if err != nil {
        return nil, fmt.Errorf("invalid PORT: %w", err)
    }
    return &Config{Port: port, Host: getEnv("HOST", "0.0.0.0")}, nil
}

func getEnv(key, fallback string) string {
    if v, ok := os.LookupEnv(key); ok {
        return v
    }
    return fallback
}

Part 3: Concurrency and Errors

Goroutines

A goroutine is a function running concurrently, started by putting go in front of a call. Goroutines are not OS threads — they start at about 2KB of stack (versus ~1MB for a thread) and the Go runtime multiplexes many of them onto a handful of real threads, which is why running a million goroutines is routine, not exceptional:

go sayHello("Alex")   // runs concurrently, does not block main

main does not wait for goroutines on its own — if it returns, every goroutine still running is simply discarded. sync.WaitGroup is the correct way to wait:

var wg sync.WaitGroup
for i := 1; i <= 5; i++ {
    wg.Add(1)
    go func(id int) {
        defer wg.Done()
        doWork(id)
    }(i)
}
wg.Wait()

When multiple goroutines touch the same variable without coordination, you get a race condition — Go’s built-in detector (go run -race main.go) catches these during development, and you should run it routinely on any concurrent code. sync.Mutex fixes the race by serializing access:

var mu sync.Mutex
mu.Lock()
counter++
mu.Unlock()

Running with -race on a genuine data race prints exactly which line read and which line wrote, from which goroutine — this is the tool, not manual code reading, that you should trust to find races:

WARNING: DATA RACE
Read at 0x00c0000b4010 by goroutine 8:
  main.main.func1()
      /main.go:16 +0x6a
Previous write at 0x00c0000b4010 by goroutine 7:
  main.main.func1()
      /main.go:16 +0x80

sync.RWMutex is the read-heavy variant — any number of goroutines can hold the read lock (RLock) simultaneously, but a write (Lock) requires exclusive access. It’s the standard shape for an in-memory cache or store hit by many concurrent readers and occasional writers:

type SafeMap struct {
    mu   sync.RWMutex
    data map[string]int
}

func (m *SafeMap) Set(key string, value int) {
    m.mu.Lock()         // exclusive — blocks readers too
    defer m.mu.Unlock()
    m.data[key] = value
}

func (m *SafeMap) Get(key string) (int, bool) {
    m.mu.RLock()         // shared — many readers can hold this at once
    defer m.mu.RUnlock()
    val, ok := m.data[key]
    return val, ok
}

Always pass a WaitGroup or Mutex as a pointer (&wg) — a copy doesn’t share state with the original, and your program will hang waiting on a counter that never decrements.

Channels

Go’s philosophy for concurrency, stated directly in the language proverbs: “do not communicate by sharing memory; share memory by communicating.” A channel is a typed pipe between goroutines:

ch := make(chan string)     // unbuffered
go func() { ch <- "done" }() // send
msg := <-ch                   // receive — blocks until something is sent

An unbuffered channel synchronizes sender and receiver — the send blocks until someone is ready to receive, which is often exactly the coordination you want. A buffered channel (make(chan int, 5)) decouples them up to the buffer size. Restricting a channel’s direction in a function signature (chan<- int send-only, <-chan int receive-only) turns a whole class of misuse into a compile error.

func producer(out chan<- int) { out <- 42; close(out) }
func consumer(in <-chan int)  { for v := range in { fmt.Println(v) } }

Reach for unbuffered when you need the send and receive to happen in lockstep — a handoff, a signal that something is ready. Reach for buffered when the sender and receiver run at different speeds and you want to absorb that difference instead of forcing them to synchronize on every single value; a producer that occasionally bursts ahead of a slower consumer is the textbook case.

Rules that prevent the two most common channel bugs: only the sender closes a channel (closing from the receiver side, or sending on an already-closed channel, both panic), and range over a channel keeps blocking until that channel is closed — forgetting close() on the sender side hangs the receiver forever.

The worker pool is the pattern you’ll reach for most in real services — a fixed number of goroutines pulling from a shared jobs channel, which caps concurrency at exactly the number of workers you started:

jobs, results := make(chan Job, 100), make(chan Result, 100)
for w := 1; w <= 3; w++ {
    go worker(jobs, results)   // 3 workers, however many jobs
}

Two more channel patterns you’ll recognize once you’ve seen them. A generator is a function that returns a receive-only channel, hiding the goroutine that produces values behind a clean API:

func fibonacci(n int) <-chan int {
    ch := make(chan int)
    go func() {
        a, b := 0, 1
        for i := 0; i < n; i++ {
            ch <- a
            a, b = b, a+b
        }
        close(ch)
    }()
    return ch
}

for val := range fibonacci(10) { fmt.Println(val) }

A pipeline chains several of these together, each stage its own goroutine connected by a channel, so data flows through transformations one element at a time instead of allocating an intermediate slice per stage:

func square(in <-chan int) <-chan int {
    out := make(chan int)
    go func() {
        for n := range in { out <- n * n }
        close(out)
    }()
    return out
}

result := square(fibonacci(10))   // stages compose by passing channels

Select, Context, and Error Patterns

select waits on multiple channel operations and proceeds with whichever is ready first — the concurrency equivalent of a switch over channels:

select {
case msg := <-ch1:
    fmt.Println(msg)
case <-time.After(1 * time.Second):
    fmt.Println("timeout")
}

That time.After branch is the standard pattern for timing out an operation that might hang — and it generalizes into context.Context, which every function that might block should accept as its first parameter by convention:

func fetchData(ctx context.Context) (string, error) {
    select {
    case data := <-resultChan:
        return data, nil
    case <-ctx.Done():
        return "", ctx.Err()
    }
}

ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()   // always defer cancel, even if the context times out on its own
data, err := fetchData(ctx)

context.WithCancel lets you cancel a whole tree of goroutines from outside by closing ctx.Done(); context.WithTimeout/WithDeadline do the same automatically after a duration. defer cancel() immediately after creating any of these — even a context that expires on its own needs cancel() called to release its internal resources.

Add a default case to select and it stops blocking entirely — it fires immediately if no channel is ready, which is how you poll a channel without waiting on it:

select {
case msg := <-ch:
    fmt.Println("Received:", msg)
default:
    fmt.Println("No message available")   // fires immediately, never blocks
}

Fan-out (many goroutines reading one channel, to spread work) and fan-in (many channels merged into one, to collect results) are the two directions worker pools compose in:

func fanIn(channels ...<-chan string) <-chan string {
    merged := make(chan string)
    var wg sync.WaitGroup
    for _, ch := range channels {
        wg.Add(1)
        go func(c <-chan string) {
            defer wg.Done()
            for v := range c { merged <- v }
        }(ch)
    }
    go func() { wg.Wait(); close(merged) }()
    return merged
}

Error handling gets more structured than if err != nil once you’re chaining calls. Wrap errors with %w (never %v — that breaks the chain) so the original error stays inspectable:

if err != nil {
    return fmt.Errorf("readConfig(%s): %w", path, err)
}

errors.Is(err, ErrNotFound) checks whether a specific sentinel error appears anywhere in the wrapped chain — this is the correct replacement for err == ErrNotFound, which breaks the moment the error gets wrapped once. errors.As(err, &target) pulls a specific custom error type out of the chain so you can read its fields:

var valErr *ValidationError
if errors.As(err, &valErr) {
    fmt.Println("invalid field:", valErr.Field)
}

Define sentinel errors as package-level vars for conditions callers need to branch on by identity:

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

For errors that need to carry structured data (not just a message), define a custom type and implement Error() string. Add an Unwrap() error method and errors.Is/errors.As can see straight through it to whatever it wraps:

type APIError struct {
    Code int
    Err  error
}

func (e *APIError) Error() string { return fmt.Sprintf("API error %d: %v", e.Code, e.Err) }
func (e *APIError) Unwrap() error { return e.Err }   // lets errors.As walk past APIError to find e.Err

Without Unwrap, an error wrapped inside APIError is invisible to errors.As — the method is what stitches your custom type into the standard chain-walking machinery.

Goroutines can’t return values through a normal return, so collecting errors from a group of them needs either a channel or, more commonly, golang.org/x/sync/errgroup, which starts goroutines, waits for all of them, and surfaces the first error while cancelling the shared context for the rest:

g, ctx := errgroup.WithContext(context.Background())
for _, url := range urls {
    url := url
    g.Go(func() error { return fetchAPI(ctx, url) })
}
if err := g.Wait(); err != nil {
    fmt.Println("first error:", err)
}

Reserve panic/recover for truly unrecoverable situations (a corrupt config at startup) — never for ordinary, expected failures; those are what error returns are for. The one place recover earns its keep in application code is at a goroutine or HTTP-handler boundary, to stop one bad request from taking the whole server down:

defer func() {
    if r := recover(); r != nil {
        log.Printf("recovered: %v", r)
    }
}()

Part 4: Web, Generics, and Tooling

HTTP Servers and Generics

Go 1.22 added method-and-path routing directly to the standard library — for many services, net/http alone is enough, no framework required:

mux := http.NewServeMux()
mux.HandleFunc("GET /tasks/{id}", func(w http.ResponseWriter, r *http.Request) {
    id := r.PathValue("id")
    json.NewEncoder(w).Encode(map[string]string{"id": id})
})
http.ListenAndServe(":8080", mux)

For production APIs, Gin is the most common choice — route groups, JSON binding with struct-tag validation, and a large middleware ecosystem:

r := gin.Default()   // includes Logger + Recovery middleware
r.GET("/tasks/:id", func(c *gin.Context) {
    c.JSON(http.StatusOK, gin.H{"id": c.Param("id")})
})

Bind and validate a request body in one call — ShouldBindJSON reads the binding struct tags and returns a descriptive error on failure:

type CreateTaskRequest struct {
    Title string `json:"title" binding:"required,min=2"`
}
var req CreateTaskRequest
if err := c.ShouldBindJSON(&req); err != nil {
    c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
    return
}

binding tags come from go-playground/validator under the hood — email, gte=1, lte=150, and oneof=admin user moderator cover most request validation without hand-written if chains. Route groups keep versioned or auth-gated routes organized and let middleware apply to a whole group at once:

v1 := r.Group("/api/v1")
protected := v1.Group("/")
protected.Use(AuthMiddleware())   // every route below requires auth

Any API a browser-based frontend calls from a different origin needs a CORS middleware — without one, the browser blocks the response before your JavaScript ever sees it, even though the server responded successfully:

func CORSMiddleware() gin.HandlerFunc {
    return func(c *gin.Context) {
        c.Header("Access-Control-Allow-Origin", "https://yourapp.com")   // never "*" in production
        c.Header("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE")
        if c.Request.Method == "OPTIONS" {
            c.AbortWithStatus(http.StatusNoContent)   // preflight request, nothing to run
            return
        }
        c.Next()
    }
}

Gin isn’t the only option — Echo and Chi are close alternatives with similar concepts, and Fiber trades net/http compatibility for raw speed via fasthttp. For a service with only a handful of routes, plain net/http (shown above) is often enough and keeps your dependency list at zero; reach for Gin once you need route groups, structured validation, and a middleware ecosystem you don’t want to hand-roll.

Generics (Go 1.18+) solve the problem interfaces alone can’t: writing one function that works across types while still using operators like < and +, which no interface can express. A type parameter with a constraint replaces writing the same function three times:

func Min[T cmp.Ordered](values []T) T {
    min := values[0]
    for _, v := range values[1:] {
        if v < min { min = v }
    }
    return min
}

Min([]int{5, 3, 8})           // works
Min([]string{"b", "a", "c"})  // also works, same function

cmp.Ordered and comparable are the two constraints you’ll reach for most — comparable restricts a type parameter to whatever supports ==/!=, which is exactly what a generic Contains needs:

func Contains[T comparable](slice []T, target T) bool {
    for _, v := range slice {
        if v == target { return true }
    }
    return false
}

Generic types, not just functions, are where containers get real type safety without interface{} and a type assertion at every access:

type Stack[T any] struct{ items []T }

func (s *Stack[T]) Push(item T) { s.items = append(s.items, item) }
func (s *Stack[T]) Pop() (T, bool) {
    if len(s.items) == 0 {
        var zero T
        return zero, false
    }
    item := s.items[len(s.items)-1]
    s.items = s.items[:len(s.items)-1]
    return item, true
}

Stack[int]{} and Stack[string]{} are both fully type-checked — pushing a string onto an int stack is a compile error, something interface{}-based containers can never catch. The classic Map/Filter/Reduce trio is one function each, usable across any element type:

func Map[T, U any](s []T, f func(T) U) []U {
    result := make([]U, len(s))
    for i, v := range s { result[i] = f(v) }
    return result
}

The rule of thumb that resolves most “generics or interfaces?” questions: reach for generics when you need operators (<, ==, +) across types — type-safe containers, Map/Filter/Reduce helpers. Reach for interfaces when you need behavior — polymorphism, dependency injection, anything with methods to call. And don’t reach for either when a function only ever needs to work with one concrete type — a generic wrapper around a single-type function is pure ceremony.

Testing, Databases, and Deployment

Go’s test runner needs no external dependency — a file ending _test.go, a function starting Test and taking *testing.T:

func TestAdd(t *testing.T) {
    if got := Add(2, 3); got != 5 {
        t.Errorf("Add(2, 3) = %d, want 5", got)
    }
}

Table-driven tests are the idiomatic pattern for covering many cases without duplicating the assertion logic — a slice of inputs and expected outputs, run through t.Run as named subtests:

tests := []struct{ name string; a, b, want int }{
    {"positive", 2, 3, 5},
    {"negative", -1, -2, -3},
}
for _, tt := range tests {
    t.Run(tt.name, func(t *testing.T) {
        if got := Add(tt.a, tt.b); got != tt.want {
            t.Errorf("got %d, want %d", got, tt.want)
        }
    })
}

httptest.NewRecorder() tests HTTP handlers without a real network socket — construct a request, call ServeHTTP, and assert on the recorded response. For dependencies, Go doesn’t need a mocking framework: since interfaces are implicit, a hand-written struct with the right methods works as a test double for free — a MockUserRepo backed by a plain map satisfies the same UserRepository interface the real Postgres implementation does.

testify is the near-universal add-on for readable assertions — assert reports a failure and keeps running the rest of the test, require reports and stops immediately, which matters when a later assertion would be meaningless after an earlier one failed (a nil pointer, an unopened connection):

result, err := Divide(10, 2)
require.NoError(t, err)      // stop here if this fails — nothing below is safe to check
assert.Equal(t, 5, result)   // continues even if this fails, reporting the mismatch

Benchmarks (func BenchmarkX(b *testing.B), run with go test -bench=. -benchmem) and fuzz tests (func FuzzX(f *testing.F), run with go test -fuzz=FuzzX) are both built into the standard toolchain with no extra dependency — fuzzing in particular generates random inputs against a seed corpus and saves any input that crashes your function into testdata/fuzz/, so it re-runs on every future go test automatically. t.Helper() marks a custom assertion function so failures report the caller’s line number instead of the line inside the helper — small, but it’s the difference between a useful stack trace and a wild goose chase.

For the database layer, sqlx extends the standard database/sql with struct scanning and named parameters, while keeping you writing real SQL instead of hiding it behind an ORM:

var user User
db.Get(&user, "SELECT * FROM users WHERE id = $1", id)   // scans directly into the struct

var users []User
db.Select(&users, "SELECT * FROM users ORDER BY id")       // one line for a full slice

Named queries read from struct fields via :field_name instead of positional $1/$2, which is easier to keep straight once a query has more than three or four parameters:

db.NamedQuery(`INSERT INTO users (name, email) VALUES (:name, :email) RETURNING id`, user)

Transactions guarantee several statements succeed or fail together — begin with db.Beginx(), defer a rollback that only fires if you never reach Commit():

tx, err := db.Beginx()
defer func() {
    if err != nil { tx.Rollback() }
}()
tx.Exec(`INSERT INTO users ...`)
tx.Exec(`INSERT INTO posts ...`)
err = tx.Commit()   // both succeed together, or neither does

Tune the connection pool explicitly in production rather than trusting the defaults — db.SetMaxOpenConns(25) caps total connections to what your database plan can handle, SetMaxIdleConns keeps some warm for reuse, and SetConnMaxLifetime recycles connections periodically to avoid stale ones surviving a database failover.

Always use $1, $2 placeholders — never fmt.Sprintf into a query string, which opens SQL injection. For deployment, Go’s static binary is what makes multi-stage Docker builds so effective — the final image contains nothing but the compiled binary and CA certificates:

FROM golang:1.26-alpine AS builder
WORKDIR /app
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 GOOS=linux go build -ldflags="-s -w" -o server .

FROM alpine:3.20
COPY --from=builder /app/server .
CMD ["./server"]

CGO_ENABLED=0 disables linking against the C library so the binary is fully static; -ldflags="-s -w" strips debug symbols to shrink it further. The base image you finish on changes the final size by two orders of magnitude:

Base imageFinal sizeTrade-off
golang:1.26 (single-stage)~1.2 GBIncludes the whole toolchain — never ship this
alpine:3.20~15 MBShell + CA certs for debugging and HTTPS — good default
scratch~8-10 MBNothing at all — no shell, copy CA certs in manually if you need HTTPS

For cross-platform builds, Go needs no extra tooling — GOOS=linux GOARCH=arm64 go build produces a Linux/ARM64 binary from any development machine, which is what makes docker buildx build --platform linux/amd64,linux/arm64 work without a matching build machine per architecture.

Production API Practices

A handful of concerns separate a working API from a production-ready one. Input validation with go-playground/validator catches bad requests before they touch business logic, and custom rules extend the tag vocabulary when the built-ins (required, email, oneof=...) aren’t enough:

validate.RegisterValidation("no_profanity", func(fl validator.FieldLevel) bool {
    return !strings.Contains(strings.ToLower(fl.Field().String()), "spam")
})

Pagination comes in two shapes. Offset-based (?page=2&limit=20) is simple and familiar but can skip or repeat rows if the underlying data changes between requests; cursor-based (?cursor=abc123, using the last item’s ID as the next query’s starting point) avoids that at the cost of losing random access to arbitrary pages — use cursors for feeds and infinite scroll, offsets for anything with a page-number UI.

Rate limiting with golang.org/x/time/rate gives each client (typically keyed by IP) its own token bucket:

limiter := rate.NewLimiter(10, 20)   // 10 requests/sec sustained, burst of 20
if !limiter.Allow() {
    return http.StatusTooManyRequests
}

Graceful shutdown finishes in-flight requests instead of dropping them the instant a deploy sends SIGTERM:

quit := make(chan os.Signal, 1)
signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
<-quit
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
server.Shutdown(ctx)   // waits up to 10s for active requests, then forces close

For logging, log/slog (standard library since Go 1.21) replaces fmt.Println debugging with structured, machine-parseable output — every call takes key-value pairs instead of a formatted string, which is what makes the output greppable in Grafana or Datadog:

slog.Info("request completed", "method", r.Method, "path", r.URL.Path, "duration_ms", elapsed)
// {"time":"...","level":"INFO","msg":"request completed","method":"GET","duration_ms":12}

File I/O rounds out the standard library toolkit you’ll reach for outside HTTP handlers. os.ReadFile/os.WriteFile are the one-line entry points for small files; for anything large, bufio.Scanner reads line by line instead of loading the whole thing into memory (bump scanner.Buffer(...) past its 64KB default if a single line can exceed that), and bufio.Writer batches many small writes into fewer disk operations — always writer.Flush() before closing, or buffered data never reaches disk. io.Copy(dst, src) connects any io.Reader to any io.Writer without you managing the buffer yourself, and filepath.WalkDir recursively visits a directory tree, returning filepath.SkipDir from the callback to prune a subtree you don’t want to descend into. For structured files, encoding/csv’s Reader/Writer and encoding/json’s Marshal/Unmarshal cover the two formats you’ll hit constantly when a service needs to import or export data. Across all of it: check os.IsNotExist(err) and os.IsPermission(err) to give callers a specific reason instead of a generic failure, and always defer file.Close() immediately after a successful open — an unclosed file leaks a file descriptor, and a long-running server can exhaust them.

On the CLI side, Cobra (used by Docker, kubectl, and this blog’s own Hugo) pairs with Viper for layered configuration — Viper checks command-line flags first, then environment variables, then a config file, then hard-coded defaults, so the same binary is configurable at any of four levels without extra code:

viper.BindPFlag("port", cmd.Flags().Lookup("port"))
viper.SetEnvPrefix("MYAPP")
viper.AutomaticEnv()          // MYAPP_PORT overrides the flag's default
port := viper.GetInt("port")   // flag > env var > config file > default, in that order

gRPC for Service-to-Service Calls

REST plus JSON is the right default for a public API — human-readable, curl-able, no special tooling. Once services start talking to each other rather than to a browser, gRPC is often the better fit: it serializes with Protocol Buffers (a compact binary format, not text), runs over HTTP/2, and generates both client and server code from one .proto definition, so the two sides can never drift out of sync on the wire format the way a hand-maintained REST client can.

service TaskService {
  rpc CreateTask(CreateTaskRequest) returns (Task);
  rpc StreamTasks(ListRequest) returns (stream Task);   // server streams results back
}

protoc generates Go types and a service interface from that file; your server implements the interface, embedding pb.UnimplementedTaskServiceServer so adding a new RPC to the .proto later doesn’t break existing implementations that haven’t added it yet. gRPC supports four call shapes — unary (one request, one response, the REST-equivalent default), server streaming (one request, many responses — a live feed), client streaming (many requests, one response — batch upload), and bidirectional streaming (both sides stream — chat, live collaboration).

Errors use gRPC’s own status codes rather than HTTP’s, though the mapping is close enough to be intuitive: codes.NotFound (404), codes.InvalidArgument (400), codes.PermissionDenied (403), codes.Internal (500). status.Error(codes.NotFound, "task not found") on the server becomes an inspectable status.FromError(err) on the client. If you need both — gRPC internally for speed, REST externally for browsers and simple curl testing — grpc-gateway generates a reverse proxy that translates one into the other from the same .proto file, so you write the service logic exactly once.

Part 5: Build TaskTrail — a REST API and CLI Sharing One Model

Everything above is enough to build something real. TaskTrail is a task tracker: one domain model backs a JWT-protected REST API and a Cobra CLI, showing how interfaces, error wrapping, and the repository pattern designed in isolation hold up once two different front ends depend on them.

The Domain Model

package task

import (
    "context"
    "errors"
    "time"
)

var ErrNotFound = errors.New("task not found")

type Task struct {
    ID        int       `json:"id" db:"id"`
    Title     string    `json:"title" db:"title"`
    Done      bool      `json:"done" db:"done"`
    CreatedAt time.Time `json:"created_at" db:"created_at"`
}

// Repository is the interface both the in-memory store and Postgres implement.
type Repository interface {
    Create(ctx context.Context, title string) (*Task, error)
    GetAll(ctx context.Context) ([]Task, error)
    Complete(ctx context.Context, id int) (*Task, error)
}

type Service struct {
    repo Repository
}

func NewService(repo Repository) *Service {
    return &Service{repo: repo}
}

func (s *Service) CreateTask(ctx context.Context, title string) (*Task, error) {
    if title == "" {
        return nil, errors.New("title cannot be empty")
    }
    task, err := s.repo.Create(ctx, title)
    if err != nil {
        return nil, fmt.Errorf("CreateTask: %w", err)
    }
    return task, nil
}

func (s *Service) ListTasks(ctx context.Context) ([]Task, error) {
    return s.repo.GetAll(ctx)
}

func (s *Service) CompleteTask(ctx context.Context, id int) (*Task, error) {
    task, err := s.repo.Complete(ctx, id)
    if err != nil {
        return nil, fmt.Errorf("CompleteTask(%d): %w", id, err)
    }
    return task, nil
}

Notice how much of Parts 1-3 shows up with no new concepts: a struct with json/db tags for the shape, error returns with %w wrapping for failure, and a Repository interface the service depends on instead of a concrete database type — the same accept-interfaces pattern from Part 2.

In-Memory and Postgres, Same Interface

type MemoryRepository struct {
    mu     sync.RWMutex
    tasks  map[int]*Task
    nextID int
}

func NewMemoryRepository() *MemoryRepository {
    return &MemoryRepository{tasks: make(map[int]*Task), nextID: 1}
}

func (r *MemoryRepository) Create(ctx context.Context, title string) (*Task, error) {
    r.mu.Lock()
    defer r.mu.Unlock()
    task := &Task{ID: r.nextID, Title: title, CreatedAt: time.Now()}
    r.tasks[r.nextID] = task
    r.nextID++
    return task, nil
}

func (r *MemoryRepository) Complete(ctx context.Context, id int) (*Task, error) {
    r.mu.Lock()
    defer r.mu.Unlock()
    task, ok := r.tasks[id]
    if !ok {
        return nil, ErrNotFound
    }
    task.Done = true
    return task, nil
}

A PostgresRepository implementing the same Repository interface with sqlx swaps in without touching Service at all — this is the entire payoff of accepting interfaces instead of concrete types.

Auth Service — bcrypt and JWT

Passwords are never stored in plain text — bcrypt hashes them with a built-in, tunable cost factor, and the same package verifies a login attempt against the stored hash without ever decrypting it back:

func (s *AuthService) HashPassword(password string) (string, error) {
    bytes, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
    return string(bytes), err
}

func (s *AuthService) CheckPassword(hash, password string) bool {
    return bcrypt.CompareHashAndPassword([]byte(hash), []byte(password)) == nil
}

A JWT carries the user’s identity in a signed, tamper-evident token so the server doesn’t need a session store — GenerateToken signs it, ValidateToken verifies the signature and expiry before trusting the claims inside:

func (s *AuthService) GenerateToken(userID int, email string) (string, error) {
    claims := jwt.MapClaims{
        "user_id": userID,
        "email":   email,
        "exp":     time.Now().Add(24 * time.Hour).Unix(),
    }
    token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
    return token.SignedString(s.jwtSecret)
}

AuthMiddleware runs before every protected route, rejecting requests with a missing or invalid Authorization: Bearer <token> header before the handler ever sees them — the same short-circuit-on-failure shape as the validation checks in Service, just one layer earlier in the request.

REST API with Gin and JWT

func RegisterRoutes(r *gin.Engine, svc *task.Service, authSvc *AuthService) {
    r.POST("/api/login", loginHandler(authSvc))

    protected := r.Group("/api", AuthMiddleware(authSvc))
    protected.GET("/tasks", func(c *gin.Context) {
        tasks, err := svc.ListTasks(c.Request.Context())
        if err != nil {
            c.JSON(http.StatusInternalServerError, gin.H{"error": "internal error"})
            return
        }
        c.JSON(http.StatusOK, tasks)
    })

    protected.POST("/tasks", func(c *gin.Context) {
        var req struct {
            Title string `json:"title" binding:"required"`
        }
        if err := c.ShouldBindJSON(&req); err != nil {
            c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
            return
        }
        created, err := svc.CreateTask(c.Request.Context(), req.Title)
        if err != nil {
            c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
            return
        }
        c.JSON(http.StatusCreated, created)
    })

    protected.POST("/tasks/:id/complete", func(c *gin.Context) {
        id, err := strconv.Atoi(c.Param("id"))
        if err != nil {
            c.JSON(http.StatusBadRequest, gin.H{"error": "invalid id"})
            return
        }
        completed, err := svc.CompleteTask(c.Request.Context(), id)
        if errors.Is(err, task.ErrNotFound) {
            c.JSON(http.StatusNotFound, gin.H{"error": "task not found"})
            return
        }
        c.JSON(http.StatusOK, completed)
    })
}

errors.Is(err, task.ErrNotFound) is doing real work here — it sees through the fmt.Errorf("CompleteTask(%d): %w", ...) wrapping in the service layer to find the sentinel error underneath, and maps it to the correct HTTP 404.

The Same Service, from a CLI

func completeCmd(svc *task.Service) *cobra.Command {
    return &cobra.Command{
        Use:  "complete [task id]",
        Args: cobra.ExactArgs(1),
        RunE: func(cmd *cobra.Command, args []string) error {
            id, err := strconv.Atoi(args[0])
            if err != nil {
                return fmt.Errorf("invalid task ID: %s", args[0])
            }
            task, err := svc.CompleteTask(cmd.Context(), id)
            if err != nil {
                return err   // Cobra prints it and exits with code 1
            }
            fmt.Printf("Completed #%d: %s\n", task.ID, task.Title)
            return nil
        },
    }
}

Both the HTTP handler and the CLI command call the exact same svc.CompleteTask — the validation, the error wrapping, and the not-found handling are written once in Service, not duplicated per front end.

Testing Both

func TestCreateTask_EmptyTitle(t *testing.T) {
    svc := task.NewService(task.NewMemoryRepository())
    _, err := svc.CreateTask(context.Background(), "")
    if err == nil {
        t.Error("expected error for empty title")
    }
}

func TestCompleteTask_NotFound(t *testing.T) {
    svc := task.NewService(task.NewMemoryRepository())
    _, err := svc.CompleteTask(context.Background(), 999)
    if !errors.Is(err, task.ErrNotFound) {
        t.Errorf("expected ErrNotFound, got %v", err)
    }
}
func TestListTasksHandler(t *testing.T) {
    r := gin.Default()
    svc := task.NewService(task.NewMemoryRepository())
    RegisterRoutes(r, svc, testAuthService())

    w := httptest.NewRecorder()
    req, _ := http.NewRequest("GET", "/api/tasks", nil)
    req.Header.Set("Authorization", "Bearer "+testToken())
    r.ServeHTTP(w, req)

    if w.Code != http.StatusOK {
        t.Errorf("status = %d, want 200", w.Code)
    }
}

Neither test touches a real database or a real HTTP socket — MemoryRepository and httptest.NewRecorder() keep both fast and isolated, exactly the mocking-without-a-framework pattern from Part 4.

Project Layout

tasktrail/
  cmd/
    api/main.go       # Gin server entry point
    cli/main.go         # Cobra CLI entry point
  internal/
    task/
      model.go          # Task, Repository interface, Service
      memory.go          # MemoryRepository
      postgres.go         # PostgresRepository (sqlx)
    auth/
      service.go          # JWT generation/validation
      middleware.go         # Gin auth middleware
  Dockerfile
  docker-compose.yml

One Service, two front ends, one interface separating business logic from storage. That is the actual return on everything in Parts 1-4: goroutine-safe in-memory storage for development, a drop-in Postgres implementation for production, error wrapping that survives the trip from repository to HTTP status code, and a CLI that reuses every line of validation the API has. Natural next steps: add the worker-pool pattern from Part 3 to process tasks in the background, or add gRPC alongside REST for service-to-service calls.

Gotchas That Catch Everyone Once

A handful of behaviors are surprising exactly once, then obvious forever after — worth collecting in one place since each one costs someone a debugging session the first time.

defer evaluates its arguments immediately, not when it runs. The deferred call executes late, but the values it captures are frozen at the defer line:

x := 0
defer fmt.Println("x =", x)   // captures x = 0 right here
x = 42
// prints: x = 0 — NOT 42

defer inside a loop doesn’t run per-iteration — every deferred call waits for the enclosing function to return, not the loop body. Opening ten files in a loop with defer f.Close() inside it means all ten stay open until the function exits, not after each iteration:

for _, name := range files {
    f, _ := os.Open(name)
    defer f.Close()   // all files stay open until the function returns, not the loop
}

Wrap the loop body in its own function (or call Close() directly) when you need per-iteration cleanup.

Middleware order in Gin runs like an onion, not top-to-bottom: code before c.Next() executes in registration order, code after it executes in reverse — the last middleware registered finishes its post-Next() code first. This is exactly the shape you want for timing middleware: start the clock before c.Next(), read the elapsed time after.

N+1 queries sneak in through ORM-style code that looks innocent: fetching a list of users, then calling user.Posts inside a loop over them issues one query per user instead of one query total. sqlx’s explicit SQL makes this visible rather than hidden, but you still have to notice it — fetch related data with a single JOIN or a second batched query (WHERE user_id IN (...)) instead of querying inside the loop.

Where to Go From Here

The complete, working code for the capstone project (TaskTrail) is on GitHub: github.com/kemalcodes/go-tutorial.