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 —
AdminembedsUser, 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