Skip to content

Appendix 04

Source Design Index: Recurring Design Patterns ​

Xia A and Lao Z compare unlabeled mechanism models with a magnifying glass and group only those with matching structures.

Xiao A pointed at a structure during a code walkthrough and said it with full confidence: "That's the Observer pattern, right? The module is called subscribe. Has to be."

Lao Z did not argue. He just asked him to draw the call graph first.

When Xiao A finished, he saw he had spoken too soon. subscribe does exist, but how events get sent, who receives them, and how exceptions travel — none of that is clear until you draw it. In his diagram, the listeners registered by Agent.subscribe include ones that write the session and ones that refresh the UI, and they get called one after another in registration order; the next listener is not invoked until the Promise returned by the current one settles, and the loop does not advance to the next event until all of them settle. That is not a clean, tidy "Observer". It is an asynchronous event-dispatch chain with a timing dependency.

"A pattern is a tool for understanding, not a label to stick on," Lao Z said. "Unless a comment or a name states it plainly, write everything below as 'can be understood as'. Whether a structure deserves a pattern's name does not depend on how closely it matches a textbook diagram; it depends on whether its benefits and its costs match the pattern's. This appendix serves Part II · Pi in Source. Use it after you have read the source and need to name a structure and the trade-off it carries."

How to read this appendix: it is not a GoF pattern tutorial. It picks nine structures out of the real code in pi@v1.0.0 (commit a13d35a7) that you can understand through classic design patterns, and takes each one apart: what the pattern definition says, what the structure looks like in the source, what using it buys you, what it costs, what goes wrong when you misjudge it, and which chapters of Part I · Concepts and Part II · Pi in Source it answers. Every pattern comes with a real code excerpt and a counter-argument.


A pattern is a tool for understanding, not a label ​

Lao Z started by correcting a mistake Xiao A had: "You think that if you can name the pattern, you have understood the code?"

Xiao A nodded.

"You have it backwards," Lao Z said. "A pattern name is only the starting point of understanding. Calling it 'Observer' tells you there is some publish-subscribe here. The real value is in the three questions that follow: who subscribes, what a subscriber can change, and how exceptions propagate. If you cannot answer those three, the pattern name is decoration."

Why you cannot reverse it: naming before understanding ​

Xiao A pushed back: "What if I settle on the name first and then read the details? The name gives me an anchor."

"An anchor becomes an anchor that pins you to the wrong expectation," Lao Z said. "You see subscribe, you call it 'Observer', and now you expect loose coupling and observers that cannot interfere with each other. Then you find out that listener execution order affects the timing of session writes, and your expectation and reality start fighting. What do you do then? Either you force reality into the 'Observer' box and say 'it's just a slightly non-standard Observer', or you doubt your own reading of the code. Both reactions take you further from the truth."

Lao Z drew a line on the paper. "The correct order is: draw the facts, then pick the name, then check the cost."

The order of judgment

  1. Draw the facts. Write out the call relationships and the state transitions. Who calls whom, what gets passed, how exceptions travel, when state changes. This step forces you to see what the code actually does, not what you assume it does.
  2. Pick the name. Choose a name from the twenty-three GoF patterns that does not overstate the facts. The test is not "does it look like the UML diagram" but "does the code really satisfy the pattern's core constraints". If the code does not satisfy them, prefer no name at all over forcing one on.
  3. Check the cost. Take the pattern's known costs from the textbook and check, one by one, whether the code pays them. A cost the code does not handle is either a bug or a sign that the pattern does not fully apply here. Mark either case.

"Only after all three steps is a pattern name useful," Lao Z said. "Skip step one and name directly, and you will mistake imagination for fact. Skip step three and look only at benefits, and you will treat the pattern as a silver bullet."

The cost column is the contract ​

Xiao A pressed on: "Why does the cost column matter so much? Isn't the benefit the whole reason to use a pattern?"

"The benefit tells you why you picked it. The cost tells you what it promised," Lao Z said. "Take the standard promise of the publisher pattern: 'every subscriber receives the updates'. But in pi-mono, a service subscription's snapshot reflects the server's state at one instant. It does not guarantee strong consistency across a network partition, and it does not guarantee exactly-once. Look only at the benefit — 'let the client start from a complete state' — and you will think it guarantees consistency. The cost column tells you it cannot. The pattern name is the entrance; the cost column is the contract."

That principle runs through the whole appendix. Each of the nine patterns below lists benefits and costs together. The cost is not a flaw. It is the real price you pay for using the pattern: if you are unwilling to pay a particular cost, you should not use the pattern — rather than use it and pretend the cost is not there.


The nine patterns at a glance ​

Here is the overview table first. Each pattern gets a full worked case in the sections that follow.

#StructureUnderstandable asSource locationCore benefitCore cost
①createProviderSimple Factorypackages/ai/src/models.ts:1034Centralizes Provider constructionAdapter-layer differences grow with each Provider
②Provider interfaceStrategypackages/ai/src/models.ts:144One uniform call site for the stream entry pointsEvery strategy must keep error semantics identical
③convert*Messages per APIAdapterpackages/ai/src/api/openai-responses-shared.ts:145 and othersHides vendor message shapesMay drop vendor-specific information
④Agent.subscribeObserver (with an ordering constraint)packages/agent/src/agent.ts:266Decouples the UI from the loopThe async event chain is harder to trace
⑤resolveProviderAuthChain of Responsibilitypackages/ai/src/auth/resolve.ts:33Expresses credential precedenceDiagnostics must say how far the chain got
⑥SessionRouter.runForClientPer-key serial queuepackages/server/src/session-router.ts:146Session operations on one connection stay orderedNot a full transaction, not the Command pattern
⑦withFileMutationQueueResource-level concurrency controlpackages/coding-agent/src/core/tools/file-mutation-queue.ts:32Writes to the same path serializeProtects no external-service side effects
⑧ValidatedMessageDecoderPipeline / Guardpackages/protocol/src/codec.ts:65Layered validation of the bytesThe decoder must be rebuilt after a failure
⑨Service subscription (the subscribe branch of handleRequest)Publisher (snapshot plus deltas)packages/server/src/server.ts:306Lets the client start syncing from a snapshotA snapshot does not prove a command committed

Cross-chapter callback: these nine patterns are not isolated. ①②③⑤ all live in pi-ai (chapters 19 and 20 of Part II · Pi in Source), ④ in pi-agent-core (chapter 21), ⑦ in coding-agent (chapter 25), ⑧ in protocol (chapter 26), and ⑥⑨ in server (chapter 27). All line numbers are upstream v1.0.0 line numbers; the pi/ snapshot in this book's repository has added comments, so its line numbers sit further down. When you reach the matching chapter, come back and compare against this appendix.


Pattern ①: createProvider — Simple Factory ​

Pattern definition ​

