Skip to content

Table of Contents

01 — CLI Todo

A tiny, single-binary command-line task manager: add, list, complete, and remove todos, persisted to a human-readable JSON file. Zero third-party dependencies.

status Go deps tests

Overview

todo is a beginner-friendly but cleanly layered Go CLI. It manages a list of tasks stored as pretty-printed JSON in ~/.todo/tasks.json (override with TODO_FILE). The point of the project is to practice the everyday Go toolkit — flag parsing, structs/slices, encoding/json, safe file I/O, and idiomatic error handling — inside a small, layered, fully tested codebase. See the full SPEC.

Demo

$ 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 --all
ID  STATUS  TITLE
1   [x]     Buy milk
2   [ ]     Read Effective Go

$ todo list --json
[
  {
    "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: task not found
$ echo $?
1

Architecture

The program is a thin CLI shell over a small domain core. main builds the dependency graph and installs a signal-cancelled context; the cli layer parses args and renders output; the service layer orchestrates each use case; the task package holds pure domain logic; and store is the only component that touches the filesystem. Each layer depends only on the one beneath it, and the two interfaces (Repository, TaskService) are defined at the consumer.

flowchart TD
    User([User / Terminal]) -->|args & flags| Main[cmd/todo/main.go<br/>wiring + signal ctx]
    Main --> CLI[internal/cli<br/>flag parsing + rendering]
    CLI -->|TaskService iface| Service[internal/service<br/>use cases: add/list/done/rm]
    Service -->|domain ops| Task[internal/task<br/>Task, List, rules]
    Service -->|Repository iface| Store[internal/store<br/>JSON persistence]
    Store -->|read & atomic write| File[("~/.todo/tasks.json")]
    CLI -->|stdout / stderr| Out([Terminal])

Key decisions:

  • Layered with consumer-defined interfaces. service.Repository and cli.TaskService are declared where they are used, so dependencies point inward and the concrete store can be swapped (e.g. for SQLite) without touching higher layers.
  • Pure domain. internal/task does no I/O, so its rules (ID assignment, empty-title rejection, idempotent completion) are trivially unit-tested.
  • Atomic writes. store.Save writes to a temp file then os.Renames it into place, so a crash mid-write never corrupts existing data.
  • Injected clock. The service takes a Clock func, making timestamps deterministic in tests.
  • No os.Exit outside main. Every layer returns errors; only main translates them into exit codes.

Tech Stack

Go 1.22 · flag · encoding/json · text/tabwriter · os/path/filepath · testingstandard library only, no third-party imports.

Getting Started

Prerequisites

  • Go 1.22+

Run

This project is part of the repo root module github.com/nabin747/go-from-zero, so there is no local go.mod. Run from this directory:

cd projects/01-cli-todo

make build                 # builds ./bin/todo
./bin/todo add "Buy milk"

# or without building:
make run ARGS="add Buy milk"
go run github.com/nabin747/go-from-zero/projects/01-cli-todo/cmd/todo list

Point the data file somewhere local instead of ~/.todo:

TODO_FILE=./tasks.json ./bin/todo add "scratch task"

Test

make test            # go test -race -cover ./...
make cover           # HTML coverage report

Project Layout

01-cli-todo/
├── cmd/
│   └── todo/
│       └── main.go            # entrypoint: wiring, signal ctx, exit codes
├── internal/
│   ├── task/                  # domain: Task, List, rules (no I/O)
│   │   ├── task.go
│   │   └── task_test.go
│   ├── store/                 # persistence: JSON load/save, atomic write
│   │   ├── store.go
│   │   └── store_test.go
│   ├── service/               # use cases over Repository
│   │   ├── service.go
│   │   └── service_test.go
│   └── cli/                   # transport: flag parsing + rendering
│       ├── cli.go
│       └── cli_test.go
├── Makefile
├── README.md
├── SPEC.md
└── .gitignore

API

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

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

Exit codes: 0 success, 1 runtime error (e.g. unknown ID), 2 usage error.

Data file: ~/.todo/tasks.json (override with TODO_FILE). Stored as a versioned JSON object:

{
  "version": 1,
  "tasks": [
    { "id": 1, "title": "Buy milk", "done": false, "created_at": "2026-06-26T09:00:00Z" }
  ]
}

Testing Strategy

Table-driven tests throughout, organised by layer:

  • task — pure domain assertions: monotonic IDs, trimmed/empty titles, idempotent completion, removal, errors.Is(err, ErrNotFound).
  • store — real filesystem via t.TempDir(): save/load round-trip (including done_at pointer omission), missing file → empty list, malformed JSON → wrapped error, no temp-file leftovers, overwrite semantics.
  • service — an in-memory fake Repository for deterministic, fast tests; verifies orchestration, error wrapping, and context cancellation.
  • cli — drives the whole command surface through App.Run, capturing stdout/stderr buffers and asserting on output and exit codes.
go test -race -cover ./...

Lessons Learned

  • Splitting task (domain) from store (persistence) and service (use cases) made each layer testable in isolation — the domain needs no files, the service needs no disk (in-memory fake repo), and only store tests touch t.TempDir().
  • Wrapping infrastructure errors with %w while returning domain sentinels (ErrNotFound, ErrEmptyTitle) directly keeps CLI messages clean and lets callers branch with errors.Is.
  • The temp-file-plus-rename write is a few extra lines that buy crash safety: the real file is only ever replaced atomically.

Future Improvements

  • Due dates (--due) and overdue highlighting
  • Priorities and tags with filtered listing
  • todo edit <id> and undo of the last mutation
  • Swap the JSON store for SQLite behind the same Repository interface
  • Shell completion scripts

🎒 Portfolio

Résumé bullets:

  • "Built a cross-platform CLI todo manager in Go, distributed as a single static binary with zero runtime dependencies and sub-50 ms startup."
  • "Designed a four-layer architecture (CLI / service / domain / storage) with consumer-defined interfaces and crash-safe atomic JSON persistence, covered by table-driven tests with t.TempDir() isolation and an injected clock."

Interview talking points:

  • Why interfaces (Repository, TaskService) are defined at the consumer and how that keeps dependencies pointing inward and enables swapping JSON for SQLite.
  • Idiomatic error handling: sentinel errors vs. %w wrapping, surfaced as clean CLI messages and distinct exit codes (0/½).
  • Guaranteeing data integrity with atomic temp-file-plus-rename writes instead of writing in place.

Projects · Repo README