EasyRpc Architecture Documentation

Copy Markdown View Source

Version: 0.10.0 Last Updated: 2025


Table of Contents

  1. Overview
  2. Architecture Diagram
  3. Module Hierarchy
  4. Core Components
  5. Plug Pipeline
  6. Data Flow
  7. Design Patterns
  8. Extension Points
  9. Performance Considerations
  10. Testing Strategy

Overview

EasyRpc is a modular library for wrapping remote procedure calls in Elixir. The architecture follows SOLID principles with a plug-based middleware pipeline (inspired by Phoenix Plug) at its core.

Key Architectural Principles

  • Pluggability: Every concern (node selection, logging, retry, error handling) is a composable plug
  • Extensibility: Add caching, telemetry, circuit breakers by writing a plug — no monkey-patching
  • Modularity: Each component has a single, well-defined responsibility
  • Consistency: Unified error handling and logging across all components
  • Type Safety: Complete typespec coverage with Dialyzer validation
  • Backward Compatibility: Legacy shims while introducing modern patterns

Architecture Diagram


                         EasyRpc Library                                  
                      (Public API & Docs)                                 

                                 
                
                                                 
                      
          RpcWrapper                      DefRpc       
          (Config-based)                (Declarative)  
                      
                                                 
                
                                 
                        
                         FunctionGenerator
                           (Utilities)    
                        
                                 
                
                                                 
                      
         WrapperConfig                  NodeSelector   
         (Configuration)                 (Strategy)    
                      
                                                 
                
                                 
                        
                          PluginPipeline 
                          (Middleware)   
                        
                                 
                
                                                
          
           Context         Plug        Individual    
           (Conn-like)   (Behaviour)   Plugs (6+)    
          
                                 
                
                                                 
                      
            RpcCall                        Error       
           (Executor)                    (Unified)     
                      
                
        
         RpcExecutor    
          (Behavior)    
        

Module Hierarchy

Layer 1: Public API

EasyRpc
 Documentation & Examples
 Version Information
 Public Interface

Layer 2: Wrapper Implementations

RpcWrapper (Config-based)          DefRpc (Declarative)
 Macro: __using__/1             Macro: __using__/1
 Function Generation            Macro: defrpc/2
 Compile-time Config Loading    Runtime Node Config Loading

Layer 3: Utilities & Shared Logic

FunctionGenerator                  WrapperConfig               NodeSelector
 normalize_function_info/1      load_config!/2           new/4
 resolve_function_name/2        load_from_options!/1     select_node/2
 merge_config/2                 new!/2-6                 Strategy: random
 parse_arity/1                  validate!/1              Strategy: round_robin
 generate_arg_vars/1            Type Specs               Strategy: hash
 validate_function_opts!/1                                  Sticky Node Support

Layer 4: Plug Pipeline

Pipeline                         Context                         Plug
 call/2                       config                      @callback call/3
 run/3, run/4                 function, args               @behaviour
 extract_result/2              node, result, error
 default/0                    assigns, halted, attempt
 Pipeline execution            halt/1, errored?/1

Plugs (built-in):
 NodeSelector      selects target node
 Executor          calls :erpc.call
 Retry             re-executes on failure
 Logger            structured logging
 ErrorHandler      normalises result/error
 Telemetry         emits telemetry events (optional)
 Cache             ETS-based caching (example)

Layer 5: Core Execution

RpcCall (implements RpcExecutor)
 execute/3               primary API (uses pipeline for safe mode)
 execute_with_retry/3    primary API
 execute_dynamic/4       primary API (DefRpc path)
 rpc_call/2              backward-compat alias
 rpc_call_dynamic/3      backward-compat alias

Layer 6: Cross-Cutting Concerns

Error (Unified)                   RpcExecutor (Behavior)
 Type: config_error            Callback: execute/3
 Type: rpc_error               Callback: execute_with_retry/3
 Type: node_error              Callback: validate_config/1 (optional)
 Type: timeout_error
 Type: validation_error
 wrap_exception/2
 format/1
 log/2

Core Components