The core of the Simple Factory pattern is to concentrate an object's construction logic in one function. The caller passes arguments and does not care how the object is actually created with new. GoF's Factory Method and Abstract Factory are its advanced forms; pi-mono uses the plainest one: a function that takes input and returns a configured object.

The real code in the source ​

Xiao A opened packages/ai/src/models.ts and found line 1034:

ts
export function createProvider<TApi extends Api = Api>(input: CreateProviderOptions<TApi>): Provider<TApi> {
	const single = input.api && typeof (input.api as ProviderStreams).stream === "function" ? /* single implementation */ : undefined;
	const byApi = single || !input.api ? undefined : (input.api as Partial<Record<string, ProviderStreams>>);
	// images and classifiers work the same way; if none of the three exists, throw
	// ... merge baseline and dynamic models, build the dispatch ...
	return {
		id: input.id,
		name: input.name ?? input.id,
		auth: input.auth,
		getModels: () => currentModels().filter((model) => isModelType(model, "chat")),
		getAllModels: currentModels,
		refreshModels: fetchModels ? async (context) => { /* restore the cache first, then refresh over the network, publish through context.publish */ } : undefined,
		stream: (model, context, options) => dispatch(model, (streams) => streams.stream(model, context, options)),
		streamSimple: (model, context, options) =>
			dispatch(model, (streams) => streams.streamSimple(model, context, options)),
	};
}

"Look at what it actually does," Lao Z said. "It takes a CreateProviderOptions containing an id, models, and an api implementation; in v1.0.0 it may also carry image and classifier implementations, plus an optional fetchModels. Then it runs a chain of internal assembly: merge the baseline and dynamic models, build a dispatch function that picks the stream implementation from each model's api field, and hand the refresh results to ModelsImpl, which publishes them by id. Finally it returns a complete Provider object."

"How is that any different from new Provider(...)?" Xiao A asked.

"The difference is that the complexity of the construction logic is encapsulated," Lao Z said. "If you new Provider directly, the model merging, the API dispatch, and the refresh management all have to be spread across the call sites. createProvider pulls all of that in: the caller passes one description object and gets back a usable Provider."

Benefit: centralized dispatch, simple callers ​

Lao Z had Xiao A look at the packages/ai/src/providers/ directory: anthropic, openai-responses, openai-completions, google — each one is its own Provider configuration, yet every one is constructed through the same createProvider.

"What does that mean?" Lao Z asked.

"It means that to add a new Provider, I only prepare a CreateProviderOptions and call createProvider. I don't have to care how dispatch routes or how models get merged," Xiao A said.

"Right. That is the factory's benefit: construction logic centralized, callers simple," Lao Z said. "However complex the Provider is inside, from the outside it is one function call."

Cost: adapter-layer differences grow with each Provider ​

"But there is a cost," Lao Z said. "dispatch selects streams by the model.api field. If a new Provider uses an API type you have not seen, you not only write a new stream implementation — you also have to make sure dispatch can find it. The factory centralizes construction; it does not eliminate the differences. It pushes them into the adapter layer."

Xiao A opened the packages/ai/src/api/ directory, and there it was: every API type had its own set of conversion files.

"So the real cost of adding a Provider is: one more branch here in the factory, and a whole extra set of message conversion, event mapping, and error handling in the adapter layer," Lao Z said. "That cost does not vanish because you call this thing a 'factory'. Plenty of people size only the factory part when they add a Provider, then get stuck in the adapter layer."

Counter-argument: what a misjudgment costs ​

Lao Z told a case he had seen himself: "One team treated createProvider as a 'registry' and assumed a Provider became usable as soon as you added it. Their new Provider used an API type that dispatch did not have, and at runtime it threw Provider has no API implementation for "xxx". It took them two days to find it: the factory succeeded at construction, and the error only surfaced at the stream call."

Lesson: factory success ≠ Provider usable. createProvider checks only "at least one implementation exists" at construction time. The stream method on the object it returns checks whether an API implementation exists for that particular model, at runtime. Succeeding at construction only proves the description object is valid; it does not prove the stream can run. A factory defers complexity to runtime, and the price is that errors surface late.

Cross-chapter callback ​

This pattern is developed in detail in Part II · Pi in Source, Part II Chapter 2 Catalog and Registration — pi-ai, which follows how createProvider gets called by ModelsImpl and how the returned Provider gets looked up by requireProvider. If you finish chapter 19 still wondering "where does a Provider come from", come back here for the factory's complete internal logic.


Pattern ②: the Provider interface — Strategy ​

Pattern definition ​

The core of the Strategy pattern is to define an interface and let a family of interchangeable algorithms implement it, so the caller does not care which one it is using. GoF's original wording is "encapsulate a family of interchangeable algorithms". In pi-mono it looks more like a family of interchangeable service boundaries: each model vendor is one strategy, implementing the same Provider interface.

The real code in the source ​

Xiao A found line 144 in models.ts:

ts
export interface Provider<TApi extends Api = Api> {
	readonly id: string;
	readonly name: string;
	readonly auth: ProviderAuth;
	getModels(): readonly Model<TApi>[];
	getAllModels?(): readonly ProviderModel<TApi>[];
	refreshModels?(context: RefreshModelsContext): Promise<void>;
	filterModels?(models: readonly Model<TApi>[], credential: Credential | undefined): readonly Model<TApi>[];
	stream<T extends TApi>(model: Model<T>, context: TranscriptContext, options?: ApiStreamOptions<T>): AssistantMessageEventStream;
	streamSimple(model: Model<TApi>, context: TranscriptContext, options?: SimpleStreamOptions): AssistantMessageEventStream;
	generateImages?(/* ... */): Promise<AssistantImages>;
	classify?(/* ... */): Promise<ClassifierResult>;
	// plus baseUrl, headers, fetchDeferred, cancelDeferred, filterAllModels and others; omitted from this excerpt
}

"This is the strategy interface," Lao Z said. "Anthropic, OpenAI, Google — whoever you are, you implement this interface. When the layer above, ModelsImpl, calls provider.streamSimple(...), it does not care which vendor is behind it. It only cares that it gets back an AssistantMessageEventStream."

Benefit: one uniform call site upstream ​

"This benefit pairs with the factory's," Lao Z said. "The factory is responsible for constructing strategy instances; the strategy interface is responsible for keeping the layer above ignorant of which instance it has. Only together do they give you 'swap the Provider without touching the code above'."

Xiao A counted the question marks: "refreshModels, generateImages, classify all have a question mark. stream and streamSimple don't."

"Correct," Lao Z said. "Both stream entry points are mandatory for every strategy. Refreshing the catalog, generating images, classifying — those capabilities may be absent, so strategies are allowed to differ. The benefit of the Strategy pattern is not that all strategies are identical. It is that they honour one minimal contract, and the optional methods are the balance point between that minimal contract and extended capability."

