A Real CLI

Interfaces: Separate What from Where

LuettavaEsikatselu

Katselet ilmaista esikatseluoppituntia.

Goal

By the end of this lesson, the Store no longer knows anything about files. It works with any storage that can load and save tasks. You write two storages: one that uses a JSON file, and one that keeps tasks only in memory. You add tasker shell -memory, a practice mode that never touches your real tasks.

What you will learn

  • What an interface is, and how a type satisfies it without saying so
  • Where to define an interface in Go, and why
  • How to check at compile time that a type implements an interface
  • How small interfaces such as error and fmt.Stringer work
  • How to avoid import cycles between packages
  • How to undo a change in memory when saving fails

Why this matters

Right now, the store has two jobs: it manages tasks, and it reads and writes a JSON file. In Part 2, Tasker stores tasks in Postgres. Without a change, you would have to rewrite the store. With an interface, the store says only what it needs, "something that can load and save tasks", and you can plug in a file, a database, or a fake storage for tests.

Interfaces are the main tool Go uses to connect parts of a program loosely. You will see them in almost every Go library: io.Reader, http.Handler, error, and many more.

Step 1: What is an interface?

An interface is a type that lists method signatures. Any type that has all those methods satisfies the interface automatically.

Create internal/task/storage.go:

package task

// Storage loads and saves the full list of tasks.
type Storage interface {
	Load() ([]Task, error)
	Save(tasks []Task) error
}

This says: "A Storage is anything with a Load method and a Save method with these exact signatures."

In languages such as Java or C#, a class must declare implements Storage. Go has no such keyword. If a type has the methods, it is a Storage. This is called implicit implementation. The type does not even need to know that the interface exists.

Step 2: You have already used interfaces

Two interfaces from the standard library have been in your code for a while:

type error interface {
	Error() string
}
type Stringer interface {
	String() string
}

error is a built-in interface. Every error value, from errors.New or fmt.Errorf, is some type with an Error() string method.

fmt.Stringer lives in the fmt package. In Lesson 3.1, you gave Task a String() method. You never wrote "Task implements Stringer", but fmt.Println saw the method and used it. Now you know why.

Both interfaces have one method. Small interfaces are a strong Go habit. The fewer methods an interface has, the easier it is to implement, and the more types can satisfy it.

Step 3: Make the store use Storage

Replace internal/task/store.go:

package task

import (
	"fmt"
	"maps"
	"slices"
)

// Store keeps tasks in memory and saves every change to its storage.
type Store struct {
	storage Storage
	tasks   map[int]Task
	nextID  int
}

// Open loads all tasks from storage and returns a store that is ready to use.
func Open(storage Storage) (*Store, error) {
	list, err := storage.Load()
	if err != nil {
		return nil, err
	}

	s := &Store{
		storage: storage,
		tasks:   make(map[int]Task),
	}
	highest := 0
	for _, t := range list {
		if err := ValidateTitle(t.Title); err != nil {
			return nil, fmt.Errorf("task %d: %w", t.ID, err)
		}
		s.tasks[t.ID] = t
		highest = max(highest, t.ID)
	}
	s.nextID = highest + 1
	return s, nil
}

// Add validates the title, creates a task, and saves it.
func (s *Store) Add(title string, tags []string) (Task, error) {
	if err := ValidateTitle(title); err != nil {
		return Task{}, err
	}
	t := New(s.nextID, title, tags)
	s.tasks[t.ID] = t

	if err := s.save(); err != nil {
		delete(s.tasks, t.ID)
		return Task{}, err
	}
	s.nextID++
	return t, nil
}

// Get returns the task with the given ID.
func (s *Store) Get(id int) (Task, error) {
	t, ok := s.tasks[id]
	if !ok {
		return Task{}, ErrNotFound
	}
	return t, nil
}

// Complete marks the task with the given ID as done and saves the change.
func (s *Store) Complete(id int) (Task, error) {
	t, err := s.Get(id)
	if err != nil {
		return Task{}, err
	}
	if t.Done {
		return t, ErrAlreadyDone
	}
	t.Done = true
	s.tasks[id] = t

	if err := s.save(); err != nil {
		t.Done = false
		s.tasks[id] = t
		return Task{}, err
	}
	return t, nil
}

// List returns all tasks, sorted by ID.
func (s *Store) List() []Task {
	var list []Task
	for _, id := range slices.Sorted(maps.Keys(s.tasks)) {
		list = append(list, s.tasks[id])
	}
	return list
}

// Len returns the number of tasks in the store.
func (s *Store) Len() int {
	return len(s.tasks)
}

func (s *Store) save() error {
	if err := s.storage.Save(s.List()); err != nil {
		return fmt.Errorf("save tasks: %w", err)
	}
	return nil
}

