Commiq Docs
Usage Patterns

Dependency Injection

Provide typed dependencies to command handlers for testability and environment flexibility.

Command handlers often need external services — API clients, analytics, storage adapters, clocks. Importing them directly couples store logic to one implementation, which makes tests depend on module mocking and makes the same store unusable in a second environment.

withInjector provides typed dependencies through ctx.deps, supplied when the store is created.

The problem

stores/user.ts
import { apiClient } from "../services/api";

_store.addCommandHandler(UserCommand.load, async (ctx, cmd) => {
  const user = await apiClient.fetchUser(cmd.data.id);
  ctx.setState((prev) => ({ ...prev, user }));
});

This works until you need to test with a stub, run the same store server-side against a different base URL, or swap the implementation per environment.

Inject at creation time

withInjector is curried: withInjector<S>() fixes the state type, and calling the result with the dependency object infers its shape.

stores/user.ts
import { createStore, createCommandDef, createEvent, sealStore } from "@naikidev/commiq";
import { withGuard, withInjector } from "@naikidev/commiq-context";
import type { StoreOptions } from "@naikidev/commiq";
import type { ApiClient } from "../services/api";
import type { AnalyticsService } from "../services/analytics";

export type User = { id: string; name: string };

type UserState = {
  readonly user: User | null;
};

export type UserDeps = {
  api: ApiClient;
  analytics: AnalyticsService;
};

export const UserCommand = {
  load: createCommandDef<{ id: string }>("user:load"),
};

export const UserEvent = {
  Loaded: createEvent<{ id: string }>("user:loaded"),
};

const initialState: UserState = { user: null };

export function createUserStore(deps: UserDeps, options?: StoreOptions) {
  const _store = createStore<UserState>(initialState, options)
    .useExtension(withInjector<UserState>()(deps))
    .useExtension(withGuard<UserState>())
    .addCommandHandler(UserCommand.load, async (ctx, cmd) => {
      ctx.guard(cmd.data.id !== "", "A user ID is required");

      const user = await ctx.deps.api.fetchUser(cmd.data.id);

      ctx.setState({ user });
      ctx.deps.analytics.track("user_loaded", { id: cmd.data.id });
      ctx.emit(UserEvent.Loaded, { id: cmd.data.id });
    });

  return sealStore(_store);
}

ctx.deps is typed as UserDeps inside the handler — the extension widens the command context type, so property access is checked. withInjector provides deps on both command and event contexts.

Note there is no try/catch. Letting a fetch failure escape is what marks the command as failed: the store reports it through onError, publishes commandHandlingError, and settles the handle as { status: "failed", error }. The factory also forwards StoreOptions so callers — especially tests — can supply their own onError.

Production setup

app.ts
import { createUserStore } from "./stores/user";
import { ApiClient } from "./services/api";
import { PosthogAnalytics } from "./services/analytics";

export const userStore = createUserStore(
  {
    api: new ApiClient({ baseUrl: "/api" }),
    analytics: new PosthogAnalytics(),
  },
  {
    onError: (report) =>
      errorReportingService.capture(report.error, {
        source: report.source,
        command: report.command?.name,
      }),
  },
);

Test setup

Each test gets its own store with exactly the dependencies it needs. No module mocking.

stores/user.test.ts
import { describe, expect, it, vi } from "vitest";
import { createUserStore, UserCommand } from "./user";
import type { UserDeps } from "./user";

function createDeps(overrides?: Partial<UserDeps["api"]>): UserDeps {
  return {
    api: {
      fetchUser: vi.fn().mockResolvedValue({ id: "1", name: "Test User" }),
      ...overrides,
    } as UserDeps["api"],
    analytics: { track: vi.fn() } as unknown as UserDeps["analytics"],
  };
}

