The Nestor v3.5 release that landed today is mostly a refactor. The numbers are satisfying enough that I want to share both the result and the three patterns that made it tractable across very different files.
Three TypeScript files in the Nestor monorepo had grown into the kind of god-objects that nobody enjoys touching. Each had a different shape — a database access layer, an agent runtime class, an interactive REPL — and yet the same three patterns ended up unblocking all three.
| File | Before | After | Δ | New modules |
|---|---|---|---|---|
packages/db/src/store.ts | 5 828 | 1 902 | −67% | 30+ domain stores |
packages/agent/src/runtime.ts | 2 107 | 1 372 | −35% | 13 helper modules |
packages/cli/src/commands/shell.ts | 2 973 | 1 327 | −55% | 14 focused modules |
| Total | 10 908 | 4 601 | −56% | 50+ new modules |
What I want to focus on isn't "refactoring is good" (boring) but the three concrete patterns that ended up doing 90% of the work — and why each one mattered for a specific shape of god-object.
Why bother
The honest answer: contributor experience. None of these files were buggy. All three had grown organically over a year of feature work. They passed tests, shipped on time, and powered a public npm package with thousands of downloads. There was no production reason to touch them.
But every time someone wanted to add a new domain to the database — say, a new missions domain — they had to read 5 800 lines of unrelated code to find where to add their CRUD methods. Same for adding a slash command to the shell, or a new helper to the agent runtime. The friction was real and it was killing momentum.
The constraint going in was hard: zero public API breaks. nestor-sh is published on npm and consumed by @nestor/server, @nestor/cli, the Studio UI, the MCP server, and a handful of external integrations. Anything that broke NestorStore's method signatures or AgentRuntime's constructor shape was a non-starter.
Pattern 1 — StoreCore delegation with Parameters<> / ReturnType<>
Used for: store.ts. The shape: a single class NestorStore with 200+ methods covering 40+ different domain entities (agents, runs, missions, skills, memory, audit logs, webhooks, scheduled tasks, etc.). Methods within the same domain were near-neighbors but methods across domains were unrelated.
The split moves each domain into its own file under packages/db/src/store/. Each domain file exports a class extending a small StoreCore interface that gives access to the underlying SQLite handle and a few cross-cutting helpers (encryption, telemetry).
// packages/db/src/store/skills.ts
import type { StoreCore } from './core.js';
export class SkillsStore {
constructor(private core: StoreCore) {}
createSkill(skill: SkillRecord): void { /* ... */ }
listSkills(): SkillRecord[] { /* ... */ }
// 9 more methods, all skill-specific
}
NestorStore then composes the domain stores in its constructor and delegates with thin pass-through methods. The trick that made this actually backwards-compatible was using TypeScript's Parameters and ReturnType utility types to derive delegate signatures from the source of truth:
// packages/db/src/store.ts
export class NestorStore {
private skillsStore!: SkillsStore;
constructor(/* ... */) {
this.skillsStore = new SkillsStore(core);
}
// Type signature derived from the source — never drifts
createSkill(s: Parameters<SkillsStore['createSkill']>[0]): ReturnType<SkillsStore['createSkill']> {
return this.skillsStore.createSkill(s);
}
listSkills(): ReturnType<SkillsStore['listSkills']> {
return this.skillsStore.listSkills();
}
}
This means every external consumer (server routes, CLI commands, tests) keeps calling store.createSkill(...) and store.listSkills() exactly as before. The class is unchanged at the call site. Only the internals are decomposed.
The delegate-signature derivation matters because if you write the signature manually in store.ts, it can drift from the domain implementation. With Parameters<> the compiler enforces consistency — change a parameter in SkillsStore.createSkill, and the delegate updates automatically.
What broke (briefly)
One subtle gotcha: when the server package imported a return type that originated from a domain store, TypeScript started complaining with TS2742: The inferred type of 'getStats' cannot be named without a reference to '../../node_modules/@nestor/db/dist/store/approval-requests.js'. The fix was a one-line re-export from the package's main entry:
// packages/db/src/index.ts
export type { ApprovalRequestStats } from './store/approval-requests.js';
Easy to fix once spotted. Trivial to spot when you run a full pnpm -r build before each commit (which I now do religiously after this incident).
Pattern 2 — Minimal interfaces for helper extraction
Used for: shell.ts. The shape: a 2 973-line file driving the interactive REPL, with a single ShellSession object holding 30+ fields (active agent, conversation history, total tokens, total cost, approval mode, multiline buffer, fork snapshots, current runtime, current router, verbose flag, and so on).
Many helpers needed only 3 or 4 fields off that session. But typing them as (session: ShellSession) => void coupled the helper module to the entire session shape — defeating the point of extraction.
The fix: each extracted module declares its own minimal interface describing only the fields it actually reads:
// packages/cli/src/commands/shell/ui-helpers.ts
export interface UiSession {
activeAgent: { name: string; adapterConfig: Record<string, unknown> } | null;
totalTokensIn: number;
totalTokensOut: number;
totalCostUsd: number;
totalToolCalls: number;
}
export function renderStatusBar(session: UiSession): void { /* ... */ }
export function printInlineBudget(session: UiSession): void { /* ... */ }
The full ShellSession in the parent file structurally satisfies UiSession, so calls compile without casts. But the helper module compiles in complete isolation — you can read it without scrolling back to the 30-field session definition.
Same trick applied to CompleterSession (for tab completion, needs 1 field), PlanSession (for /plan dry-run, needs 6), HistoryConfig (for shell-history persistence, needs 3 paths). Each helper exports a minimal contract.
This is the trick I'd reach for first in any TS codebase with big stateful objects. It's free at runtime — the interfaces are erased — and it keeps each helper file readable on its own.
Pattern 3 — Callback injection for cross-module dependencies
Used for: shell.ts again, and runtime.ts. The shape: an extracted slash command (/pair, /plan, /handoffs) needs to invoke a function that still lives in the parent file (executeAgentTask, importAgentModule) — and importing the parent from the child creates a dependency cycle.
The fix: pass the function as a callback parameter at the call site, and define a clean type for it in the child module:
// packages/cli/src/commands/shell/pair-command.ts
export type PairTaskRunner = (
agent: AgentConfig,
task: string,
session: PairSession,
) => Promise<void>;
export type PairAgentModuleLoader = () => Promise<PairAgentModuleLike | null>;
export async function cmdPairMode(
args: string[],
session: PairSession,
rl: readline.Interface,
importAgentModule: PairAgentModuleLoader,
executeAgentTask: PairTaskRunner,
): Promise<void> { /* ... */ }
Then the parent calls in:
// packages/cli/src/commands/shell.ts
import { cmdPairMode } from './shell/pair-command.js';
// in handleSlashCommand:
await cmdPairMode(args, session, rl, importAgentModule, executeAgentTask);
The dependency direction is one-way: parent → child. The child has zero knowledge of the parent. You can read pair-command.ts in isolation and understand exactly what it depends on (everything is a parameter).
This is functional dependency injection — no DI framework, no decorators, no class hierarchy. Just plain function parameters with named types. It composes well: each child module declares its callbacks, the parent wires them together at the top level.
What I would not do again
Two things, learned the hard way.
1. Don't trust pnpm -r test alone before committing. One commit pushed with a TypeScript error in @nestor/db because the test command silently skipped builds. The fix: always run pnpm -r build as a build verification gate before git commit. Tests passing on cached dist/ outputs lull you into a false sense of correctness.
2. Don't force-extract just to push the line count down. By the time runtime.ts got to 1 372 lines (from 2 107), every remaining big chunk was either:
- a 380-line constructor of stateful subsystem wiring (
HotSwapAdapter,StuckDetector,CircuitBreaker,CompletionDetector, etc.) — splitting this means returning multi-value tuples from a "constructor helper", which is more indirection than insight; - two big async loops (
_runInternal,runStreaming) that orchestrate already-extracted helpers — these are doing exactly what a runtime should do, and breaking them up hides the control flow; - or 8 trivial accessor methods (
getEvents,getMessages,isRunning) that are 3 lines each.
Diminishing returns are real. −35% on runtime.ts was the right place to stop. Forcing more extraction would have added indirection without clarity gain.
Reproducing the pattern on your own monorepo
If you have a TypeScript monorepo with one or more god-objects that contributors avoid touching, the rough sequence I'd suggest:
- Inventory the file by domain or concern. Identify the natural seams. Each domain should have ≥3 methods that touch overlapping state.
- Extract one domain end-to-end using the StoreCore-style delegation pattern. Verify the public API is unchanged. Run all tests. Commit.
- Repeat per domain, one commit each. Small commits make bisecting easy if something breaks subtly later.
- For helper extraction (shell-style, not store-style), define minimal interfaces for the state the helper needs. Don't pass the full session/runtime/context object.
- For cross-module deps, use callback injection. Avoid two-way imports.
- Stop at diminishing returns. The signal: each new extraction adds more indirection than it removes.
- Always build before committing.
pnpm -r buildfor monorepos. Don't trusttestalone.
None of these patterns are novel. They're standard TS / OO techniques applied with restraint. What was novel was the discipline of applying them to three different shapes of god-object across a single release while keeping the public API frozen.
Try the v3.5 release
If you want to see the result, the refactored Nestor is on npm:
npx nestor-sh install
The full diff is on GitHub; the release page with the complete changelog is at github.com/lrochetta/nestor/releases/tag/v3.5.0.
If you split a god-object using one of these patterns, I'd love to hear about it — drop a note in Discussions.