/ Docs

Injecting Dependencies with Collaborators

Overview

In the policies step, you logged a notification directly to the console. That works for prototyping, but production code needs to call external services — email APIs, messaging queues, third-party integrations. Hardcoding those calls into command handlers makes them difficult to test and impossible to swap.

Collaborators solve this by injecting dependencies into commands. You define an interface in the command, create one or more implementations, and select the active one through configuration.

In this step, you will extract the notification logic into a command with a collaborator, create a console implementation, and wire it up through the config.

Create the Send Notification Command

Commands with collaborators use a directory instead of a single file. Create the directory:

mkdir -p src/domain/task/commands/send-notification

Create src/domain/task/commands/send-notification/index.ts:

import type { Command } from "./+types/send-notification";

export async function handler({ stateResolver, notifier, events }: Command.HandlerArgs) {
  const state = await stateResolver();
  await notifier({ state });
  return events.notificationSent({ state });
}

The handler receives notifier as a dependency alongside the standard stateResolver and events. The collaborator is a function that the handler calls without knowing the underlying implementation.

Note: Collaborator names are derived from the implementation file names. The notifier collaborator corresponds to files named notifier.<variant>.ts.

Create the Console Implementation

Create src/domain/task/commands/send-notification/notifier.console.ts:

import type { Command } from "./+types/send-notification";

export const notifier = async ({ state }: Command.CollaboratorArgs) => {
  console.log(`[console] Notification for task: ${state.title}`);
};

The file name follows the pattern <collaborator-name>.<variant>.ts. Here, notifier is the collaborator name and console is the variant. You could create additional variants like notifier.email.ts or notifier.slack.ts without changing the command handler.

Add the Notification Sent Event

The command emits a notificationSent event. Create src/domain/task/notification-sent.ts:

import type { Event } from "./+types/notification-sent";

export const payload: Event.PayloadFunction = ({ z }) =>
  z.object({
    notifiedAt: z.string(),
  });

export function apply({ state }: Event.ApplierArgs) {
  return { ...state, lastNotifiedAt: new Date().toISOString() };
}

Configure the Collaborator

Update bounda.config.ts to tell Bounda which implementation to use:

import type { Config } from "@bounda-dev/config";

export default {
  rootDir: "src",
  domain: {
    task: {
      eventStore: { type: "in-memory" },
      commands: {
        sendNotification: {
          notifier: { use: "console" },
        },
      },
    },
  },
  read: {
    "task-board": {
      type: "sqlite",
      path: "./db/task-board.db",
    },
  },
  policies: {
    retry: { retryStrategy: "exponential", maxRetries: 3 },
    maxChainDepth: 5,
  },
} satisfies Config;

The commands.sendNotification.notifier.use value matches the variant suffix in the file name. Setting it to "console" selects notifier.console.ts. Changing it to "email" would select notifier.email.ts.

Update the Policy

Now that notification logic lives in a dedicated command, update the policy from the previous step to dispatch it instead of logging directly. Rename or replace src/domain/task/policies/notify-assignee-on-task-assigned.ts:

import type { Policy } from "./+types/notify-assignee-on-task-assigned";

export async function handler({ event, commands }: Policy.HandlerArgs) {
  await commands.sendNotification({
    aggregateId: event.aggregateId,
  });
}

The policy no longer knows how notifications are sent. It dispatches a command, and the collaborator configuration determines the implementation.

Regenerate Types

npx bounda generate

Try It Out

import { boot } from "@bounda-dev/core";

const app = await boot();

await app.commands.createTask({
  aggregateId: "task-1",
  title: "Design the API",
});

await app.commands.assignTask({
  aggregateId: "task-1",
  assignee: "carol",
});

The assign command triggers the policy, which dispatches sendNotification, which calls the console notifier:

[console] Notification for task: Design the API

Swapping Implementations

To switch from console logging to email, you would:

  1. Create src/domain/task/commands/send-notification/notifier.email.ts with the email implementation.
  2. Change the config: notifier: { use: "email" }.
  3. No changes to the command handler or the policy.

This makes collaborators powerful for testing as well. You can create a notifier.test.ts variant that records calls in memory, then select it in your test configuration.

Why Collaborators Matter

Without collaboratorsWith collaborators
External calls hardcoded in handlersExternal calls injected as dependencies
Changing providers requires editing handlersChanging providers requires editing config
Testing requires mocking modulesTesting uses a test variant
One implementation at a timeMultiple implementations, config selects one

What We Built

  • Created a send-notification command that receives a notifier collaborator.
  • Implemented a console variant of the notifier.
  • Configured the collaborator selection in bounda.config.ts.
  • Updated the policy to dispatch the command instead of handling notifications directly.
  • Learned how to swap implementations by changing configuration.

Collaborators decouple your domain logic from external dependencies. In the final step, you will learn how to schedule commands to run after a delay.

Next step: Scheduling Delayed Commands