Cost: every strategy must keep error semantics identical ​

"The most hidden cost of the Strategy pattern is here," Lao Z said, pointing at AssistantMessageEventStream. "Every Provider returns this type, but the error semantics inside can differ: Anthropic's SDK throws one kind of exception on a network timeout, OpenAI's SDK may throw another. If the strategy layer does not unify those error semantics, the layer above ends up facing a pile of differently shaped exceptions. It thinks it depends on one interface; in practice it depends on N error formats."

"So how do you unify them?" Xiao A asked.

"That is one of the reasons lazyStream exists," Lao Z said, opening packages/ai/src/api/lazy.ts. "It defers stream creation until subscription time and unifies the error entry point: no matter which Provider fails to create a stream, the error comes out of the same boundary. But errors inside the stream — a disconnect in the middle of generation, say — still depend on each Provider's adapter implementation. The strategy interface unified the entrance. It did not unify the interior. That is the intrinsic cost of the pattern."

Counter-argument: treating an optional method as mandatory ​

Lao Z brought up another incident: "A team assumed every Provider implemented refreshModels and called it directly, with no null check. When they hooked up a static Provider — one that doesn't support it — the runtime threw refreshModels is not a function. The question mark on the interface told you it was optional. You treated it as required. That is the consequence of reading the name instead of the contract."

Lesson: the optional methods on a strategy interface are part of the contract. The question mark on refreshModels? is not a suggestion; it is a constraint. It says you may not assume every strategy implements it. Treat that constraint as decoration and you will hit undefined at runtime.

Comparison matrix: factory vs strategy ​

Xiao A had mixed up ① and ②: "Factory and strategy — what is the actual difference?"

Lao Z wrote out a table:

Dimension① Simple Factory createProvider② Strategy, the Provider interface
What problem it solveshow the object gets constructedhow the constructed object gets called uniformly
What changesthe factory's input, when you add a Provideryou implement the interface, when you add a Provider
What the caller knowsthat a factory function existsnothing about which concrete strategy it has
When errors surfaceat construction (the description object is invalid)at runtime (the stream call fails)
Relationship to the other onethe factory produces strategy instancesthe strategy defines the shape the factory returns

"They are a pair," Lao Z said. "The factory produces the strategy; the strategy defines the shape the factory returns. Look at either one alone and you miss the other half. That is why plenty of books put Factory Method and Strategy in adjacent chapters — in real code they almost always show up together."

Cross-chapter callback ​

The strategy interface is followed in both Part II Chapter 2 Catalog and Registration — pi-ai and Part II Chapter 3 Ticket First, Work Later — pi-ai of Part II · Pi in Source. Part II Chapter 2 looks at how the Provider interface gets delegated to by ModelsImpl; chapter 20 looks at how the AssistantMessageEventStream a strategy returns passes through the adapter layer and becomes uniform events. Only the two chapters together show the full landing of the Strategy pattern in pi-mono.


Pattern ③: each API's convert*Messages — Adapter ​

Pattern definition ​

The core of the Adapter pattern is to convert one interface into another so that classes which were originally incompatible can work together. GoF's classic image is a power adapter: your laptop wants a two-prong outlet, the wall has only a three-prong one, and the adapter converts three prongs into two. In pi-mono, each model vendor's message format is a different kind of outlet, and the message conversion functions in each API implementation file are the adapters.

The real code in the source ​

Xiao A first opened packages/ai/src/api/transform-messages.ts, line 64:

ts
export function transformMessages<TApi extends Api>(
	messages: Message[],
	model: Model<TApi>,
	normalizeToolCallId?: (id: string, model: Model<TApi>, source: AssistantMessage) => string,
): Message[] {
	// downgrade images the target model does not support, handle thinking blocks, normalize tool call IDs ……
}

"Don't call that an adapter yet," Lao Z said. "Its input and its output are both pi-ai's own Message[]. What it does is cross-vendor preprocessing: downgrade images when the target model does not support them, normalize tool call IDs — because OpenAI Responses IDs can run past four hundred characters while Anthropic accepts only alphanumerics up to 64 characters. The thing that really turns the unified message into a vendor's shape is each API file's own conversion function."

He then opened packages/ai/src/api/openai-responses-shared.ts, line 145:

ts
export function convertResponsesMessages<TApi extends Api>(
	model: Model<TApi>,
	context: TranscriptContext,
	allowedToolCallProviders: ReadonlySet<string>,
	options?: ConvertResponsesMessagesOptions,
): ResponseInput {
	// resolveTranscript first, then transformMessages, then convert each entry into a Responses input item
}

"The counterpart in anthropic-messages.ts is convertMessages, which outputs Anthropic content blocks," Lao Z said. "One and the same set of Message objects: converted for Anthropic it becomes a content blocks array, converted for OpenAI Responses it becomes input items, converted for Chat Completions it becomes messages organized by role. Every one of these conversion functions starts by calling transformMessages for the shared preprocessing."

Benefit: hides external shapes ​

"The adapter's benefit fits in one sentence: the layer above deals only with the unified Message and never touches a vendor format," Lao Z said. "The loop turns an AgentMessage into a Message through convertToLlm and sends it out, and what comes back is a unified AssistantMessage. The complexity of vendor formats stays locked inside the API files. Swap vendors, and not one line of the code above changes."

Xiao A thought of the other side: "Then what gets lost?"

Cost: vendor-specific information may be dropped ​

"This is the adapter's most hidden cost," Lao Z said. "The unified Message is a common subset. It defines only the fields every vendor supports. If a vendor has a field of its own — OpenAI's annotations, Anthropic's citations — it may be dropped when you convert to the unified message, because the unified type has no field for it."

"That's like translation," Xiao A picked it up. "Take a Chinese word for a bond of fate. Into English: whichever way you translate it, something is lost."

"Right. What an adapter guarantees is compatibility, not losslessness," Lao Z said. "If you use an adapter, you accept information loss. If a product depends hard on some vendor's proprietary field, that field must not pass through the adapter — handle it separately, outside the adapter. Treat 'the adapter handles everything' as a premise, and after some version upgrade you will find the proprietary field quietly disappeared."

Counter-argument: the asymmetry of two-way adaptation ​

Lao Z pointed out another common mistake: "convert*Messages is one-way: unified message out, vendor format. On the way back, the vendor's stream events become a unified AssistantMessage through a different piece of code, processResponsesStream, for example. Some people assume an adapter is symmetric both ways and reuse the outbound function to convert inbound, and the message shapes do not match. The adapter's direction is part of the contract: converting out and converting in are two independent adaptation processes, each with its own information loss."

Cross-chapter callback ​

