Dependency Injection
Also known as "passing arguments"
What is Dependency Injection? Someone once called it 'really just a pretentious way to say "taking an argument,"' and while it does involve taking or passing arguments, not every instance of that qualifies as Dependency Injection.
Some function arguments are local—say, the return value of one function passed to another—and often used just once. Others, like a database instance, are global and needed across many functions. Traditionally, when something must be shared across functions, we might export it from a module and let other modules import it directly:
// db.ts
export const db = createDb();
// user.ts
import { db } from "./db"; // 🚨 Direct import creates tight coupling
This turns db into a service locator, a well-known anti-pattern—other modules "locate" the service by importing it. The problem? It's hard to test (you can't easily swap db for a mock) and hard to refactor (every module that imports db is tightly coupled to that specific instance).
The alternative is to pass dependencies explicitly to where they're needed. But where do we create and wire them together? In a Composition Root—a single place (typically your app's entry point) where all dependencies are instantiated and composed. From there, dependencies flow down through function arguments.
This guide explains Pure DI conventions Evolu uses for sync code. For async code, see Task dependency injection.
Imagine we have a function that does something with time:
// 🚨 Implicitly depends on global Date—a service we "locate" from global scope
const timeUntilEvent = (eventTimestamp: number): number => {
const currentTime = Date.now();
return eventTimestamp - currentTime;
};
This is better, but still not ideal:
const timeUntilEvent = (date: Date, eventTimestamp: number): number => {
const currentTime = date.getTime();
return eventTimestamp - currentTime;
};
- We are mixing function dependencies (
date) with function arguments (eventTimestamp) - Passing dependencies like that is tedious and verbose.
- We only need the current time, but we're using the entire
Dateinstance (which is hard to mock).
We can do better. Let’s start with a simple interface:
/** Retrieves the current time in milliseconds, similar to `Date.now()`. */
export interface Time {
readonly now: () => number;
}
Note we’re using an interface instead of a class. This is called "programming against interfaces."
Defining dependencies as interfaces rather than concrete implementations simplifies testing with mocks, enhances composition by decoupling components, and improves maintainability by allowing swaps without rewriting logic.
Let's use the Time dependency:
// Currying splits dependencies from the function’s arguments
const timeUntilEvent =
(time: Time) =>
(eventTimestamp: number): number => {
const currentTime = time.now();
return eventTimestamp - currentTime;
};
This is better, but what if we need another dependency, like a Logger?
export interface Logger {
readonly log: (
message?: unknown,
...optionalParams: ReadonlyArray<unknown>
) => void;
}
Passing multiple dependencies can get verbose:
const timeUntilEvent =
(time: Time, logger: Logger) =>
(eventTimestamp: number): number => {
logger.log("...");
const currentTime = time.now();
return eventTimestamp - currentTime;
};
This attempt isn’t ideal either:
// 🚨 Don't do that.
const timeUntilEvent =
(deps: Time & Logger) =>
(eventTimestamp: number): number => {
deps.log("...");
const currentTime = deps.now();
return eventTimestamp - currentTime;
};
The previous example isn't ideal because dependencies with overlapping property names would clash. And we even haven’t yet addressed creating dependencies or making them optional. Long story short, let’s look at the complete example.
Example
The example calculates the time remaining until a given event timestamp using a Time dependency, with an optional Logger for logging. Dependencies are defined as interfaces (Time and Logger) and wrapped in distinct types (TimeDep and LoggerDep) to avoid clashes.
Factory functions (createTime and createLogger) instantiate these dependencies, and they’re passed as a single deps object to the timeUntilEvent function. The use of Partial<LoggerDep> makes the logger optional.
export interface Time {
readonly now: () => number;
}
export interface TimeDep {
readonly time: Time;
}
export interface Logger {
readonly log: (
message?: unknown,
...optionalParams: ReadonlyArray<unknown>
) => void;
}
export interface LoggerDep {
readonly logger: Logger;
}
const timeUntilEvent =
// Partial makes LoggerDep optional
(deps: TimeDep & Partial<LoggerDep>) =>
(eventTimestamp: number): number => {
deps.logger?.log("Calculating time until event...");
const currentTime = deps.time.now();
return eventTimestamp - currentTime;
};
/** Creates a {@link Time} using Date.now(). */
export const createTime = (): Time => ({
now: () => Date.now(),
});
/** Creates a {@link Logger} using console.log. */
export const createLogger = (): Logger => ({
log: (...args) => {
console.log(...args);
},
});
const enableLogging = true;
// Composition Root: where we wire all dependencies together
const deps: TimeDep & Partial<LoggerDep> = {
time: createTime(),
// Inject the dependency conditionally
...(enableLogging && { logger: createLogger() }),
};
timeUntilEvent(deps)(1742329310767);
Note that passing deps manually isn't as verbose as you might think:
export interface TimeDep {
readonly time: Time;
}
export interface LoggerDep {
readonly logger: Logger;
}
const runApp = (deps: LoggerDep & TimeDep) => {
// Over-providing is OK—doSomethingWithTime needs only TimeDep,
// but passing the whole `deps` object is fine
doSomethingWithTime(deps);
doSomethingWithLogger(deps);
};
const doSomethingWithTime = (deps: TimeDep) => {
deps.time.now();
};
// Over-depending is not OK—this function requires TimeDep but doesn't use it
const doSomethingWithLogger = (deps: LoggerDep & TimeDep) => {
deps.logger.log("foo");
};
type AppDeps = LoggerDep & TimeDep;
const appDeps: AppDeps = {
logger: createLogger(),
time: createTime(),
};
runApp(appDeps);
Remember:
- ✅ Over-providing is OK: A function requires
ADep, but you provideADep & BDep—just pass the wholedepsobject. - 🚫 Over-depending is not: A function requires
ADep & BDepbut only usesADep—keep dependencies lean.
The last thing you need to know is that factory functions can also be dependencies. Sometimes, we must delay creating a dependency until a prerequisite is available (e.g., a Logger needs a Config that’s not ready yet, like in a Web Worker).
A factory function as a dependency:
export interface LoggerConfig {
readonly level: "info" | "debug";
}
export interface Logger {
readonly log: (
message?: unknown,
...optionalParams: ReadonlyArray<unknown>
) => void;
}
export type CreateLogger = (config: LoggerConfig) => Logger;
export interface CreateLoggerDep {
readonly createLogger: CreateLogger;
}
export const createLogger: CreateLogger = (config) => ({
log: (...args) => {
console.log(`[${config.level}]`, ...args);
},
});
type AppDeps = CreateLoggerDep & TimeDep;
// Note we pass `createLogger` as a factory, not calling it yet.
// It will be called later when LoggerConfig becomes available.
const appDeps: AppDeps = {
createLogger,
time: createTime(),
};
runApp(appDeps);
Guidelines
- Start with an interface or type—everything can be a dependency.
- To avoid clashes, wrap dependencies (
TimeDep,LoggerDep). - Write factory functions (
createTime,createRun). - Both regular functions and factory functions accept a single argument named
deps, combining one or more dependencies (e.g.,A & B & C). - Sort dependencies alphabetically when combining them, and place
Partialdeps last.
Never export a global instance from a shared module (e.g., export const logger = createLogger()). Other modules might import it directly instead of
receiving it through proper dependency injection, turning it into a service
locator—a
pattern that's hard to test and refactor. Creating instances at module scope
is fine in the Composition
Root, the application's
entry point where dependencies are wired together.
Btw, Evolu provides Console.
Error Handling
Dependencies often perform operations that can fail. Use the Result type to make errors explicit and type-safe. The key principle: expose domain errors, not implementation errors.
import { Result, ok, err } from "@evolu/common";
// Domain errors that callers care about
interface StorageFullError {
readonly type: "StorageFullError";
}
interface PermissionError {
readonly type: "PermissionError";
}
// Good: Dependency interface exposes domain errors
export interface Storage {
readonly save: (
data: Data,
) => Result<void, StorageFullError | PermissionError>;
}
export interface StorageDep {
readonly storage: Storage;
}
Testing Error Paths
With typed errors, testing failure scenarios is straightforward:
const createFailingStorage = (): Storage => ({
save: () => err({ type: "StorageFullError" }),
});
test("handles storage full error", () => {
const deps = { storage: createFailingStorage() };
const result = saveUserData(deps)(userData);
expect(result).toEqual(err({ type: "StorageFullError" }));
});
Testing
Avoiding global state makes testing and composition easier. Here's an example with mocked dependencies:
const testCreateTime = (): Time => ({
now: () => 1234567890, // Fixed time for testing
});
test("timeUntilEvent calculates correctly", () => {
const deps = { time: testCreateTime() };
expect(timeUntilEvent(deps)(1234568890)).toBe(1000);
});
Evolu provides testCreateTime out of the box—a Time implementation that returns monotonically increasing values, useful for tests that need predictable, ordered timestamps.
Tips
Merging Deps
Since deps are regular JS objects, you can spread them:
const appDeps: AppDeps = {
...fooDeps,
...barDeps,
};
Optional Deps
Use Partial and conditional spreading to make deps optional:
const deps: TimeDep & Partial<LoggerDep> = {
time: createTime(),
// Inject the dependency conditionally
...(enableLogging && { logger: createLogger() }),
};
Refining Deps
To reuse existing deps while swapping specific parts, use Omit. For example, if AppDeps includes CreateSqliteDriverDep and other deps, but you want to replace CreateSqliteDriverDep with SqliteDep:
export type AppDeps = CreateSqliteDriverDep & LoggerDep & TimeDep;
export type AppInstanceDeps = Omit<AppDeps, keyof CreateSqliteDriverDep> &
SqliteDep;
To remove multiple deps, like CreateSqliteDriverDep and LoggerDep, use a union of keys:
export type TimeOnlyDeps = Omit<
AppDeps,
keyof CreateSqliteDriverDep | keyof LoggerDep
>;
Handling Clashes
When combining deps with & (e.g., LoggerDep & TimeDep), property clashes are rare but possible. The fix is simple—use distinct wrappers:
export interface LoggerADep {
readonly loggerA: LoggerA;
}
export interface LoggerBDep {
readonly loggerB: LoggerB;
}
FAQ
What qualifies as a dependency?
A dependency is anything that interacts with the outside world—like time (Date), logging (console), databases—or holds shared state, like Ref and Store. Regular function arguments are not dependencies because they are immutable—now you know why Evolu recommends readonly objects.
interface CounterRefDep {
readonly counterRef: Ref<number>;
}
const increment = (deps: CounterRefDep) => {
deps.counterRef.modify((n) => n + 1);
};
Before reaching for DI, consider if you can restructure your code as an impure/pure/impure sandwich—gather impure data first, pass it to pure functions, then perform impure effects with the result. This often eliminates the need for dependencies entirely.
Why shouldn't dependencies use generic arguments?
Dependencies must not use generic type parameters—that would leak implementation details into domain logic and tightly couple consumers to specific implementations.
// Avoid: Generic parameter leaks implementation detail
interface Storage<Row> {
query: (sql: string) => ReadonlyArray<Row>;
}
// Now every function using Storage must know about Row
const getUsers = (deps: { storage: Storage<UserRow> }) =>
deps.storage.query("SELECT * FROM users");
The problem: UserRow might be SQLite-specific. If you switch to IndexedDB, the row shape could differ, breaking all code that depends on Storage<UserRow>.
// Good: No generic, implementation hidden
interface Storage {
getUsers: () => Result<ReadonlyArray<User>, StorageError>;
}
// Consumer doesn't know or care how data is stored
const getUsers = (deps: StorageDep) => deps.storage.getUsers();
By hiding the generic, the Storage interface becomes implementation-agnostic. You can swap SQLite for IndexedDB without changing any code that depends on Storage.
Key points:
- Decoupling: Code remains agnostic to underlying implementation (SQLite, IndexedDB, in-memory, etc.)
- Simplicity: Consumers don't need to know implementation-specific types
- Testability: Easy to mock without matching generic parameters
Why use type aliases for composed deps instead of interfaces?
Use type for dependency compositions like A & B & Partial<C>. Interfaces can be declaration-merged across files, which is useful for some extensibility patterns but risky for dependency contracts because accidental merges can silently change required deps.
// Prefer: closed composition
type AppDeps = LoggerDep & TimeDep & Partial<MetricsDep>;
// Avoid for composed deps: interface can be merged elsewhere
interface AppDeps extends LoggerDep, TimeDep {
readonly metrics?: Metrics;
}
Use interfaces for concrete dependency contracts (Time, Logger, Storage) and dep wrappers (TimeDep, LoggerDep). Use type aliases when combining multiple deps.