11 - Architecture: Small Files, Clear Seams
Chapter 11 - Architecture: Small Files, Clear Seams

Day 4 closes with the whole camp, not a single fire. A laid-out camp has zones: the kitchen downwind of the tents, the fire ring clear of both, the wash station off to the side. Each zone owns one job. The paths between them are narrow and named — nobody carries a pot through the sleeping tents, and nothing is stored in two places. Architecture is that layout drawn in code: each piece lives somewhere, each seam between pieces carries one kind of traffic, and shared facts live in exactly one zone. This chapter draws the camp first, then wires it.
Modules own seams
A module is a file or folder with one job and a narrow export list: the patrol store, the roster loader, the report parser. A boundary is where two modules meet, and shared types live on exactly one side of it — imported by the other, never copied. Copy a Scout into two files and the copies drift; import it from one and the compiler keeps every consumer honest. Small files follow from narrow seams, not the reverse. If a file is growing, the seam is misplaced — find the second job hiding inside it and give that job its own module.
New here: modules, boundaries, single-owner types. Docs:
Modules.
Watch:
092 — Modules
(“Now the best way we can do that in TypeScript is by using the
ES6 module system where we can import and export things from
different files when we need to” — the Invoice moves out of
app.ts into its own module; larger projects stay readable
when split into modular files).
Try It — Inline: In VS Code, duplicate
Scoutinto two files and hover — the second file’sScoutis a stranger. Delete the copy, addimport { Scout } from "./scout"and watch the duplicate error vanish.
Depend on the seam, not the store
A repository is an interface describing storage without naming it. The service takes one as a parameter — dependency injection with no framework, just a function argument. The lab’s contract, kept verbatim from the kit:
export interface PatrolStore {
list(): Promise<readonly Scout[]>;
save(scouts: readonly Scout[]): Promise<void>;
}
// src: static/labs/mini-architecture/src/app.ts
The service closes over that seam and nothing else:
export function createPatrolService(store: PatrolStore): PatrolService {
return {
certified: async () => (await store.list()).filter((s) => s.certified),
byRole: async (role) => (await store.list()).filter((s) => s.role === role),
rename: async (oldName, newName) => {
const next = (await store.list()).map((s) =>
s.name === oldName ? { ...s, name: newName } : s,
);
await store.save(next);
return next;
},
};
}
Tests hand a memory store; production hands the real one. The certified rule lives in the service, which never learns where scouts sleep — the kit’s own rule is that the service only calls store.list() and store.save(), never importing a concrete store. Swap the store, keep the service: that sentence is the whole pattern, and the fourth lab test proves it by replacing the store under a finished service. No decorators, no container, nothing to configure. Frameworks can add convenience later; the seam is just a parameter.
New here: repository interface, injected dependency, closure service. Docs:
Interfaces.
Watch:
093 — Interfaces
(“an interface allows us to enforce a certain structure of a
class or an object. We can use it to describe what properties
and what methods and what the types of those properties and
return types of those methods are” — the IsPerson
contract enforced on objects and function parameters alike).
Try It — Inline: In VS Code, hover
storeinsidecreatePatrolService— it isPatrolStore, notMemoryStore. Change the call tostore.save("oops")and watchtscflag thestringvsreadonly Scout[]mismatch (TS2345) before you save.
Model the problem first — Edible Fire
A model is the shape of the real problem in code, before you store or send it. Here the domain is the Edible Fire (GSNorCal 2020): paper plate safety circle, marshmallow fire ring, potato-stick tinder, pretzel kindling, pretzel-log fuel — A-frame logs open to the wind, teepee of tinder inside — plus the assignment of scouts to fires. The deck slide’s model, kept verbatim:
type FireSupply = "SafetyCircle" | "FireRing" | "WaterBucket"
| "Tinder" | "Kindling" | "Fuel" | "Match" | "Sparks";
type FireStage = "A-Frame" | "Teepee" | "Lit" | "Cold";
interface EdibleFire {
readonly scout: string;
readonly ring: readonly FireSupply[];
readonly stage: FireStage;
}
interface DutyAssignment {
readonly scout: string;
readonly fire: EdibleFire;
readonly certified: boolean;
}
const aaliyahFire: EdibleFire = {
scout: "Aaliyah",
ring: ["SafetyCircle", "FireRing", "Tinder", "Kindling", "Fuel"],
stage: "A-Frame",
};
// src: assets/typescript/src/decks/architecture/slides/060-domain.ts
The FireSupply literal union closes the set: the troop allows exactly these supplies — logs are Fuel, tinder is Tinder, kindling is Kindling — so a mistyped supply fails at the keyboard. FireStage closes the build: A-Frame logs facing the wind, Teepee tinder cone, Lit, then Cold to the touch. readonly freezes each fire after creation: updates build new fires instead of mutating the paper plate. DutyAssignment pairs a scout with her fire and certification — the PatrolStore persists these assignments later, but the model lives first. Close what the patrol fixes, leave open what the world owns.
New here: domain model, closed literal unions, readonly records, DutyAssignment. Docs:
Literal Types.
Watch:
015 — Interfaces
(the structure-enforcement companion: object shapes checked
at the boundary, before any logic trusts them).
Try It — Inline: In VS Code, type
const f: FireSupply = "Tindre"— the typo underlines in red (TS2322). Correct to"Tinder"and autocomplete offers only the eight closed supplies.
Three separations that scale
Three separations carry small apps into large ones. State from rendering: the roster facts live in the in-memory store, formatting lives wherever it is displayed — the same split as the duty board and the duty roster printout. Decisions from effects: pure functions choose, thin wrappers perform — the telemetry send from chapter 10 decides nothing, it reports. Domain from infrastructure: Scout and DutyAssignment know the trail and the fire, the store knows the persistence. The Modules reference documents the import mechanics behind all three; the patterns decide what the imports mean. Reach for the reference when the syntax is unclear; reach for the separations when the placement is unclear.
Try It — Inline: In VS Code, move
formatFire(fire: EdibleFire): stringinto its ownformat.ts— the import line appears automatically; deleting the import breaks the call with TS2304.
DIY simulator drills
You are the simulator. Cover the answers, predict, then verify. Log each miss in one sentence. One drill per file — Chapter 03 showed why.
Drill 1 — Be the freezer
Which line errors, and with which code?
interface Scout {
readonly name: string;
readonly role: string;
readonly certified: boolean;
}
const amara: Scout = { name: "Amara", role: "Lookout", certified: true };
amara.certified = false;
Verify with npx tsc --noEmit --strict --target es2020. (readonly
freezes the record after creation — the reassignment errors
with TS2540, “Cannot assign because it is a read-only
property.” Updates build new scouts; say the code before
running.)
Drill 2 — Be the contract checker
Does the store compile, and with which code if not?
interface PatrolStore {
list(): readonly DutyAssignment[];
}
class MemoryStore implements PatrolStore {}
Verify with npx tsc --noEmit --strict --target es2020. (An empty
class honors no contract — the missing member errors with
TS2420, naming the exact property the class forgot. The
unfinished case is not yours to ignore; chapter 08’s steal
arm was the same shape.)
Drill 3 — Be the stranger detector
Which line errors, and with which code?
type FireSupply = "Tinder" | "Kindling" | "Fuel";
interface EdibleFire {
readonly scout: string;
readonly ring: readonly FireSupply[];
}
const badFire: EdibleFire = { scout: "Maya", ring: ["Tinder", "Logs"] };
Verify with npx tsc --noEmit --strict --target es2020. (Object
literals may only specify known properties — the invented
"Logs" errors with TS2322 (not assignable to FireSupply).
The union is the closed kit; the kit has no Logs entry, only Fuel.)
Drill 4 — Be the runtime
What prints on each line?
function createService(store) {
return {
certified: async () => (await store.list()).filter((s) => s.certified),
byRole: async (role) => (await store.list()).filter((s) => s.role === role),
};
}
const store = {
list: async () => [
{ name: "Amara", role: "Lookout", certified: true },
{ name: "Nia", role: "Navigator", certified: true },
{ name: "Zola", role: "Navigator", certified: false },
],
};
const service = createService(store);
service.certified().then((s) => console.log(s.map((x) => x.name).join(",")));
service.byRole("Navigator").then((s) => console.log(s.map((x) => x.name).join(",")));
Verify with node. (The service never learns the store’s shape — it asks through the
seam and filters what comes back. Amara,Nia, then Nia,Zola. Say both lines before
running.)
Lab: wire a mini app (VS Code)
Open static/labs/mini-architecture/ in VS Code and run setup.sh from the integrated terminal. You get the domain types (DutyAssignment + EdibleFire + PatrolStore) plus a throwing createPatrolService stub, and a red suite of four tests: certified-only listing, by-role filter, rename persisted through the in-memory store, and a store swap that must change nothing about the service. Implement against the PatrolStore interface with no any and no casts, go green with npm test && npm run typecheck in VS Code — then write a second memory store yourself, point the service at it, and confirm the suite stays green. The seam holds; that is the demo. Keep the Problems panel visible: the PatrolStore contract underlines any direct store import in red.