The adapter is followed in detail in Part II · Pi in Source, Part II Chapter 3 Ticket First, Work Later — pi-ai, which shows how Anthropic's content_block_start / content_block_delta / content_block_stop events pass through the adapter and become the uniform text / tool_call / done events. If chapter 20 made you feel "the event formats are such a mess", the mess comes from each vendor's native format, and the adapter's job is exactly to gather it up. This section is its definition.


Pattern ④: Agent.subscribe — Observer, with an ordering constraint ​

Pattern definition ​

The core of the Observer pattern is that one object maintains a set of dependents and notifies them when its state changes. The classic implementation is addListener / notify: the subject neither knows nor cares who the listeners are, and only broadcasts. pi-mono's Agent.subscribe has one non-standard aspect: it waits for listeners to settle. Its observers therefore are not loosely coupled; they are constrained by ordering.

The real code in the source ​

Xiao A had already drawn the call graph, and Lao Z still made him go back to the source and read it once more. packages/agent/src/agent.ts, line 266 (upstream line numbers throughout):

ts
private readonly listeners = new Set<(event: AgentEvent, signal: AbortSignal) => Promise<void> | void>();

subscribe(listener: (event: AgentEvent, signal: AbortSignal) => Promise<void> | void): () => void {
	this.listeners.add(listener);
	return () => this.listeners.delete(listener);
}

"Look at the listener signature." Lao Z pointed at (event: AgentEvent, signal: AbortSignal) => Promise<void> | void. "It may return a Promise, which means the Agent waits for it to settle. That is the biggest difference from the textbook Observer."

Lao Z turned to processEvents at line 565:

ts
private async processEvents(event: AgentEvent): Promise<void> {
	switch (event.type) {
		case "message_start":
			this._state.streamingMessage = event.message;
			break;
		// ... update state ...
	}
	// after the state update, await each listener in registration order
	for (const listener of this.listeners) {
		await listener(event, signal);
	}
}

"See the await in that for loop?" Lao Z said. "The Agent notifies listeners one at a time in registration order, and calls the next one only after the previous settles; it advances to the next event only after all of them settle. That is the ordering constraint: the loop does not emit the next event while AgentSession, the listener that writes the session, is still writing."

Benefit: decouples the UI from the loop ​

"It still has the standard Observer's benefit," Lao Z said. "The TUI does not need to call the loop's internal methods; it subscribes to events. The loop does not need to know how the UI renders; it broadcasts events. That really is decoupling."

"Then where exactly does it differ from a standard Observer?" Xiao A pressed.

Cost: the async event chain is harder to trace ​

"It differs in the timing dependency," Lao Z said. "A standard Observer fires and forgets. pi-mono's observer fires and waits. One listener with a performance problem slows the entire loop: if the session-write listener takes 200 milliseconds each time, every event waits an extra 200 milliseconds. Debugging is harder too: a bug may hide in the order events arrive in, and not in any single function."

Lao Z drew the timeline:

text
loop emits the message_end event
  → processEvents updates internal state
  → await listener A (AgentSession writes the session)   ← if this is slow, the loop stalls
  → await listener B (TUI refresh)
  → only after all settle does the loop advance to the next event

"What you chase is not who called whom. It is the order in which events get handled by whom, and which listener stalled. That is a debugging difficulty specific to async event chains: the stack tells you where the error is, the timeline tells you why it got there."

Counter-argument: what a misjudgment costs ​

Lao Z had stepped in this one himself: "Early on I assumed listeners were fire-and-forget, so I did a slow network call inside one — reporting metrics. The whole Agent got slow, because the loop was waiting for my listener to settle. I thought I was observing from a side channel. In fact I had hung a blocking point on the main path."

Lesson: the name subscribe hints at "watching from the side", but the per-listener await makes it a synchronization point on the main path. If you need true fire-and-forget — log shipping, say — do not do slow work inside a listener: use a Promise you never await, or move the work to its own queue.

Why this does not fully deserve the name "Observer" ​

Xiao A was still not satisfied: "So is it an Observer or not?"

"It is, but with a qualifier," Lao Z said. "It satisfies the core definition and just adds the constraint of waiting for settle. The more accurate name is an Observer with an ordering constraint, or a synchronous Observer. If you say only 'Observer' and leave out the constraint, whoever reads it next will assume it is fire-and-forget and step into the hole I stepped into."

Comparison matrix: standard Observer vs pi-mono's observer ​

DimensionGoF standard Observerpi-mono's Agent.subscribe
Notification stylefire and forgetawait listeners one at a time, in registration order
Effect of a blocking listenerdoes not affect the subjectblocks the whole loop
How you debugfollow the call stackfollow the event timeline
Typical misjudgmenta listener throws and the exception is swallowedassume a side channel while you are on the main path
Suited toUI refresh, loggingcases that must finish a listener before advancing

Cross-chapter callback ​

This pattern is followed in Part II · Pi in Source, Part II Chapter 4 The Double Loop — pi-agent-core and Part II Chapter 5 The Dispatch Desk — pi-coding-agent. Part II Chapter 4 shows how the loop produces events and how processEvents notifies; chapter 22 shows how AgentSession writes the session as a listener. The answer to "how does AgentSession know when to write the session" was already taken apart above: it is in the ordering constraint of the per-listener await.


Pattern ⑤: resolveProviderAuth — Chain of Responsibility ​

Pattern definition ​

The core of the Chain of Responsibility pattern is to arrange several handlers into a chain, pass the request along it, and let each handler decide whether to handle it or pass it on. The classic example is an approval flow: a manager approves amounts under 1000, a director approves 1000 to 10000, the CEO approves anything over 10000. In pi-mono, auth resolution is exactly such a chain: explicit override → stored credential → environment variable. Each level returns a result if it can resolve a credential, and hands the request to the next level if it cannot.

The real code in the source ​

Lao Z showed Xiao A line 33 of packages/ai/src/auth/resolve.ts. In v1.0.0 the exported function does one thing: wrap the resolution in an abort signal and delegate to the internal resolveProviderAuthWithSignal. The chain itself lives in the internal function:

ts
// packages/ai/src/auth/resolve.ts, resolveProviderAuthWithSignal (v1.0.0, excerpt; the signal parameter omitted from the signature discussion)
async function resolveProviderAuthWithSignal(
	provider: { id: string; auth: ProviderAuth },
	credentials: CredentialStore,
	authContext: AuthContext,
	overrides: AuthResolutionOverrides | undefined,
	signal: AbortSignal,
): Promise<AuthResult | undefined> {
	signal.throwIfAborted();
	const requestAuthContext = overrides?.env ? overlayEnvAuthContext(authContext, overrides.env) : authContext;

	// level 1: explicit API key override
	if (overrides?.apiKey !== undefined && provider.auth.apiKey) {
		return resolveApiKey(requestAuthContext, provider.auth.apiKey, provider.id, {
			type: "api_key", key: overrides.apiKey, env: overrides.env,
		}, signal);
	}

	// level 2: stored credential (OAuth or API key)
	const stored = await readCredential(credentials, provider.id, signal);
	if (stored) {
		if (stored.type === "oauth" && provider.auth.oauth) {
			return resolveStoredOAuth(/* ... */);
		}
		if (stored.type === "api_key" && provider.auth.apiKey) {
			return resolveApiKey(/* ... */);
		}
		return undefined;
	}

	// level 3: environment variable (ambient)
	return provider.auth.apiKey
		? resolveApiKey(requestAuthContext, provider.auth.apiKey, provider.id, undefined, signal)
		: undefined;
}

