In Solve your main building block is the controller. Controllers are composed in the app state graph, and they can depend on each other. UI can subscribe to the controller state. Each controller has its own internal state, and each controller has read access to the exposed state of its dependencies.
Controllers are implemented as GenServers.
Responsibilities
- Manage internal user state
- Handle UI events
- Refresh cached dependency state when dependencies change
- Track observers (both UIs and other controllers)
- Send state updates directly to observers
- Monitor observer processes and clean them up
Direct Communication
Controllers communicate directly with subscribers using %Solve.Message{} envelopes
with %Solve.Update{} payloads.
Example Controller
defmodule MyApp.CounterController do
use Solve.Controller, events: [:increment, :decrement, :reset]
@impl true
def init(_init_params, _dependencies) do
%{count: 0, private_data: "secret"}
end
@impl true
def expose(state, _dependencies, _init_params) do
%{count: state.count}
end
def increment(_payload, state) do
%{state | count: state.count + 1}
end
def decrement(_payload, state) do
%{state | count: state.count - 1}
end
def reset(_payload) do
%{count: 0, private_data: "secret"}
end
endWith Dependencies
defmodule MyApp.ComputedController do
use Solve.Controller, events: []
@impl true
def init(_init_params, _dependencies) do
%{multiplier: 10}
end
@impl true
def expose(state, dependencies, _init_params) do
source_value = get_in(dependencies, [:source, :count]) || 0
%{result: source_value * state.multiplier}
end
endParams and Callbacks
init_params can have any shape, but it must be truthy. If it is nil or false, the
controller stops with {:invalid_init_params, controller_name, value}.
callbacks is a plain map passed only to declared event handlers. Event handlers can be
defined with arity 1 through 5, and Solve passes the leading runtime inputs the handler
asks for:
event_name(event_payload)
event_name(event_payload, state)
event_name(event_payload, state, dependencies)
event_name(event_payload, state, dependencies, callbacks)
event_name(event_payload, state, dependencies, callbacks, init_params)Declaring the same event at multiple arities is a compile error.
Subscription Helpers
subscribe(controller, subscriber \ self())registers the subscriber and returns the current exposed state synchronously.dispatch(controller, event, payload \ %{})sends an event to the controller.
Controllers can also define a Solve-style handle_info callback to react to non-Solve
messages such as PubSub notifications, timer messages, or process monitors set up in
init/2.
handle_info uses the same leading-subset convention as events and returns the next
user state directly:
handle_info(message, state)
handle_info(message, state, dependencies)
handle_info(message, state, dependencies, callbacks)
handle_info(message, state, dependencies, callbacks, init_params)In normal app code, prefer Solve.dispatch/4 so dispatch goes through the Solve
runtime and stays aligned with controller lifecycle changes. Solve.Controller.dispatch/3
is the low-level primitive for callers that already have a controller pid.
Solve reserves these message shapes for controller internals and they will not be
forwarded to controller-defined handle_info clauses:
{:solve_event, event}{:solve_event, event, payload}%Solve.Message{}%Solve.DependencyUpdate{}- subscriber monitor
{:DOWN, ref, :process, pid, reason}messages owned by Solve
Exposed State
A running controller must expose a plain map. nil is reserved for the Solve runtime
to mean that a controller is currently off/stopped. The :events_ key is reserved for
Solve.Lookup augmentation and should not be returned from expose/3.
Using Structs for State
Controller user state can be any term. Structs are often a good choice because they provide better documentation and tooling, but they are not required.
Summary
Callbacks
Computes the exposed state visible to subscribers and dependent controllers.
Initializes the controller user state.
Functions
Dispatches an event to a controller.
Starts a controller GenServer.
Subscribes a process to controller updates and returns the current exposed state.
Types
Callbacks
@callback expose(state(), dependencies(), init_params()) :: map()
Computes the exposed state visible to subscribers and dependent controllers.
Defaults to returning the full user state. Running controllers must return a plain map.
@callback init(init_params(), dependencies()) :: state()
Initializes the controller user state.
init_params can have any truthy shape. dependencies contains cached exposed state
from upstream controllers.
Functions
@spec dispatch(GenServer.server(), term(), term()) :: :ok
Dispatches an event to a controller.
@spec start_link( module(), keyword() ) :: GenServer.on_start()
Starts a controller GenServer.
@spec subscribe(GenServer.server(), pid()) :: any()
Subscribes a process to controller updates and returns the current exposed state.