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 Date instance (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 provide ADep & BDep—just pass the whole deps object.
  • 🚫 Over-depending is not: A function requires ADep & BDep but only uses ADep—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 Partial deps 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.