Nie możesz wybrać więcej, niż 25 tematów Tematy muszą się zaczynać od litery lub cyfry, mogą zawierać myślniki ('-') i mogą mieć do 35 znaków.

51KB

Spec Driven Development — Chapter-14: 6.5 - Creating and listing tasks in practice: the whole loop, file by file

  • Source: /library/Spec Driven Development/source-file.pdf
  • PDF pages: 129–193
  • Pages without text: 193

6.5 - Creating and listing tasks in practice: the whole loop, file by file How to read this chapter Chapter 6 followed the first lap around the cycle with the spotlight on specify . Here you see the same lap in full, but from the other side: not the didactics of the step, but what got written to disk when it ended. Everything that follows is a faithful reproduction of what is versioned in the example app's repository, in the 001-criar-tarefa feature: the prompt that went in, the specification that came out, the plan, the design artifacts, the tasks, and each code and test file the agent produced. This is a reference chapter, made to come back to when you need it, more than to read in one sitting. The idea is that you can, at any moment, compare what you asked for with what you received, and see how a sentence becomes a layered system without anyone deciding that along the way. The other fractional-numbered chapters (7.5, 8.5, 9.5, and 10.5) do the same for the following laps, and it is by comparing one with another that what matters most becomes visible: the architecture doesn't change from one feature to the next. The specification and plan files appear as running text, the way Spec Kit wrote them (the internal titles were lowered one level so as not to compete with the material's titles). The code appears in

blocks, each preceded by a short sentence saying what it is and why it exists. The input: the /speckit-specify prompt The whole lap starts with a sentence. It doesn't have to be born finished: in the flow Chapter 6 described, sentences like this come out of the backlog (the draft.md where the ideas waited their turn), polished at the moment of pulling the feature from the queue. This was the one, recorded literally in the specification's Input field: “Create and list tasks in a personal to-do list. The user can create a task by giving a title and a description, and can see the list of all tasks already created. On creation, two rules hold: the title is required (it can't be left blank) and the title can't repeat that of a task that already exists. Out of this feature: completing, editing, filtering, and deleting tasks, as well as categories, due dates, priority, users or login, and syncing.” Notice that the sentence describes behavior and boundaries, without a word of technology: no React, no database, no “text field.” That is exactly the cut specify demands, and it is from it that the document below comes. What specify and clarify returned: spec.md The specification is the first written artifact. The two clarify questions (persistence between sessions and the order of the list) already come folded into the Clarifications section, and each

decision became a traceable functional requirement. Feature Specification: Create and list tasks Feature Branch: 001-criar-tarefa Created: 2026-06-27 Status: Draft Input: User description: “Create and list tasks in a personal to-do list. The user can create a task by giving a title and a description, and can see the list of all tasks already created. On creation, two rules hold: the title is required (it can't be left blank) and the title can't repeat that of a task that already exists. Out of this feature: completing, editing, filtering, and deleting tasks, as well as categories, due dates, priority, users or login, and syncing.” Clarifications Session 2026-06-27 Q: Should the created tasks survive closing and reopening the app, or is it enough for them to exist during the session? → A: The tasks are preserved between sessions of use: they survive closing and reopening the app. The storage medium is a Plan decision (Technology Agnosticism). Q: In what order does the list present the existing tasks? → A: In order of creation, from oldest to newest. User Scenarios & Testing (mandatory) User Story 1 - Create a task (Priority: P1)

The person using the To-Do wants to record something they need to do. They give a title and, if they want, a description, and the task comes to exist in the list. It is the founding act of the app: without creating, there is nothing to list nor, later on, to complete, edit, or filter. Why this priority: It is the core of the feature and the base of all future features. Delivered on its own, it already produces value: the person can capture tasks. Independent Test: It can be tested in isolation by creating a task with a valid title and verifying that it comes to appear among the existing tasks. Acceptance Scenarios:

  1. Given the task list (empty or with tasks), When the person creates a task with a filled-in and unique title, Then the task is created and comes to exist among the tasks.
  2. Given the intent to create a task, When the title is given blank (empty or only spaces), Then the creation is refused with an error result explaining that the title is required, and no task is created.
  3. Given an already existing task with a certain title, When the person tries to create another task with that same title, Then the creation is refused with an error result explaining that the title already exists, and no new task is created.
  4. Given the creation of a task with a valid title, When the description is left blank, Then the task is created normally (the description is optional). User Story 2 - See the task list (Priority: P1)

The person wants to see everything they have recorded so far. They open the list and see the existing tasks, with their titles and descriptions. It is the visible return of the act of creating and the starting point of the following features. Why this priority: Without seeing the list, creating a task has no noticeable effect. Together with creation, it forms the minimum pair that makes the To-Do usable. Independent Test: It can be tested in isolation by verifying that the list presents all the tasks already created and that, when there are none, the list presents itself empty. Acceptance Scenarios:

  1. Given one or more tasks already created, When the person sees the list, Then all the existing tasks appear, each with its title and its description, in the order they were created.
  2. Given no task created, When the person sees the list, Then the list presents itself empty, with no error.
  3. Given tasks created in a previous session, When the person closes and reopens the app and sees the list, Then the tasks created before are still present. Edge Cases Title with only spaces: a title made up only of blank spaces is treated as empty and refused by the required-title rule. Duplicate title: the duplication comparison ignores spaces at the ends of the title; two titles that are equal after that adjustment are considered the same title. Missing description: the description is optional; its absence doesn't prevent creation. Empty list: seeing the list with no task created is a valid state, not an error.

Requirements (mandatory) Functional Requirements FR-001: The system MUST allow creating a task from a title and an optional description. FR-002: The system MUST refuse the creation when the title is blank (empty or made up only of spaces), returning an explicit error result that identifies the required title as the cause, without creating the task. FR-003: The system MUST refuse the creation when a task with the same title already exists (compared after trimming spaces at the ends), returning an explicit error result that identifies the duplication as the cause, without creating the task. FR-004: The system MUST represent the predictable creation failures (missing required title; duplicate title) as explicit success or error results, and MUST NOT signal them as exceptions across layers. FR-005: The system MUST allow seeing the list of all existing tasks, presenting, for each one, its title and its description, in order of creation (from oldest to newest). FR-006: The system MUST preserve the created tasks between sessions of use, so that they keep appearing when you see the list after the app is closed and reopened. The storage medium is decided in the Plan. FR-007: The business rule of creation and listing MUST reside in a layer of its own, independent of the user interface; the interface only collects the input and presents the result. Key Entities (include if feature involves data) Task: represents a to-do recorded by the person. Essential

attributes: a title (required and non-repeated among existing tasks) and a description (free text, optional). There is, in this feature, no completion state, due date, priority, category, or owner. Success Criteria (mandatory) Measurable Outcomes SC-001: The person creates a task with a valid and unique title and finds it among the existing tasks when seeing the list. SC-002: An attempt to create a task with a blank title is refused with a clear error result, and the task count doesn't change. SC-003: An attempt to create a task with an already existing title is refused with a clear error result, and the task count doesn't change. SC-004: When seeing the list, the person finds all the tasks they created, each with a title and description, in order of creation, and a list with no tasks presents itself empty without error. SC-005: Tasks created in a session keep appearing in the list after the app is closed and reopened. Assumptions The To-Do is a personal, single-user app; there are no users, login, or separation of data by owner in this feature. The feature is the first of the app; “completing,” “editing,” “filtering,” and “deleting” tasks are features of their own, out of this scope, each with its own cycle. Explicitly out of scope: categories, due dates, priority, users/login, and syncing (YAGNI).

The choices of language, framework, database, interface architecture, and state management pattern don't belong to this specification; they are decided in the Plan (Technology Agnosticism). The title-duplication comparison considers the text of the title after trimming spaces at the ends; further refinements (for example, ignoring case differences) are not assumed in this version. The requirements check: checklists/requirements.md Before planning, a checklist checks whether the specification is ready to become a plan. It works like an automated peer review of the spec itself: each item points to something that needed to be resolved before any line of code. Checklist: Requirements Quality Check of spec.md before implementing. All items must be checked. Each functional requirement (FR-001..FR-007) is verifiable and unambiguous. The two business rules (required title; non-repeated title) are explicit, with corresponding acceptance scenarios. The predictable failures are described as an error result, not an exception (FR-004). The listing order is defined (creation, oldest first, FR-005). Persistence between sessions is defined without fixing technology (FR-006).

The non-goals are explicit (no completing/editing/filtering/deleting, categories, due dates, priority, login, sync). The spec stays agnostic about language/framework/database/state (decisions in the plan). Each Success Criterion (SC-001..SC-005) is measurable and traceable to an FR. What plan decided: plan.md It is in the plan, and only in it, that technology enters. Note the Constitution Check table: each principle of the project's constitution is confronted with a concrete decision. It is that document that anchors the architecture all the following laps will inherit. Implementation Plan: Create and list tasks Branch: 001-criar-tarefa | Spec: spec.md Input: Feature specification at specs/001-criar-tarefa/spec.md Summary Implement the To-Do's first feature, create a task (required and unique title, optional description) and see the list of all tasks in order of creation, with persistence between sessions. The business rule stays isolated in a domain layer (pure TypeScript), with the predictable failures represented as error as value; the React interface only collects input and presents the state.

Technical Context Language/Version: TypeScript 5.x on Node 20+; execution target: browser (React web). Main dependencies: React 18, UI library (web, not React Native). Vite, bundler and dev server. Zustand, presentation state management (a thin store that orchestrates the domain use cases). Persistence: browser localStorage , behind the domain's TaskRepository interface (web equivalent of “persist on the device,” FR-006). The domain doesn't know localStorage . Tests: Vitest for the domain (use cases, no DOM) and for the store; React Testing Library optional for the components. Target platform: modern browser (single-page app). Project type: single-page web application, single-user, no backend. Constitution Check Principle How the plan meets it I. Layered Architecture domain (pure) ← data + presentation ; composition only at the edge ( main.tsx ). II. Isolated Business Logic Creation/listing rules live in pure TS use cases; React and Zustand decide nothing. III. Error as Value Result<S, F> and CreateTaskFailure (discriminated union); no exceptions across layers. Exceptions only at the I/O

edge ( localStorage ). IV. TDD Use case and store tests written before the implementation (see tasks.md). V. Simplicity/YAGNI No categories, due dates, priority, login, or sync; minimalist Zustand store. VI. Technology Agnosticism The spec doesn't mention React/Zustand; the stack is decided here, in the plan. Result: PASS, no violation; no entry in the complexity table. Project Structure todo-app/ ├── index.html ├── package.json ├── tsconfig.json ├── vite.config.ts ├── src/ │ ├── domain/ │ │ ├── result.ts # Result<S, F> (Success | Failure) │ │ ├── entities/task.ts # Task type │ │ ├── failures/create-task-failure.ts │ │ ├── repositories/task-repository.ts # interface (port) │ │ └── usecases/ │ │ ├── create-task.ts │ │ └── list-tasks.ts │ ├── data/ │ │ └── local-storage-task-repository.ts # implements TaskRepository │ ├── presentation/ │ │ ├── store/task-store.ts # Zustand store (orchestrates use cases ) │ │ ├── components/ │ │ │ ├── TaskForm.tsx │ │ │ └── TaskList.tsx │ │ └── App.tsx │ └── main.tsx # composition: repo → use cases → store → UI └── test/

├── domain/ │ ├── create-task.test.ts │ └── list-tasks.test.ts ├── presentation/ │ └── task-store.test.ts └── helpers/in-memory-task-repository.ts Phase 0 - Research Technical decisions recorded in research.md. Phase 1 - Design Domain entities and contracts in data-model.md. Contracts of the ports and use cases in contracts/. Manual validation in quickstart.md. The plan ‘s design artifacts The plan doesn't produce only the plan.md . It generates a small set of supporting documents that detail the decisions: why each technology, what the exact shape of the data is, what each piece's contract is, and how to validate by hand. They are what make the implement almost mechanical afterward. The technical decisions: research.md Each technology decision is recorded with the chosen option, the justification, and what was discarded. It is the record of the “why,” not just the “what.”

Research: Create and list tasks Technical decisions that hold up the plan.md. Each decision records the chosen option, the justification, and the discarded alternatives. D1 - State management: Zustand Decision: use Zustand as the presentation store. Justification: minimalist (store as a hook, near-zero boilerplate), popular in the React ecosystem, and aligned with Simplicity/YAGNI (Principle V). The store only orchestrates the domain use cases and exposes the state to the UI. Discarded alternatives: Redux Toolkit (more ceremony than this feature demands); Context + useReducer (would reinvent part of what Zustand already delivers). D2 - Error as value: discriminated union Result<S, F> Decision: represent success/failure with Result<S, F> ( Success | Failure ) and the creation failures with CreateTaskFailure ( TitleRequired | DuplicateTitle ). Justification: it makes the predictable failures part of the use cases’ signature (Principle III); TypeScript forces exhaustive handling via the discriminated union. Discarded alternatives: throwing exceptions across layers (forbidden by the constitution); returning null /boolean (loses the cause of the failure). D3 - Persistence: localStorage behind the interface Decision: LocalStorageTaskRepository serializes the tasks as JSON in localStorage , implementing the domain's TaskRepository interface.

Justification: it meets FR-006 (survive closing/reopening) without a backend; it is the web equivalent of “persist on the device.” The domain doesn't know localStorage . Discarded alternatives: IndexedDB (overkill for a simple single-user app); in-memory state only (doesn't survive the reload). D4 - Build and test tool: Vite + Vitest Decision: Vite as the bundler/dev server and Vitest for tests. Justification: the current standard for React web; Vitest runs the domain tests without a DOM and integrates with the same Vite configuration. Discarded alternatives: Create React App (discontinued); Jest standalone (extra configuration for ESM/TS that Vitest avoids). D5 - Ordering by creation Decision: order the tasks by createdAt ascending when reading from the repository. Justification: it meets FR-005/SC-004 (oldest first) in a stable way, independent of the serialization order. Discarded alternatives: relying on the JSON insertion order (fragile to external edits of the storage file). The shape of the data: data-model.md The domain model in pure TypeScript: the entity, the error-as- value types, the use case signatures, the persistence repository, and the direction of the dependencies.

Data Model: Create and list tasks Domain model (pure TypeScript), independent of React, Zustand, and localStorage . Entity: Task Field Type Rules id string Identity generated on creation. title string Required; non-repeated (compared after trim ). description string Optional (empty string when absent). createdAt string (ISO 8601) Moment of creation; defines the display order. export type Task = { readonly id: string; readonly title: string; readonly description: string; readonly createdAt: string; };

Error as value // Generic Result export type Result<S, F> = | { readonly kind: “success”; readonly value: S } | { readonly kind: “failure”; readonly error: F }; // Predictable creation failures export type CreateTaskFailure = | { readonly kind: “title-required” } | { readonly kind: “duplicate-title” }; Use cases CreateTask: (input: { title: string; description?: string }) => Promise<Result<Task, CreateTaskFailure» 1. trim the title; if empty → failure(title-required) . 2. If a task with the same title already exists (after trim ) → failure(duplicate-title) . 3. Otherwise, create Task and persist → success(task) .

ListTasks: () => Promise<Task[]> , returns all the tasks in order of creation (oldest first). An empty list is a valid state. Port: TaskRepository export interface TaskRepository { getAll(): Promise<Task[]>; // ordered by createdAt asc add(task: Task): Promise; } Direction of the dependencies presentation (React + Zustand) data (localStorage) \ / v v domain (Task, Result, use cases, TaskRepository) The domain imports nothing from presentation or from data . The composition (instantiating the concrete repository and injecting it into the use cases) happens only in main.tsx . The contracts: contracts/ The contracts describe the expected behavior of each piece before there is any code. There are two: the one for the use cases and the one for the persistence repository.

contracts/use-cases.md Contract: CreateTask and ListTasks use cases CreateTask createTask(input: { title: string; description?: string }) : Promise<Result<Task, CreateTaskFailure» Applies the two business rules in the order below and returns error as value:

  1. Required title (FR-002): after trim , if the title is empty → failure({ kind: “title-required” }) . No task is created.
  2. Non-repeated title (FR-003): if a task whose title, after trim , is equal already exists → failure({ kind: “duplicate-title” }) . No task is created.
  3. Success (FR-001): creates Task with generated id and createdAt , optional description (empty string when absent), persists via TaskRepository.add , and returns success(task) . Invariant: creation NEVER throws an exception for a rule violation; the failures are typed values (Principle III). ListTasks listTasks(): Promise<Task[]> Returns all the tasks in order of creation (oldest first, FR- 005).

An empty list is a valid state, not an error (SC-004). Doesn't filter, doesn't paginate, doesn't order by another criterion (YAGNI). contracts/task-repository.md Contract: TaskRepository (port) Interface declared by the domain and implemented by the data layer. export interface TaskRepository { getAll(): Promise<Task[]>; add(task: Task): Promise; } getAll Returns: all the persisted tasks, ordered by createdAt ascending (oldest first). Empty list: valid state; returns [] , never an error. add Receives: a Task already validated by the use case (required and unique title already guaranteed by CreateTask ).

Effect: persists the task so that it survives closing/reopening the app (FR-006). Doesn't validate business rules: the title's uniqueness is the use case's responsibility, not the repository's. Implementations LocalStorageTaskRepository (production): serializes as JSON in localStorage . InMemoryTaskRepository (tests): keeps the tasks in an in-memory array. Both respect the same contract, including the ordering by createdAt . The manual validation: quickstart.md Finally, the plan leaves a script for validating by hand, mapped to the Success Criteria. It is what a person would do to check, without automated tests, that each criterion actually happens on the screen. Quickstart: Create and list tasks Run npm install npm run dev # opens the app in the browser (Vite)

Test npm test # runs the domain and store tests (Vitest) npm run build # type-check + production build Manual validation (mapped to the Success Criteria)

  1. SC-001: create a task with a valid and unique title → it appears in the list.
  2. SC-002: try to create with a blank title → “required title” error; the list doesn't change.
  3. SC-003: try to create with an already existing title → “title already exists” error; the list doesn't change.
  4. SC-004: see the list with tasks → they all appear with a title and description, in order of creation; with no tasks → empty list, no error.
  5. SC-005: create tasks, reload the page (close/reopen) → the tasks stay in the list (persistence in localStorage ). The execution list from tasks : tasks.md tasks turns the plan into an ordered sequence, in TDD order: each test comes before the implementation that makes it pass. The [P] marks tasks that could run in parallel because they touch distinct files. The are the record that the lap was completed in full.

Tasks: Create and list tasks Branch: 001-criar-tarefa | Plan: plan.md TDD order: tests before the corresponding implementation. [P] marks tasks that can run in parallel (distinct files, no dependency). Phase 1 - Setup T001 Scaffold React + TS (Vite): package.json , tsconfig.json , index.html , src/main.tsx , src/index.css in the plan.md structure. T002 Add dependencies: zustand ; dev: vitest . (Domain/store tests run in a node environment, no DOM, so React Testing Library/jsdom weren't needed.) T003 Configure build and tests: vite.config.ts (React plugin) and vitest.config.ts ( environment: node ); dev / build / test scripts in package.json . Phase 2 - Domain (core, pure TS) T004 [P] src/domain/result.ts : Result<S, F> type with success / failure helpers. T005 [P] src/domain/failures/create-task-failure.ts : CreateTaskFailure union. T006 [P] src/domain/entities/task.ts : Task type. T007 [P] src/domain/repositories/task-repository.ts : TaskRepository interface. T008 test/helpers/in-memory-task-repository.ts : fake that implements TaskRepository (orders by createdAt ). T009 test/domain/create-task.test.ts : tests for CreateTask (success; empty title → title-required ; only spaces → title-required ; duplicate → duplicate-title ; optional description). Fails first.

T010 src/domain/usecases/create-task.ts : implement until T009 passes. T011 test/domain/list-tasks.test.ts : tests for ListTasks (empty list; creation order). Fails first. T012 src/domain/usecases/list-tasks.ts : implement until T011 passes. Phase 3 - Data T013 src/data/local-storage-task-repository.ts : implements TaskRepository over localStorage (JSON serialization, ordering by createdAt , I/O exceptions contained at the edge). T013b test/data/local-storage-task-repository.test.ts : persistence test between sessions with a fake Storage (a new instance over the same storage simulates reopening the app, SC-005). Phase 4 - Presentation T014 test/presentation/task-store.test.ts : Zustand store tests with InMemoryTaskRepository (load list; successful create updates list; failure exposes message without changing the list). Fails first. T015 src/presentation/store/task-store.ts : Zustand store that orchestrates CreateTask / ListTasks and exposes state ( tasks , lastError ). Accompanied by src/presentation/store/task-store- context.tsx (provider + selector hook). T016 [P] src/presentation/components/TaskForm.tsx : creation form (title

  • description) that triggers the store's action. T017 [P] src/presentation/components/TaskList.tsx : task list (title + description), empty state with no error. T018 src/presentation/App.tsx : composes TaskForm + TaskList and loads the list on mount. T019 src/main.tsx : composition at the edge, instantiates

LocalStorageTaskRepository , injects it into the use cases, creates the store, and mounts the App . Phase 5 - Validation T020 npm test (13 green tests) and npm run build (clean type- check + production build). T021 Validation of the Success Criteria: SC-001..SC-004 covered by the domain/store tests; SC-005 (persistence on reopening) covered by the LocalStorageTaskRepository test (T013b). The code implement generated From here on it is all code produced by the agent, in the state it was left at the end of this lap. The order follows that of the layers, from the inside out: first the domain (which knows no one), then the data, then the presentation, and finally the tests. It is the same direction of dependencies that data-model.md drew. Domain The heart of the system, pure TypeScript, with no import of React, Zustand, or localStorage . Everything that decides anything lives here. The Result<S, F> is the spine of “error as value”: every operation that can fail in a predictable way returns one of these two shapes, and the kind forces exhaustive handling. // src/domain/result.ts /**

  • Explicit result of an operation that can fail in a predictable way .
  • Error as value (constitution, Principle III): instead of throwing a n
  • exception, the operation returns success with the value or failure wit h
  • the typed failure. The discriminated union by kind forces exhaustiv e
  • handling in TypeScript .

/ export type Result<S, F> = | { readonly kind: “success”; readonly value: S } | { readonly kind: “failure”; readonly error: F }; export const success = <S, F>(value: S): Result<S, F> => ({ kind: “success”, value });

export const failure = <S, F>(error: F): Result<S, F> => ({ kind: “failure”, error }); The Task entity: four fields, all readonly . The immutability is deliberate: changing a task is producing another, never altering the existing one in place. // src/domain/entities/task.ts /**

  • A to-do recorded by the person .
  • The title is required and non-repeated among existing tasks; the descripti on
  • is optional. createdAt (ISO 8601) defines the display order (oldest firs t).

/ export type Task = { readonly id: string; readonly title: string;

readonly description: string; readonly createdAt: string; }; The predictable creation failures, one for each business rule. Notice that there is a type just for this: the error is as first-class as the success. // src/domain/failures/create-task-failure.ts /**

  • Predictable failures when creating a task, one for each business rule .
  • title-required: empty title or only spaces (FR-002) .
  • duplicate-title: a task with the same title already exists, after trim (FR-003).

/ export type CreateTaskFailure = | { readonly kind: “title-required” }

| { readonly kind: “duplicate-title” }; The persistence repository, declared by the domain. The domain says what it needs ( getAll , add ), and leaves it to the data layer to decide how. // src/domain/repositories/task-repository.ts import type { Task } from “../entities/task”; /**

  • Domain repository to persist and read tasks .
  • The domain declares this interface; the data layer implements it. That wa y
  • the business rule doesn't know the storage medium .

/ export interface TaskRepository { /** All the tasks, in order of creation (oldest first). */

getAll(): Promise<Task[]>; /** Persists a new task. */ add(task: Task): Promise; } The creation use case: the two business rules, in the order of the contract, returning failure as value. generateId and now are injected as parameters with a default, a small detail that makes the use case deterministic in the tests. // src/domain/usecases/create-task.ts import type { Task } from “../entities/task”; import type { CreateTaskFailure } from “../failures/create-task-failure”; import type { TaskRepository } from “../repositories/task-repository”; import { failure, success, type Result } from “../result”; export type CreateTaskInput = { title: string; description?: string };

export type CreateTask = (input: CreateTaskInput) => Promise<Result<Task, Cre ateTaskFailure»; /**

  • Creates a task applying the two business rules and returning error as valu e:
  • required title (FR-002) and non-repeated title (FR-003) .
  • generateId and now are injected to keep the use case pure and testable .

/ export const makeCreateTask = ( repository: TaskRepository, generateId: () => string = () => crypto.randomUUID(), now: () => Date = () => new Date() ): CreateTask => async ({ title, description = "” }) => {

const trimmedTitle = title.trim(); if (trimmedTitle.length === 0) { return failure({ kind: “title-required” }); } const existing = await repository.getAll(); const isDuplicate = existing.some((task) => task.title.trim() === trimmed Title); if (isDuplicate) { return failure({ kind: “duplicate-title” }); } const task: Task = { id: generateId(), title: trimmedTitle,

description: description.trim(), createdAt: now().toISOString() }; await repository.add(task); return success(task); }; The listing use case is deliberately tiny: it just delegates to the repository, whose order already comes guaranteed. Anything beyond that would be YAGNI. // src/domain/usecases/list-tasks.ts import type { Task } from “../entities/task”; import type { TaskRepository } from “../repositories/task-repository”; export type ListTasks = () => Promise<Task[]>; /**

  • Returns all the existing tasks, in order of creation (FR-005). An empty li st
  • is a valid state, not an error (SC-004) .

/ export const makeListTasks = (repository: TaskRepository): ListTasks => () => repository.getAll(); Data The only concrete implementation of the repository in this lap. It is here, and only here, that localStorage appears, with the I/O exceptions contained at the edge. // src/data/local-storage-task-repository.ts import type { Task } from “../domain/entities/task”; import type { TaskRepository } from “../domain/repositories/task-repository”; const STORAGE_KEY = “todo-app.tasks”; /**

  • Persists the tasks as JSON in the browser's localStorage. It is the we b
  • equivalent of “persist on the device” (FR-006): it survives closing an d
  • reopening the app. The I/O exceptions stay contained at this edg e
  • (constitution, Principle III); the domain never sees localStorage .

/ export class LocalStorageTaskRepository implements TaskRepository { constructor(private readonly storage: Storage = localStorage) {} async getAll(): Promise<Task[]> { const raw = this.storage.getItem(STORAGE_KEY); if (raw === null || raw.trim().length === 0) { return []; }

const tasks = JSON.parse(raw) as Task[]; return [...tasks].sort((a, b) => a.createdAt.localeCompare(b.createdAt)); } async add(task: Task): Promise { const tasks = await this.getAll(); const updated = [...tasks, task]; this.storage.setItem(STORAGE_KEY, JSON.stringify(updated)); } } Presentation The outermost layer. The Zustand store orchestrates the use cases and translates the typed failure into a message; the React components only collect input and draw state. The store contains no rule: it triggers createTask / listTasks and, at most, converts a CreateTaskFailure into text for the screen. Note the exhaustive switch in messageFor , it is TypeScript demanding the

handling of each failure. // src/presentation/store/task-store.ts import { createStore } from “zustand/vanilla”; import type { Task } from “../../domain/entities/task”; import type { CreateTaskFailure } from “../../domain/failures/create-task-fai lure”; import type { CreateTask } from “../../domain/usecases/create-task”; import type { ListTasks } from “../../domain/usecases/list-tasks”; export type TaskStoreState = { readonly tasks: Task[]; readonly lastError: string | null; loadTasks: () => Promise; createTask: (title: string, description: string) => Promise; };

const messageFor = (failure: CreateTaskFailure): string => { switch (failure.kind) { case “title-required”: return “The title is required."; case “duplicate-title”: return “A task with this title already exists."; } }; /**

  • Presentation store (Zustand) that orchestrates the domain use cases an d
  • exposes the state to the UI. Contains no business rule: it just trigger s
  • createTask/listTasks and translates the typed failure into a message f or
  • the screen .

/ export const createTaskStore = (createTask: CreateTask, listTasks: ListTasks) => createStore((set) => ({ tasks: [], lastError: null, loadTasks: async () => { const tasks = await listTasks(); set({ tasks, lastError: null }); }, createTask: async (title, description) => { const result = await createTask({ title, description }); if (result.kind === “failure”) { set({ lastError: messageFor(result.error) });

return false; } const tasks = await listTasks(); set({ tasks, lastError: null }); return true; } })); The context that delivers the composed store to the React tree, with a selector hook that re-renders only when the observed slice changes. // src/presentation/store/task-store-context.tsx import { createContext, useContext, type ReactNode } from “react”; import { useStore, type StoreApi } from “zustand”; import type { TaskStoreState } from “./task-store”;

const TaskStoreContext = createContext<StoreApi | null>(null) ; /** Makes the already-composed store (with the use cases injected) available to the tree. */ export const TaskStoreProvider = ({ store, children }: { store: StoreApi; children: ReactNode; }) => <TaskStoreContext.Provider value={store}>{children}</TaskStoreContext.P rovider>; /** Reads a slice of the store's state, re-rendering only when it changes. */ export function useTaskStore(selector: (state: TaskStoreState) => T): T { const store = useContext(TaskStoreContext);

if (store === null) { throw new Error(“useTaskStore must be used within a TaskStoreProvider."); } return useStore(store, selector); } The creation form: it collects title and description, triggers the action and, on success, clears the fields. The error message comes straight from the store. // src/presentation/components/TaskForm.tsx import { useState, type FormEvent } from “react”; import { useTaskStore } from “../store/task-store-context”; /** Creation form: collects title and description and triggers the store's ac tion. */ export function TaskForm() { const createTask = useTaskStore((state) => state.createTask);

const lastError = useTaskStore((state) => state.lastError); const [title, setTitle] = useState(""); const [description, setDescription] = useState(""); const handleSubmit = async (event: FormEvent) => { event.preventDefault(); const created = await createTask(title, description); if (created) { setTitle(""); setDescription(""); } }; return (

setTitle(event.target.value)} /> setDescription(event.target.value)} /> {lastError !== null && <p role="alert" className="task-form__error">{la stError}</p>} <button type="submit">Add task</button> <!-- PDF page 172 --> </form> ); } The list, which only draws the existing tasks. The empty list has its own branch, it is a valid state, with a sentence inviting you to create the first one. // src/presentation/components/TaskList.tsx import { useTaskStore } from "../store/task-store-context"; /** Lists the existing tasks in order of creation; an empty list is a valid s tate. */ export function TaskList() { const tasks = useTaskStore((state) => state.tasks); if (tasks.length === 0) { return <p className="task-list__empty">No tasks yet. Create the first one above.</p>; } <!-- PDF page 173 --> <p>return ( <ul className="task-list"> {tasks.map((task) => ( <li key={task.id} className="task-list__item"> <h3>{task.title}</h3> {task.description.length > 0 && <p>{task.description}</p>} </li> ))} </ul> ); } The App , which composes the form and the list and loads the tasks on mount. // src/presentation/App.tsx import { useEffect } from “react”;</p> <!-- PDF page 174 --> <p>import { TaskForm } from “./components/TaskForm”; import { TaskList } from “./components/TaskList”; import { useTaskStore } from “./store/task-store-context”; export function App() { const loadTasks = useTaskStore((state) => state.loadTasks); useEffect(() => { void loadTasks(); }, [loadTasks]); return ( <main className="app"> <h1>To-Do</h1> <TaskForm /> <TaskList /></p> <!-- PDF page 175 --> </main> ); } The main.tsx is the edge of the composition, and it is the only place where the three layers meet: it instantiates the concrete repository, injects it into the use cases, creates the store, and mounts the App . Everything above was written without knowing this file exists. // src/main.tsx import { StrictMode } from "react"; import { createRoot } from "react-dom/client"; import { LocalStorageTaskRepository } from "./data/local-storage-task-reposit ory"; import { makeCreateTask } from "./domain/usecases/create-task"; import { makeListTasks } from "./domain/usecases/list-tasks"; import { App } from "./presentation/App"; import { createTaskStore } from "./presentation/store/task-store"; import { TaskStoreProvider } from "./presentation/store/task-store-context"; <!-- PDF page 176 --> <p>import “./index.css”; // Composition at the edge: the concrete repository is instantiated here and // injected into the use cases; only at this point do the layers meet. const repository = new LocalStorageTaskRepository(); const store = createTaskStore(makeCreateTask(repository), makeListTasks(repos itory)); const rootElement = document.getElementById(“root”); if (rootElement === null) { throw new Error(“Element #root not found in index.html."); } createRoot(rootElement).render( <StrictMode> <TaskStoreProvider store={store}></p> <!-- PDF page 177 --> <p><App /> </TaskStoreProvider> </StrictMode> ); Tests The tests that came before the code (the order in tasks.md makes that explicit). They are what prove that each functional requirement actually happens, and it is their presence that lets the four following laps touch this base without fear. Two helpers hold up all the tests. The InMemoryTaskRepository is an implementation of the repository that lives in memory, and MemoryStorage is a fake Storage , to test the production repository without a browser. // test/helpers/in-memory-task-repository.ts import type { Task } from “../../src/domain/entities/task”; import type { TaskRepository } from “../../src/domain/repositories/task-repos itory”; /** In-memory implementation of <code>TaskRepository</code> for the domain and store tes ts. */</p> <!-- PDF page 178 --> <p>export class InMemoryTaskRepository implements TaskRepository { private readonly tasks: Task[] = []; async getAll(): Promise<Task[]> { return [...this.tasks].sort((a, b) => a.createdAt.localeCompare(b.created At)); } async add(task: Task): Promise<void> { this.tasks.push(task); } } // test/helpers/memory-storage.ts /** In-memory <code>Storage</code> to test the repository without a browser. */ export class MemoryStorage implements Storage { private readonly map = new Map<string, string>();</p> <!-- PDF page 179 --> <p>get length(): number { return this.map.size; } clear(): void { this.map.clear(); } getItem(key: string): string | null { return this.map.has(key) ? this.map.get(key)! : null; } key(index: number): string | null { return [...this.map.keys()][index] ?? null; }</p> <!-- PDF page 180 --> <p>removeItem(key: string): void { this.map.delete(key); } setItem(key: string, value: string): void { this.map.set(key, value); } } The CreateTask tests cover the two business rules and the optionality of the description. Notice how each failure case also verifies that nothing was created. // test/domain/create-task.test.ts import { describe, expect, it } from “vitest”; import { makeCreateTask } from “../../src/domain/usecases/create-task”; import { InMemoryTaskRepository } from “../helpers/in-memory-task-repository” ;</p> <!-- PDF page 181 --> <p>describe(“CreateTask”, () => { it(“creates a task with a valid and unique title”, async () => { const repository = new InMemoryTaskRepository(); const createTask = makeCreateTask(repository); const result = await createTask({ title: “Buy bread”, description: “Corne r bakery” }); expect(result.kind).toBe(“success”); const all = await repository.getAll(); expect(all).toHaveLength(1); expect(all[0].title).toBe(“Buy bread”); expect(all[0].description).toBe(“Corner bakery”); }); it(“refuses an empty title with title-required”, async () => {</p> <!-- PDF page 182 --> <p>const repository = new InMemoryTaskRepository(); const createTask = makeCreateTask(repository); const result = await createTask({ title: "” }); expect(result).toEqual({ kind: “failure”, error: { kind: “title-required” } }); expect(await repository.getAll()).toHaveLength(0); }); it(“treats a title with only spaces as empty”, async () => { const repository = new InMemoryTaskRepository(); const createTask = makeCreateTask(repository); const result = await createTask({ title: " " }); expect(result).toEqual({ kind: “failure”, error: { kind: “title-required” } });</p> <!-- PDF page 183 --> <p>}); it(“refuses a duplicate title (ignoring spaces at the ends) with duplicate- title”, async () => { const repository = new InMemoryTaskRepository(); const createTask = makeCreateTask(repository); await createTask({ title: “Study SDD” }); const result = await createTask({ title: " Study SDD " }); expect(result).toEqual({ kind: “failure”, error: { kind: “duplicate-title " } }); expect(await repository.getAll()).toHaveLength(1); }); it(“accepts a missing description (optional)", async () => { const repository = new InMemoryTaskRepository(); const createTask = makeCreateTask(repository);</p> <!-- PDF page 184 --> <p>const result = await createTask({ title: “No description” }); expect(result.kind).toBe(“success”); if (result.kind === “success”) { expect(result.value.description).toBe(""); } }); }); The ListTasks tests cover the empty list and the creation order. The trick of injecting a controlled id and now makes the order deterministic. // test/domain/list-tasks.test.ts import { describe, expect, it } from “vitest”; import { makeCreateTask } from “../../src/domain/usecases/create-task”; import { makeListTasks } from “../../src/domain/usecases/list-tasks”;</p> <!-- PDF page 185 --> <p>import { InMemoryTaskRepository } from “../helpers/in-memory-task-repository” ; describe(“ListTasks”, () => { it(“returns an empty list when there are no tasks”, async () => { const listTasks = makeListTasks(new InMemoryTaskRepository()); expect(await listTasks()).toEqual([]); }); it(“returns the tasks in order of creation (oldest first)", async () => { const repository = new InMemoryTaskRepository(); let tick = 0; const createTask = makeCreateTask( repository, () => <code>id-${tick}</code>, () => new Date(2026, 0, 1, 0, 0, tick++)</p> <!-- PDF page 186 --> <p>); await createTask({ title: “First” }); await createTask({ title: “Second” }); await createTask({ title: “Third” }); const listTasks = makeListTasks(repository); const titles = (await listTasks()).map((task) => task.title); expect(titles).toEqual([“First”, “Second”, “Third”]); }); }); The production repository test is the one that proves persistence between sessions (SC-005): a new instance over the same Storage simulates reopening the app. // test/data/local-storage-task-repository.test.ts import { describe, expect, it } from “vitest”;</p> <!-- PDF page 187 --> <p>import { LocalStorageTaskRepository } from “../../src/data/local-storage-task -repository”; import { makeCreateTask } from “../../src/domain/usecases/create-task”; import { MemoryStorage } from “../helpers/memory-storage”; describe(“LocalStorageTaskRepository”, () => { it(“returns an empty list when nothing was saved”, async () => { const repository = new LocalStorageTaskRepository(new MemoryStorage()); expect(await repository.getAll()).toEqual([]); }); it(“preserves the tasks between sessions (closing and reopening the app)", async () => { const storage = new MemoryStorage(); let tick = 0; const createTask = makeCreateTask( new LocalStorageTaskRepository(storage),</p> <!-- PDF page 188 --> <p>() => <code>id-${tick}</code>, () => new Date(2026, 0, 1, 0, 0, tick++) ); await createTask({ title: “First” }); await createTask({ title: “Second” }); // A new instance over the same Storage simulates reopening the app. const reopened = new LocalStorageTaskRepository(storage); const titles = (await reopened.getAll()).map((task) => task.title); expect(titles).toEqual([“First”, “Second”]); }); }); Finally, the store tests, which verify the orchestration: load the list, create successfully, and expose the right message on each failure, without changing the list. // test/presentation/task-store.test.ts</p> <!-- PDF page 189 --> <p>import { describe, expect, it } from “vitest”; import { makeCreateTask } from “../../src/domain/usecases/create-task”; import { makeListTasks } from “../../src/domain/usecases/list-tasks”; import { createTaskStore } from “../../src/presentation/store/task-store”; import { InMemoryTaskRepository } from “../helpers/in-memory-task-repository” ; const buildStore = () => { const repository = new InMemoryTaskRepository(); return createTaskStore(makeCreateTask(repository), makeListTasks(repository )); }; describe(“task store”, () => { it(“loads the list of existing tasks”, async () => { const store = buildStore(); await store.getState().createTask(“Task A”, “");</p> <!-- PDF page 190 --> <p>await store.getState().loadTasks(); expect(store.getState().tasks.map((task) => task.title)).toEqual([“Task A “]); }); it(“a successful create updates the list and clears the error”, async () => { const store = buildStore(); const created = await store.getState().createTask(“New”, “a description”) ; expect(created).toBe(true); expect(store.getState().tasks).toHaveLength(1); expect(store.getState().lastError).toBeNull(); }); it(“a required-title failure exposes a message without changing the list”, async () => {</p> <!-- PDF page 191 --> <p>const store = buildStore(); const created = await store.getState().createTask(” “, “"); expect(created).toBe(false); expect(store.getState().tasks).toHaveLength(0); expect(store.getState().lastError).toBe(“The title is required."); }); it(“a duplicate-title failure exposes a specific message”, async () => { const store = buildStore(); await store.getState().createTask(“Duplicate”, “"); const created = await store.getState().createTask(“Duplicate”, “"); expect(created).toBe(false);</p> <!-- PDF page 192 --> <p>expect(store.getState().lastError).toBe(“A task with this title already e xists."); expect(store.getState().tasks).toHaveLength(1); }); }); The portrait of the lap A sentence went in. Out came a specification with seven traceable requirements, a plan that chose the stack without contradicting any principle, five design documents, a list of twenty-two tasks in TDD order, and nineteen code and test files organized in three layers, with thirteen green tests at the end. None of it was improvised along the way: each file has a place the plan already foresaw, and each decision has a document that explains it. Keep the shape of this skeleton, because it is exactly the one you will run into again in the next laps. In Chapter 7.5, the feature of completing and reopening a task doesn't invent a new architecture: it adds a use case, a failure type, and a few fields, fitting into the same places. It is that repetition that proves, in practice, that the method holds the architecture up. The three- layer design that showed up here is the convention this book adopts for the example, and it is justified in FOCUS Architecture (2026, https://books.kodel.com.br/en/books/focus/), the second volume in this trilogy, which answers where each rule lives and why dependencies point inward. You do not need it to follow the next laps: what is enough to keep is that the plan decided this division once and that the four laps that followed fit inside it.</p> <!-- PDF page 193 -->

Powered by TurnKey Linux.