Typed interfaces for side effects, real implementations as defaults, accepted
as params, tested with plain object mocks. No vi.mock — dependencies flow
through signatures, so code is runtime-independent (Deno / Node / tests) via
parameterization, not magic mocks or ambient singletons.
Small standalone *Deps interfaces, composed bottom-up. Leaf functions
import small interfaces directly; the entry point assembles app-level
composites for wiring and threads them down; leaf functions never take the
composite.
// fuz_app's runtime layer is the exemplar
export interface EnvDeps {
env_get: (name: string) => string | undefined;
env_set: (name: string, value: string) => void;
}
export interface FsReadDeps {
stat: (path: string) => Promise<StatResult | null>;
read_text_file: (path: string) => Promise<string>;
readdir: (path: string) => Promise<Array<string>>;
}
export interface CommandDeps {
run_command: (cmd: string, args: Array<string>, options?: RunCommandOptions) => Promise<CommandResult>;
}
// Functions declare exactly what they need via intersection
export const setup_env_file = async (
deps: FsReadDeps & FsWriteDeps & CommandDeps,
env_path: string,
example_path: string
): Promise<void> => { /* ... */ };
// App-level composite — wiring layer only
export interface RuntimeDeps extends EnvDeps, FsReadDeps, FsWriteDeps, CommandDeps, LogDeps /* … */ {
env_all: () => Record<string, string>;
readonly args: ReadonlyArray<string>;
cwd: () => string;
}Platform factories build the composite once at the entry point —
create_deno_runtime(args), create_node_runtime(args),
create_mock_runtime(args). There is no browser factory: browser/component
DI is Svelte context (§Scope).
Why not Pick<GodType>: Pick<AppRuntime, 'env_get'> forces every
consumer to import the god type. Standalone interfaces are shareable across
projects, trivially mocked ({env_get: () => 'value', env_set: () => {}}),
composable by intersection, and self-documenting. Pick<> on a small
*Deps is fine; a Pick<> narrowing reused across many sites is a named
interface waiting to happen — fuz_app's action factories take
ActionFactoryDeps {log, audit} (auth/deps.ts) rather than repeating
Pick<RouteFactoryDeps, 'log' | 'audit'> at every site.
Bundles vs single capabilities: *Deps names the injected bundle. Its
members are often pure-noun service interfaces or classes (Keyring, Logger,
AuditEmitter, FactStore), and a standalone single-capability interface
keeps its noun name (fuz_util's FactStore — "interface only, backends live
downstream"). Don't suffix a single service interface with Deps; the suffix
marks the parameter-bundle role.
| Suffix | Contains | Test behavior | Rule |
|---|---|---|---|
*Deps | Capabilities (functions, services) | Fresh mock factories per test case | Things swapped for testing/platform |
*Options | Data (config values, limits, flags) | Literal objects, constructed once, reused | Static values — no mock factory |
*Context | Scoped world for a callback/handler | Depends on scope (may contain deps + data) | The world available within a bounded scope |
*Context examples: per-request RouteContext ({db, pending_effects, ...}),
per-setup-callback AppServerContext. A *Context may structurally satisfy a
*Deps — route handlers pass RouteContext to query_* functions because it
satisfies QueryDeps = {db: Db}. No *Config — ? already expresses
optionality; all parameter bags are *Options. *Input is reserved for
mutation payloads.
Keep the categories separate. A *Deps mixing capabilities with config
values (thresholds, paths) blurs two things that test differently — split into
*Deps + *Options, or use the ad-hoc form below deliberately and say so.
(Rust collapses them into one *Options struct on purpose; TS holds them
apart — each is the language-appropriate shape.)
| What | Convention | Example |
|---|---|---|
| Small interface | {Domain}Deps | EnvDeps, FsReadDeps, CacheDeps |
| Capability bundle | {Scope}Deps | AppDeps, RouteFactoryDeps |
| Full composite | RuntimeDeps | extends all small *Deps |
| Default impl | default_{domain}_deps | default_cache_deps |
| Mock factory | create_mock_{domain}_deps | create_mock_cache_deps |
| Stub factory | stub_{scope}_deps | stub_app_deps |
Files: deps.ts (interfaces) + deps_defaults.ts (production defaults) + a
test-side mock module (fuz_css: src/test/fixtures/mock_deps.ts) — fuz_css's
CacheDeps / default_cache_deps / create_mock_cache_deps is the cleanest
exemplar.
Legacy *Operations (fuz_repos): GitOperations /
default_git_operations / create_mock_git_ops under a GitopsOperations
composite with an ops param — migrating to *Deps opportunistically (fuz_css
already did; fuz_repos's task entry points and npm_registry.ts take *Deps,
e.g. GitopsPublishDeps, NpmRegistryDeps). Never author new
*Operations; follow local naming when touching fuz_repos's *Operations
code.
L0 — platform shims (FsReadDeps, CommandDeps): mirror the platform —
positional params, throw on error, like Deno.readTextFile / node:fs.
Stable, trivially implemented by any runtime.
L1 — domain wrappers (CacheDeps, git/npm operations): single
options-object params, uniform Result returns with typed errors — reads,
writes, and queries all return Result<{value: T}, FsError>; no mixing
string | null reads with Result writes. Implementations route thrown errors
through fs_classify_error(error) (@fuzdev/fuz_util/fs.ts), mapping
ENOENT/EACCES/EPERM/EEXIST to a discriminated kind:
type FsError =
| { kind: 'not_found'; message: string }
| { kind: 'permission_denied'; message: string }
| { kind: 'already_exists'; message: string }
| { kind: 'io_error'; message: string };
// FsJsonError adds {kind: 'invalid_json'} — where missing vs corrupt must be distinguishable
if (!r.ok && r.kind !== 'not_found') throw new Error(r.message); // rm -f semanticsCallers branch on kind, not regex on message. The uniform shape keeps the
contract symmetric with the Rust twin. Don't mix the two contracts on one
interface; don't leak platform types (node's SpawnOptions) through an L1 shape.
create_account_route_specs(deps: RouteFactoryDeps, options: AccountRouteOptions).const { deps = default_cache_deps } = options;.deps: FsReadDeps & FsWriteDeps & CommandDeps & EnvDeps.export interface BootstrapAccountDeps {
db: Db;
token_path: string; // data mixed in deliberately — one-signature convenience
read_text_file: (path: string) => Promise<string>;
password: Pick<PasswordHashDeps, 'hash_password'>;
log: Logger;
}create_app_backend(options) builds AppDeps and returns it with
lifecycle metadata; create_app_server({backend, ...}) consumes it.
Post-assembly extension points register through documented methods on the
capability, not by re-shaping the bundle: the audit emitter's add_listener
(same identifier as its Rust twin), and AppDeps.connection_closer's
add(closer) — create_app_backend creates the closer empty and
create_app_server adds each WebSocket transport it mounts and its audit
stream registry, so a revocation handler built from the bundle closes on
every transport the server mounts. Both registrations return a remover
(Rust: add_listener returns an id for remove_listener), and
create_app_server runs its removers when assembly throws and on close, so
the bundle's emitter and closer end where they started. A transport mounted
by hand passes deps.connection_closer to its mount helper
(register_ws_endpoint, create_audit_log_sse), which adds it permanently —
or passes null and adds it itself to hold the remover.bootstrap_status flag
object) is passed separately, never smuggled into a deps bundle.Logger singleton — the label belongs to the consumer.
Where a consumer has no logger, its adapter delegates explicitly to
console.warn. Diagnostic-only log? on leaf helpers (silently absent = no
extra diagnostics) is a different, acceptable shape — the rule is about
capabilities the function needs on some path.Plain objects implementing the interfaces — no vi.mock(), no Sinon;
individual vi.fn() for call tracking is fine.
Partial<T> overrides spread last:
create_mock_git_deps = (overrides: Partial<GitDeps> = {}): GitDeps => ({ current_branch_name: async () => ({ ok: true, value: 'main' }), …, ...overrides }).create_mock_cache_deps(state: MockFsState) reading/writing
state.files and returning {ok: false, kind: 'not_found', …} on a miss.create_tracking_process_deps() returns
{deps, get_spawned_commands}: a call log exposed via getters next to the deps.testing/stubs.ts):
create_throwing_stub<T>(label) — a Proxy throwing on any access;
stub_app_deps builds a whole bundle of these. Prefer this default — a
silent no-op can mask setup mistakes. create_noop_stub<T>(label) /
create_stub_app_deps() for tests where incidental access is fine.create_mock_runtime(args) returns full
RuntimeDeps with observable state (mock_env, mock_fs, exit_calls,
command_calls); exit throws MockExitError. Stub factories accept the
same narrow *Deps contracts production uses — never Pick<GodType>.read_file?:
defaulting to a module-level node:fs import couples the module to one
runtime and hides the effect. Require the dep, or default at an explicit
platform factory — not per-field at module scope.connection_closer?:, a revocation handed none still succeeds
and the connections it should have ended stay open; nothing fails. Make the
capability a required member and name the no-op: a backend with no live
connections passes noop_connection_closer, so opting out is written at the
call site.*Deps name — config values mixed with
capabilities, several optional-with-fallback; tests can't tell what needs a
mock vs a literal. Split, or use the ad-hoc form deliberately.query_account_by_id(...) imported directly) leave vi.mock as the only
seam; this is how module-mocking creeps back. Thread the function through a
deps param. fuz_app documents its remaining auth query_* module-mock cluster
as a carve-out with vi.restoreAllMocks() hygiene; treat any new instance as
a smell.Pick<Composite> at leaves, or passing the composite
down more than one level.{...deps, extra} downstream re-shapes the bundle
mid-flight. Building a purpose-built object at a wiring point where sources
converge is legitimate; spreading to extend someone else's bundle is not.
Inline narrowing ({db} selected from a bundle) is fine.*Deps params across a component tree — browser/UI uses Svelte
context (create_context; fuz_app's *_rpc_context adapters).
Function-param deps are for plain TS call graphs.fs.ts, process.ts, git.ts)
export bare functions over the platform plus the shared contracts (FsError,
Result) that *Deps interfaces are typed against — they're what default
implementations are made of.AnalysisLog, satisfied by fuz_util's Logger and Vite's
logger) are the same spirit without the suffix.*Deps is TS-only — Rust traits are capabilities. Rust uses pure-noun
capability traits (PasswordHasher, Storage) and *Options structs, with
cfg/features, the crate graph, and enum dispatch covering much of what TS
solves with injection. Full treatment: ./rust-patterns §Dependency Injection.
| Flavor | Exemplar | Injection style |
|---|---|---|
Narrow platform deps + RuntimeDeps composite | fuz_app runtime/ | Required first param; composite at entry points |
App capability bundle (AppDeps, RouteFactoryDeps, QueryDeps, ActionFactoryDeps) | fuz_app server | Required first param; two-step composition root |
Focused domain deps (CacheDeps) | fuz_css | Optional param with default (deps = default_cache_deps) |
Grouped legacy *Operations | fuz_repos | Optional ops param — migrating to *Deps |