Solve
Solve is a controller-graph state runtime for Elixir applications.
It models application state as a graph of focused controllers instead of a tree that mirrors UI structure. Each controller owns one slice of behavior and state, exposes a plain map, and declares its dependencies explicitly.
This keeps state structure and UI structure separate. Your UI can still render as a tree, but your application state does not have to follow that shape.
Install Solve
Add solve to your dependencies:
def deps do
[
{:solve, "~> 0.1.0"}
]
endModel state around interaction
Users do not interact with one widget in isolation. They interact with the application as a whole.
A click, a filter change, a draft edit, or a menu selection is presented in one place, but it often affects several other parts of the system:
- visible content
- counters and summaries
- enabled actions
- local editing state
- background processes
That is why Solve models state around ownership and interaction, not around the nearest rendered component.
A controller is not just a bucket of values. A controller models one coherent slice of application behavior.
Learn the core ideas
- controller - a
GenServerthat owns one slice of state and behavior - exposed state - the plain map a controller shares with subscribers, dependents, and view code
- dependency - another controller's exposed state made available to a controller
- callback - a function passed from the app into a controller for explicit cross-controller writes
- collection source - a controller spec that manages many item controllers
such as
{:todo, 1}or{:todo, 2} - Solve app - the coordinating
GenServerthat owns the controller graph - Solve.Lookup - a process-local API for cached reads and direct event refs
Declared event handlers take the leading subset of runtime inputs they need:
payloadstatedependenciescallbacksinit_params
For example, an event handler may be defined at any of these arities:
event_name(payload)
event_name(payload, state)
event_name(payload, state, dependencies)
event_name(payload, state, dependencies, callbacks)
event_name(payload, state, dependencies, callbacks, init_params)Start with one controller
Start with one controller.
A controller is the smallest useful unit in Solve. It owns one slice of state, handles a small set of events, and exposes a plain map for the rest of the application to read.
defmodule MyApp.CounterController do
use Solve.Controller, events: [:increment, :decrement]
@impl true
def init(_params, _dependencies), do: %{count: 0}
def increment(_payload, state), do: %{state | count: state.count + 1}
def decrement(_payload, state), do: %{state | count: state.count - 1}
endThis controller models one small interaction boundary: incrementing and decrementing a counter.
That is the right place to begin. Start with one interaction and give it one controller.
Run controllers in an app
Controllers run inside a Solve app.
The app starts the controller graph, keeps it alive, and routes events to the right controller instance.
defmodule MyApp.App do
use Solve
@impl Solve
def controllers do
[
controller!(name: :counter, module: MyApp.CounterController)
]
end
end
Start the app like any other GenServer:
{:ok, app} = MyApp.App.start_link(name: MyApp.App)At this point:
MyApp.CounterControllermodels one slice of behaviorMyApp.Appruns that controller- the app becomes the stable runtime entrypoint for reads, dispatch, and subscriptions
Describe data flow in the app
The app is not only a runtime container. It also defines how controllers interact.
That is the main role of the controller graph.
An app defines:
- which controllers exist
- which controllers read from others through dependencies
- which writes cross ownership boundaries through callbacks
- which repeated controller instances are materialized through collections
For example:
defmodule MyApp.App do
use Solve
@impl Solve
def controllers do
[
controller!(name: :task_list, module: MyApp.TaskList),
controller!(
name: :create_task,
module: MyApp.CreateTask,
callbacks: %{
submit: fn title -> dispatch(:task_list, :create_task, title) end
}
),
controller!(
name: :filter,
module: MyApp.Filter,
dependencies: [:task_list]
),
controller!(
name: :task_editor,
module: MyApp.TaskEditor,
variant: :collection,
dependencies: [:task_list],
collect: fn _context = %{dependencies: %{task_list: task_list}} ->
Enum.map(task_list.ids, fn id ->
{id, [params: %{id: id, title: task_list.tasks[id].title}]}
end)
end
)
]
end
endThis graph describes data flow directly:
:filterreads from:task_list:create_taskwrites back to:task_listthrough a callback:task_editormaterializes one controller per task:task_listremains the owner of the canonical data
Controllers implement behavior. The app defines how controllers read from and write to each other.
Keep controllers focused
Each controller owns one coherent interaction boundary.
Good controller boundaries include things like:
- current screen selection
- draft input state
- active filter state
- canonical list data
- one edit session per item
A focused controller has:
- one clear responsibility
- one small event surface
- one exposed state map
- one reason to change
For example:
defmodule MyApp.Screen do
use Solve.Controller, events: [:set]
@screens [
%{id: :tasks, label: "Tasks"},
%{id: :reports, label: "Reports"}
]
@impl true
def init(_params, _dependencies), do: %{current: :tasks}
def set(screen, state) when screen in [:tasks, :reports] do
%{state | current: screen}
end
def set(_screen, state), do: state
@impl true
def expose(state, _dependencies, _params) do
%{current: state.current, screens: @screens}
end
endUse dependencies for derived state
Use dependencies when one controller needs another controller's exposed state as input.
Dependencies are for reads.
They are the right place for:
- filtered ids
- grouped sections
- status summaries
- enabled actions
- derived counts
For example, a filter controller exposes visible_ids instead of making the UI
recompute filtering logic during rendering:
defmodule MyApp.Filter do
use Solve.Controller, events: [:set]
@filters [:all, :active, :completed]
@impl true
def init(_params, _dependencies), do: %{active: :all}
def set(filter, _state) when filter in @filters, do: %{active: filter}
def set(_filter, state), do: state
@impl true
def expose(state, _dependencies = %{task_list: task_list}, _params) do
%{
filters: @filters,
active: state.active,
visible_ids: visible_ids(state.active, task_list)
}
end
defp visible_ids(:all, %{ids: ids}), do: ids
defp visible_ids(:active, %{ids: ids, tasks: tasks}) do
Enum.reject(ids, fn id -> tasks[id].completed? end)
end
defp visible_ids(:completed, %{ids: ids, tasks: tasks}) do
Enum.filter(ids, fn id -> tasks[id].completed? end)
end
endThis keeps derived state out of the UI layer.
Use callbacks for explicit writes
Use callbacks when one controller needs to request a write from another controller.
Dependencies describe reads. Callbacks describe writes.
This keeps the dependency graph acyclic while preserving ownership.
For example, an input controller owns the draft title while another controller owns the canonical list:
defmodule MyApp.App do
use Solve
@impl Solve
def controllers do
[
controller!(name: :task_list, module: MyApp.TaskList),
controller!(
name: :create_task,
module: MyApp.CreateTask,
callbacks: %{
submit: fn title -> dispatch(:task_list, :create_task, title) end
}
)
]
end
endThe controller still owns its own local transition logic:
defmodule MyApp.CreateTask do
use Solve.Controller, events: [:set_title, :submit]
@impl true
def init(_params, _dependencies), do: %{title: ""}
def set_title(title) when is_binary(title) do
%{title: title}
end
def submit(_payload, state, _dependencies, _callbacks = %{submit: submit}) do
case String.trim(state.title) do
"" ->
%{title: ""}
title ->
submit.(title)
%{title: ""}
end
end
endThis keeps ownership explicit:
- one controller owns the draft input
- another controller owns the canonical data
- the write boundary is visible in the app graph
Use collections for repeated item state
Use a collection source when many items need the same local behavior.
This fits cases like:
- one edit session per row
- one expanded state per item
- one upload state per file
- one inspector state per node
A collection source reuses one controller design while keeping each item's local state separate.
controller!(
name: :task_editor,
module: MyApp.TaskEditor,
variant: :collection,
dependencies: [:task_list],
collect: fn _context = %{dependencies: %{task_list: task_list}} ->
Enum.map(task_list.ids, fn id ->
{id, [params: %{id: id, title: task_list.tasks[id].title}]}
end)
end
)Each item controller then owns only its own local behavior:
defmodule MyApp.TaskEditor do
use Solve.Controller, events: [:begin_edit, :cancel_edit, :set_title, :save_edit]
@impl true
def init(params, _dependencies), do: %{editing?: false, title: params.title}
def begin_edit(_payload, state), do: %{state | editing?: true}
def set_title(title, %{editing?: true} = state) when is_binary(title) do
%{state | title: title}
end
def cancel_edit(_payload, _state, _dependencies, _callbacks, params) do
%{editing?: false, title: params.title}
end
@impl true
def expose(state, _dependencies, params), do: Map.put(state, :id, params.id)
end
This keeps per-item local state out of the canonical list while still making
every item controller addressable as {:task_editor, id}.
Read and write state directly
The base API is Solve.subscribe/3 and Solve.dispatch/4.
iex> {:ok, app} = MyApp.App.start_link(name: MyApp.App)
iex> Solve.subscribe(app, :counter)
%{count: 0}
iex> :ok = Solve.dispatch(app, :counter, :increment, %{})
iex> Solve.subscribe(app, :counter)
%{count: 1}Solve.subscribe/3:
- returns the current exposed state synchronously
- registers the subscriber for future updates
- works with singleton targets, collected child targets, and collection sources
Solve.dispatch/4:
- routes an event through the Solve app
- forwards the event to the current controller pid for that target
- becomes a no-op if the target is off or missing
Solve also exposes a few inspection helpers:
Solve.controller_pid/2Solve.controller_events/2Solve.controller_variant/2
Use these when a test, worker, or tool needs raw access to the runtime.
Use Solve.Lookup for process-local access
Use Solve.Lookup when a long-running process needs:
- cached reads
- update handling
- direct event refs
Solve.Lookup is framework-agnostic. It works in ordinary GenServer
processes, workers, and UI processes.
The main helpers are:
solve(app, target)for singleton controllers and collected child targetscollection(app, source)for collection sourcesevents(item)to read direct event refsevent(item, name)andevent(item, name, payload)to build direct event tuples
Item lookups return the exposed state map augmented with an :events_ key.
Collection lookups return %Solve.Collection{ids, items} whose items are
augmented item maps.
Use Solve.Lookup in a GenServer
Solve.Lookup works well in ordinary GenServer processes.
defmodule MyApp.CounterWorker do
use GenServer
use Solve.Lookup
def start_link(app) do
GenServer.start_link(__MODULE__, app, name: __MODULE__)
end
@impl true
def init(app), do: {:ok, %{app: app}}
@impl true
def handle_cast(:increment, state) do
counter = solve(state.app, :counter)
case event(counter, :increment) do
{pid, message} -> send(pid, message)
nil -> :ok
end
{:noreply, state}
end
def render(%{app: app} = state) do
IO.inspect(solve(app, :counter), label: "counter")
state
end
@impl Solve.Lookup
def handle_solve_updated(_updated, state) do
{:ok, render(state)}
end
endKey properties:
-
the first
solve/2call subscribes the process and populates its local cache -
later
solve/2calls read from that cache event(counter, :increment)gives you a direct{pid, message}tuplehandle_solve_updated/2handles the process-specific reaction to Solve state changes
use Solve.Lookup defaults to handle_info: :auto. %Solve.Message{}
envelopes refresh the local cache and call handle_solve_updated/2 for you.
Use handle_info: :manual when the process needs to inspect updates itself and
decide which ones matter.
Use Solve.Lookup in Emerge
Solve.Lookup also fits naturally into Emerge viewports.
In Emerge, views read Solve state in render/0 or render/1, bind events from
lookup items, and rerender from handle_solve_updated/2.
defmodule MyApp.Viewport do
use Emerge
use Solve.Lookup
@impl Viewport
def render(_state) do
counter = solve(MyApp.App, :counter)
row([], [
button("+", event(counter, :increment)),
el([], text("Count: #{counter.count}")),
button("-", event(counter, :decrement))
])
end
@impl Solve.Lookup
def handle_solve_updated(_updated, state) do
{:ok, Viewport.rerender(state)}
end
endThis keeps state reads and event wiring close to the view code that uses them.
Model larger applications
As an application grows, one controller stops being enough. The common shape is:
- one controller for canonical data
- one or more controllers for derived state
- collection controllers for repeated local item behavior
A task app can be split like this:
| Controller | Owns | Depends on | Purpose |
|---|---|---|---|
:task_list | canonical task data | none | create, update, delete, toggle tasks |
:create_task | draft input value | none | manage input state and submit new tasks |
:filter | active filter | :task_list | expose visible ids derived from task state |
{:task_editor, id} | local edit state for one task | :task_list | manage editing UI for a single item |
This is a common Solve structure:
- canonical data in one controller
- derived state in another
- local per-item state in a collection source
Split into separate apps only when parts of your system become genuinely independent domains. That means they:
- have their own controller graph
- evolve independently
- do not share much internal state ownership
- are composed together at a higher level rather than tightly coordinated
Separate apps also fit when you need different variants of the same graph. A user-facing app and an admin app can reuse the same core controllers while the admin app adds moderation, audit, or other admin-only controllers.
Controllers stay reusable because they do not know which app they live in. The app defines how they are wired together. This works when reused controllers still receive the dependency keys, params, and callbacks they expect.
One concrete example of this style is the Emerge TodoMVC demo: https://github.com/emerge-elixir/emerge/tree/main/example
Pick the right primitive
Use these rules when choosing between Solve primitives:
- Use a singleton controller for one focused state owner.
- Use a dependency when one controller derives state from another.
- Use a callback when one controller requests a write from another.
- Use a collection source when each item needs its own local behavior or state.
-
Use
Solve.Lookupwhen a process needs cached reads and update-aware event wiring.
Respect the invariants
- Running controller instances expose plain maps.
-
Collection sources expose
%Solve.Collection{ids, items}throughSolve.subscribe/3andSolve.Lookup.collection/2. nilmeans a singleton or collected child is off or stopped.:events_is reserved in exposed maps for lookup augmentation.Solve.subscribe/3returns raw exposed state for singleton targets and collection sources.Solve.Lookup.solve/2returns augmented singleton or collected-child views.Solve.Lookup.collection/2returns an augmented collection view.
Keep reading
ARCHITECTURE.mdcovers the runtime model and lifecycle rules in more detail.
Acknowledge the influences
Solve draws significant conceptual inspiration from Keechma Next, especially in its emphasis on controller-oriented state management, explicit data flow, and keeping UI structure separate from state structure.