Skip to content

Table of Contents

01 — CLI Todo App

Overview

A small, single-binary command-line todo manager written in Go. It lets you add tasks, list them, mark them done, and remove them, with everything persisted to a human-readable JSON file in your home directory (~/.todo/tasks.json).

This is a Beginner-level project. Its goal is not to be a feature-rich product but to give you hands-on practice with the bread-and-butter of everyday Go: parsing CLI arguments, modeling data with structs and slices, reading/writing JSON, and handling errors the idiomatic Go way. By the end you will have a real tool you use yourself, and a clean codebase you can show off.

You can build it with the standard library flag package or with spf13/cobra. The spec covers both; start with flag if this is your first Go CLI, then optionally graduate to cobra.

Learning Objectives

  • Parse subcommands, positional arguments, and flags from the command line.
  • Model domain data with structs and manipulate collections with slices.
  • Serialize and deserialize data with encoding/json (struct tags, indentation).
  • Read and write files safely, including creating directories and atomic writes.
  • Practice idiomatic error handling: returning errors, wrapping with %w, sentinel errors, and errors.Is / errors.As.
  • Separate concerns: keep CLI parsing, business logic, and persistence in distinct packages (internal/task, internal/store).
  • Write table-driven tests and use t.TempDir() for filesystem isolation.
  • Build and distribute a static Go binary for multiple platforms.

Requirements

Functional

  • todo add <title> creates a new task with a unique ID and a creation timestamp.
  • todo list shows outstanding tasks; todo list --all shows completed ones too.
  • todo done <id> marks the task with the given ID as completed.
  • todo rm <id> permanently deletes the task with the given ID.
  • IDs are stable, human-friendly integers that increase monotonically.
  • Tasks persist between runs in a JSON file under the user's home directory.
  • --json flag on list emits machine-readable JSON instead of a table.
  • Helpful usage output is printed for todo, todo -h, and unknown commands.

Non-Functional

  • Single statically linked binary, no runtime dependencies.
  • Cross-platform: builds and runs on Linux, macOS, and Windows.
  • The data file is human-readable and hand-editable (pretty-printed JSON).
  • Writes are durable: never corrupt the existing file on a failed write.
  • All errors surface a clear message and a non-zero exit code; success exits 0.
  • Core logic (task, store) is unit-tested and free of os.Exit calls.
  • Startup is effectively instant (well under 50 ms for typical task counts).

Architecture

The program is a thin CLI shell over a small domain core. main parses arguments and dispatches to a command layer, which calls into the store to load/save and into task for domain operations. The store is the only component that touches the filesystem.

flowchart TD
    User([User / Terminal]) -->|args & flags| CLI[cmd/todo: arg parsing<br/>flag or cobra]
    CLI -->|dispatch| Cmd[Command layer<br/>add / list / done / rm]
    Cmd -->|domain ops| Task[internal/task<br/>Task struct + rules]
    Cmd -->|Load / Save| Store[internal/store<br/>JSON persistence]
    Store -->|read & atomic write| File[(~/.todo/tasks.json)]
    Cmd -->|formatted output| Out([stdout / stderr])

Suggested Project Layout

Follows the golang-standards/project-layout conventions: the executable lives under cmd/, and private packages live under internal/ so they cannot be imported by other modules.

01-cli-todo/
├── cmd/
│   └── todo/
│       └── main.go            # entrypoint: arg parsing + dispatch
├── internal/
│   ├── task/
│   │   ├── task.go            # Task struct, constructors, list operations
│   │   └── task_test.go       # table-driven domain tests
│   └── store/
│       ├── store.go           # Load/Save JSON, path resolution, atomic write
│       └── store_test.go      # tests using t.TempDir()
├── go.mod
├── go.sum
├── README.md
├── .gitignore
└── SPEC.md                    # this document

Data Model / Database

The core domain type is Task. JSON struct tags use snake_case so the file is pleasant to read and edit by hand.

// internal/task/task.go
package task

import "time"

type Task struct {
    ID        int        `json:"id"`
    Title     string     `json:"title"`
    Done      bool       `json:"done"`
    CreatedAt time.Time  `json:"created_at"`
    DoneAt    *time.Time `json:"done_at,omitempty"`
}
  • ID — monotonic integer, assigned as max(existing IDs) + 1.
  • Title — the task description; must be non-empty (trimmed).
  • Done — completion flag, toggled by todo done.
  • CreatedAt — set once at creation, RFC 3339 in JSON.
  • DoneAt — pointer so it is omitted from JSON until the task is completed.

