Table of Contents
- 01 — CLI Todo App
- Overview
- Learning Objectives
- Requirements
- Architecture
- Suggested Project Layout
- Data Model / Database
- API Design
- Tech Stack
- Implementation Milestones
- Testing Strategy
- Deployment
- Documentation Deliverables
- Stretch Goals / Future Improvements
- Lessons-Learned Prompts
- Portfolio & Resume
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, anderrors.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 listshows outstanding tasks;todo list --allshows 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.
--jsonflag onlistemits 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 ofos.Exitcalls. - 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 asmax(existing IDs) + 1.Title— the task description; must be non-empty (trimmed).Done— completion flag, toggled bytodo 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) usingflag.NewFlagSetper subcommand; orspf13/cobraonce you want richer help, shell completion, and nested commands. - Persistence:
encoding/jsonwithMarshalIndentfor readable output. - Filesystem:
os,path/filepathfor cross-platform paths and atomic writes. - Output (optional):
olekukonko/tablewriterortext/tabwriter(stdlib) for aligned table output. - Testing (optional):
stretchr/testifyfor terser assertions; the stdlibtestingpackage is enough.
Implementation Milestones¶
Milestone 1 — Domain core
- Define the
Taskstruct 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()withMkdirAll+ 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
--alland--jsonflags tolist. - Print friendly usage and handle unknown commands.
Milestone 4 — Polish
- Aligned table output via
tabwriterortablewriter. - Consistent error reporting and non-zero exit codes.
- Tests for
taskandstorepackages. - 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
taskpackage in isolation: ID assignment, marking done, removing a missing ID returnsErrNotFound(verified witherrors.Is), empty title is rejected. - Test the
storepackage against the real filesystem usingt.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:
SavethenLoadyields 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.Exitout of testable code — return errors and letmainexit. testifyis optional;require.NoError/assert.Equalreduce 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 thetodobinary 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/--jsonflags. - 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.jsonlocation and format so users know where their data lives and how to back it up.
Stretch Goals / Future Improvements¶
- Due dates — add
--dueparsing and highlight overdue tasks. - Priorities — low/med/high, with sorting and colored output.
- Tags / projects —
--tag workand filtered listing. - Edit & undo —
todo 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¶
- How did separating
task(domain) fromstore(persistence) affect how easy the code was to test? Where would you draw the boundary differently? - When did you choose to wrap an error with
%wversus return it directly or create a sentinel? How did that decision shape your CLI's error messages? - Why is the atomic write (temp file + rename) worth the extra code? What failure did it actually protect against?
- If you started with
flagand later triedcobra, what did cobra give you and what did it cost in dependencies and complexity? - How did
t.TempDir()change the way you tested filesystem code compared to mocking or sharing a fixture file? - 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/taskandinternal/store, and how that made the domain logic testable without touching the filesystem. - My approach to idiomatic Go error handling: sentinel errors,
%wwrapping, anderrors.Is/errors.Asat 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
flagandcobra, 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.