Course / Lesson 22  ·  PT-BR
Lesson 22 · Lab · hands-on 1 of 2

Lab: build a ports-and-injection subsystem

Time to build, not just read. In this lab you write a tiny NoteStore from scratch — a bounded, file-backed store with an injected FsPort, validated at the boundary with Zod, that returns Result<T, Error> and never throws. It is the same skeleton the shipped MemoryStore and SkillStore were built on (lessons 5, 7, 12), reduced to the smallest thing that still earns the pattern. By the end you will have written every layer the engine demands of a subsystem — and a "your turn" exercise extends it.

What you need. Nothing to run for this lab — read and write the code in your head or in an editor. The types here are real: FsPort is from @alembic/etl (packages/etl/src/fs-port.ts:61) and Result/ok/err are from @alembic/contracts. Everything compiles against the actual workspace.

The target: a store with four properties

The engine's "Rules for safe changes" (CLAUDE.md) say a subsystem must be minimal, testable, and fail-closed. Concretely, our NoteStore must have the four properties every shipped subsystem has:

PropertyHow we get itWhy
IO is injectedFsPort in the constructor — no import 'node:fs'Testable in-memory; store-agnostic (invariant 2)
Validated at boundaryZod safeParse on every inputUntrusted input can't corrupt state
Never throwsreturns Result<T, Error>; wraps IO in tryCatchAsyncFailure is a value (ADR-0009)
Boundeda max-entries cap, enforced before writeNo unbounded growth — mirrors the memory char budget
NoteStore.add(text) : Promise<Result<Outcome, Error>> — never throws ① Zod safeParse bad input ⇒ err ② cap check over limit ⇒ err ③ mutate state in-memory array ④ fs.writeFileAtomic injected port each fallible step short-circuits with err; the IO call is wrapped in tryCatchAsync

Step 1 — the boundary schema

// notes/schema.ts

The schema is the contract for what a valid note is. We validate at the boundary because, in production, this input may come from a model or a network call — it is untrusted until parsed. This mirrors the memory store's memoryActionSchema and the learning loop's reviewProposalSchema.

// A note is a non-empty, trimmed string under 280 chars.
import { z } from 'zod';

export const noteSchema = z.string().trim().min(1).max(280);
export type Note = z.infer<typeof noteSchema>;

Step 2 — the outcome type

// notes/note-store.ts (top)

A mutating op returns a small terminal outcome reflecting live state — exactly like MemoryOpOutcome (memory-store.ts:71). Naming the success payload (not just returning void) is what lets the caller observe what happened without re-reading the store.

import { ok, err, tryCatchAsync, type Result } from '@alembic/contracts';
import type { FsPort } from '@alembic/etl';
import { noteSchema } from './schema.js';

export const DEFAULT_MAX_NOTES = 50;
const FILENAME = 'NOTES.md';
const DELIMITER = '\n---\n';

export interface NoteOutcome {
  readonly message: string;
  readonly count: number;   // live entry count after the write
  readonly max: number;
}

Step 3 — the class: inject the port, never touch fs directly

The constructor takes the FsPort and a base directory. This is the whole trick. The class never imports node:fs; it only knows the shape of a filesystem (readText, writeFileAtomic, stat, joinPath, ensureDir). In tests you pass a Map-backed fake; in production you pass createNodeFsPort() — the class can't tell the difference, which is the point.

export class NoteStore {
  private entries: string[] = [];

  constructor(
    private readonly fs: FsPort,      // ← injected. no `import fs`.
    private readonly baseDir: string,
    private readonly max: number = DEFAULT_MAX_NOTES,
  ) {}

  private path(): string {
    return this.fs.joinPath(this.baseDir, FILENAME);
  }

Step 4 — load(): read through the port, fail-closed

Every IO call is wrapped in tryCatchAsync, which turns a thrown exception (ENOENT, permission, decode error) into an err value. A missing file is not an error — it's an empty store — so we check stat first, exactly as MemoryStore.readEntries does (memory-store.ts:306).

  async load(): Promise<Result<void, Error>> {
    const ensured = await tryCatchAsync(() => this.fs.ensureDir(this.baseDir));
    if (!ensured.ok) return ensured;          // short-circuit on IO failure

    const meta = await tryCatchAsync(() => this.fs.stat(this.path()));
    if (!meta.ok) return meta;
    if (!meta.value) { this.entries = []; return ok(undefined); }  // no file ⇒ empty

    const read = await tryCatchAsync(() => this.fs.readText(this.path()));
    if (!read.ok) return read;
    this.entries = read.value.split(DELIMITER).map((s) => s.trim()).filter(Boolean);
    return ok(undefined);
  }

Step 5 — add(): validate, bound, mutate, persist

This is the heart. Four guards, in order, each returning err on failure — and only the last step touches disk. Notice the cap is checked before the write, so an over-limit add never half-mutates: state and disk stay consistent.

