Skip to main content

Core API

7 min read

Resolvers

Resolvers do the actual work – they fulfill requirements raised by constraints.


Basic Resolvers

Define resolvers in your module to handle requirements:

const userModule = createModule("user", {
  schema: {
    facts: {
      userId: t.number(),
      user: t.object<User>().nullable(),
      loading: t.boolean(),
      error: t.string().nullable(),
    },
    requirements: {
      // Define the shape of requirements this module can raise
      FETCH_USER: { userId: t.number() },
    },
  },

  resolvers: {
    fetchUser: {
      requirement: "FETCH_USER",
      resolve: async (req, context) => {
        // Signal loading state
        context.facts.loading = true;

        try {
          // Fetch the user using the requirement payload
          context.facts.user = await api.getUser(req.userId);
          context.facts.error = null;
        } catch (error) {
          context.facts.error = error.message;
        } finally {
          context.facts.loading = false;
        }
      },
    },
  },
});

Resolver Anatomy

PropertyTypeDescription
requirementstring | (req) => req is RWhich requirements this resolver handles
resolve(req, context) => Promise<void>Handler for single requirements
key(req) => stringCustom deduplication key
retryRetryPolicyRetry configuration
timeoutnumberTimeout in ms for resolver execution
batchBatchConfigBatching configuration
resolveBatch(reqs, context) => Promise<void>All-or-nothing batch handler
resolveBatchWithResults(reqs, context) => Promise<BatchItemResult[]>Per-item batch handler

Requirement Matching

The requirement field accepts a string or a function:

resolvers: {
  // String match – handles exactly "FETCH_USER" requirements
  fetchUser: {
    requirement: "FETCH_USER",
    resolve: async (req, context) => { /* ... */ },
  },

  // Prefix match – handles any requirement starting with "API_"
  apiHandler: {
    requirement: (req): req is Requirement => req.type.startsWith("API_"),
    resolve: async (req, context) => { /* ... */ },
  },

  // Payload match – only handles high-priority FETCH requirements
  highPriorityFetch: {
    requirement: (req): req is Requirement =>
      req.type === "FETCH" && req.priority === "high",
    resolve: async (req, context) => { /* ... */ },
  },

  // Catch-all – logs unhandled requirements (place last)
  fallback: {
    requirement: (req): req is Requirement => true,
    resolve: async (req, context) => {
      console.warn(`Unhandled requirement: ${req.type}`);
    },
  },
},

Resolvers are checked in definition order. The first matching resolver wins, so place specific matchers before wildcards.


Resolver Context

The context object provides:

resolve: async (req, context) => {
  // Read and write facts (mutations are auto-batched)
  context.facts;

  // Pass to fetch() or check for cancellation
  context.signal;

  // Get a read-only snapshot of current facts
  context.snapshot();
}

Fact mutations inside resolve are automatically batched – all synchronous writes are coalesced into a single notification.


Retry Policies

Configure automatic retries for transient failures:

resolvers: {
  fetchData: {
    requirement: "FETCH_DATA",

    // Retry up to 3 times with exponential backoff
    retry: {
      attempts: 3,
      backoff: "exponential",
      initialDelay: 100,
      maxDelay: 5000,
    },

    resolve: async (req, context) => {
      context.facts.data = await api.getData(req.id);
    },
  },
}

Retry Options

OptionTypeDefaultDescription
attemptsnumber1Maximum number of attempts
backoff"none" | "linear" | "exponential""none"Delay growth strategy
initialDelaynumber100First retry delay in ms
maxDelaynumber30000Maximum delay between retries
shouldRetry(error, attempt) => booleanPredicate to control whether to retry

Conditional Retries

Use shouldRetry to only retry specific errors. Return true to retry, false to stop immediately:

resolvers: {
  fetchData: {
    requirement: "FETCH_DATA",
    retry: {
      attempts: 5,
      backoff: "exponential",
      initialDelay: 200,

      // Only retry server errors, not client errors
      shouldRetry: (error, attempt) => {
        if (error.message.includes("404") || error.message.includes("403")) {
          return false;  // Don't retry – client error
        }
        return true;  // Retry server errors and network failures
      },
    },

    resolve: async (req, context) => {
      context.facts.data = await api.getData(req.id);
    },
  },
}

If shouldRetry is omitted, all errors are retried up to attempts.

Backoff Calculation

  • "none" – constant delay (initialDelay every time)
  • "linear"initialDelay * attempt (100ms, 200ms, 300ms...)
  • "exponential"initialDelay * 2^(attempt-1) (100ms, 200ms, 400ms...)

Retries are AbortSignal-aware – cancelling a resolver immediately interrupts retry sleep.


Timeout Handling

Set timeouts to prevent hanging operations:

resolvers: {
  fetchData: {
    requirement: "FETCH_DATA",
    timeout: 10000, // Abort after 10 seconds

    resolve: async (req, context) => {
      context.facts.data = await api.getData(req.id);
    },
  },
}

When a resolver times out, it throws an error. If retry is configured, the next attempt begins after the backoff delay.


Custom Identity Keys

Control requirement deduplication with custom keys:

resolvers: {
  fetchUser: {
    requirement: "FETCH_USER",

    // Deduplicate by userId – prevents parallel requests for the same user
    key: (req) => `fetch-user-${req.userId}`,

    resolve: async (req, context) => {
      context.facts.user = await api.getUser(req.userId);
    },
  },
}

Key Strategies

// Default – uses constraintName:type
key: undefined

// Entity-based – one active request per entity
key: (req) => `user-${req.userId}`

// Time-based – allows refresh every minute
key: (req) => `data-${req.id}-${Math.floor(Date.now() / 60000)}`

