Version: 0.10.0 Last Updated: 2025
Table of Contents
- Overview
- Architecture Diagram
- Module Hierarchy
- Core Components
- Plug Pipeline
- Data Flow
- Design Patterns
- Extension Points
- Performance Considerations
- 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 InterfaceLayer 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 LoadingLayer 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 SupportLayer 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 aliasLayer 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/2Core 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
endKey Functions:
version/0- Returns library version
2. EasyRpc.Dsl (Spark DSL Extension)
Purpose: Define the Spark DSL structure for EasyRpc
Responsibilities:
- Define
configsection with global settings - Define
pipelinesection withplugentities - Define
rpc_functionssection withrpc_functionentities - 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 configuration3. 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_result5. 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
WrapperConfigvalues - Parse arity specifications (integer,
[], or named-atom list) - Generate AST variable lists for macro-expanded
defbodies - Validate per-function option keys and values
7. WrapperConfig (Configuration Management)
Purpose: Validate and manage RPC configuration
Fields:
| Field | Type | Default | Description |
|---|---|---|---|
node_selector | %NodeSelector{} or nil | — | Node selection strategy |
module | atom | required | Remote module to call |
timeout | pos_integer | :infinity | 5_000 | Per-call timeout in milliseconds |
retry | non_neg_integer | 0 | Number of retry attempts on failure |
sleep_before_retry | non_neg_integer | 0 | Milliseconds to sleep between retry attempts |
error_handling | boolean | false | Return 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: trueorretry > 0): delegates to the plug pipeline
Primary API:
execute/3— respectsconfig.error_handlingandconfig.retryexecute_with_retry/3— always uses error handlingexecute_dynamic/4— resolvesNodeSelectorat call time (used byDefRpc)
10. EasyRpc.Context
Purpose: Carries all state through the plug pipeline (analogous to Plug.Conn)
Fields:
| Field | Type | Description |
|---|---|---|
config | WrapperConfig.t() | Configuration for this call |
function | atom() | Remote function name |
args | list() | Function arguments |
node | node() | nil | Selected target node (set by NodeSelector) |
result | term() | nil | Raw RPC result (set by Executor) |
error | Error.t() | nil | Error when call fails |
assigns | map() | Free-form map for plug authors |
halted | boolean() | When true, pipeline stops |
attempt | non_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:
- The current
%EasyRpc.Context{} - Options configured when the plug was added to the pipeline
- A
nextfunction 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 originalRpcCallbehaviour)call/2— composes plugs into a chain and runs it with a contextrun/4— convenience: creates Context, calls pipeline, extracts resultextract_result/2— extracts final result from context (handles bare/safe modes)
Default Pipeline Order (outermost → innermost):
ErrorHandler → Retry → NodeSelector → Logger → ExecutorPlugs 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
| Plug | Purpose |
|---|---|
NodeSelector | Selects a target node, stores in ctx.node |
Executor | Calls :erpc.call with node/module/fun/args/timeout; catches exceptions |
Retry | Re-executes the inner chain when ctx.error is set and retries remain |
Logger | Structured logging (--> call, <-- success, <<< retry, !!! failure) |
ErrorHandler | Normalises context: wraps result as {:ok, result} or {:error, error} in safe mode |
Telemetry | Emits [:easy_rpc, :call, :start/stop/exception] events (optional dep) |
Cache | ETS-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 → callerRuntime return order (upstream):
caller → ErrorHandler → Retry → NodeSelector → Logger → ExecutorWriting 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}
endCustom 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
endWhen 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 raisesPlug 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
endAdd 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
end2. Dynamic Node Discovery
Use MFA tuples for runtime node resolution:
config do
nodes_provider {ClusterHelper, :get_nodes, [:backend]}
end3. 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
end4. 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
endComponent 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) WrapperConfigloading 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) viaExecutorplug - Logging via
Loggerplug - Retry logic via
Retryplug - Error handling via
ErrorHandlerplug - Custom plug execution
Testing Strategy
Unit Testing
- Test each plug in isolation
- Use
Node.self()as a loopback node for real:erpccalls 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_envfor configs used byuse RpcWrapper/use DefRpcmust be called at module body level, not insidesetup_all- Custom pipeline tests use
Code.compile_quotedto 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