/ Docs

Scheduling Delayed Commands

Overview

Not every action should happen immediately. A reminder sent 24 hours after a task is created, a follow-up triggered a week after assignment, or a cleanup job that runs after a deadline — these all require scheduled commands.

Bounda’s scheduler persists delayed commands and dispatches them when their time arrives. In this final step, you will configure the scheduler, create a reminder command, and schedule it from a policy.

Configure the Scheduler

Update bounda.config.ts to add the scheduling section:

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,
  },
  scheduling: {
    type: "sqlite",
    path: "./db/task-scheduler.db",
    pollingIntervalMs: 1000,
  },
} satisfies Config;

The scheduling configuration declares:

  • type — the storage backend for scheduled commands. SQLite works well for development and single-node deployments.
  • path — where to store the scheduler database.
  • pollingIntervalMs — how often the scheduler checks for commands that are ready to dispatch. A 1-second interval is responsive for development; production workloads may use a longer interval.

Create the Send Reminder Command

Create src/domain/task/commands/send-reminder.ts:

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

export const payload: Command.PayloadFunction = ({ z }) =>
  z.object({
    message: z.string().optional(),
  });

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

  if (state.status === "completed") {
    return;
  }

  console.log(`Reminder: Task "${state.title}" is still open`);

  return events.reminderSent({
    aggregateId: command.aggregateId,
  });
}

The handler checks whether the task is still open before sending the reminder. If the task was completed before the scheduled time, the reminder is silently skipped.

Add the Reminder Sent Event

Create src/domain/task/reminder-sent.ts:

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

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

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

Create the Scheduling Policy

Create src/domain/task/policies/schedule-reminder-on-task-created.ts:

import type { Policy } from "./+types/schedule-reminder-on-task-created";

export async function handler({ event, commands }: Policy.HandlerArgs) {
  await commands.sendReminder({
    aggregateId: event.aggregateId,
    delay: "24h",
  });
}

The delay property tells the scheduler to hold this command for 24 hours before dispatching it. Supported formats include "30s", "15m", "24h", and "7d".

When a task is created, this policy schedules a reminder for the next day. The scheduler persists the command to its SQLite database. After 24 hours, it dispatches the sendReminder command, which checks whether the task is still open and sends the reminder if needed.

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: "Prepare release notes",
});

After this call, the policy schedules a sendReminder command with a 24-hour delay. If the task is still open when the scheduler fires, you will see:

Reminder: Task "Prepare release notes" is still open

If the task is completed before the 24 hours elapse:

await app.commands.completeTask({ aggregateId: "task-1" });

The reminder command still fires at the scheduled time, but the handler detects that the task is completed and returns without side effects.

How the Scheduler Works

Policy dispatches command with delay
    --> Scheduler persists to SQLite
    --> Polling loop checks every 1s
    --> When delay expires, command is dispatched
    --> Handler runs as if it were called directly

Scheduled commands go through the same validation and handler pipeline as immediate commands. The only difference is the timing of dispatch.

The Complete Architecture

Over the course of this tutorial, you have built a task management system that uses every major Bounda concept:

ConceptWhat it doesFiles you created
EventsDefine state transitionstask-created.ts, task-completed.ts, task-assigned.ts, notification-sent.ts, reminder-sent.ts
CommandsExpress intent and enforce rulescreate-task.ts, complete-task.ts, assign-task.ts, send-notification/, send-reminder.ts
Read modelsQuery-optimized views of dataview.ts, projections, get-tasks.ts
PoliciesReact to events with side effectsnotify-assignee-on-task-assigned.ts, schedule-reminder-on-task-created.ts
Process managersOrchestrate long-running workflowstask-review/
CollaboratorsInject swappable dependenciesnotifier.console.ts
Scheduled commandsDelay command dispatchsend-reminder.ts with delay

What We Built

  • Configured the scheduler with SQLite storage and a polling interval.
  • Created a send-reminder command that checks task status before acting.
  • Created a policy that schedules a reminder 24 hours after task creation.
  • Learned how scheduled commands are persisted and dispatched.

Where to Go Next

You have built a complete event-sourced application from scratch. Here are the recommended next steps:

  • Concepts — deeper explanations of aggregates, CQRS, event sourcing, and process managers.
  • Configuration — full reference for bounda.config.ts options including production event stores and read model adapters.
  • Adapters — swap the in-memory event store and SQLite read model for production-grade backends.
  • Integrations — connect Bounda to React Router and other frameworks.