Skip to main content

Advanced

6 min read

Runtime Dynamics

Modify your system's behavior at runtime – register new definitions, override existing ones, toggle constraints and effects on and off.


Overview

Every definition declared in a module (constraints, resolvers, derivations, effects) can also be registered, overridden, toggled, and removed at runtime. All four subsystems share the same dynamic definition interface, making runtime modification predictable and consistent.


The Dynamic Definition Interface

All four subsystems expose the same six methods:

MethodDescription
register(id, def)Add a new definition at runtime
assign(id, def)Override an existing definition (static or dynamic)
unregister(id)Remove a dynamically registered definition
call(id, ...)Execute/evaluate a definition directly
isDynamic(id)Check if a definition was registered at runtime
listDynamic()List all dynamically registered IDs

Semantics

The methods behave consistently across all subsystems:

MethodID exists (static)ID exists (dynamic)ID doesn't exist
registerthrowsthrowscreates
assignoverridesoverridesthrows
unregisterdev warning, no-opremovesdev warning, no-op
callexecutesexecutesthrows

Deferred during reconciliation

If you call register, assign, or unregister during a reconciliation cycle (e.g., inside a resolver or effect), the operation is automatically deferred and applied after the current cycle completes. This prevents mid-cycle inconsistencies.


Registering New Definitions

Add definitions that didn't exist in the original module:

Constraints

system.constraints.register("emergencyOverride", {
  when: (facts) => facts.emergencyVehicle === true,
  require: { type: "TRANSITION", to: "green" },
  priority: 100,
});

Resolvers

system.resolvers.register("loadData", {
  requirement: "LOAD_DATA",
  resolve: async (req, context) => {
    const data = await fetch(`/api/data/${req.source}`);
    context.facts.data = await data.json();
  },
});

Derivations

system.derive.register("tripled", (facts) => facts.count * 3);

// Access like any other derivation
system.derive.tripled; // => 15

Reserved names

Derivation IDs cannot be register, assign, unregister, call, isDynamic, or listDynamic – these names are reserved for the runtime registration methods on the derive proxy.

Effects

system.effects.register("analytics", {
  run: (facts) => {
    trackEvent("page_view", { page: facts.currentPage });
  },
});

Overriding Existing Definitions

Replace the implementation of a definition (static or dynamic) while keeping its ID:

// Override a constraint's logic
system.constraints.assign("transition", {
  when: (facts) => facts.phase === "red" && facts.elapsed > 10,
  require: { type: "TRANSITION", to: "green" },
  priority: 200,
});

// Override a resolver's implementation
system.resolvers.assign("fetchUser", {
  requirement: "FETCH_USER",
  resolve: async (req, context) => {
    context.facts.user = await newUserService.get(req.userId);
  },
});

// Override a derivation's computation
system.derive.assign("doubled", (facts) => facts.count * 20);

// Override an effect's behavior
system.effects.assign("log", {
  run: (facts, prev) => {
    if (prev?.status !== facts.status) {
      console.log(`Status changed: ${facts.status}`);
    }
  },
});

When you assign() a static definition, the original is preserved internally – see the next section.


Retrieving and Restoring Originals

When assign() overrides a static (module-defined) definition, Directive saves the original so you can restore it later:

// 1. Override the original constraint
system.constraints.assign("transition", {
  when: (facts) => facts.override === true,
  require: { type: "FORCE_TRANSITION" },
});

// 2. Retrieve the original definition (before override)
const original = system.getOriginal("constraint", "transition");
// => the original constraint definition from the module

// 3. Restore the original, removing the override
const restored = system.restoreOriginal("constraint", "transition");
// => true (restoration succeeded)

API

// Get the original definition saved before assign() overrode it
system.getOriginal(
  type: "constraint" | "resolver" | "derivation" | "effect",
  id: string
): unknown | undefined

// Restore the original and remove the override tracking
system.restoreOriginal(
  type: "constraint" | "resolver" | "derivation" | "effect",
  id: string
): boolean  // true if restored, false if no original exists

The full lifecycle:

// Original behavior
system.derive.doubled; // => 10

// Override
system.derive.assign("doubled", (facts) => facts.count * 100);
system.derive.doubled; // => 500

// Inspect the original
system.getOriginal("derivation", "doubled");
// => (facts) => facts.count * 2

// Restore
system.restoreOriginal("derivation", "doubled"); // => true
system.derive.doubled; // => 10 (back to original)

Removing Dynamic Definitions

Remove definitions that were registered at runtime:

// Remove a dynamically registered constraint
system.constraints.unregister("emergencyOverride");

// Remove a dynamically registered resolver
system.resolvers.unregister("loadData");

// Remove a dynamically registered derivation
system.derive.unregister("tripled");

// Remove a dynamically registered effect
system.effects.unregister("analytics");

Static (module-defined) definitions cannot be removed – calling unregister() on a static ID logs a dev warning and does nothing.