Storage format. Tasks are stored as a JSON object (not a bare array) so the schema can evolve without breaking older files:

{
  "version": 1,
  "tasks": [
    {
      "id": 1,
      "title": "Write the SPEC",
      "done": true,
      "created_at": "2026-06-26T09:00:00Z",
      "done_at": "2026-06-26T10:15:00Z"
    },
    {
      "id": 2,
      "title": "Implement the store package",
      "done": false,
      "created_at": "2026-06-26T09:05:00Z"
    }
  ]
}

Location. Resolved as filepath.Join(os.UserHomeDir(), ".todo", "tasks.json"). The ~/.todo directory is created on first write (os.MkdirAll, 0o755). A missing file is treated as an empty task list, not an error. Writes are atomic: data is written to a temp file in the same directory, then os.Renamed over the target so a crash mid-write never corrupts existing data.

API Design

The "API" here is the CLI command surface. General shape: todo <command> [arguments] [flags].

Command Arguments Flags Description
todo add <title> Add a new task
todo list --all, --json List tasks (open by default)
todo done <id> Mark a task complete
todo rm <id> Delete a task
todo -h / help Show usage

Example invocations

$ todo add "Buy milk"
Added task #1: Buy milk

$ todo add "Read Effective Go"
Added task #2: Read Effective Go

$ todo list
  ID  STATUS  TITLE
  1   [ ]     Buy milk
  2   [ ]     Read Effective Go

$ todo done 1
Completed task #1: Buy milk

$ todo list
  ID  STATUS  TITLE
  2   [ ]     Read Effective Go

$ todo list --all
  ID  STATUS  TITLE
  1   [x]     Buy milk
  2   [ ]     Read Effective Go

$ todo list --json
[
  {"id":1,"title":"Buy milk","done":true,"created_at":"2026-06-26T09:00:00Z","done_at":"2026-06-26T10:15:00Z"},
  {"id":2,"title":"Read Effective Go","done":false,"created_at":"2026-06-26T09:05:00Z"}
]

$ todo rm 2
Removed task #2: Read Effective Go

$ todo done 99
Error: no task with id 99
$ echo $?
1

Tech Stack

  • Language: Go 1.22+.
  • CLI parsing: standard library flag (zero dependencies, recommended first) using flag.NewFlagSet per subcommand; or spf13/cobra once you want richer help, shell completion, and nested commands.
  • Persistence: encoding/json with MarshalIndent for readable output.
  • Filesystem: os, path/filepath for cross-platform paths and atomic writes.
  • Output (optional): olekukonko/tablewriter or text/tabwriter (stdlib) for aligned table output.
  • Testing (optional): stretchr/testify for terser assertions; the stdlib testing package is enough.

Implementation Milestones

Milestone 1 — Domain core

  • Define the Task struct with JSON tags.
  • Implement New(title) and ID assignment logic.
  • Implement list operations: add, find by ID, mark done, remove.
  • Define sentinel errors (e.g. ErrNotFound, ErrEmptyTitle).

Milestone 2 — Persistence

  • Resolve the data file path from the home directory.
  • Implement Load() (missing file → empty list).
  • Implement Save() with MkdirAll + atomic temp-file rename.
  • Round-trip a list of tasks through JSON.

Milestone 3 — CLI wiring

  • Parse the subcommand and dispatch.
  • Wire add, list, done, rm.
  • Add --all and --json flags to list.
  • Print friendly usage and handle unknown commands.

Milestone 4 — Polish

  • Aligned table output via tabwriter or tablewriter.
  • Consistent error reporting and non-zero exit codes.
  • Tests for task and store packages.
  • README with install and usage instructions.

Testing Strategy

  • Table-driven tests are the default style. Each case is a struct in a slice; iterate with t.Run(tc.name, ...) for clear sub-test names.
  • Test the task package in isolation: ID assignment, marking done, removing a missing ID returns ErrNotFound (verified with errors.Is), empty title is rejected.
  • Test the store package against the real filesystem using t.TempDir(), which gives each test an isolated directory that is auto-cleaned. Override the path resolution (e.g. inject the directory or set an env var) so tests never touch the real ~/.todo.
  • Cover the round-trip: Save then Load yields an equal task list; loading a non-existent file yields an empty list with no error; loading malformed JSON returns a wrapped error.
  • Keep os.Exit out of testable code — return errors and let main exit.
  • testify is optional; require.NoError / assert.Equal reduce boilerplate.
