A Real CLI
Interfaces: Separate What from Where
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
errorandfmt.Stringerwork - 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
storagefield of typeStorage. It can hold any value that satisfies the interface. OpenreplacesNewStoreandLoad. It loads tasks from the storage, validates them, and builds the map. The validation from the oldLoadJSONlives here now, because it is a rule about tasks, not about files.- The store saves itself.
AddandCompletecalls.save()after every change. Callers can no longer forget to save. - A failed save is undone. If
savefails,Addremoves the new task from the map, andCompletemarks the task as open again. The store in memory always matches what is saved.nextIDonly goes up after a successful save. saveis unexported. Only the store decides when to save.ToJSONandLoadJSONare 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
Storageis in thetaskpackage, the package that uses it. - The implementations are in the
storagepackage, which importstask.
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 theaddanddonecases. - In the
jsoncase, replacestore.ToJSON()withjson.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
- A broken storage. In a new folder
cmd/play, write a typebrokenStoragewhoseSavealways returnserrors.New("disk is full"). Open a store with it, callAdd, and print the error andstore.Len(). You should seeerror: save tasks: disk is fulland0, because the store undid the change. - Missing method. Remove the
Savemethod frombrokenStorageand build again. Read the compiler message. Which method does it say is missing? - Log every save. Write a storage
Loggingthat holds anothertask.Storagein a field and printssaving N tasksbefore it calls the innerSave. Use it to wrap the file storage inopenStore. This pattern is called a decorator. - Your own
Stringer. Givestorage.FileaString()method that returns"file: " + path. Print the storage in the shell when it starts. - Find interfaces in the standard library. Look up
io.Readerandio.Writerin the Go documentation at pkg.go.dev. How many methods does each one have? Name two types that implementio.Writer. Hint: you have usedos.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
Storageis defined intaskand implemented instorage - 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"
