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
end

The 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"}
  ]
end

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

Or a unary function:

say_hi = fn _input -> :hi end

Running 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 callback

Timeout

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
end

Timeout 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)
end

Delivering 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, &notify_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     # => nil

Callbacks 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 started

Full 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

Statestarted_atfinished_attimedout_atoutputerror
Not startednilnilnilnilnil
StartedDateTime.t()nilnilnilnil
SucceededDateTime.t()DateTime.t()nilany()nil
FailedDateTime.t()DateTime.t()nilnilException.t()
Timed outDateTime.t()nilDateTime.t()nilnil

Examples

The examples/ directory contains ready-to-run use cases you can try in iex -S mix:

iex> import Interact.UseCase
iex> create(EchoUseCase) |> run(42)
iex> create(SayHiUseCase) |> async() |> reply(self()) |> run(nil)
iex> flush()

Roadmap