Delete internal/task/file.go. Its code moves to a new package in Step 4.

What changed:

  • The store has a storage field of type Storage. It can hold any value that satisfies the interface.
  • Open replaces NewStore and Load. It loads tasks from the storage, validates them, and builds the map. The validation from the old LoadJSON lives here now, because it is a rule about tasks, not about files.
  • The store saves itself. Add and Complete call s.save() after every change. Callers can no longer forget to save.
  • A failed save is undone. If save fails, Add removes the new task from the map, and Complete marks the task as open again. The store in memory always matches what is saved. nextID only goes up after a successful save.
  • save is unexported. Only the store decides when to save.
  • ToJSON and LoadJSON are gone. JSON is a detail of the file storage, so the store does not need it.

Step 4: Write the file storage

Create a new package. Add internal/storage/file.go:

// Package storage contains the places where Tasker can keep its tasks.
package storage

import (
	"encoding/json"
	"errors"
	"fmt"
	"os"
	"path/filepath"

	"github.com/yourname/tasker/internal/task"
)

// File stores tasks as JSON in a file.
type File struct {
	path string
}

// This line makes the compiler check that *File implements task.Storage.
var _ task.Storage = (*File)(nil)

// NewFile returns a storage that uses the file at path.
func NewFile(path string) *File {
	return &File{path: path}
}

// Load reads all tasks from the file. A missing file means there are no tasks yet.
func (f *File) Load() ([]task.Task, error) {
	data, err := os.ReadFile(f.path)
	if errors.Is(err, os.ErrNotExist) {
		return nil, nil
	}
	if err != nil {
		return nil, fmt.Errorf("read task file: %w", err)
	}

	var tasks []task.Task
	if err := json.Unmarshal(data, &tasks); err != nil {
		return nil, fmt.Errorf("parse %s: %w", f.path, err)
	}
	return tasks, nil
}

// Save writes all tasks to the file.
// It writes to a temporary file first, so a crash never leaves a half-written file.
func (f *File) Save(tasks []task.Task) error {
	data, err := json.MarshalIndent(tasks, "", "  ")
	if err != nil {
		return fmt.Errorf("encode tasks: %w", err)
	}

	dir := filepath.Dir(f.path)
	if err := os.MkdirAll(dir, 0o755); err != nil {
		return fmt.Errorf("create folder: %w", err)
	}

	tmp, err := os.CreateTemp(dir, "tasks-*.tmp")
	if err != nil {
		return fmt.Errorf("create temporary file: %w", err)
	}
	defer os.Remove(tmp.Name())

	if _, err := tmp.Write(data); err != nil {
		tmp.Close()
		return fmt.Errorf("write temporary file: %w", err)
	}
	if err := tmp.Close(); err != nil {
		return fmt.Errorf("close temporary file: %w", err)
	}

	if err := os.Rename(tmp.Name(), f.path); err != nil {
		return fmt.Errorf("replace task file: %w", err)
	}
	return nil
}

Most of this code is your Save and Load from Lesson 3.3. The difference: File keeps the path in a field, so callers no longer pass it each time.

The comment above package storage is a package comment. It describes the whole package, and Go's documentation tools show it.

var _ task.Storage = (*File)(nil) looks strange, but it is a common Go idiom. It tries to assign a *File to a task.Storage variable and throws the result away with _. If *File ever loses a method or a signature changes, this line stops the build with a clear message. Go checks interfaces where values are used, and this line creates such a use right next to the type.

Step 5: Where to define an interface

Look at where the pieces live:

  • The interface Storage is in the task package, the package that uses it.
  • The implementations are in the storage package, which imports task.

This is the Go way: define interfaces where they are used, not where they are implemented. The task package says what it needs. Any package can provide it.

It also avoids an import cycle. storage imports task, because it needs the Task type. If task also imported storage, each package would need the other to compile first. Go does not allow this, and the build stops with import cycle not allowed. With the interface in task, the arrows point in one direction only: cmd/tasker → storage → task.

Step 6: Write a memory storage

Create internal/storage/memory.go:

package storage

import (
	"slices"

	"github.com/yourname/tasker/internal/task"
)

// Memory keeps tasks only in memory. All tasks are lost when the program ends.
type Memory struct {
	tasks []task.Task
}

var _ task.Storage = (*Memory)(nil)

// NewMemory returns an empty memory storage.
func NewMemory() *Memory {
	return &Memory{}
}

// Load returns a copy of the stored tasks.
func (m *Memory) Load() ([]task.Task, error) {
	return slices.Clone(m.tasks), nil
}

// Save replaces the stored tasks with a copy of tasks.
func (m *Memory) Save(tasks []task.Task) error {
	m.tasks = slices.Clone(tasks)
	return nil
}

