/skills/fuz-stack/references/dependency-injection
  • docs
  • skills
  • fuz-stack
    • Code Generation
    • CSS Patterns
    • Database Query Patterns
    • Dependency Injection
    • Documentation System
    • fuz_util
    • mdz — Strict Markdown Dialect
    • Approved npm Dependencies
    • Path References in Documentation
    • Approved Rust Dependencies
    • Rust Patterns for the Fuz Ecosystem
    • Rust Performance Patterns
    • Rust Spine & Consumer Servers
    • Svelte 5 Patterns
    • Task Patterns
    • Testing Patterns
    • TSDoc Comment Style Guide
    • Twin Implementations (TS ↔ Rust)
    • WASM Patterns
    • Zod Schemas
  • grimoire
  • tools
  • hash

Dependency Injection

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.

Convention

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.

Parameter Type Suffixes

SuffixContainsTest behaviorRule
*DepsCapabilities (functions, services)Fresh mock factories per test caseThings swapped for testing/platform
*OptionsData (config values, limits, flags)Literal objects, constructed once, reusedStatic values — no mock factory
*ContextScoped world for a callback/handlerDepends 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.)

Naming

WhatConventionExample
Small interface{Domain}DepsEnvDeps, FsReadDeps, CacheDeps
Capability bundle{Scope}DepsAppDeps, RouteFactoryDeps
Full compositeRuntimeDepsextends all small *Deps
Default impldefault_{domain}_depsdefault_cache_deps
Mock factorycreate_mock_{domain}_depscreate_mock_cache_deps
Stub factorystub_{scope}_depsstub_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.

Layer Contracts (L0 platform vs L1 domain)

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 semantics

Callers 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.

Consumption Patterns

  • Required first param for internal/library functions: create_account_route_specs(deps: RouteFactoryDeps, options: AccountRouteOptions).
  • Optional with default for public API surfaces: const { deps = default_cache_deps } = options;.
  • Narrow intersection — exactly the capabilities used: deps: FsReadDeps & FsWriteDeps & CommandDeps & EnvDeps.
  • Ad-hoc per-function deps — a unique combination gets its own co-located interface; use when sharing would add coupling without reuse: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; }
  • Composition root — assembled once, flows down. fuz_app's two-step assembly: 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.

Design Principles

  • Result returns, never throw in L1; L0 mirrors the platform and throws.
  • Stateless capabilities — mutable state (a bootstrap_status flag object) is passed separately, never smuggled into a deps bundle.
  • Runtime agnosticism — no module-level env/fs imports in code that might run outside one runtime; load via deps. Direct platform imports are for platform factory files and explicitly single-runtime modules (document the carve-out at the module).
  • Logging in shared deps: required, never optional-with-fallback. A shared module can't own a 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.

Testing

Plain objects implementing the interfaces — no vi.mock(), no Sinon; individual vi.fn() for call tracking is fine.

  • Mock factory with overrides — every method with a sensible default, Partial<T> overrides spread last: create_mock_git_deps = (overrides: Partial<GitDeps> = {}): GitDeps => ({ current_branch_name: async () => ({ ok: true, value: 'main' }), …, ...overrides }).
  • In-memory state mock — state object created separately so tests seed and inspect it: create_mock_cache_deps(state: MockFsState) reading/writing state.files and returning {ok: false, kind: 'not_found', …} on a miss.
  • Tracking mock — create_tracking_process_deps() returns {deps, get_spawned_commands}: a call log exposed via getters next to the deps.
  • Stubs, two safety levels (fuz_app 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.
  • Observable runtime mock — 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>.

Traps

  • Optional capability with a silent platform fallback — 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.
  • Optional capability whose absence skips a security effect — with a per-factory 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.
  • Category blurring under a *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.
  • No seam at the call site — functions called by name from middleware (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.
  • God-type coupling — Pick<Composite> at leaves, or passing the composite down more than one level.
  • Deps spreading — {...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.
  • Forcing *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.

Scope — where the pattern doesn't apply

  • Floor-tier utility modules (fuz_util's 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.
  • Pure libraries (parsers, renderers, formatters) have no side effects; a rendering/plugin seam (mdz's component injection) is composition, not DI.
  • Narrow duck-typed interfaces matching multiple existing objects (svelte-docinfo's AnalysisLog, satisfied by fuz_util's Logger and Vite's logger) are the same spirit without the suffix.

Rust Analog

*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.

Quick Reference

FlavorExemplarInjection style
Narrow platform deps + RuntimeDeps compositefuz_app runtime/Required first param; composite at entry points
App capability bundle (AppDeps, RouteFactoryDeps, QueryDeps, ActionFactoryDeps)fuz_app serverRequired first param; two-step composition root
Focused domain deps (CacheDeps)fuz_cssOptional param with default (deps = default_cache_deps)
Grouped legacy *Operationsfuz_reposOptional ops param — migrating to *Deps