Interact
An Elixir library for defining and running use cases — the application-layer business logic units described in Clean Architecture (Uncle Bob), Domain-Driven Design (application services), and Hexagonal Architecture (interactors).
"The code should scream the architecture." — Robert C. Martin, Screaming Architecture
What is a use case?
A use case is an isolated, named piece of business logic. It takes an input, does work, and produces an output. It knows nothing about HTTP, databases, queues, or any other infrastructure concern — those are the caller's problem.
In interact, a use case is either a module that exports an exec/1 function or a unary function:
defmodule MyApp.CreateOrderUseCase do
def exec(%{user_id: user_id, items: items}) do
# pure business logic here
{:ok, order} = Orders.create(user_id, items)
order
end
end
create_order = fn %{user_id: user_id, items: items} ->
{:ok, order} = Orders.create(user_id, items)
order
endThe library handles the rest: synchronous or asynchronous execution, result delivery, concurrency control, and lifecycle tracking.
Why?
Without use cases, business logic tends to accumulate in controllers, LiveView handlers, or context modules — mixed with HTTP parsing, database calls, and async scaffolding. This makes it hard to see what the application actually does.
Extracting use cases into dedicated modules makes the architecture visible at a glance, and separates the what (business logic) from the how (execution strategy).
Installation
Add interact to your dependencies in mix.exs:
def deps do
[
{:interact, "~> 0.1"}
]
endinteract starts its own supervision tree automatically — no manual setup needed in your application supervisor.
Defining a use case
A valid use case is either a module with an exec/1 function:
defmodule MyApp.SayHiUseCase do
def exec(_input) do
:hi
end
endOr a unary function:
say_hi = fn _input -> :hi endRunning a use case
Import (or alias) Interact.UseCase and build a pipeline:
import Interact.UseCase
create(MyApp.SayHiUseCase)
|> input(:ignored)
|> run()
# => %Interact.UseCase{output: :hi, error: nil, ...}
The input can be anything accepted by the use cases (a keyword list, a list, a map, a struct, a plain value, etc. or nil by default).
run/2 is a shorthand for input/2 |> run/1:
create(MyApp.SayHiUseCase) |> run(:ignored)
Functions work too — both fn closures and &Module.function/1 captures:
create(fn x -> x + 1 end) |> run(41)
# => %Interact.UseCase{output: 42, ...}
create(&String.upcase/1) |> run("hello")
# => %Interact.UseCase{output: "HELLO", ...}
The returned struct always carries the full picture: input, output, error, started_at, timedout_at, finished_at.
Asynchronous execution
By default, run/1 blocks until the use case finishes. Add async/1 to fire and forget:
create(MyApp.HeavyUseCase)
|> async()
|> run(input)
# returns immediately; result arrives later via reply or callbackTimeout
Add timeout/2 to kill the underlying task if it exceeds a deadline.
The result struct is returned with timedout_at set to the moment the
deadline was detected. finished_at, output and error remain nil.
uc = create(MyApp.SlowUseCase) |> timeout(5_000) |> run(params)
if uc.timedout_at do
# use case was killed after 5 seconds
endTimeout works for both synchronous and asynchronous use cases. Callbacks and replies fire with the timed-out struct in both cases.
create(MyApp.SlowUseCase)
|> async()
|> timeout(5_000)
|> reply(self())
|> run(params)
receive do
%Interact.UseCase{timedout_at: t} when not is_nil(t) -> handle_timeout()
%Interact.UseCase{output: o} when not is_nil(o) -> handle_output(o)
%Interact.UseCase{error: e} when not is_nil(e) -> handle_error(e)
endDelivering results
To a process — reply/2
Send the completed use case struct to one or more PIDs:
create(MyApp.ScanNetworkUseCase)
|> async()
|> reply(self())
|> run(cidr)
receive do
%Interact.UseCase{output: result} -> handle(result)
end
Particularly useful in LiveView — pass self() and handle the result in handle_info/2.
Via a callback — callback/2
Run one or more functions when the use case completes:
create(MyApp.ScanNetworkUseCase)
|> async()
|> callback(fn uc -> Logger.info("Scan finished: #{inspect(uc.output)}") end)
|> run(cidr)Pass a list for multiple callbacks:
|> callback([&log_result/1, ¬ify_user/1])Callbacks are invoked asynchronously, even for synchronous use cases.
Broadcasting to a PubSub
interact has no PubSub dependency. Use callback/2 to broadcast through whatever PubSub your application already uses:
create(MyApp.ScanNetworkUseCase)
|> async()
|> callback(fn uc ->
Phoenix.PubSub.broadcast(MyApp.PubSub, "scans", {:scan_done, uc.output})
end)
|> run(cidr)Error handling
Exceptions raised inside the use case
If exec/1 raises, the exception is captured in the error field, the stacktrace in stacktrace, and output is set to nil. The use case struct is always returned — execution never crashes the caller.
uc = create(fn _ -> raise "oops" end) |> run(nil)
uc.error # => %RuntimeError{message: "oops"}
uc.stacktrace # => [...]
uc.output # => nilCallbacks and replies still fire with the failed struct, so you can handle errors uniformly:
|> callback(fn
%{error: nil, output: result} -> handle_success(result)
%{error: error} -> handle_failure(error)
end)Invocation errors
Passing a module that does not implement exec/1 — whether it exists but lacks the function, or does not exist at all — is treated as any other runtime error: the exception is captured in error and stacktrace, and the struct is returned normally.
# Module exists but has no exec/1
uc = create(String) |> run("hello")
uc.error # => %UndefinedFunctionError{module: String, function: :exec, arity: 1}
# Module does not exist
uc = create(MyApp.ForgotToDefineThisUseCase) |> run(nil)
uc.error # => %UndefinedFunctionError{...}
# Anonymous function with wrong arity
uc = create(fn x, y -> x + y end) |> run(1)
uc.error # => %BadArityError{...}Calling pipeline functions on a started use case
Once run/1 is called the use case is considered started. Any further
pipeline call on the same struct — async/1, callback/2,
reply/2, input/2, or run/1 again — raises ArgumentError immediately.
It does not capture into error. Create a new struct for each execution.
uc = create(EchoUseCase) |> run(42)
run(uc) # => raises ArgumentError: use case already started
input(uc, 1) # => raises ArgumentError: use case already started
async(uc) # => raises ArgumentError: use case already startedFull pipeline reference
create(MyApp.MyUseCase) # wraps a use case module or function
|> async() # fire and forget (default: synchronous)
|> timeout(ms) # kill the task after ms milliseconds (default: :infinity)
|> callback(fn_or_list) # functions called after completion (default: [])
|> reply(pid_or_list) # processes to receive the result struct (default: [])
|> input(data) # input passed to the use case (default: nil)
|> run() # executes the use case
Every pipeline function accepts a module or function directly, so create/1
is executed internally, this means that the pipeline can start at any step:
MyApp.MyUseCase |> async() |> run(data)
fn x -> x * 2 end |> callback(&log/1) |> run(21)run/1 (or run/2) must always be the last step. All steps before it are
optional and order-independent.
Lifecycle and invariant
| State | started_at | finished_at | timedout_at | output | error |
|---|---|---|---|---|---|
| Not started | nil | nil | nil | nil | nil |
| Started | DateTime.t() | nil | nil | nil | nil |
| Succeeded | DateTime.t() | DateTime.t() | nil | any() | nil |
| Failed | DateTime.t() | DateTime.t() | nil | nil | Exception.t() |
| Timed out | DateTime.t() | nil | DateTime.t() | nil | nil |
Examples
The examples/ directory contains ready-to-run use cases you can try in iex -S mix:
EchoUseCase: returns its input unchangedSayHiUseCase: sleeps briefly, prints a greeting, returns:hiJustRaiseUseCase: always raises (demonstrates error capture)SlowUseCase: returns:doneafter a while
iex> import Interact.UseCase
iex> create(EchoUseCase) |> run(42)
iex> create(SayHiUseCase) |> async() |> reply(self()) |> run(nil)
iex> flush()Roadmap
- Persistence: lightweight result history via ETS as a first step; full persistence as a future option
- Restart policy: re-run a failed use case up to N times, dependent on persistence support
- Composition: sequential chaining, pipes (output of one use case becomes input of the next), and parallel execution
- Periodic use cases: run a use case N times with a given periodicity
- Singleton use cases: run a use case as singleton to prevent concurrent executions
- Process pools: limit the number of concurrent executions of a given use case (singleton would be a particular case with one concurrent execution) or for any use case