DEV Community

Cover image for Building a Useless Machine in Elixir
Mark Markaryan
Mark Markaryan

Posted on

Building a Useless Machine in Elixir

A Useless Machine does one thing: when you turn it on, a mechanical paw reaches out and flips the switch back off. Useless, but fun.

Let's build one in Elixir!

To save us some code, and to give our Useless Machine object permanence, we'll define it as a reactive durable workflow using Journey.

1. Prerequisites

To play with this, you'll want to have Elixir 1.18 or 1.19, Docker (if you want to run your postgres database in a docker container), and some familiarity with Elixir.

If you use asdf, here is the .tool-versions file I used for this exercise:

$ cat .tool-versions
erlang 27.1.2
elixir 1.19.1-otp-27
Enter fullscreen mode Exit fullscreen mode

2. Source Code

The source code for this exercise (+ tests) is available at https://github.com/markmark206/useless_machine

If you want to just start playing with Useless Machine, not implement it, just clone the repo, follow its setup steps, and fast forward to section 5. Useless Machine: Let's Play.

If you want to follow along... well, follow along.

3. Create and Configure the Project

We'll start by creating a basic Elixir project, wiring up its dependencies (postgres, journey), configuring its database and journey logging, fetching dependencies, and creating the database.

This is largely standard Elixir stuff, so I'll list the steps, but won't spend much time discussing them:

Create the initial Elixir project:

$ mix new useless_machine
$ cd useless_machine
Enter fullscreen mode Exit fullscreen mode

Wire Up Dependencies

Update your new project's mix.exs to add Journey, Ecto, and Postgrex:

defp deps do
  [
    {:journey, "~> 0.10.40"},
    {:ecto, "~> 3.12 or ~> 3.13"},
    {:postgrex, "~> 0.20 or ~> 0.21"}
  ]
end
Enter fullscreen mode Exit fullscreen mode

Configure the Database and Logging

Create config/config.exs, with the following configuration:

import Config

config :useless_machine, ecto_repos: [Journey.Repo]

config :journey, Journey.Repo,
  database: "useless_machine",
  username: "postgres",
  password: "postgres",
  hostname: "localhost"

config :logger,
       :console,
       format: "$time [$level] $metadata$message\n",
       level: :warning,
       metadata: [:pid, :mfa]
Enter fullscreen mode Exit fullscreen mode

Fetch Dependencies

$ mix deps.get
Enter fullscreen mode Exit fullscreen mode

Start a Postgres Instance, Create the Database

Start a Postgres instance in a container:

$ docker run --rm --name postgres -p 5432:5432 -e POSTGRES_PASSWORD=postgres -d postgres:16
Enter fullscreen mode Exit fullscreen mode

Create the database:

$ mix ecto.create
Enter fullscreen mode Exit fullscreen mode

4. Implement the Useless Machine

Now that we have the placeholder project in place, let's implement our Useless Machine.

A Useless Machine may look similar to this:

  β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
  β”‚                                             β”‚
  β”‚  β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”             β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”  β”‚
  β”‚  β”‚            β”‚ 2. triggers β”‚            β”‚  β”‚
  β”‚  β”‚  :switch   │────────────▢│    :paw    β”‚  β”‚
  β”‚  β”‚  (input)   β”‚             β”‚  (mutate)  β”‚  β”‚
  β”‚  β”‚            │◀────────────│            β”‚  β”‚
  β”‚  β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ 3. "off"    β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜  β”‚
  β”‚        β–²                                    β”‚
  β””β”€β”€β”€β”€β”€β”€β”€β”€β”Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
           β”‚ 1. "on"
       β”Œβ”€β”€β”€β”΄β”€β”€β”€β”
       β”‚ user  β”‚
       β””β”€β”€β”€β”€β”€β”€β”€β”˜
Enter fullscreen mode Exit fullscreen mode

Luckily, it will only be a few lines of code!

Replace the contents of lib/useless_machine.ex with these 15 lines:

defmodule UselessMachine do
  import Journey.Node

  def graph() do
    Journey.new_graph([
      input(:switch),
      mutate(:paw, [:switch], &lol_no/1, mutates: :switch)
    ])
  end

  defp lol_no(%{switch: switch} = _values) do
    IO.puts("paw says: '#{switch}? lol no'")
    {:ok, "off"}
  end
end
Enter fullscreen mode Exit fullscreen mode

This module defines

  1. a graph with two nodes:

    • input(:switch) β€” an input node where we can set a value
    • mutate(:paw, [:switch], &lol_no/1, mutates: :switch) β€” watches :switch, and whenever it changes, runs lol_no/1, which sets :switch back to "off"
  2. the function, lol_no/1, which :paw will execute whenever the :switch is turned "on"

This defines your Useless Machine!

One last thing we need to do is to register the graph with Journey by adding these lines to config/config.exs:

config :journey, :graphs, [
  &UselessMachine.graph/0
]
Enter fullscreen mode Exit fullscreen mode

5. Useless Machine: Let's Play

Now that we have everything in place, let's play with our Useless Machine!

Let's fire up iex!

$ iex -S mix
Enter fullscreen mode Exit fullscreen mode

In iex, let's create a new Useless Machine execution:

iex(1)> graph = UselessMachine.graph(); :ok
:ok
iex(2)> execution = graph |> Journey.start(); :ok
:ok
Enter fullscreen mode Exit fullscreen mode

Turn the :switch on!! ... and watch :paw promptly turn the :switch off:

iex(3)> execution = Journey.set(execution, :switch, "on"); :ok
paw says: 'on? lol no'
:ok
iex(4)> Journey.values(execution)
%{
  switch: "off",
  paw: "updated :switch",
  execution_id: "EXECRDYHBJ8X0RVD8BG0A7A5",
  last_updated_at: 1764109938
}
Enter fullscreen mode Exit fullscreen mode

Was it a fluke? Let's try again:

iex(5)> execution = Journey.set(execution, :switch, "on"); :ok
paw says: 'on? lol no'
:ok
iex(6)> Journey.values(execution)
%{
  switch: "off",
  paw: "updated :switch",
  execution_id: "EXECRDYHBJ8X0RVD8BG0A7A5",
  last_updated_at: 1764109952
}
Enter fullscreen mode Exit fullscreen mode

Ok, not a fluke.

No matter how many times you set the switch to "on", the paw immediately turns it back off.

6. Durable Useless Machine

Because Journey workflows are durable, our Useless Machine execution can persist across time and reboots.

Let's try it!

Take a note of your execution id (e.g. EXECRDYHBJ8X0RVD8BG0A7A5), and quit iex (Ctrl+C twice), then start a fresh session:

$ iex -S mix
Enter fullscreen mode Exit fullscreen mode

Load the execution by its ID:

iex(1)> execution = Journey.load("EXECRDYHBJ8X0RVD8BG0A7A5"); :ok
:ok
Enter fullscreen mode Exit fullscreen mode

and make sure that the execution is just as we left it:

iex(2)> Journey.values(execution)
%{
  execution_id: "EXECRDYHBJ8X0RVD8BG0A7A5",
  last_updated_at: 1764110021,
  paw: "updated :switch",
  switch: "off"
}
Enter fullscreen mode Exit fullscreen mode

Just like before, as soon as you flip the :switch "on", its :paw flips it off:

iex(3)> execution = Journey.set(execution, :switch, "on"); :ok
paw says: 'on? lol no'
:ok
iex(4)> Journey.values(execution)
%{
  switch: "off",
  paw: "updated :switch",
  execution_id: "EXECRDYHBJ8X0RVD8BG0A7A5",
  last_updated_at: 1764110367
}
Enter fullscreen mode Exit fullscreen mode

The execution survived a restart. It is just as useless as before. ;)

7. Wrap-up

In under 20 lines of code, we built a (durable, crash-safe, and scalable πŸ˜‚!) Useless Machine!

8. Links

Things referenced in this walkthrough:

Top comments (0)