tutorialGoBackendProgramming

Go Series #2: Defer, Structs & Interfaces

Day 1, Session 2. Go has no classes — here's what it uses instead, and why the result is cleaner than what most OOP languages produce.

8 min read

Session 1 covered the data layer: variables, slices, maps, and functions. You can now write Go code that stores and transforms data.

Session 2 covers the structure layer: how Go organizes behavior without classes, how it cleans up resources without try/finally, and how it abstracts behavior without explicit interface declarations.

This is the part that will feel most alien if you come from Java, PHP, or TypeScript — and the part that will feel most natural once it clicks.

Defer — Guaranteed Cleanup

DeferDeferSchedules a function call to run when the surrounding function returns. schedules a function call to run when the surrounding function returns, regardless of how it returns — normal exit, early return, or even a panic.

package main

import (
    "fmt"
    "os"
)

func readFile(path string) error {
    f, err := os.Open(path)
    if err != nil {
        return fmt.Errorf("readFile: %w", err)
    }
    defer f.Close() // runs when readFile exits, no matter what

    fmt.Println("Reading", path)
    // imagine reading lines here
    return nil
}

func main() {
    if err := readFile("notes.txt"); err != nil {
        fmt.Println("Error:", err)
    }
}

Without defer, you'd need to call f.Close() before every return. Miss one and you have a resource leak. With defer, you write the cleanup once — immediately after the resource is acquired — and it's guaranteed.

Deferred calls run in LIFO order (last in, first out):

func demo() {
    defer fmt.Println("A — deferred first, runs last")
    defer fmt.Println("B — deferred second, runs second")
    defer fmt.Println("C — deferred third, runs first")
    fmt.Println("function body")
}
// Output:
// function body
// C — deferred third, runs first
// B — deferred second, runs second
// A — deferred first, runs last

This order matters when you have nested locks or multiple resources to release in the right sequence.

Common defer patterns you'll see in real Go code:

// Unlock a mutex
mu.Lock()
defer mu.Unlock()

// Close a database connection
db, _ := sql.Open("postgres", dsn)
defer db.Close()

// Log how long a function took
start := time.Now()
defer func() {
    fmt.Printf("took %v\n", time.Since(start))
}()

defer is Go's answer to try/finally in Java, with in Python, and using in C#. But it's lighter — just one keyword, no nesting.

Structs — Grouping Data

Go has no class keyword. The first replacement is struct — a named collection of fields.

If you come from Java or PHP, a struct is a class with only fields and no inheritance. If you come from Python, it's a cleaner dataclass. If you come from TypeScript, it's close to an interface but holds actual values.

package main

import "fmt"

type User struct {
    Name  string
    Email string
    Age   int
}

func main() {
    // Struct literal
    u := User{
        Name:  "Nanda",
        Email: "nanda@example.com",
        Age:   28,
    }

    fmt.Println(u.Name)  // Nanda
    fmt.Println(u.Age)   // 28

    // Pointer to a struct
    p := &User{Name: "Alice", Email: "alice@example.com", Age: 30}
    p.Age = 31 // Go auto-dereferences — no need for (*p).Age
    fmt.Println(p.Age) // 31
}

Structs can be nested and composed:

type Address struct {
    City    string
    Country string
}

type Employee struct {
    User           // embedded — promotes all User fields and methods
    Address        // embedded — promotes all Address fields
    Department string
}

emp := Employee{
    User:       User{Name: "Bob", Email: "bob@co.com", Age: 25},
    Address:    Address{City: "Jakarta", Country: "Indonesia"},
    Department: "Engineering",
}

fmt.Println(emp.Name)    // Bob — promoted from User
fmt.Println(emp.City)    // Jakarta — promoted from Address
fmt.Println(emp.Department) // Engineering

This is compositionCompositionBuilding complex types by combining simpler ones, instead of inheriting from a parent. — building complex types by embedding simpler ones. It replaces inheritance entirely in Go, not as a workaround, but as the intended design.

Methods — Attaching Behavior to Structs

Methods are functions with a receiver — a struct they belong to. This is Go's version of class methods.

type User struct {
    Name  string
    Email string
    Age   int
}

// Value receiver — works on a copy, cannot modify the original
func (u User) Greet() string {
    return fmt.Sprintf("Hi, I'm %s", u.Name)
}

// Pointer receiver — works on the original, can modify it
func (u *User) HaveBirthday() {
    u.Age++
}

func main() {
    user := User{Name: "Nanda", Email: "nanda@example.com", Age: 28}

    fmt.Println(user.Greet()) // Hi, I'm Nanda

    user.HaveBirthday()
    fmt.Println(user.Age) // 29
}

The receiver is just the type in parentheses before the method name. Two kinds:

| Receiver | Type | Use when | |---|---|---| | (u User) | Value receiverValue ReceiverA method that works on a copy — the original stays unchanged. | Reading only, small struct | | (u *User) | Pointer receiverPointer ReceiverA method that operates on the original value, not a copy. | Modifying the struct, or large struct |

Rule of thumb: If any method on a type uses a pointer receiver, all methods on that type should use pointer receivers. Mixing them creates subtle bugs.

Promoted methods from embedded structs work automatically:

type Admin struct {
    User
    Permissions []string
}

admin := Admin{
    User:        User{Name: "Nanda", Email: "nanda@example.com", Age: 28},
    Permissions: []string{"read", "write", "delete"},
}

