< Boundaries | Up: Guides | Index | Stateful Doubles >

Dispatch is the uniform call-resolution mechanism that sits between every facade and its implementation. All three facade types (ContractFacade, BehaviourFacade, DynamicFacade) use the same dispatch infrastructure — there is one mechanism, with one set of handler types, and one logging system.

Dispatch paths

The dispatch path for each facade is selected at compile time via the :test_dispatch? and :static_dispatch? options:

                         
                           Function call   
                           facade.op(args) 
                         
                                  
                    
                      test_dispatch? true?     
                      (default: not prod)      
                    
                     yes                    no
                
                                                 
                   
       call/4                          static_dispatch 
                                       ? true?         
       1. NimbleOwner-                 (default: prod) 
          ship lookup                
       2. App.get_env                         
       3. Raise                      yes       no
              
                                                   
                              
                              Inlined      call_config  
                              direct       /4           
                              call to                   
                              impl         App.get_env  
                              (zero         apply/3    
                              overhead)     Raise      
                              

Static dispatch

When static_dispatch?: true (default in :prod) and the implementation is available in config at compile time, the facade generates inlined direct function calls to the implementation module. The call compiles to identical bytecode to calling the impl directly. Falls back to call_config/4 if the config isn't available at compile time.

Runtime config dispatch

DoubleDown.Dispatch.call_config/4 reads Application.get_env(otp_app, contract)[:impl] and calls apply(impl, operation, args). Used in production when static dispatch isn't available, and when test_dispatch?: false.

Test handler dispatch

DoubleDown.Dispatch.call/4 checks NimbleOwnership for a process-scoped test handler, falling back to config. This is the default in non-prod environments and is what makes DoubleDown.Double work.

DynamicFacade dispatch

DynamicFacade.dispatch/3 is a parallel dispatch path — it checks NimbleOwnership for a test handler, falling back to the original (backed-up) module. There is no config-based resolution because there is no contract module to configure. There is no impact outside of test, because DynamicFacade shims are only installed in test.

Handler types

When call/4 resolves a test handler, it matches one of three handler types stored in NimbleOwnership:

HandlerInstalled viaDispatch logicReturns
ModuleDouble.fallback(contract, module)apply(impl, operation, args)whatever the module's function returns; %Defer{} unwraps to deferred result
StatelessDouble.fallback(contract, fn)fun.(contract, operation, args)whatever the function returns; %Defer{} unwraps to deferred result
StatefulDouble.fallback(contract, fn, state)fun.(contract, operation, args, state) — inside NimbleOwnership.get_and_update for atomicity{result, new_state}; can wrap result in %Defer{} for re-entrant calls

All three return {contract, operation, normalize_args(args)} from key/3 and support Defer for re-entrant dispatch.

5-arity stateful handlers

Stateful handlers can also accept a 5th argument — a read-only snapshot of all contract states — for cross-contract state access:

# 4-arity (default)
fn contract, operation, args, state -> {result, new_state} end

# 5-arity — cross-contract state
fn contract, operation, args, state, all_states -> {result, new_state} end

The all_states map is keyed by contract module. Handlers can inspect another contract's state but only update their own via the return value.

Defer — re-entrant dispatch

Stateful handler functions run inside NimbleOwnership.get_and_update, which holds a lock on the ownership GenServer. If a handler calls another facade directly, it deadlocks — the second call re-enters the same GenServer.

DoubleDown.Dispatch.Defer solves this. Return a Defer from a handler and the deferred function runs after the lock is released:

{DoubleDown.Dispatch.Defer.new(fn ->
  # Runs outside the lock — safe to call other facades
  {:ok, record} = MyApp.Repo.insert(changeset)
  record
end), new_state}

DoubleDown.Double.defer/1 is a convenience wrapper.

Public API

FunctionPurpose
call/4Test-aware dispatch (NimbleOwnership → config)
call_config/4Config-only dispatch
key/3Canonical key for test stub matching (normalized args)
get_state/1Read current stateful handler state for a contract
restore_state/3Replace a contract's stateful handler state
handler_active?/1Check if a test handler is installed for a contract

< Boundaries | Up: Guides | Index | Stateful Doubles >