solid · git:20260523.deb156a · 2026-05-23 · sha256 30e9b7d19571242c

solid git:20260523.deb156aC

Immutable. This exact content never changes and is served at /api/v1/blob/30e9b7d19571242c.

# SOLID Skill

## Overview

SOLID is Robert C. Martin's five object-oriented design principles. In a
TypeScript codebase like Flight School — which mixes a Next.js API surface,
a Copilot SDK adapter layer, GitHub data fetching, and a streaming UI —
SOLID is mostly a tool for keeping modules **swappable, testable, and
free of hidden coupling**. We don't have deep class hierarchies, but we
do have:

- multiple AI providers behind one interface,
- multiple persistence backends (in-memory dev, Cosmos in production),
- many API routes that share auth + rate-limit + audit concerns, and
- streams generated by very different sources (Copilot SDK, mocks, tests).

SOLID gives us shared vocabulary for when to split a module, when to add
an interface, and when *not* to.

## Authoritative reference

This skill is reference material. **The source of truth for code style,
naming, exports, and module organization is
[`.github/instructions/typescript.instructions.md`](../../instructions/typescript.instructions.md).**

If anything here appears to conflict with that file, the instructions
win. In particular:

- Single Responsibility Functions, module organization, and DRY rules
  are already covered by `typescript.instructions.md` — this skill links
  to them rather than restating them.
- Documentation conventions (TSDoc) are covered by
  [`documentation.instructions.md`](../../instructions/documentation.instructions.md).

---

## S — Single Responsibility Principle

> A module should have one, and only one, reason to change.

In practice: split modules along **axes of change**. If two pieces of
logic change for different reasons (different team, different deploy
cadence, different test setup), they don't belong in the same file.

### Spot it

- A file imports both `fs`/`fetch` *and* business rules.
- One module exports a "god object" used by everything (`utils.ts`,
  `helpers.ts`, ambient singletons).
- A test for one function must mock five unrelated subsystems.
- A change to UI copy forces a change in the data layer.

### Example in this codebase

The GitHub client used to be a process-wide singleton Octokit that also
owned **ambient token resolution** — reading `GITHUB_TOKEN` from the
environment or shelling out to `gh auth token`. That single module owned
three responsibilities at once: ambient token discovery, Octokit
construction, and request instrumentation — and shared one cached
instance across users. The "reason to change" was different for each
(multitenancy, telemetry, auth), but they were entangled.

The current `src/lib/github/client.ts` has no ambient auth surface at
all. Token resolution lives in the auth layer
(`src/lib/auth/context.ts`); this module only knows how to *use* a
token it was handed:

```ts
// src/lib/github/client.ts
export function getOctokitForToken(token: string): Octokit {
  const instance = new Octokit({ auth: token });
  instrumentOctokitRequests(instance);
  return instance;
}

export async function getOctokitForRequest(): Promise<Octokit> {
  const { accessToken } = await requireUserContext();
  return getOctokitForToken(accessToken);
}
```

Each function has one job: build an Octokit for a given token, or bind
to the current request's authenticated user. The file-level docstring is
explicit about what was *removed* and why:

> There is intentionally **no** ambient token resolution in this
> module — no `GITHUB_TOKEN` env fallback, no `gh auth token` CLI
> lookup, no process-wide cache. Every caller must supply a token that
> came from the authenticated user context.

The previous module collapsed three axes of change (instance lifecycle,
ambient auth discovery, per-user authorisation) into one file. Splitting
them — and deleting the ambient path entirely — is SRP in service of a
security boundary: a leak test (`src/lib/github/client.test.ts`'s
`'does not export legacy token resolvers'`) now pins that the helpers
can never come back.