func TestMarkDone(t *testing.T) {
    tests := []struct {
        name    string
        id      int
        wantErr error
    }{
        {"existing task", 1, nil},
        {"missing task", 99, task.ErrNotFound},
    }
    for _, tc := range tests {
        t.Run(tc.name, func(t *testing.T) {
            list := task.List{{ID: 1, Title: "x"}}
            err := list.MarkDone(tc.id)
            if !errors.Is(err, tc.wantErr) {
                t.Fatalf("got %v, want %v", err, tc.wantErr)
            }
        })
    }
}

Deployment

  • Local install: go install ./cmd/todo@latest (or from the repo root, go install ./cmd/todo) drops the todo binary into $GOBIN/$GOPATH/bin.
  • Manual build: go build -o todo ./cmd/todo.
  • Static cross-compilation: Go cross-compiles trivially. Examples:
GOOS=linux   GOARCH=amd64 go build -o dist/todo-linux-amd64   ./cmd/todo
GOOS=darwin  GOARCH=arm64 go build -o dist/todo-darwin-arm64  ./cmd/todo
GOOS=windows GOARCH=amd64 go build -o dist/todo-windows-amd64.exe ./cmd/todo

Shrink binaries with -ldflags "-s -w"; set CGO_ENABLED=0 for a fully static build. - Releases: publish prebuilt binaries to GitHub Releases. Automate with goreleaser, which builds the matrix, generates checksums, and uploads artifacts from a GitHub Actions workflow on tag push.

Documentation Deliverables

  • README.md — what the tool does, install instructions, a quick-start, and the full command/flag reference with example output.
  • Usage examples — a copy-pasteable session covering add/list/done/rm and the --all / --json flags.
  • Godoc comments — every exported identifier (types, functions, errors) has a doc comment starting with its name; package-level comments explain each package's purpose. Verify with go doc ./....
  • Notes on the data file — document the ~/.todo/tasks.json location and format so users know where their data lives and how to back it up.

Stretch Goals / Future Improvements

  • Due dates — add --due parsing and highlight overdue tasks.
  • Priorities — low/med/high, with sorting and colored output.
  • Tags / projects--tag work and filtered listing.
  • Edit & undotodo edit <id> and an undo of the last mutation.
  • TUI — an interactive interface with charmbracelet/bubbletea.
  • SQLite backend — swap the JSON store for database/sql + SQLite behind the same store interface, proving the value of the abstraction.
  • Shell completion — generate Bash/Zsh/Fish completions (free with cobra).

Lessons-Learned Prompts

  1. How did separating task (domain) from store (persistence) affect how easy the code was to test? Where would you draw the boundary differently?
  2. When did you choose to wrap an error with %w versus return it directly or create a sentinel? How did that decision shape your CLI's error messages?
  3. Why is the atomic write (temp file + rename) worth the extra code? What failure did it actually protect against?
  4. If you started with flag and later tried cobra, what did cobra give you and what did it cost in dependencies and complexity?
  5. How did t.TempDir() change the way you tested filesystem code compared to mocking or sharing a fixture file?
  6. What would have to change to support the SQLite stretch goal, and how much of your existing code could you keep?

Portfolio & Resume

Resume Bullets

  • Built a cross-platform CLI todo manager in Go distributed as a single static binary for Linux, macOS, and Windows, with zero runtime dependencies and sub-50 ms startup.
  • Designed a layered architecture (CLI / domain / storage) with crash-safe atomic JSON persistence, achieving 90%+ unit-test coverage on core packages via table-driven tests and t.TempDir() isolation.
  • Automated multi-platform release builds and checksums with goreleaser and GitHub Actions, publishing installable artifacts on every tagged release.

Interview Talking Points

  • Why I split the code into internal/task and internal/store, and how that made the domain logic testable without touching the filesystem.
  • My approach to idiomatic Go error handling: sentinel errors, %w wrapping, and errors.Is/errors.As at the boundaries, surfaced as clean CLI messages and exit codes.
  • How I guaranteed data integrity with atomic temp-file-plus-rename writes instead of writing in place.
  • The trade-offs between stdlib flag and cobra, and when I'd reach for each.
  • How designing a small store abstraction would let me swap JSON for SQLite with minimal churn — and how I verified it with isolated, table-driven tests.