  async add(input: unknown): Promise<Result<NoteOutcome, Error>> {
    const parsed = noteSchema.safeParse(input);            // ① boundary Zod
    if (!parsed.success) return err(new Error(`Invalid note: ${parsed.error.message}`));
    const note = parsed.data;

    if (this.entries.includes(note)) {                     // ② dedup (no-op success)
      return ok({ message: 'exists', count: this.entries.length, max: this.max });
    }
    if (this.entries.length >= this.max) {                  // ③ bound BEFORE write
      return err(new Error(`At capacity (${this.max}). Remove a note first.`));
    }

    this.entries.push(note);                                // ④ mutate, then persist
    const saved = await tryCatchAsync(() =>
      this.fs.writeFileAtomic(this.path(), this.entries.join(DELIMITER)));
    if (!saved.ok) { this.entries.pop(); return saved; }   // roll back on IO fail
    return ok({ message: 'added', count: this.entries.length, max: this.max });
  }

  list(): readonly string[] { return this.entries; }
}
The rollback at ④ is a deliberate touch. The shipped MemoryStore pushes then saves (memory-store.ts:214-217); if the save fails, the in-memory state would lead disk. For this lab we pop() on failure so memory and disk never diverge — a small hardening you can carry back. Either way, the public method still returns a Result and never throws.

Step 6 — prove it with a fake FsPort

Because IO is injected, the test needs no temp directory and no real disk — a Map-backed fake satisfies the port. This is invariant 2 paying off directly: the same code is exercised in-memory.

// notes/note-store.test.ts — the fake is ~10 lines
const makeFakeFs = (): FsPort => {
  const store = new Map<string, string>();
  return {
    joinPath: (...p) => p.join('/'),
    stat: async (p) => (store.has(p) ? { size: 0, mtimeMs: 0, isFile: true, isDirectory: false } : undefined),
    readText: async (p) => store.get(p) ?? '',
    writeFileAtomic: async (p, c) => { store.set(p, c); },
    ensureDir: async () => {},
    // readDir / appendLine / openLineStream: unused here — stub as needed
  } as FsPort;
};

it('rejects an empty note and never throws', async () => {
  const s = new NoteStore(makeFakeFs(), '/x');
  expect((await s.add('   ')).ok).toBe(false);  // err, not a throw
});
1. Why does NoteStore take an FsPort in its constructor instead of calling fs.writeFile directly?
Correct: b. Injecting the side-effect is the engine's second invariant ("pure kernel, injected side-effects"). The class depends only on the shape of a filesystem, so a Map-backed fake exercises the exact same code path a real disk would — no temp dirs, no flakiness.
2. add() checks the capacity limit before pushing and persisting. Why does the order matter?
Correct: c. Guards run in order and short-circuit; the bound is enforced before any mutation. The shipped MemoryStore does the same — it builds a test array and checks the joined length against the limit before committing (memory-store.ts:201-212).
3. The IO call is wrapped in tryCatchAsync. What does that buy the public method's signature?
Correct: d. tryCatchAsync from @alembic/contracts is the bridge from the throwing world (Node's fs) to the value world (Result). Without it, a disk error would escape as an exception and break the never-throws invariant (ADR-0009).

Your turn — extend the store

Exercise: add remove(needle)

Implement remove(needle: string): Promise<Result<NoteOutcome, Error>> that deletes the single entry containing needle as a substring. Requirements, drawn straight from the shipped MemoryStore.remove + locateUnique (memory-store.ts:261-276, 369-392):

Then test it with the Map-backed fake: seed two notes, remove one by a unique substring, assert list() has one left; seed two notes sharing a substring and assert the ambiguous remove returns err. That is the same shape as the real test "Multiple entries matched" in memory-store.test.ts.

Stretch goal. Add a renderSnapshot() that captures the entries once (frozen) and returns them unchanged even after later add() calls — the prompt-prefix-cache trick from Lesson 7. The shipped store sets this.snapshot in load() and never mutates it mid-session (memory-store.ts:132-136).