Skip to main content

Advanced

5 min read

Multi-Module

Build complex applications with composable modules.


Basic Composition

Pass a modules map to create a namespaced system:

import { createSystem } from '@directive-run/core';

// Each key becomes a namespace for accessing that module's state
const system = createSystem({
  modules: {
    auth: authModule,
    cart: cartModule,
    user: userModule,
  },
});
    ┌─────────────────── createSystem ───────────────────┐
    │                                                    │
    │  ┌──────────┐   ┌──────────┐   ┌──────────┐        │ 
    │  │   auth   │   │   cart   │   │    ui    │        │
    │  │  module  │   │  module  │   │  module  │        │
    │  └──────────┘   └──────────┘   └──────────┘        │
    │                                                    │
    └────────────────────────┬───────────────────────────┘

                  system.facts.auth.token
                  system.facts.cart.items
                  system.facts.ui.theme

Namespaced Access

Facts, derivations, and events are accessed by namespace:

// Read authentication state from the auth namespace
system.facts.auth.isAuthenticated;
system.facts.auth.token;

// Read shopping cart state from the cart namespace
system.facts.cart.items;
system.facts.cart.total;

// Dispatch events – the system routes them to the right module
system.dispatch({ type: "ADD_ITEM", item: product });

Module Definition

Each module defines its own schema, constraints, resolvers, and effects:

const cartModule = createModule("cart", {
  // Define the shape of cart state with typed schema fields
  schema: {
    facts: {
      items: t.array(t.object<CartItem>()),
      couponCode: t.string().nullable(),
      discount: t.number(),
    },
  },

  // Set default values when the module initializes
  init: (facts) => {
    facts.items = [];
    facts.couponCode = null;
    facts.discount = 0;
  },

  // Auto-tracked derivations recompute when their dependencies change
  derive: {
    subtotal: (facts) =>
      facts.items.reduce((sum, item) => sum + item.price * item.qty, 0),

    // Derivations can reference other derivations via the second argument
    total: (facts, derived) =>
      derive.subtotal - facts.discount,
  },

  // Constraints declare "when X is true, require Y"
  constraints: {
    applyCoupon: {
      when: (facts) => facts.couponCode !== null && facts.discount === 0,
      require: { type: "APPLY_COUPON" },
    },
  },

  // Resolvers fulfill requirements emitted by constraints
  resolvers: {
    applyCoupon: {
      requirement: "APPLY_COUPON",
      resolve: async (req, context) => {
        const result = await api.validateCoupon(context.facts.couponCode);
        context.facts.discount = result.discount;
      },
    },
  },
});

Cross-Module Constraints

Constraints in one module can reference facts from other modules using crossModuleDeps. This is the primary mechanism for inter-module coordination:

const cartModule = createModule("cart", {
  schema: {
    facts: {
      items: t.array(t.object<CartItem>()),
      checkoutInProgress: t.boolean(),
    },
  },
  // Declare cross-module dependencies at the module level
  crossModuleDeps: { auth: authSchema },

  init: (facts) => {
    facts.items = [];
    facts.checkoutInProgress = false;
  },

  constraints: {
    blockCheckoutIfNotAuthenticated: {
      // facts.self.* for own module, facts.auth.* for cross-module
      when: (facts) =>
        facts.self.checkoutInProgress && !facts.auth.isAuthenticated,
      require: { type: "REQUIRE_LOGIN" },
    },
  },
});

Declare crossModuleDeps as a module-level object mapping dependency names to their schemas. Inside derive, constraints, and effects, access own-module facts via facts.self.* and cross-module facts via facts.{dep}.*. Constraint ordering across modules uses the after property with the "moduleName::constraintName" format:

constraints: {
  afterAuth: {
    after: ["auth::validateSession"],  // Wait for auth's constraint to resolve
    when: (facts) => facts.needsData,
    require: { type: "FETCH_DATA" },
  },
}

Dynamic Module Registration

Add modules to a running system with system.registerModule(). This is useful for code-split features that load on demand:

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

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

// Immediately available through namespaced access
system.facts.chat.messages;
system.events.chat.sendMessage({ text: "Hello!" });

The registered module is fully wired into the system – its constraints, resolvers, effects, and derivations all activate immediately. Existing modules continue running uninterrupted. See Runtime Dynamics for more on runtime registration, overrides, and introspection across all subsystems.

Restrictions

  • Cannot register during reconciliation (throws an error)
  • Cannot register on a destroyed system (throws an error)
  • Module names must be unique (schema key collisions are caught at registration time)

Module Factory

Use createModuleFactory() to produce named instances from a single definition. This is useful for multi-instance UIs like tabs, panels, or multi-tenant layouts where you need isolated state from the same schema:

import { createModuleFactory, t } from '@directive-run/core';

const chatRoom = createModuleFactory({
  schema: {
    facts: {
      messages: t.array<string>(),
      users: t.array<string>(),
    },
    derivations: {
      messageCount: t.number(),
    },
  },
  init: (facts) => {
    facts.messages = [];
    facts.users = [];
  },
  derive: {
    messageCount: (facts) => facts.messages.length,
  },
});

// Create independent instances with different names
const system = createSystem({
  modules: {
    lobby: chatRoom("lobby"),
    support: chatRoom("support"),
  },
});

system.start();

// Each instance has isolated state
system.facts.lobby.messages;   // []
system.facts.support.messages; // []

createModuleFactory preserves crossModuleDeps when provided, so factory-produced modules work correctly with cross-module dependencies.


Independent Systems

You can also run modules as separate systems and coordinate through your application layer:

// Create separate systems – each module runs independently
const authSystem = createSystem({ module: authModule });
const cartSystem = createSystem({ module: cartModule });

authSystem.start();
cartSystem.start();

// Coordinate across systems in your application logic
function handleLogout() {
  authSystem.facts.token = null;  // Clear the session
  cartSystem.facts.items = [];    // Empty the cart on logout
}

React with Multiple Modules

With independent systems, pass each system directly to the components that need it –no provider needed:

// Pass each independent system to the components that need it
function App() {
  return (
    <Layout authSystem={authSystem} cartSystem={cartSystem} />
  );
}

Or use a single namespaced system and pass it to hooks:

// Combine modules into a single namespaced system
const system = createSystem({
  modules: { auth: authModule, cart: cartModule },
});
system.start();

function App() {
  // Read facts through the module namespace
  const isAuthenticated = system.facts.auth.isAuthenticated;

  return <Layout system={system} />;
}

Next Steps

Previous
Overview

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 Runtime for TypeScript | AI Guardrails & State Management