1. EasyRpc (Main Module)

Purpose: Main DSL module using Spark DSL

Responsibilities:

  • Spark DSL entry point for users
  • Library overview and usage guide
  • API documentation
  • Version management

Usage Pattern:

defmodule MyApp.RemoteApi do
  use EasyRpc

  config do
    nodes [:"api@node1", :"api@node2"]
    select_mode :round_robin
    module RemoteNode.Api
    timeout 5_000
  end

  pipeline do
    plug EasyRpc.Plugs.Retry
    plug EasyRpc.Plugs.Cache, ttl: 5_000
    plug EasyRpc.Plugs.NodeSelector
    plug EasyRpc.Plugs.Logger
    plug EasyRpc.Plugs.Executor
    plug EasyRpc.Plugs.ErrorHandler
  end

  rpc_functions do
    rpc_function :get_user, 1
    rpc_function :create_user, 2
  end
end

Key Functions:

  • version/0 - Returns library version

2. EasyRpc.Dsl (Spark DSL Extension)

Purpose: Define the Spark DSL structure for EasyRpc

Responsibilities:

  • Define config section with global settings
  • Define pipeline section with plug entities
  • Define rpc_functions section with rpc_function entities
  • Validate DSL options
  • Work with Spark transformers and verifiers

Architecture:

Spark.Dsl.Extension
    
config section (nodes, module, timeout, retry, etc.)
    
pipeline section  plug entities (customisable middleware chain)
    
rpc_functions section  rpc_function entities
    
Transformers generate wrapper functions (using custom pipeline or default)
    
Verifiers validate configuration

3. EasyRpc.Info (Info Module)

Purpose: Provide introspection functions for the DSL

Responsibilities:

  • Generate accessor functions for DSL sections
  • Allow querying config, pipeline, and function entities
  • Provide config_nodes/1, config_module/1, rpc_functions/1, etc.

4. EasyRpc.Transformers.GenerateRpcFunctions

Purpose: Generate RPC wrapper functions from DSL definitions

Responsibilities:

  • Read DSL state to get config, pipeline, and function entities
  • If custom pipeline defined: generate functions using EasyRpc.Pipeline.run/4
  • If no custom pipeline: generate functions using RpcCall.execute/3 (unchanged)
  • Inject function definitions into the module

Architecture:

DSL State  Transformer.get_entities/2  Function Entities
                                      
                             custom pipeline? 
                                                    
              Pipeline.run(config, fun, args,     RpcCall.execute(config, fun, args)
                pipeline)                              
                                           (continues with original RpcCall flow)
              Context  Pipeline.call
                    
              Pipeline.extract_result

5. EasyRpc.Verifiers.ValidateConfig

Purpose: Validate DSL configuration at compile time

Responsibilities:

  • Validate required fields (nodes, module)
  • Validate node list is not empty
  • Validate timeout and retry values
  • Validate function names and arities

6. FunctionGenerator

Purpose: Extract common compile-time function generation logic shared by RpcWrapper and DefRpc

Responsibilities:

  • Normalize function specifications into canonical {name, arity, opts} tuples
  • Merge global and per-function WrapperConfig values
  • Parse arity specifications (integer, [], or named-atom list)
  • Generate AST variable lists for macro-expanded def bodies
  • Validate per-function option keys and values

7. WrapperConfig (Configuration Management)

Purpose: Validate and manage RPC configuration

Fields:

FieldTypeDefaultDescription
node_selector%NodeSelector{} or nilNode selection strategy
moduleatomrequiredRemote module to call
timeoutpos_integer | :infinity5_000Per-call timeout in milliseconds
retrynon_neg_integer0Number of retry attempts on failure
sleep_before_retrynon_neg_integer0Milliseconds to sleep between retry attempts
error_handlingbooleanfalseReturn tagged tuples instead of raising
functions[function_spec][]Function list for RpcWrapper

8. NodeSelector (Selection Strategy)

Purpose: Select target nodes for RPC calls