"This chain has three links," Lao Z said, pointing along them one by one:

  1. Explicit override: if the caller passed overrides.apiKey, use it directly and never look at what follows.
  2. Stored credential: read this Provider's record from the credential store. Find an OAuth record, go through OAuth refresh. Find an API key record, use it directly.
  3. Environment variable: when the first two levels found nothing, try to resolve from the environment.

"The logic at each level is: return if you can resolve a credential, otherwise let the code fall through to the next level," Lao Z said. "That is a chain of responsibility — not the mutually exclusive choice you get from if/else, but an ordered fallback chain."

Benefit: expresses credential precedence ​

"The biggest advantage of a chain of responsibility is that the precedence is written in the code order," Lao Z said. "Read this code top to bottom and you have precedence, highest to lowest: the explicit override first, the stored credential next, the environment variable as backstop. You don't need the docs or the configuration file. The code itself tells you the precedence."

Xiao A followed the thought downward: "And if I want to add a level? Pull credentials from a secrets manager, say."

"Add it between the stored credential and the environment variable — one if. Try the secrets service first, fall through to the environment variable if that finds nothing. That is a chain's extensibility: adding a level changes one place and touches no other level."

Cost: diagnostics must say how far the chain got ​

"But a chain has an intrinsic cost: when something goes wrong, you must know how far along the chain it got," Lao Z said. "A user reports 'my Agent can't reach the model'. The problem could be at any level: a typo in the explicit override key, an expired stored OAuth token, an unset environment variable. Tell the user only 'authentication failed' and they have nowhere to start."

"So the diagnostic message matters?"

"It is mandatory," Lao Z said. "Each level should state in the logs or the diagnostics what it tried and why it did not succeed — 'tried the explicit apiKey override: key format invalid', 'tried the stored credential: no record found for provider anthropic', 'tried the environment: ANTHROPIC_API_KEY not set'. Without those, the chain becomes a black box: the user knows authentication did not work, but not which level to inspect."

Counter-argument: reading a chain as if/else ​

Lao Z knew another failure of this kind: "A team understood resolveProviderAuth as ordinary if/else, so they wrapped another layer around it: 'if the environment has a key, don't call resolveProviderAuth'. That external check duplicated the chain's first level, with a worse outcome: the user passed an explicit override, but the external check saw the environment variable first and used it, bypassing the higher-precedence explicit override. A chain's value is that the chain manages precedence itself. Add your own decision outside the chain and you break the chain's precedence semantics."

Lesson: a chain's precedence is decided only by the order inside the chain; a check made outside it cannot override that. To change precedence, change the order inside the chain. Adding a parallel decision outside the chain means you have established two precedence chains at once, and when they conflict, behaviour becomes unpredictable.

Cross-chapter callback ​

This pattern is mentioned in Part II · Pi in Source, Part II Chapter 2 Catalog and Registration — pi-ai, where the discussion of ModelsImpl.applyAuth notes that auth merging calls resolveProviderAuth. If chapter 19 left you unclear on "how auth gets merged from several sources", come back here for the chain's three levels: each level is one source, and the precedence lives in the code order.


Pattern ⑥: SessionRouter.runForClient — a per-key serial queue ​

Pattern definition ​

This one is not a standard GoF pattern, but the problem it solves is classic concurrency control: operations on the same key must run serially, while operations on different keys may run concurrently. It sits between a global lock (too coarse — every operation queues) and no lock at all (too dangerous — same-key operations collide). Read it as a lightweight per-resource serializer.

An older version of pi-agent-core had a general-purpose KeyedOperationQueue; v1.0.0 removed it along with the harness. The same construction is still visible in packages/server/src/session-router.ts, where the key is a remote connection.

The real code in the source ​

Lao Z pulled up line 146 of session-router.ts:

ts
// packages/server/src/session-router.ts, SessionRouter.runForClient (v1.0.0)
private runForClient<T>(client: object, operation: () => Promise<T>): Promise<T> {
	const previous = this.clientOperations.get(client) ?? Promise.resolve();
	const result = previous.catch(() => {}).then(operation);
	const tail = result.then(
		() => undefined,
		() => undefined,
	);
	this.clientOperations.set(client, tail);
	void tail.finally(() => {
		if (this.clientOperations.get(client) === tail) this.clientOperations.delete(client);
	});
	return result;
}

Xiao A stared at this for a long time. "What is it doing?"

"It does something quite clever," Lao Z said, line by line:

  • previous = this.clientOperations.get(client): take the last Promise queued for this connection — the "tail".
  • previous.catch(() => {}).then(operation): wait for the preceding operation to finish, whether it succeeded or failed, then run the new one.
  • tail = result.then(...): the new "tail" always resolves, so one operation failing does not make the ones behind it fail.
  • this.clientOperations.set(client, tail): record the new tail, for later operations on this connection to wait on.
  • tail.finally(...): after the operation completes, drop the entry if this is still the last tail, so the Map cannot grow without bound.

"Who goes through this queue?" Xiao A asked.

"Attach, detach, disconnect cleanup, and the admission step of every session service call," Lao Z said. "attachClient, detachClient, disconnect, and executeServiceCall all go through runForClient. So on one connection, 'attach to session A first, then send a session request' always happens in that order. Different connections never wait on each other."

Benefit: ordered per key, concurrent across keys ​

"This benefit matters most during an attach switch," Lao Z said. "A connection may send a request for an old session at the same moment it attaches to a new one. Run those concurrently and the request could execute against an attachment that is being released. Chain them, and the request either is admitted before the switch or is rejected after it by requireAttachment — it never lands in the in-between state. Different connections each have their own chain, so they run concurrently."

Cost: not a full transaction, not the Command pattern ​

"But you should know its costs," Lao Z said:

  • Not a transaction: on the same connection, if operation A succeeds and B fails, B does not roll back A. What the queue guarantees is execution order, not atomicity.
  • Not the Command pattern: the queue stores Promises, not revocable command objects. You cannot replay or undo an operation that already ran.
  • Admission only, not completion: what executeServiceCall does inside the queue is startServiceCall — it takes the attachment, starts the call, and returns. The call itself continues outside the queue. What actually waits for the call to finish is Promise.allSettled(attachment.operations) before the attachment is released. Keep those two layers apart when you read it.
  • No global cap: it serializes per connection only. It does not limit how many operations all connections run at once.

