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.