describe("user store", () => {
  it("loads a user", async () => {
    const deps = createDeps();
    const store = createUserStore(deps);

    const result = await store.queue(UserCommand.load, { id: "1" });

    expect(result.status).toBe("handled");
    expect(store.state.user).toEqual({ id: "1", name: "Test User" });
    expect(deps.api.fetchUser).toHaveBeenCalledWith("1");
  });

  it("reports a failed fetch without touching state", async () => {
    const onError = vi.fn();
    const deps = createDeps({
      fetchUser: vi.fn().mockRejectedValue(new Error("Network error")),
    });
    const store = createUserStore(deps, { onError });

    const result = await store.queue(UserCommand.load, { id: "1" });

    expect(result.status).toBe("failed");
    expect(result.error).toEqual(new Error("Network error"));
    expect(store.state.user).toBeNull();
    expect(onError).toHaveBeenCalledWith(
      expect.objectContaining({ source: "commandHandler" }),
    );
  });

  it("rejects an empty user ID before calling the API", async () => {
    const deps = createDeps();
    const store = createUserStore(deps);

    const result = await store.queue(UserCommand.load, { id: "" });

    expect(result.status).toBe("failed");
    expect(deps.api.fetchUser).not.toHaveBeenCalled();
  });
});

Awaiting the CommandHandle returned by queue() is enough here — each test dispatches one command and asserts on its result. Use await store.flush() when event handlers queue follow-up commands you also need to settle.

Passing onError in the failure test keeps the expected error out of the test output; the default reporter would log it.

withInjector vs. a factory closure

withInjector is not the only way to inject. A plain closure works too:

export function createUserStore(deps: UserDeps) {
  const _store = createStore<UserState>(initialState);

  _store.addCommandHandler(UserCommand.load, async (ctx, cmd) => {
    const user = await deps.api.fetchUser(cmd.data.id); // captured, not injected
    ctx.setState({ user });
  });

  return sealStore(_store);
}

This is simpler and has no extension overhead. Reach for withInjector when:

  • Handlers live in other files. A handler exported from handlers/load-user.ts cannot close over the factory's argument, but it can read ctx.deps.
  • You want the dependency set visible in the handler's type. ctx.deps documents what a handler is allowed to touch; a closure hides it.
  • Devtools or logging should see the dependency boundary. deps is part of the context, not an invisible capture.

For a store whose handlers are all defined inline in one file, the closure is usually the better trade.

deps occupies the context key deps. Registering two withInjector extensions on the same store, or another extension that also provides deps, throws at registration with a key-conflict error. The reserved keys are state, setState, emit, and signal for command contexts, and state and queue for event contexts.

Injecting non-determinism

Clocks and random sources are the dependencies most worth injecting, because they are what make tests flaky:

export type UserDeps = {
  api: ApiClient;
  now: () => number;
  newId: () => string;
};

_store.addCommandHandler(UserCommand.load, async (ctx, cmd) => {
  const user = await ctx.deps.api.fetchUser(cmd.data.id);
  ctx.setState({ user, fetchedAt: ctx.deps.now() });
});
const store = createUserStore({
  api: stubApi,
  now: () => 1_700_000_000_000,
  newId: () => "fixed-id",
});

State assertions then compare against exact values rather than expect.any(Number).

Combining with withDefer

When a handler acquires a resource from an injected dependency, withDefer guarantees release. Because withDefer takes the store as its argument, the store has to be created before the chain starts:

stores/export.ts
export function createExportStore(deps: ExportDeps) {
  const _store = createStore<ExportState>(initialState);

  _store
    .useExtension(withInjector<ExportState>()(deps))
    .useExtension(withDefer(_store))
    .addCommandHandler(ExportCommand.generate, async (ctx, cmd) => {
      const connection = await ctx.deps.database.connect();
      ctx.defer(() => connection.release());

      const rows = await connection.query("SELECT * FROM exports");
      const output = ctx.deps.formatter.format(rows, cmd.data.format);

      ctx.setState({ output, generated: true });
    });

  return sealStore(_store);
}

Even if query() or format() throws, the connection is released. See deferred cleanup — note in particular that a cleanup failure is now reported through onError rather than swallowed.

On this page