Solve.Controller behaviour (Solve v0.2.1)

Copy Markdown View Source

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
end

With 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
end

Params 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()

@type callbacks() :: map()

dependencies()

@type dependencies() :: %{optional(atom()) => map() | Solve.Collection.t(map()) | nil}

dependency_encoder()

@type dependency_encoder() :: (map() -> term())

init_params()

@type init_params() :: any()

state()

@type state() :: any()

Callbacks

expose(state, dependencies, init_params)

(optional)
@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.

init(init_params, dependencies)

@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

dispatch(controller, event, payload \\ %{})

@spec dispatch(GenServer.server(), term(), term()) :: :ok

Dispatches an event to a controller.

start_link(module, opts \\ [])

@spec start_link(
  module(),
  keyword()
) :: GenServer.on_start()

Starts a controller GenServer.

subscribe(controller, subscriber \\ self())

@spec subscribe(GenServer.server(), pid()) :: any()

Subscribes a process to controller updates and returns the current exposed state.