Counter-argument: reading "serial per key" as "safe" ​

Lao Z produced another case: "Someone assumed 'serial per key' meant 'safe across processes', so they hosted the same session in two separate server processes and expected each queue to keep the order. They forgot that the queue only works inside one process: each process has its own SessionRouter, and each one's 'serial per key' is invisible to the other. Ordering across processes needs single-writer design or locking at the host layer. This queue cannot help you."

Lesson: serialization by a Promise chain like this holds only within one JS process: it can order same-key operations there, and it neither knows nor governs what other processes do. Mistaking in-process serialization for cross-process safety is one of the most common sources of concurrency bugs.

Cross-chapter callback ​

This pattern appears in the attachment section of Part II · Pi in Source, Part II Chapter 10 The Blackboard and the Sticky Notes — pi-server + pi-client. Writing the session file does not use a queue: chapter 22, The Dispatch Desk — pi-coding-agent, followed SessionManager._persist, which appends synchronously, and its ordering comes from Agent awaiting listeners one at a time — precisely the ordering constraint of pattern ④.


Pattern ⑦: withFileMutationQueue — resource-level concurrency control ​

Pattern definition ​

This pattern solves the same problem as ⑥: operations on the same resource must run serially. Two things differ. Its key is not a connection object but a file's realpath — the resolved, actual path. And its queue is a module-level Map shared by the whole process, not held on some instance. Read it as a lightweight lock for "queue writes to the same path".

The real code in the source ​

Xiao A opened packages/coding-agent/src/core/tools/file-mutation-queue.ts, line 32:

ts
export async function withFileMutationQueue<T>(filePath: string, fn: () => Promise<T>): Promise<T> {
	const registration = registrationQueue.then(async () => {
		const key = await getMutationQueueKey(filePath);
		const currentQueue = fileMutationQueues.get(key) ?? Promise.resolve();
		let releaseNext!: () => void;
		const nextQueue = new Promise<void>((resolveQueue) => { releaseNext = resolveQueue; });
		const chainedQueue = currentQueue.then(() => nextQueue);
		fileMutationQueues.set(key, chainedQueue);
		return { key, currentQueue, chainedQueue, releaseNext };
	});
	// ...
	await currentQueue;
	try {
		return await fn();
	} finally {
		releaseNext();
		if (fileMutationQueues.get(key) === chainedQueue) {
			fileMutationQueues.delete(key);
		}
	}
}

"Look at getMutationQueueKey first." Lao Z pointed at line 16:

ts
async function getMutationQueueKey(filePath: string): Promise<string> {
	const resolvedPath = resolve(filePath);
	try {
		return await realpath(resolvedPath);
	} catch (error) {
		if (isMissingPathError(error)) return resolvedPath;
		throw error;
	}
}

"It does resolve first — turns a relative path into an absolute one — then realpath, which resolves symlinks to the actual path. The point is that two paths that look different but point at the same file map to the same key. Take ./a.ts, /workspace/a.ts, and /workspace/link-to-a.ts where link-to-a is a symlink: after realpath they are all the same actual path, so they queue in the same line."

Xiao A got stuck again on the cleanup: "What are releaseNext and delete in the finally for?"

"Both are mechanisms against deadlock and leaks," Lao Z said:

  • releaseNext(): nextQueue is a Promise you control by hand, and releaseNext is its resolve function. Once fn finishes, call releaseNext() so the operations queued behind it can start. It sits in finally rather than after the return so that fn releases the queue whether it succeeds or throws — otherwise the queue stays blocked forever.
  • delete: removes the entry from fileMutationQueues. Note it checks fileMutationQueues.get(key) === chainedQueue first: it deletes only if this queue is still the newest link on the chain, so it cannot mistakenly delete a queue registered later.

Benefit: writes on one path serialize, different paths run in parallel ​

"The benefit is the same as ⑥'s, but applied at the tool layer," Lao Z said. "The Agent may issue two file-writing tool calls at once — one writes config.ts, one writes README.md. withFileMutationQueue guarantees the writes to each file are serialized (one finishes completely before the next), while the two files proceed in parallel. The tool layer needs no elaborate locking protocol for this: wrap a function around it."

Cost: it protects no external-service side effects ​

"But be clear about its boundaries," Lao Z said:

  • It protects file writes only, not external-service side effects. If the Agent issues two HTTP requests at the same time — POSTing to the same API, say — withFileMutationQueue cannot help you. It knows file paths; it does not know URLs.
  • It is in-process. Like ⑥, two separate processes each have their own fileMutationQueues Map, and cross-process writes to the same file still collide.
  • realpath resolution has its own cost. Every call performs filesystem I/O, which is extra overhead for high-frequency writes to small files.

Counter-argument: reading "serial per path" as "safe writes" ​

Lao Z recalled a similar misreading: "Someone saw the function name and concluded 'all file writes queue up, so it's safe'. Then they wrapped an external tool's side effect inside it — calling an external lint service, for instance. But that service's state is not something the filesystem can perceive, and the two lint requests still went out concurrently. The queue applies to filePath, not to every side effect connected to that file."

Lesson: withFileMutationQueue(filePath, fn) queues writes to filePath only; every other side effect of fn is outside the queue's boundary. Treat the queue as "an overall lock for this block of logic" and you will miss the non-file side effects.

Comparison matrix: what separates ⑥ and ⑦ ​

Dimension⑥ SessionRouter.runForClient⑦ withFileMutationQueue
How it is useda private Map on the instance, called inside the routera module-level Map, called as a function
Key typethe connection objectthe file path (normalized by realpath)
Resource awarenessnone — pure logical serializationaware of the filesystem (symlink resolution)
Layer it applies toremote session attachment and admission (pi-server)tool writes (coding-agent)
Key registrationtakes the tail synchronously, no racethe key needs an async realpath, so a separate registrationQueue serializes registration

Cross-chapter callback ​

This pattern is followed in detail in Part II · Pi in Source, Part II Chapter 8 Queuing at the Checkout — pi-coding-agent, which analyses withFileMutationQueue's release in finally, its key normalization, and its failure boundary. To "will two write requests overwrite each other", the serialization mechanism in this section is the complete answer.


Pattern ⑧: ValidatedMessageDecoder — pipeline / guard ​

Pattern definition ​

The core of the Pipeline pattern is to run one input through several stages in sequence, each stage doing one thing, with one stage's output feeding the next. In pi-mono's protocol layer, decoding is a four-stage pipeline: FrameDecoder cuts out complete frames → decodeCbor decodes a frame into an unknown value → parseClientMessage validates it against the schema → a message comes out. ValidatedMessageDecoder is this pipeline's dispatcher and, at the same time, its guard: if any stage fails, the whole pipeline is invalidated.

