Skip to main content

Advanced Patterns

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
Resolver Binding (owns)

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