04 — Methods, interfaces, packages, and modules

Goal

Organize Go code into small packages and learn how interfaces support storage replacement and testing.

Step 1: Create a task package

From the repository root, create server/internal/tasks/task.go:

package tasks

import "errors"

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

type Status string

const (
    StatusTodo       Status = "TODO"
    StatusInProgress Status = "IN_PROGRESS"
    StatusDone       Status = "DONE"
)

type Task struct {
    ID     string
    Title  string
    Status Status
}

func (t Task) IsComplete() bool {
    return t.Status == StatusDone
}

internal packages can be imported only from within the parent module tree. A method has a receiver (t Task). Constants give status values one shared source of truth.

Methods that need to modify a struct usually use a pointer receiver, such as func (t *Task) Rename(title string). A pointer holds the address of a value; Go uses &task to take an address and *ptr to dereference it. Avoid storing pointers to values you don't intend to share or mutate. Go passes arguments by value, including pointers (the copied pointer still refers to the same value).

An interface is a small promise about behavior. Repository says callers can save and fetch tasks, without saying whether the data lives in memory or PostgreSQL. Because Go interfaces are satisfied implicitly, the memory and PostgreSQL types can both implement this contract without importing each other. The caller depends on the operations it needs, which makes replacement and testing easier.

Step 2: Add a repository interface

Create internal/tasks/repository.go:

package tasks

import "context"

type Repository interface {
    Get(context.Context, string) (Task, error)
    Save(context.Context, Task) error
}

Interfaces describe behavior. Go types satisfy an interface implicitly by implementing its methods; there is no implements declaration.

The interface deliberately includes context.Context as its first argument. Context carries cancellation and deadlines down to slow work. A database query can stop if the client disconnects, rather than consuming a connection and CPU after nobody is waiting for the answer.

Step 3: Implement an in-memory repository

Create internal/tasks/memory.go:

package tasks

import (
    "context"
    "sync"
)

type MemoryRepository struct {
    mu    sync.RWMutex
    items map[string]Task
}

func NewMemoryRepository() *MemoryRepository {
    return &MemoryRepository{items: make(map[string]Task)}
}

func (r *MemoryRepository) Get(ctx context.Context, id string) (Task, error) {
    if err := ctx.Err(); err != nil {
        return Task{}, err
    }
    r.mu.RLock()
    defer r.mu.RUnlock()
    task, ok := r.items[id]
    if !ok {
        return Task{}, ErrNotFound
    }
    return task, nil
}

func (r *MemoryRepository) Save(ctx context.Context, task Task) error {
    if err := ctx.Err(); err != nil {
        return err
    }
    r.mu.Lock()
    defer r.mu.Unlock()
    r.items[task.ID] = task
    return nil
}

Step 4: Use the package

Create server/sandbox/main.go with a small caller that imports github.com/example/field-notes/server/internal/tasks, creates a repository, saves a task, and reads it back. Run go run ./server/sandbox from the repository root.

Checkpoint

Explain why the interface accepts context.Context, why the map needs a mutex if concurrent callers may use it, and how a database repository could replace MemoryRepository without changing its callers.

results matching ""

    No results matching ""