The real code in the source ​

Xiao A opened packages/protocol/src/codec.ts, line 65:

ts
class ValidatedMessageDecoder<T> {
	private failed = false;
	private readonly frames: FrameDecoder;

	push(chunk: Uint8Array): T[] {
		if (this.failed) throw new ProtocolValidationError(`${this.kind} message decoder has failed`);
		try {
			const messages: T[] = [];
			for (const frame of this.frames.push(chunk)) {
				messages.push(this.parse(decodeCbor(frame, { maxByteLength: this.maxFrameLength })));
			}
			return messages;
		} catch (error) {
			this.failed = true;
			if (error instanceof ProtocolValidationError) throw error;
			throw new ProtocolValidationError(`Invalid ${this.kind} protocol frame: ${boundedErrorMessage(error)}`);
		}
	}

	end(): void {
		if (this.failed) throw new ProtocolValidationError(`${this.kind} message decoder has failed`);
		try {
			this.frames.end();
		} catch (error) {
			this.failed = true;
			throw new ProtocolValidationError(`Invalid ${this.kind} protocol framing: ...`);
		}
	}
}

"Now look at the loop inside push," Lao Z said. "this.frames.push(chunk) returns a batch of complete frames, and each frame goes through decodeCbor → this.parse in turn. A single network chunk can carry several complete frames, so one push can produce multiple messages."

Xiao A pointed at the failed flag: "What is this for?"

"The guard is right here," Lao Z said. "If any stage of the pipeline fails — a bad frame, bad CBOR, a schema mismatch, all of it counts — the catch sets failed to true. From then on, every push and end call throws decoder has failed on its first line. Failure is not recoverable: once the pipeline is contaminated, you rebuild the decoder. You cannot keep feeding it bytes."

Benefit: layered validation, clean boundaries ​

"The pipeline's benefit is that each layer does one thing, so error localization stays clear," Lao Z said:

  • FrameDecoder only cuts complete frames out of a byte stream. It does not care whether a frame's content is CBOR or JSON.
  • decodeCbor only decodes a frame into an unknown value. It does not care whether that value is legal.
  • parseClientMessage only validates whether the unknown value is a legal ClientMessage. It does not care where the bytes came from.

"When one bad message comes in, the error tells you directly which stage broke: Invalid client protocol frame is the framing layer, Invalid client protocol message is the schema layer. Failure is segmented, so localization is segmented too. That is a pipeline's debugging advantage."

Cost: the decoder must be rebuilt after a failure ​

"The cost is that failure is permanent," Lao Z said. "Once failed is true, this decoder is finished. You cannot skip the bad frame and keep reading: once frame boundaries are no longer trustworthy, the boundaries of every frame after it are untrustworthy too. You trade stricter failure semantics for cleaner boundaries."

Xiao A was a little taken aback: "That cost is harsh. One bad byte and you rebuild?"

"Yes, but that is protocol-security design," Lao Z said. "If the decoder could 'skip the bad frame', an attacker could build a half-corrupt stream and keep it parsing in an untrusted state — which is the most common source of vulnerabilities in protocol parsers. Rebuild the whole thing rather than guess your way back to health across an untrusted boundary. In security terms this is fail-closed, not fail-open."

Counter-argument: reading "rebuild after failure" as "just retry once" ​

Lao Z gave one more: "Someone wrote retry logic: the decoder errors, so new another one and keep reading. They forgot that the connection's handshake state, the protocol version negotiation, and the session attachment all sit on top of that same decoder. Rebuilding the decoder is not rebuilding the connection. Rebuild only the decoder and the new one reads a byte stream that starts mid-way, with frame boundaries wrong throughout. The correct response to a decoder failure is to rebuild the entire protocol state, handshake included — not to swap the decoder alone."

Lesson: ValidatedMessageDecoder's failed is connection-level state. When it trips, what is voided is the whole protocol session, and swapping one decoder cannot rescue that. Treat it as a trigger for a single retry and you repair one end of the pipe while leaving the chain broken.

Cross-chapter callback ​

This pattern is followed end to end in Part II · Pi in Source, Part II Chapter 9 Shipping and Customs — pi-protocol, which shows the complete chain chunk → FrameDecoder → decodeCbor → schema validation, and the semantics of failed as permanent invalidation. If chapter 26 left you wondering "why rebuild the whole connection for one bad frame", come back here for the design logic of the pipeline guard.


Pattern ⑨: service subscription — publisher, snapshot plus deltas ​

Pattern definition ​

The Observer pattern has a variant for remote settings, called the Publisher. An Observer notifies a set of listeners when state changes; a Publisher first hands the subscriber a complete current state, then keeps pushing deltas. The subscriber always holds a state it can use on its own, and the deltas merely modify it. This solves a classic problem Observer has across a network: a subscriber joins mid-stream, and events alone cannot reconstruct the full state.

An older version of pi-server did this with a snapshot publisher carrying an auto-incrementing revision. In v1.0.0 it became chord's service subscription: the snapshot comes back with the response to the subscribe request, and service_update pushes the deltas afterwards.

The real code in the source ​

Xiao A opened handleRequest at line 306 of packages/server/src/server.ts and found the subscription branch:

ts
// packages/server/src/server.ts, the subscription branch of handleRequest (v1.0.0, excerpt)
const subscribing = control?.type === "subscribe" ? control : undefined;
const pendingUpdates: { readonly update: ServiceProviderUpdate }[] = [];
let subscriptionReady = subscribing === undefined;
const publish = async (subscriptionId, update) => {
	if (subscribing !== undefined && subscriptionId === subscribing.subscriptionId && !subscriptionReady) {
		pendingUpdates.push({ update });
		return;
	}
	await this.sendServiceUpdate(state, subscriptionId, update);
};
// ……call the service, get result (the snapshot, when subscribing)……
const stateEncoder = createServiceStateEncoder();
result = stateEncoder.encodeSnapshot(parseServiceSubscriptionSnapshot(result));
state.serviceStateEncoders.set(subscribing.subscriptionId, stateEncoder);
await this.sendMessage(state, { type: "response", id: envelope.id, ok: true, result });
while (pendingUpdates.length > 0) { /* re-send them in order */ }
subscriptionReady = true;

"Look at pendingUpdates," Lao Z said. "The service can publish updates before it hands over the snapshot. The server buffers them first, then re-sends them in order once the snapshot has gone out with the response. Each subscription gets its own ServiceStateEncoder; the snapshot and all later updates are encoded through it, and each update is a delta relative to that snapshot."

Benefit: the client starts from a complete state ​