Enable / Disable

Constraints and effects support toggling without removing the definition:

Constraints

// Disable a constraint – its when() function won't be called
system.constraints.disable("expensiveCheck");

// Re-enable for future reconciliation cycles
system.constraints.enable("expensiveCheck");

// Check current state
system.constraints.isDisabled("expensiveCheck"); // true

Effects

// Disable an effect – it won't run during reconciliation
system.effects.disable("verboseLogging");

// Re-enable later
system.effects.enable("verboseLogging");

// Check current state
system.effects.isEnabled("verboseLogging"); // false

call() respects disabled state

system.effects.call() on a disabled effect is a no-op. Use enable() first if you need to execute it.

Use cases for toggling:

  • Feature flags – disable constraints that gate unreleased features
  • A/B testing – enable/disable constraint variants per user cohort
  • Maintenance windows – suppress side effects during deploys
  • Debug mode – toggle verbose logging effects

Dynamic Module Registration

Add entire modules to a running namespaced system:

const system = createSystem({
  modules: { auth: authModule },
});
system.start();

// Later, after dynamic import
const { chatModule } = await import('./features/chat');
system.registerModule("chat", chatModule);

// Immediately available
system.facts.chat.messages;

The module is fully wired – its constraints, resolvers, effects, and derivations all activate immediately. See Multi-Module for composition patterns and restrictions.


Introspection

Check whether definitions are dynamic and list all dynamic IDs:

// Per-subsystem checks
system.constraints.isDynamic("emergencyOverride"); // true
system.constraints.isDynamic("transition");         // false (module-defined)
system.constraints.listDynamic();                   // ["emergencyOverride"]

system.resolvers.isDynamic("loadData");             // true
system.resolvers.listDynamic();                     // ["loadData"]

system.derive.isDynamic("tripled");                 // true
system.derive.listDynamic();                        // ["tripled"]

system.effects.isDynamic("analytics");              // true
system.effects.listDynamic();                       // ["analytics"]

Use Cases

Feature Flags

// Toggle constraints based on feature flag service
if (!featureFlags.isEnabled("checkout-v2")) {
  system.constraints.disable("checkoutV2");
}

Plugin-Provided Definitions

function analyticsPlugin() {
  return {
    onInit(system) {
      system.effects.register("trackPageView", {
        run: (facts) => analytics.track("page_view", { page: facts.page }),
      });
    },
    onDestroy(system) {
      system.effects.unregister("trackPageView");
    },
  };
}

A/B Testing

// Override resolver behavior for test variant
if (userCohort === "B") {
  system.resolvers.assign("fetchRecommendations", {
    requirement: "FETCH_RECOMMENDATIONS",
    resolve: async (req, context) => {
      context.facts.recommendations = await mlService.getPersonalized(req.userId);
    },
  });
}

Lazy Loading

// Register resolvers only when a feature is first needed
const lazyLoadChat = async () => {
  const { chatModule } = await import('./features/chat');
  system.registerModule("chat", chatModule);
};

Admin Overrides

// Override constraint priorities for admin users
if (user.isAdmin) {
  system.constraints.assign("rateLimiter", {
    when: () => false,  // Admins bypass rate limiting
    require: { type: "RATE_LIMIT" },
  });
}

Type Safety

Dynamic definition callbacks receive typed facts — you get autocomplete, error checking, and no manual casts in single-module systems:

// ✅ facts.count is typed as number
system.constraints.register("highCount", {
  when: (facts) => facts.count > 10,
  require: { type: "LOAD_DATA", source: "dynamic" },
});

// ✅ context.facts is typed — facts.label is string
system.resolvers.register("loadData", {
  requirement: "LOAD_DATA",
  resolve: async (req, context) => {
    context.facts.label = `loaded from ${req.source}`;
  },
});

// ✅ facts.count is typed as number
system.derive.register("tripled", (facts) => facts.count * 3);

Typed call<T>() for derivations

Use the type parameter on call<T>() to specify the return type:

const value = system.derive.call<number>("tripled"); // => number

Limitations

Accessing dynamic derivations as properties still requires a cast — TypeScript can't type a property that doesn't exist in the schema at compile time:

// Dynamic property — TypeScript doesn't know about it
(system.derive as any).tripled; // cast required

// Prefer call<T>() instead
system.derive.call<number>("tripled"); // type-safe

In namespaced (multi-module) systems, control interfaces use the default unparameterized types since the flat :: key format makes typed autocomplete confusing.


Next Steps

  • Constraints – Constraint definition and evaluation
  • Resolvers – Requirement resolution and retry
  • Derivations – Computed values and composition
  • Effects – Side effects and cleanup
  • Multi-Module – Module composition and registerModule()
  • Plugins – Extend system behavior with lifecycle hooks
Previous
Multi-Module

Stay in the loop. Sign up for our newsletter.

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