Phase 1 · Node.js FoundationsModule 8~70 min read

Phase Project: Build a Task Tracker CLI

Combine the entire phase into a useful command-line task tracker that stores data, validates input, and handles failure cleanly.

What you'll learn

This project combines the entire phase into a useful task tracker. You will design a command contract, separate domain logic from persistence, store tasks as JSON, and make every success and failure understandable from the terminal.

By the end of this lesson, you'll be able to:

  • Design add, list, complete, and remove command flows
  • Separate CLI parsing, domain operations, and file persistence
  • Generate stable IDs and validate every external input
  • Ship a documented Node.js project with reliable exit behavior

Core mental model

Node.js becomes easier when you separate the JavaScript language from the runtime and the operating-system capabilities it exposes. Use this table as a decision guide.

ConceptWhat it meansDecision rule
Interface layerTurns terminal arguments into application commands and outputKeep parsing and presentation outside domain and storage logic
Domain layerOwns task rules and state transitionsWrite it as pure functions where possible
Repository layerLoads and saves tasks without deciding business rulesKeep file format and paths behind one small API
Command resultA success value or intentional error mapped to output and exit statusMake every command path observable and script-friendly

Professional workflow

Build and verify Node.js programs from the terminal in small, observable steps.

  1. Write the exact command grammar and example output before implementation.
  2. Create pure domain functions for add, complete, remove, and filtering.
  3. Build a repository that owns the JSON location, parsing, validation, and saving.
  4. Parse process.argv into one command object and reject unsupported input early.
  5. Wire the entry point: load, execute, save only when changed, and present the result.
  6. Test in a temporary data directory so development never damages real task data.
  7. Document installation, commands, examples, data location, and known limits.

Keep the feedback loop short

Run the smallest useful command after every meaningful change. Read the complete error message before editing again, and keep inputs and outputs visible while you learn.

Guided code lab

Keep task rules pure

The domain layer receives data and returns new data. It does not read arguments, print, or access files.

src/tasks.js
import { randomUUID } from "node:crypto";

export function addTask(tasks, title, now = new Date()) {
  const cleanTitle = title.trim();
  if (!cleanTitle) throw new TypeError("Task title is required");

  const task = {
    id: randomUUID(),
    title: cleanTitle,
    completed: false,
    createdAt: now.toISOString(),
  };

  return { tasks: [...tasks, task], task };
}

export function completeTask(tasks, id) {
  let found = false;
  const next = tasks.map((task) => {
    if (task.id !== id) return task;
    found = true;
    return { ...task, completed: true };
  });
  if (!found) throw new Error("Task not found: " + id);
  return next;
}

Hide JSON persistence behind a repository

The repository handles a missing first-run file, validates its top-level shape, and writes formatted UTF-8 JSON.

src/task-repository.js
import { mkdir, readFile, writeFile } from "node:fs/promises";
import { dirname } from "node:path";

export function createTaskRepository(filePath) {
  return {
    async load() {
      try {
        const value = JSON.parse(await readFile(filePath, "utf8"));
        if (!Array.isArray(value)) throw new TypeError("Task data must be an array");
        return value;
      } catch (error) {
        if (error.code === "ENOENT") return [];
        throw error;
      }
    },
    async save(tasks) {
      await mkdir(dirname(filePath), { recursive: true });
      await writeFile(filePath, JSON.stringify(tasks, null, 2) + "
", "utf8");
    },
  };
}

Make the entry point an orchestration layer

The entry point validates the command, coordinates domain and repository modules, and owns the final user-facing failure.

src/app.js
import { join } from "node:path";
import { addTask } from "./tasks.js";
import { createTaskRepository } from "./task-repository.js";

const repository = createTaskRepository(
  join(process.cwd(), ".task-tracker", "tasks.json"),
);

const [, , command, ...args] = process.argv;

try {
  if (command !== "add") {
    throw new Error("Usage: node src/app.js add <task title>");
  }
  const current = await repository.load();
  const result = addTask(current, args.join(" "));
  await repository.save(result.tasks);
  console.log("Added:", result.task.title);
} catch (error) {
  console.error("Error:", error.message);
  process.exitCode = 1;
}

Production practice

Protect user data

Write integration tests against a temporary directory and consider atomic replacement before trusting the tool with important data.

Stable commands

Treat command names, options, output, and exit codes as a public API that shell scripts and users may depend on.

Small boundaries

Keep parsing, task rules, persistence, and presentation replaceable so later lessons can add tests or a database without a rewrite.

Common failure mode

A single large app.js that parses arguments, mutates tasks, reads files, and formats output is quick to start but painful to test. Keep the entry point thin from the beginning.

Independent workshop

Complete and polish the task tracker as your Phase 1 portfolio project.

Your finished workshop must include:

  • add, list, complete, and remove commands
  • Filters for open and completed tasks
  • Stable IDs and ISO timestamps
  • Validated JSON persistence with friendly corruption errors
  • Useful stderr messages and exit codes
  • npm start/check/test scripts
  • A README with setup, examples, architecture, and limitations

Definition of done

Run the happy path and at least two edge cases, keep responsibilities separated, and add a short README explaining how to run the program.

Recap & quick check

Key takeaways

  • CLI commands are user-facing contracts
  • Pure domain functions are easy to verify
  • Repositories isolate persistence choices
  • The entry point should orchestrate rather than own every responsibility
  • Errors and exit codes are part of the interface
  • A small layered design creates room for later databases, tests, and APIs

Quick check

1. Which layer should decide whether a task title is valid?

2. Which layer should know the JSON file path?

3. Why should the CLI entry point remain thin?

4. What should an unsupported command produce?

Next: Phase 2 — Building Web Servers & HTTP APIs