slices.Clone makes a new slice with the same items. You learned in Lesson 2.2 that slices share memory. Without the copy, the caller and the storage would share the same items, and a change in one place would appear in the other.

This storage never fails, so both methods always return a nil error. They still return error, because the interface requires it.

Step 7: Connect everything in the command-line program

Open cmd/tasker/commands.go. Add the storage import:

	"github.com/yourname/tasker/internal/storage"
	"github.com/yourname/tasker/internal/task"

Replace openStore:

// openStore opens the store that uses the task file.
func openStore() (*task.Store, error) {
	path, err := dataPath()
	if err != nil {
		return nil, err
	}
	return task.Open(storage.NewFile(path))
}

storage.NewFile(path) returns a *storage.File. task.Open expects a task.Storage. Go accepts it, because *storage.File has both methods.

In runAdd, runDone, and runList, change the openStore call to:

	store, err := openStore()

Delete the store.Save(path) blocks in runAdd and runDone. The store saves itself now.

Now update the shell. Change the beginning of runShell so it accepts a -memory flag:

func runShell(args []string) error {
	flags := flag.NewFlagSet("shell", flag.ContinueOnError)
	inMemory := flags.Bool("memory", false, "keep tasks in memory only and do not touch the task file")
	err := flags.Parse(args)
	if errors.Is(err, flag.ErrHelp) {
		return nil
	}
	if err != nil {
		return err
	}

	var store *task.Store
	if *inMemory {
		store, err = task.Open(storage.NewMemory())
	} else {
		store, err = openStore()
	}
	if err != nil {
		return err
	}
	fmt.Printf("Loaded %d tasks\n", store.Len())

Add "encoding/json", "flag", and the storage package to the imports of shell.go. Then:

  • Delete the two store.Save(path) blocks in the add and done cases.
  • In the json case, replace store.ToJSON() with json.MarshalIndent(store.List(), "", " ").

In main.go, pass the arguments to the shell, return runShell(args[1:]), and update the help line to shell [-memory].

Look at the important line:

store, err = task.Open(storage.NewMemory())

The shell, the commands, and the store are exactly the same in both modes. Only the storage changes. That is the power of an interface.

Step 8: Try it

go build ./cmd/tasker
export TASKER_FILE=./data/tasks.json
./tasker add Buy milk '#shop'
./tasker add Pay rent
./tasker done 1

The commands work as before. Now try practice mode:

./tasker shell -memory
Loaded 0 tasks
> add Try something
Added: 1. [ ] Try something
> quit
Bye!

Then check the file:

./tasker list
1. [x] Buy milk #shop
2. [ ] Pay rent

The practice task is gone, and your real tasks are untouched.

Step 9: Interface values and nil

A variable of an interface type holds two things: a concrete value and its type. You can print the type with %T:

var s task.Storage
fmt.Println(s == nil)

s = storage.NewMemory()
fmt.Printf("%T\n", s)

Output:

true
*storage.Memory

The zero value of an interface is nil, which means it holds no value at all. Calling a method on a nil interface crashes, like a nil pointer. That is why Open always receives a real storage from its caller.

Why it works: depend on behavior, not on details

The store depends on two methods, Load and Save, and nothing else. It does not know about JSON, files, folders, or temporary files. You can test it with a memory storage. You can replace the file with a database in Part 2. You can even write a storage that fails on purpose to test error handling (see the exercises).

Remember this Go saying: "Accept interfaces, return structs." Open accepts the Storage interface, so callers can pass any storage. NewFile and NewMemory return concrete types (*File, *Memory), so callers get the full type with all its methods.

Exercises

  1. A broken storage. In a new folder cmd/play, write a type brokenStorage whose Save always returns errors.New("disk is full"). Open a store with it, call Add, and print the error and store.Len(). You should see error: save tasks: disk is full and 0, because the store undid the change.
  2. Missing method. Remove the Save method from brokenStorage and build again. Read the compiler message. Which method does it say is missing?
  3. Log every save. Write a storage Logging that holds another task.Storage in a field and prints saving N tasks before it calls the inner Save. Use it to wrap the file storage in openStore. This pattern is called a decorator.
  4. Your own Stringer. Give storage.File a String() method that returns "file: " + path. Print the storage in the shell when it starts.
  5. Find interfaces in the standard library. Look up io.Reader and io.Writer in the Go documentation at pkg.go.dev. How many methods does each one have? Name two types that implement io.Writer. Hint: you have used os.Stdout.

Checkpoint

You are ready for the next lesson if you can:

  • Define an interface and explain how a type satisfies it without implements
  • Explain why Storage is defined in task and implemented in storage
  • Explain what an import cycle is and how the interface avoids it
  • Use var _ task.Storage = (*File)(nil) and explain what it checks
  • Explain how the store stays correct when a save fails
  • Explain the saying "accept interfaces, return structs"