Design Principles
View SourceOverview
Jido follows a set of core design principles that shape its architecture and guide implementation decisions. These principles emphasize reliability, clarity, and flexibility while maintaining strong guarantees about system behavior.
Core Principles
1. Error Tuples Over Exceptions
Jido adopts a strict "no exceptions" policy, using tagged tuples to handle errors explicitly:
# Good - Using error tuples
def process_data(input) do
case validate_input(input) do
{:ok, validated} -> perform_processing(validated)
{:error, reason} -> {:error, "Invalid input: #{reason}"}
end
end
# Bad - Using exceptions
def process_data(input) do
validated = validate_input!(input) # May raise
perform_processing(validated)
rescue
e -> handle_error(e) # Hidden control flow
end
This approach provides:
- Explicit error handling paths
- Clear function contracts
- Predictable control flow
- Easy composition with
with
statements
2. Data-First Architecture
Jido prioritizes data transformation over behavioral inheritance:
# Data-centric approach
defmodule DataProcessor do
def process(%{type: "user", data: data} = signal) do
with {:ok, enriched} <- enrich_user_data(data),
{:ok, validated} <- validate_user(enriched) do
{:ok, %{signal | data: validated}}
end
end
end
# Instead of behavioral inheritance
defmodule UserProcessor do
@behaviour Processor
def process(signal), do: # Implementation
end
Benefits:
- Simpler testing and reasoning
- Easier refactoring
- Natural composition
- Clear data flow
3. Rich Compile-Time Discovery
Jido leverages compile-time metadata to enable rich discovery and validation:
defmodule MyApp.UserAction do
use Jido.Action,
name: "process_user",
description: "Processes user data with validation",
category: :users,
tags: [:processing, :validation],
schema: [
user_id: [
type: :string,
required: true,
doc: "Unique user identifier"
],
options: [
type: :map,
default: %{},
doc: "Processing options"
]
]
end
This enables:
- Automatic documentation generation
- Schema validation
- Runtime introspection
- Tool integration
4. Event-Driven Messaging
The system is built around event-driven communication using signals:
# Emit a signal
{:ok, signal} = Signal.new(%{
type: "user.created",
source: "/users",
data: user_data
})
# Route based on signal type
def handle_signal(%Signal{type: "user.created"} = signal) do
with {:ok, processed} <- process_user(signal.data),
{:ok, notification} <- generate_notification(processed) do
{:ok, notification}
end
end
Key aspects:
- Decoupled components
- Asynchronous processing
- Clear signal paths
- Flexible routing
5. Agents as Dynamic ETL
Agents function as dynamic Extract-Transform-Load processors:
defmodule DataTransformAgent do
use Jido.Agent,
name: "data_transformer"
def process_data(data) do
with {:ok, extracted} <- extract_data(data),
{:ok, transformed} <- apply_transformations(extracted),
{:ok, loaded} <- load_result(transformed) do
{:ok, loaded}
end
end
# Transformation steps are composed dynamically
defp apply_transformations(data) do
transformations = [
&validate_format/1,
&enrich_data/1,
&normalize_values/1
]
Enum.reduce_while(transformations, {:ok, data}, fn transform, {:ok, acc} ->
case transform.(acc) do
{:ok, result} -> {:cont, {:ok, result}}
{:error, _} = error -> {:halt, error}
end
end)
end
end
Benefits:
- Flexible processing pipelines
- Clear data transformations
- Composable operations
- Runtime adaptation
6. Balanced Data Validation
Jido implements a "validate what you know" approach to data handling:
defmodule FlexibleProcessor do
# Schema defines only known fields
use Jido.Action,
schema: [
id: [type: :string, required: true],
status: [type: {:in, [:pending, :complete]}]
]
def process(params) do
# Unknown fields pass through unchanged
case validate_required_fields(params) do
{:ok, validated} ->
# Process while preserving unknown fields
result = do_process(validated)
{:ok, Map.merge(params, result)}
{:error, _} = error -> error
end
end
end
This approach:
- Validates critical fields
- Preserves extensibility
- Enables forward compatibility
- Reduces maintenance burden
7. Robust Error Recovery
Following Erlang's "let it crash" philosophy while maintaining system stability:
defmodule ResilientAgent do
use Jido.Agent,
name: "resilient_processor"
# Supervisor ensures recovery
def child_spec(opts) do
%{
id: __MODULE__,
start: {__MODULE__, :start_link, [opts]},
restart: :transient,
shutdown: 5000
}
end
# Trap exits for cleanup
def init(state) do
Process.flag(:trap_exit, true)
{:ok, state}
end
# Handle known errors
def handle_error({:error, :recoverable} = error, state) do
Logger.warning("Recovering from error", error: error)
{:ok, reset_state(state)}
end
# Let unknown errors crash
def handle_error(error, _state) do
Logger.error("Unhandled error", error: error)
{:stop, error}
end
end
Key features:
- Supervision hierarchies
- Process isolation
- Clean error separation
- Graceful degradation