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.
| Concept | What it means | Decision rule |
|---|---|---|
| Interface layer | Turns terminal arguments into application commands and output | Keep parsing and presentation outside domain and storage logic |
| Domain layer | Owns task rules and state transitions | Write it as pure functions where possible |
| Repository layer | Loads and saves tasks without deciding business rules | Keep file format and paths behind one small API |
| Command result | A success value or intentional error mapped to output and exit status | Make every command path observable and script-friendly |
Professional workflow
Build and verify Node.js programs from the terminal in small, observable steps.
- Write the exact command grammar and example output before implementation.
- Create pure domain functions for add, complete, remove, and filtering.
- Build a repository that owns the JSON location, parsing, validation, and saving.
- Parse process.argv into one command object and reject unsupported input early.
- Wire the entry point: load, execute, save only when changed, and present the result.
- Test in a temporary data directory so development never damages real task data.
- Document installation, commands, examples, data location, and known limits.
Keep the feedback loop short
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.
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.
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.
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
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
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