Strategies:

  • :random — Randomly picks a node on each call
  • :round_robin — Circular distribution, tracked per process
  • :hash — Consistent hashing on function arguments (same args → same node)
  • sticky_node: true — Process pins to first selected node via process dictionary

9. RpcCall (Executor)

Purpose: Execute remote procedure calls

Implementation:

  • For bare mode (error_handling: false, retry: 0): direct :erpc.call — exceptions propagate naturally (backward compatible)
  • For safe/retry mode (error_handling: true or retry > 0): delegates to the plug pipeline

Primary API:

  • execute/3 — respects config.error_handling and config.retry
  • execute_with_retry/3 — always uses error handling
  • execute_dynamic/4 — resolves NodeSelector at call time (used by DefRpc)

10. EasyRpc.Context

Purpose: Carries all state through the plug pipeline (analogous to Plug.Conn)

Fields:

FieldTypeDescription
configWrapperConfig.t()Configuration for this call
functionatom()Remote function name
argslist()Function arguments
nodenode() | nilSelected target node (set by NodeSelector)
resultterm() | nilRaw RPC result (set by Executor)
errorError.t() | nilError when call fails
assignsmap()Free-form map for plug authors
haltedboolean()When true, pipeline stops
attemptnon_neg_integer()Current retry attempt (0-based)

11. EasyRpc.Plug (Behaviour)

Purpose: Middleware contract for the RPC pipeline. Inspired by Phoenix Plug.

Callback:

@callback call(context :: EasyRpc.Context.t(), opts :: term(), next :: function()) ::
            EasyRpc.Context.t()

Each plug receives:

  1. The current %EasyRpc.Context{}
  2. Options configured when the plug was added to the pipeline
  3. A next function that runs the remainder of the pipeline

Short-circuiting: Set ctx.halted = true to bypass downstream plugs (useful for cache hits).


12. EasyRpc.Pipeline

Purpose: Builds and executes the middleware chain.

Key Functions:

  • default/0 — returns the default pipeline (matches original RpcCall behaviour)
  • call/2 — composes plugs into a chain and runs it with a context
  • run/4 — convenience: creates Context, calls pipeline, extracts result
  • extract_result/2 — extracts final result from context (handles bare/safe modes)

Default Pipeline Order (outermost → innermost):

ErrorHandler  Retry  NodeSelector  Logger  Executor

Plugs are composed inside-out: the first plug in the list is the outermost wrapper and runs last on the return path.


13. Built-in Plugs

PlugPurpose
NodeSelectorSelects a target node, stores in ctx.node
ExecutorCalls :erpc.call with node/module/fun/args/timeout; catches exceptions
RetryRe-executes the inner chain when ctx.error is set and retries remain
LoggerStructured logging (--> call, <-- success, <<< retry, !!! failure)
ErrorHandlerNormalises context: wraps result as {:ok, result} or {:error, error} in safe mode
TelemetryEmits [:easy_rpc, :call, :start/stop/exception] events (optional dep)
CacheETS-based caching plug with TTL (example)

Plug Pipeline

How Plugs Compose

Plugs are composed inside-out via Enum.reduce:

# Pipeline: [ErrorHandler, Retry, NodeSelector, Logger, Executor]
#
# After reduce (inside-out):
#   identity = fn ctx -> ctx end
#   step1 = fn ctx -> ErrorHandler.call(ctx, [], identity)
#   step2 = fn ctx -> Retry.call(ctx, [], step1)
#   step3 = fn ctx -> NodeSelector.call(ctx, [], step2)
#   step4 = fn ctx -> Logger.call(ctx, [], step3)
#   step5 = fn ctx -> Executor.call(ctx, [], step4)

Runtime call order (downstream):

Executor  Logger  NodeSelector  Retry  ErrorHandler  caller

Runtime return order (upstream):

caller  ErrorHandler  Retry  NodeSelector  Logger  Executor

Writing a Custom Plug