See also: [Single Responsibility Functions
rule](../../instructions/typescript.instructions.md#single-responsibility-functions).

---

## O — Open/Closed Principle

> Modules should be open for extension, but closed for modification.

In TypeScript this rarely means inheritance. It usually means: design
the **shape of a call site** so new behaviour can be added by composing
new pieces, not by editing the existing function.

### Spot it

- Adding a new feature requires editing a `switch` / `if-else` ladder in
  a shared function.
- Every new API route copies the same setup boilerplate (auth, rate
  limit, audit) and then drifts.
- The only way to add behaviour is a new boolean parameter.

### Example in this codebase

`withUserGuards` in `src/lib/security/guard.ts` is built so any new
expensive API route gets the same auth + rate-limit + concurrent-cap +
audit treatment without modifying the wrapper itself. Cross-cutting
concerns are *configured*, not re-implemented:

```ts
// src/lib/security/guard.ts
export async function withUserGuards<T>(
  opts: GuardOptions,
  work: (ctx: UserContext) => Promise<T>,
): Promise<T> {
  const ctx = await requireUserContext();
  // ...rate limit (skipped if opts.rateLimit is omitted)...
  // ...concurrent cap (skipped if opts.concurrentCap is omitted)...
  auditLog({ type: opts.eventType, /* ... */ });
  try { return await work(ctx); } finally { release?.(); }
}
```

A new route adds the wrapper and supplies its own `eventType` / limits.
The guard module stays closed; the route extends it via composition:

```ts
export async function POST(request: NextRequest) {
  return withUserGuards(
    { rateLimit: { limit: 10, windowMs: 60_000 }, concurrentCap: 2,
      eventType: 'copilot.session.create' },
    async ({ userId }) => { /* the actual work */ },
  );
}
```

When we need a new guard (say, billing checks), we extend `GuardOptions`
or compose another wrapper — without touching every route.

---

## L — Liskov Substitution Principle

> Subtypes must be usable in place of their supertypes without surprising
> callers.

In TypeScript this is mostly about **interface contracts**: every
implementation of an interface must honour the same observable
behaviour. Throwing where the contract said you return is an LSP
violation, even if the types compile.

### Spot it

- Implementations of an interface differ in which methods *actually*
  work.
- A "real" implementation throws on calls that the in-memory one
  silently returns `null` for (or vice versa).
- Callers add `instanceof` checks to recover.

### Example in this codebase

`TokenStore` in `src/lib/auth/token-store.ts` has two implementations:

```ts
// src/lib/auth/token-store.ts
export interface TokenStore {
  getToken(userId: string): Promise<StoredToken | null>;
  setToken(userId: string, token: StoredToken): Promise<void>;
  deleteToken(userId: string): Promise<void>;
  cleanupExpired(): Promise<number>;
}

export class InMemoryTokenStore implements TokenStore { /* process-local Map */ }

export class CosmosTokenStore implements TokenStore {
  // Cosmos DB-backed with envelope encryption:
  //  - per-record AES-256-GCM data encryption key (DEK)
  //  - DEK wrapped with an Azure Key Vault KEK via `wrapKey(A256KW)`
  //  - documents partitioned by userId so a buggy or hostile caller
  //    cannot decrypt another user's row.
}
```

The intent — captured in the file docstring — is explicitly Liskov:

> The {@link TokenStore} contract is the Liskov boundary: callers MUST
> be able to swap implementations without behavioural change.

Both implementations honour the same observable contract: unknown users
return `null`, expired records return `null` (callers treat "not found"
and "expired" identically), `setToken` upserts, `deleteToken` is
idempotent. The Cosmos store adds confidentiality at rest (envelope
encryption, AAD bound to `userId`/`expiresAt`/`kekId` so tampered
documents fail decryption), but it does **not** change the observable
behaviour — that is the whole point. A caller wired through
`getTokenStore()` cannot tell which one it has, and tests can swap the
in-memory store in without rewriting code.

Where LSP could go wrong here: if `CosmosTokenStore` started throwing on
"user not found" instead of returning `null`, or made `deleteToken`
non-idempotent, every caller of `getTokenStore()` would need to learn
the difference. Keeping the contract identical is what makes the
substitution invisible.

---

## I — Interface Segregation Principle

> Don't force consumers to depend on methods they don't use.

In TypeScript: prefer **several small, focused interfaces** over one
fat one. A function that only needs an ID and a token shouldn't have to
accept the entire `SessionOptions` bag.

### Spot it

- Callers pass `undefined` for half the fields of an options object.
- Tests build huge fixture objects to call a small function.
- Adding a field to a "context" type ripples through unrelated call
  sites.

### Example in this codebase

`src/lib/copilot/server.ts` defines a narrow `SessionIdentity` interface
that is the *only* per-request input most session factories need:

```ts
// src/lib/copilot/server.ts
export interface SessionIdentity {
  userId: string;
  gitHubToken: string;
}

export async function createLoggedCoachSession(
  identity: SessionIdentity,
  operationName = 'Coach Session',
  inputPrompt = '',
) { /* ... */ }
```

Internally the Copilot SDK takes a wider `SessionOptions` (from
`src/lib/copilot/types.ts`):

```ts
// src/lib/copilot/types.ts
export interface SessionOptions {
  // Multi-tenant invariant — BOTH required:
  userId: string;       // partitions the session cache
  gitHubToken: string;  // ghu_ token forwarded to the SDK / MCP
  // Optional configuration:
  systemMessage?: string;
  includeMcpTools?: boolean;
  tools?: string[];
  model?: string;
}
```

But callers in API routes — `src/app/api/focus/route.ts`,
`src/app/api/jobs/job-executors.ts` — only ever see `SessionIdentity`
(the narrow `{ userId, gitHubToken }` pair, which happens to coincide
with the *required* subset of `SessionOptions`). They cannot accidentally
over-configure a session (wrong model, wrong tool list, wrong system
message) because the wide options shape is not in their interface.
`SessionOptions` is reserved for the small set of factory functions that
genuinely need it.

Note that `userId` and `gitHubToken` are non-optional on `SessionOptions`
itself — defaulting either would let sessions leak between users sharing
a sticky-routed process. ISP doesn't mean "make everything optional";
it means "don't expose fields callers shouldn't reason about."

The same idea applies to React props: prefer many small prop types over
one giant `ComponentProps`.

---

## D — Dependency Inversion Principle

> High-level modules should not depend on low-level modules. Both should
> depend on abstractions.

In TypeScript this usually appears as: the **transport** or
**representation** is abstracted, so the high-level orchestrator
(API route, hook) doesn't know whether the underlying source is a real
Copilot session, a fake, or a test stub.

### Spot it

- An API route directly constructs an SDK client and reads from its
  raw events.
- Replacing the event source in a test requires patching globals.
- Two routes implement the same SSE wire format slightly differently.

### Example in this codebase

`createSSEResponse` in `src/lib/api/streaming-utils.ts` is the
high-level abstraction every streaming route depends on. Routes hand it
an **async generator of events** — they do not encode SSE framing, they
do not own heartbeats, and they do not know whether the events came
from a Copilot SDK session, a mock, or an iterator under test:

```ts
// src/lib/api/streaming-utils.ts
export function createSSEResponse<T extends SSEStreamEvent, TMeta extends SSEStreamEvent = SSEStreamEvent>(
  streamGenerator: () => AsyncGenerator<T, void, undefined>,
  options?: CreateSSEResponseOptions<TMeta>,
): Response {
  // ...handles encoding, heartbeats, [DONE] marker, error frames, cleanup...
}
```

A route then composes whatever event source it wants:

```ts
const { stream, cleanup, model } = await createStreamingSession(prompt);

return createSSEResponse(
  async function* () {
    for await (const event of stream) {
      yield event;
    }
  },
  { onComplete: () => ({ type: 'meta', model, /* ... */ }), cleanup },
);
```

The route depends on the *abstraction* (an async generator of typed
events) — not on the Copilot SDK, not on `ReadableStream`, not on the
SSE wire format. Tests can hand `createSSEResponse` a generator of
fixture events and exercise the full pipeline without spinning up an
SDK client.

That inversion is what makes the streaming tests in
`src/lib/api/response-utils.test.ts` and `validation-utils.test.ts`
trivial: the wire layer and the source are no longer entangled.

---

## When to apply

Apply SOLID **as a diagnostic, not a checklist**. Reach for it when:

- **SRP** — a single file mixes I/O (fetch, fs, child_process) with
  business rules, or one module is touched by every PR.
- **OCP** — you're about to add another `switch` arm or a boolean flag
  to a shared function. Consider passing in a strategy or composing a
  wrapper instead.
- **LSP** — you have more than one implementation of an interface, and
  the substitution is meant to be invisible to callers (env-specific
  backends, fakes for tests).
- **ISP** — a function takes a wide options object but uses three
  fields. Consider exporting a narrower interface for that call site.
- **DIP** — high-level code (API routes, hooks, orchestrators) is
  hard-wired to a specific transport, vendor SDK, or storage engine.

## When NOT to over-apply

SOLID is a tool, not a religion. In this codebase, the following
patterns are intentionally pragmatic:

- **No interface for single-implementation code.** Don't introduce a
  `FooService` interface when only one `Foo` exists. Wait until a real
  second implementation (a fake, a vendor swap) appears.
- **Small files can mix concerns.** A 30-line utility module doesn't
  need to be split into three files just to honour SRP. SRP is about
  *axes of change*, not line counts.
- **Don't segregate interfaces preemptively.** Splitting `SessionOptions`
  into five tiny interfaces no caller asks for adds noise. Segregate
  when a consumer is genuinely forced to know fields it doesn't use.
- **Inversion has a cost.** Adding an abstraction (`TokenStore`,
  `createSSEResponse`) is justified by *more than one* current or
  imminent implementation. A one-off indirection just hides the call
  graph.
- **YAGNI beats OCP.** Don't pre-build extension points for hypothetical
  future guards, providers, or routes. Refactor when the second case
  arrives — by then you know what the abstraction actually needs.

If applying a SOLID principle makes the code harder to read or harder
to grep, you've probably over-applied it. Step back.

## See also

- [`typescript.instructions.md`](../../instructions/typescript.instructions.md) — authoritative TypeScript style, DRY rules, naming, exports, module organization.
- [`documentation.instructions.md`](../../instructions/documentation.instructions.md) — TSDoc standards for the interfaces you create.
- [`.github/skills/dry/SKILL.md`](../dry/SKILL.md) — companion skill on DRY (forward link; may not yet exist).
- [`.github/skills/copilot-sdk/SKILL.md`](../copilot-sdk/SKILL.md) — Copilot SDK patterns referenced in the DIP example.