// Session-based – one request per session
key: (req) => `${req.type}-${sessionId}`

Cancellation

Resolvers receive an AbortSignal via context.signal. Pass it to fetch calls or check it in long-running operations:

resolvers: {
  search: {
    requirement: "SEARCH",
    resolve: async (req, context) => {
      // Pass the AbortSignal to fetch for automatic cancellation
      const results = await fetch(`/api/search?q=${req.query}`, {
        signal: context.signal,
      });

      // Guard against processing stale results
      if (context.signal.aborted) {
        return;
      }

      context.facts.searchResults = await results.json();
    },
  },
}

When a constraint's when() becomes false while its resolver is running, the resolver is cancelled via the AbortSignal.


Batched Resolution

Prevent N+1 problems by collecting requirements that match the same resolver over a time window, then resolving them in a single call:

resolvers: {
  fetchUsers: {
    requirement: "FETCH_USER",
    batch: {
      enabled: true,
      windowMs: 50,       // Collect requirements for 50ms
      maxSize: 100,       // Process up to 100 at a time
      timeoutMs: 10000,   // Per-batch timeout
    },

    // All-or-nothing: if this throws, all requirements in the batch fail
    resolveBatch: async (reqs, context) => {
      // Collect all userIds and fetch in one API call
      const ids = reqs.map(r => r.userId);
      const users = await api.getUsersBatch(ids);
      users.forEach(user => { context.facts[`user_${user.id}`] = user; });
    },
  },
}

Batch Configuration

OptionTypeDefaultDescription
enabledbooleanfalseEnable batching for this resolver
windowMsnumber50Time window to collect requirements (ms)
maxSizenumberunlimitedMaximum batch size
timeoutMsnumberPer-batch timeout (overrides resolver timeout)

Partial Failure Handling

For cases where some items in a batch may fail while others succeed, use resolveBatchWithResults:

resolvers: {
  fetchUsers: {
    requirement: "FETCH_USER",
    batch: { enabled: true, windowMs: 50 },

    // Per-item results: some can succeed while others fail
    resolveBatchWithResults: async (reqs, context) => {
      return Promise.all(reqs.map(async (req) => {
        try {
          const user = await api.getUser(req.userId);
          context.facts[`user_${user.id}`] = user;

          return { success: true };
        } catch (error) {
          // Individual failures don't affect other items
          return { success: false, error };
        }
      }));
    },
  },
}

The returned results array must match the order of the input requirements.


Sequential vs Parallel

By default, resolvers run in parallel. Use after on constraints for ordering:

constraints: {
  // Step 1: Authenticate (high priority)
  authenticate: {
    priority: 100,
    when: (facts) => !facts.token,
    require: { type: "AUTH" },
  },

  // Step 2: Fetch data after auth completes
  fetchData: {
    priority: 50,
    after: ["authenticate"],
    when: (facts) => facts.token && !facts.data,
    require: { type: "FETCH_DATA" },
  },
}

resolvers: {
  auth: {
    requirement: "AUTH",
    resolve: async (req, context) => {
      context.facts.token = await getToken();
    },
  },

  fetchData: {
    requirement: "FETCH_DATA",
    resolve: async (req, context) => {
      // Runs after auth – token is guaranteed to exist
      context.facts.data = await api.getData(context.facts.token);
    },
  },
}

Optimistic Updates

Update facts optimistically, rollback on failure:

resolvers: {
  updateTodo: {
    requirement: "UPDATE_TODO",
    resolve: async (req, context) => {
      // Save a snapshot for rollback
      const snapshot = context.snapshot();
      const original = snapshot.todos.find((t) => t.id === req.id);

      // Apply the update optimistically (UI reflects immediately)
      context.facts.todos = context.facts.todos.map((t) =>
        t.id === req.id ? { ...t, ...req.updates } : t
      );

      try {
        // Persist to the server
        await api.updateTodo(req.id, req.updates);
      } catch (error) {
        // Rollback to the original value on failure
        context.facts.todos = context.facts.todos.map((t) =>
          t.id === req.id ? original : t
        );
        context.facts.error = "Failed to update todo";
      }
    },
  },
}

Testing Resolvers

Mock resolvers in tests:

import { createTestSystem, mockResolver } from "@directive-run/core/testing";

test("fetches user data", async () => {
  // Create a test system with a mocked resolver
  const system = createTestSystem({
    module: userModule,
    mocks: {
      fetchUser: mockResolver((req) => ({
        id: req.userId,
        name: "Test User",
      })),
    },
  });

  // Trigger the constraint by setting userId
  system.facts.userId = 123;
  await system.settle();

  // Verify the resolver populated the fact
  expect(system.facts.user.name).toBe("Test User");
});

Best Practices

Always Set Loading States

resolve: async (req, context) => {
  context.facts.loading = true;
  try {
    context.facts.data = await api.getData(req.id);
  } finally {
    context.facts.loading = false;
  }
}

Handle All Error Cases

resolve: async (req, context) => {
  try {
    context.facts.data = await api.getData(req.id);
    context.facts.error = null;
  } catch (error) {
    context.facts.error = error.message;
    context.facts.data = null;
  }
}

Use Clear Requirement Names

// Good - clear intent
"FETCH_USER"
"CREATE_ORDER"
"VALIDATE_PAYMENT"

// Avoid - vague
"DO_THING"
"PROCESS"
"HANDLE"

Next Steps

Previous
Constraints

We care about your data. We'll never share your email.

Powered by Directive. This signup uses a Directive module with facts, derivations, constraints, and resolvers – zero useState, zero useEffect. Read how it works

Directive - Constraint-Driven State Management for TypeScript