defmodule MyApp.CachePlug do
  @behaviour EasyRpc.Plug

  def call(ctx, _opts, next) do
    key = cache_key(ctx)
    case Cachex.get(:rpc_cache, key) do
      {:ok, nil} ->
        result = next.(ctx)
        if result.error == nil and result.result != nil do
          Cachex.put(:rpc_cache, key, result.result)
        end
        result

      {:ok, cached} ->
        %{ctx | result: cached, halted: true}
    end
  end

  defp cache_key(ctx), do: {ctx.config.module, ctx.function, ctx.args}
end

Custom Pipeline via DSL

defmodule MyApp.RemoteApi do
  use EasyRpc

  config do
    nodes [:"api@node1"]
    module RemoteNode.Api
  end

  pipeline do
    plug EasyRpc.Plugs.ErrorHandler
    plug EasyRpc.Plugs.Retry
    plug EasyRpc.Plugs.NodeSelector
    plug MyApp.CachePlug, ttl: 5_000
    plug EasyRpc.Plugs.Logger
    plug EasyRpc.Plugs.Executor
  end

  rpc_functions do
    rpc_function :get_user, [:id]
  end
end

When no pipeline section is defined, the default pipeline is used (identical to the original RpcCall behaviour).


Data Flow

Complete Request Flow (Safe Mode)

1. User Code
   MyApi.get_user(123)
        
2. Generated Wrapper Function
   - Calls RpcCall.execute(config, :get_user, [123])
   - OR Pipeline.run(config, :get_user, [123], pipeline)
        
3. Pipeline execution (if safe/retry mode)
   - ErrorHandler (pass-through on way down)
   - Retry (pass-through on way down, retries on error on way up)
   - NodeSelector  selects node  stores in ctx.node
   - Logger  logs "--> ... on node [timeout, retry]"
   - Executor  :erpc.call(node, mod, fun, args, timeout)
        
4. Response Handling
   - Success  Logger logs "<-- succeeded"  ErrorHandler wraps {:ok, result}
   - Exception  Executor catches  Error.wrap_exception  Retry?
        yes: Retry re-executes NodeSelector  Logger  Executor
        no:  Logger logs "!!!"  ErrorHandler wraps {:error, error}
        
5. Return to Caller (via extract_result)
   - {:ok, result} | {:error, %EasyRpc.Error{}}

Bare Mode Request Flow

1. User Code  Generated Wrapper  RpcCall.execute/3
2. RpcCall.execute detects bare mode (no error_handling, no retry)
3. Direct :erpc.call (no pipeline)  exceptions propagate naturally
4. Returns raw result or raises

Plug Lifecycle

                        
                          Context created
                        
                                 
                        
                          Pipeline.call 
                        
                                 
                
                                                
             
           Plug A         Plug B        Plug C    
          (outermost)  (middleware)  (innermost)
             
                                              
                             
                        halts?  return       
                             
                                              
                
                                
                        
                         Final Context  
                        

Design Patterns

1. Middleware Pattern (Pipeline)

  • Modules: EasyRpc.Plug, EasyRpc.Pipeline, individual plugs
  • Purpose: Composable middleware chain for RPC execution
  • Benefit: Add/remove/reorder concerns without modifying core logic
  • Inspiration: Phoenix Plug, Rack, Ring middleware

2. Behaviour Pattern

  • Module: RpcExecutor
  • Purpose: Define clear execution contracts
  • Benefit: Testability and extensibility

3. Strategy Pattern

  • Module: NodeSelector
  • Purpose: Pluggable node selection strategies
  • Strategies: Random, Round Robin, Hash, Sticky

4. Facade Pattern

  • Modules: EasyRpc, RpcWrapper, DefRpc, Pipeline
  • Purpose: Simple interface to a complex subsystem
  • Benefit: Easy to use, complexity hidden

5. Adapter Pattern

  • Modules: ConfigError, RpcError
  • Purpose: Backward compatibility while delegating to Error
  • Benefit: Smooth migration path for existing callers

6. Builder Pattern

  • Module: WrapperConfig
  • Purpose: Flexible, validated configuration construction
  • Methods: new!/2-6, load_config!/2, load_from_options!/1

Extension Points

1. Custom Pipeline Plugs (Primary Extension Point)

Write a plug and add it to the pipeline:

defmodule MyApp.MetricsPlug do
  @behaviour EasyRpc.Plug

  def call(ctx, _opts, next) do
    start = System.monotonic_time()
    result = next.(ctx)
    duration = System.monotonic_time() - start

    :telemetry.execute([:my_app, :rpc], %{duration: duration}, %{
      function: ctx.function,
      success: result.error == nil
    })

    result
  end
end

Add to your pipeline:

pipeline do
  plug EasyRpc.Plugs.ErrorHandler
  plug EasyRpc.Plugs.Retry
  plug MyApp.MetricsPlug
  plug EasyRpc.Plugs.NodeSelector
  plug EasyRpc.Plugs.Logger
  plug EasyRpc.Plugs.Executor
end

2. Dynamic Node Discovery

Use MFA tuples for runtime node resolution:

config do
  nodes_provider {ClusterHelper, :get_nodes, [:backend]}
end

3. Circuit Breaker Plug

defmodule MyApp.CircuitBreakerPlug do
  @behaviour EasyRpc.Plug

  def call(ctx, _opts, next) do
    if tripped?(ctx) do
      %{ctx | error: EasyRpc.Error.rpc_error("Circuit breaker open"), halted: true}
    else
      result = next.(ctx)
      track(result)
      result
    end
  end
end

4. Custom RPC Executor

Implement the RpcExecutor behaviour for non-pipeline scenarios:

defmodule MyCustomExecutor do
  @behaviour EasyRpc.Behaviours.RpcExecutor

  @impl true
  def execute(config, function, args) do
    # Custom implementation
  end

  @impl true
  def execute_with_retry(config, function, args) do
    # Custom retry logic
  end
end

Component Relationships

Dependency Graph

EasyRpc (public API)
    
RpcWrapper, DefRpc (wrapper macros)
    
FunctionGenerator (compile-time utilities)
    
WrapperConfig, NodeSelector (config & strategy)
    
Pipeline, Context, Plug (middleware)  NEW
    
RpcCall (executor  delegates to pipeline for safe mode)
    
RpcExecutor (behavior), Error (cross-cutting)
    
ConfigError, RpcError (backward-compat shims  Error)

Compile-Time vs Runtime

Compile-Time:

  • Function generation via macros (RpcWrapper, DefRpc, Spark DSL)
  • WrapperConfig loading and validation
  • Pipeline composition (plug list is compiled into the module)
  • Typespec and Dialyzer checking

Runtime:

  • Node selection (NodeSelector) via pipeline
  • RPC execution (:erpc.call) via Executor plug
  • Logging via Logger plug
  • Retry logic via Retry plug
  • Error handling via ErrorHandler plug
  • Custom plug execution

Testing Strategy

Unit Testing

  • Test each plug in isolation
  • Use Node.self() as a loopback node for real :erpc calls without a cluster
  • Create custom plugs and verify they compose correctly in a pipeline
  • Validate context transformations at each stage

Integration Testing

  • Custom pipeline via Spark DSL compiles correctly
  • Pipeline halts on cache hit, passes through on cache miss
  • All existing 213+ tests pass with the new pipeline architecture

Key Testing Notes

  • Application.put_env for configs used by use RpcWrapper / use DefRpc must be called at module body level, not inside setup_all
  • Custom pipeline tests use Code.compile_quoted to verify DSL compilation

Conclusion

The EasyRpc architecture is designed for:

  • Pluggability: Middleware pipeline for cross-cutting concerns
  • Clarity: Easy to understand and navigate
  • Maintainability: Clean separation of concerns, DRY via FunctionGenerator
  • Extensibility: Plug behaviour and custom pipelines
  • Reliability: Comprehensive error handling with structured logging
  • Performance: Minimal overhead, bare mode bypasses pipeline entirely
  • Type Safety: Complete typespec coverage

The plug pipeline architecture ensures that users can add custom behaviour (caching, telemetry, circuit breakers, authentication) by writing a single module and slotting it into the pipeline — without forking or modifying library code.


Last Updated: 2025 Version: 0.10.0