"This benefit matters especially in a remote setting," Lao Z said. "A client may connect at any moment, or resubscribe after a disconnect. It does not need to know what happened before. The snapshot it gets on subscribe is the complete state, and every subsequent service_update aligns with that snapshot."

"And on the client side?" Xiao A asked.

"There are two gates there too," Lao Z said. "subscribeService in client.ts holds the raw updates before the snapshot is decoded, then decodes them in order once the snapshot decodes. A decoded update still waits for the caller's start() before it is delivered. The two ends together guarantee snapshot first, deltas after, in order."

Cost: a snapshot does not prove a command committed ​

"But the publisher's cost is this: a snapshot tells you 'what the state is now'. It does not tell you 'whether the commands you sent were executed'," Lao Z said. "After a reconnect you resubscribe, get a fresh snapshot, and conclude that everything you sent before was committed. No. The snapshot reflects the server's state at that instant. The request you sent before the disconnect may have half-executed, or may never have arrived."

"Then what can it actually guarantee?" Xiao A pressed to the bottom.

"It guarantees that you see a complete state from some instant on the server, and that later deltas are applied on top of it in order. It does not guarantee each command executes exactly once," Lao Z said. "For that you need business-level request IDs, idempotent design, or manual confirmation — none of which is the subscription mechanism's job."

Counter-argument: reading "it's in the snapshot" as "my command committed" ​

Lao Z closed with a case from a client team: "After reconnecting they saw the order status had changed in the new snapshot, and concluded their 'confirm' request had succeeded. It turned out another client had changed that status; their own request never reached the server before the disconnect. A snapshot is state, not a receipt. Conflate the two and your recovery makes wrong business decisions — shipping goods because you assumed the charge succeeded."

Lesson: a snapshot answers only "what the state is now". It cannot prove whether your request committed. Keep observational consistency (the display is right) and operational semantics (the write definitely committed) apart; the publisher pattern guarantees only the first.

Comparison matrix: Observer vs Publisher ​

Xiao A put ④ and ⑨ side by side, so Lao Z drew him a table:

Dimension④ Observer, Agent.subscribe⑨ Publisher, service subscription
What is pusheda single eventa full snapshot first, then deltas
Consumption settingwithin one process (loop → UI)across a network (server → client)
Ordering guaranteeawaits listeners one at a timeboth ends buffer, so snapshot precedes deltas
Failure recoverya listener failure affects the loopa decode failure disconnects; resubscribe after reconnect
Core promisethe event gets processedthe subscriber holds a complete state it can continue from

"Observer suits in-process settings where you trust the event order; Publisher suits networks, where a subscriber may join at any time," Lao Z wrapped up. "The core difference is not who pushes. It is whether what gets pushed is an event or a state: an event is gone once it happened; a state has a starting point, and only then do deltas mean anything."

Cross-chapter callback ​

This pattern is followed in detail in Part II · Pi in Source, Part II Chapter 10 The Blackboard and the Sticky Notes — pi-server + pi-client, which shows the division of labour between the subscription snapshot and service_update, and the real boundary of "reconnect, re-attach, resubscribe" after a disconnect. Whether you can trust a snapshot after a reconnect depends on what you trust it for: showing the current state, yes; proving a commit, no.


Beyond the nine patterns: naming errors not to make ​

The nine cases are done. There is a class of error more common than misjudging any single pattern: over-naming. Xiao A collected them into a list, and Lao Z commented on each line.

The complete over-naming trap set

  • Do not call every TypeScript interface a Template Method: Template Method requires a base class to define the algorithm's skeleton and subclasses to override steps. A plain interface is a contract, with no algorithm skeleton.
  • Do not assert that a module named proxy implements the full GoF Proxy: the Proxy pattern requires access control (lazy loading, permission checks, remote proxies). "One extra layer in the middle" is not a proxy.
  • Do not call every if/switch the Strategy pattern: Strategy requires a family of interchangeable algorithm implementations behind one interface. Plain conditional branches are just logic.
  • Do not call a callback the Command pattern: Command requires encapsulating a request as an object (supporting undo, queuing, logging). A callback is a function.
  • Do not call every Map a cache: the cache pattern requires an eviction policy (TTL, LRU, capacity limit). A Map that grows without bound is a memory leak, not a cache.
  • Do not call every queue producer-consumer: that pattern requires the producer and the consumer to be decoupled and their rates unsynchronized. A plain FIFO queue is a data structure.
  • Do not call every singleton the Singleton pattern: Singleton requires global uniqueness plus a global access point plus lazy initialization. A module-level const is just module scope.

Xiao A looked at the list and asked: "Do these errors share one root cause?"

"They do, and it is always the same one: a name standing in for understanding," Lao Z said. "See proxy and assume the access control a proxy provides; see subscribe and assume fire-and-forget; see Map and assume cache eviction. The name gave you an expectation, and you skipped the step of verifying it."

"Then what is the correct posture?" Xiao A asked.

The three correct naming steps

It is the same line from the beginning: draw the facts, pick the name, check the cost. When picking the name, choose one that does not overstate the facts, and prefer no name at all if the code fails the pattern's core constraints. When checking the cost, go through the textbook's known costs one by one: a cost the code does not handle is either a bug or a sign that the pattern does not fully apply.

"You walked nine patterns through pi-mono: factory, strategy, adapter, observer, chain of responsibility, serial queue, concurrency control, pipeline, publisher," Lao Z said. "Every one came with real code, real benefits, and real costs."


Closing: patterns are scaffolding, not the destination ​

Xiao A shut his notebook and let out a long breath. "So what are patterns actually good for?"

"You just saw it in all nine cases," Lao Z said. He stacked the nine comparison matrices and pushed them across. "See subscribe and you know to think toward the observer. See createProvider and you know to think toward the factory. That is what scaffolding does. But scaffolding is not the house. The code's real behaviour and its real costs, you still have to verify yourself."

Back to the book's goal

Walking these nine patterns through pi-mono, what you take away is more than judgements like "this code looks like an observer". You also take away:

  • Every pattern has a column of real costs: the observer's ordering constraint, the adapter's information loss, the chain's diagnostic requirement, the pipeline's fail-closed, the publisher's non-transactional nature.
  • Every cost has a matching counter-case: reading side-channel observation as the main path, reading an in-process queue as a cross-process lock, reading a snapshot as a commit receipt.
  • Every pattern has a cross-chapter callback: chapter 19 for the factory, chapters 19 and 20 for the strategy, chapters 21 and 22 for the observer, chapter 27 for the queue and the publisher, chapter 25 for concurrency control, chapter 26 for the pipeline.

"Next time you open an unfamiliar codebase," Lao Z said, "it is still those three steps: draw the call graph, ask which pattern it resembles, then check the cost column. You will find the code stops being scattered and becomes a set of decisions, each carrying a price. What design patterns are really for in engineering is to let you see the trade-off behind every decision."