fmt.Println(admin.Greet()) // Hi, I'm Nanda — method promoted from User
admin.HaveBirthday()
fmt.Println(admin.Age)     // 29 — field promoted from User

Interfaces — Implicit Contracts

In Java or TypeScript, you declare that a class implements an interface:

class EmailSender implements Notifier { ... }

Go doesn't work that way. If your type has the right methods, it satisfies the interface automatically — no declaration needed.

package main

import "fmt"

// Define the interface
type Notifier interface {
    Notify(message string) error
}

// EmailSender — never mentions Notifier
type EmailSender struct {
    Address string
}

func (e EmailSender) Notify(message string) error {
    fmt.Printf("Email to %s: %s\n", e.Address, message)
    return nil
}

// SMSSender — also never mentions Notifier
type SMSSender struct {
    Phone string
}

func (s SMSSender) Notify(message string) error {
    fmt.Printf("SMS to %s: %s\n", s.Phone, message)
    return nil
}

// This function accepts any type that has Notify()
func sendAlert(n Notifier, msg string) {
    if err := n.Notify(msg); err != nil {
        fmt.Println("Failed:", err)
    }
}

func main() {
    email := EmailSender{Address: "team@example.com"}
    sms := SMSSender{Phone: "+62812345678"}

    sendAlert(email, "Deployment complete") // Email to team@example.com: Deployment complete
    sendAlert(sms, "Server is down")        // SMS to +62812345678: Server is down
}

EmailSender and SMSSender never mention Notifier. They just happen to have the right method. This is duck typingDuck TypingIf it walks like a duck and quacks like a duck, it is a duck. — but enforced at compile timeCompile TimeWhen your code is being translated into a runnable program — before it runs., not runtime.

Keep interfaces small. The most powerful interfaces in Go's standard library have one or two methods:

type Reader interface {
    Read(p []byte) (n int, err error)
}

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

type ReadWriter interface {
    Reader
    Writer
}

One method. That's it. Any type that can Read satisfies io.Reader — files, network connections, HTTP bodies, strings, test buffers. This is why Go code composes so naturally.

Storing multiple types in one slice:

notifiers := []Notifier{
    EmailSender{Address: "ops@example.com"},
    SMSSender{Phone: "+62811111111"},
}

for _, n := range notifiers {
    n.Notify("System alert")
}

One loop, two different types, one interface. This is the practical payoff of implicit interfaces.

The empty interface:

// interface{} (or 'any' in Go 1.18+) accepts any type
func printAnything(v any) {
    fmt.Printf("%T: %v\n", v, v)
}

printAnything(42)           // int: 42
printAnything("hello")      // string: hello
printAnything([]int{1,2,3}) // []int: [1 2 3]

Use any sparingly — you lose type safety. Prefer a specific interface when you know what behavior you need.

Putting It Together

Here's everything from Sessions 1 and 2 working together:

package main

import (
    "errors"
    "fmt"
)

type Product struct {
    Name  string
    Price float64
}

func (p Product) String() string {
    return fmt.Sprintf("%s ($%.2f)", p.Name, p.Price)
}

type Inventory struct {
    products []Product
}

func (inv *Inventory) Add(p Product) error {
    for _, existing := range inv.products {
        if existing.Name == p.Name {
            return fmt.Errorf("add product: %w",
                errors.New(p.Name+" already exists"))
        }
    }
    inv.products = append(inv.products, p)
    return nil
}

func (inv *Inventory) Total() float64 {
    total := 0.0
    for _, p := range inv.products {
        total += p.Price
    }
    return total
}

func (inv *Inventory) Print() {
    for _, p := range inv.products {
        fmt.Println("-", p) // calls p.String() automatically
    }
    fmt.Printf("Total: $%.2f\n", inv.Total())
}

func main() {
    inv := &Inventory{}

    items := []Product{
        {Name: "Keyboard", Price: 75.00},
        {Name: "Monitor", Price: 320.00},
        {Name: "Mouse", Price: 45.00},
    }

    for _, item := range items {
        if err := inv.Add(item); err != nil {
            fmt.Println("Error:", err)
            continue
        }
    }

    // Try adding a duplicate
    if err := inv.Add(Product{Name: "Keyboard", Price: 80.00}); err != nil {
        fmt.Println("Error:", err)
    }

    inv.Print()
}

This program uses: structs, methods, pointer receivers, slices, range, error wrapping, and fmt.Stringer interface — all from the last two sessions.

Key Takeaways

  • DeferDeferSchedules a function call to run when the surrounding function returns. guarantees cleanup code runs when a function exits — write it right after acquiring the resource
  • Deferred calls run in LIFO order — last deferred, first executed
  • Structs hold data; methods attach behavior to structs
  • Value receiversValue ReceiverA method that works on a copy — the original stays unchanged. work on a copy; pointer receiversPointer ReceiverA method that operates on the original value, not a copy. modify the original — pick one style and stick with it per type
  • CompositionCompositionBuilding complex types by combining simpler ones, instead of inheriting from a parent. via embedding replaces inheritance — Admin embeds User, getting all its fields and methods
  • InterfacesInterfaceA contract that says 'any type with these methods qualifies.' are satisfied implicitly — implement the methods and the type qualifies automatically
  • Keep interfaces small — one or two methods is usually enough

Next: Go Series #3 — Goroutines & Channels