DockYard Interview x Battle Strategy

Interview prep sections

// interview prep system — michael munavu × dockyard

Get the Senior Elixir role at DockYard.

A complete, personalised prep plan built from your CV, your projects, Mike Binns's research, and 4 years of DockYard's Elixir philosophy. Every section is tailored to you.

Personalised to your CV 4 yrs Elixir experience 12x Hackathon wins Live coding prep Mike Binns profiled
5 Companies as Sr. Engineer
4 Years Elixir experience
12 Hackathon victories
3 Live production AI products

Your strongest selling points for DockYard

Real-time systems
AMI, Podii, Uamuzi — all used LiveView + Channels for real-time delivery. DockYard's core work is exactly this.
Multi-tenant architecture
Multiple roles, Ecto multi-tenancy, row-based isolation. Mike will ask about this.
OSS curiosity & AI platforms
Bree AI, NexusScale, CallWisely — production Elixir AI systems. DockYard cares about this direction.

Strengths & Gaps

Honest analysis of your CV against what DockYard expects at senior level. Focus your energy on the red items.

✦ Strong on your CV
  • Phoenix LiveView — 4+ years, multiple production systems at AMI, Podii, Uamuzi
  • Oban — deeply used at Virgil, Uamuzi, plus all 3 personal products (Bree, NexusScale, CallWisely)
  • OTP supervision trees — mentioned across all 5 roles, not just as a buzzword
  • Phoenix PubSub + Channels — used for real-time sync in multiple verticals
  • Multi-tenancy with Ecto — Podii explicitly used Ecto multi-tenancy
  • Broadway — Uamuzi used it for message queues and event streaming
  • ETS + Cachex — Uamuzi used both for caching layers (huge with Mike)
  • Rate limiting with Hammer — Uamuzi implementation
  • GenStage — GS1 barcode pipelines, shows deep pipeline knowledge
  • Production infra — AWS EC2/RDS, Docker, DigitalOcean at GS1
  • Technical mentorship — Podii mentorship programme is a direct Mike Binns match
  • Domain-driven design — Podii role explicitly mentions DDD patterns
⚠ Gaps to address
  • Open source contributions — no public Hex libraries or GitHub OSS on your CV. Mike lives for this. Have a story ready.
  • Dialyzer & typespecs — not mentioned anywhere. DockYard uses it with Ironman scaffolding.
  • Credo code style — not on CV. Mike's Ironman tool auto-adds Credo. Know it cold.
  • Flame graphs / profiling — Mike built Flame On! for this. Have a profiling story ready.
  • LiveView Streams — new in 0.18+, replaces :temporary_assigns for large lists. Know the difference.
  • Ash Framework — listed in your skills but not in any role. Don't overstate this.
  • Property-based testing / StreamData — no testing framework mentioned on CV at all.
  • Distributed Elixir — Uamuzi mentions distributed nodes but no depth shown. Mike may probe this.
  • LiveView Native — DockYard is building this. Research it and have an opinion.
  • Beacon CMS — Mike's flagship project. Know what it is and why it matters.

Priority skill gap targets — study these first

Credo + Dialyzer (not on your CV, Mike's tools) Priority 1
LiveView Streams & assign_async Priority 2
ExUnit + Mox + LiveViewTest (no testing on CV!) Priority 3
ETS deep internals (Mike wrote an Ets library) Priority 4
Open source: have a GitHub story ready Priority 5

Know Mike Binns

DockYard Principal Engineer since 2016. Know him better than he knows himself — then connect your stories to his world.

🔥

Elixir ecosystem first

Mike doesn't just use Elixir — he builds tools for it. He authored Ets, Ironman, Flame On!, safe_code, and CobolToElixir. He is an ecosystem builder. This is the core of his identity as an engineer. Connect your open source interest here even if you haven't published a hex package yet.

📚

Teaching teams Elixir

His landmark ElixirConf 2019 talk: coaching 20+ Java/Ruby/JS devs into productive Elixir devs in under 3 months with Cars.com. He values knowledge transfer massively. You led a mentorship programme at Podii HQ — lead with this story early.

🛠

Pragmatic, real-world debugging

His 2024 blog post is literally a "quick tip" about debugging LiveView assigns using inspect(pretty: true) in a <pre> tag. He values practical tool knowledge over theory. Have specific debugging stories ready, not just concepts.

📊

Performance and profiling

He built Flame On! (flame graphs for Elixir), wrote extensively on ETS for high-performance storage, and worked on Veeps' live-streaming performance issues under load. Expect questions about bottlenecks, profiling strategies, and BEAM internals.

🖥

LiveView is his home

Veeps live streaming (LiveView + real-time), Beacon CMS (LiveView-native), video chat with OpenTok, live streaming with Mux — all LiveView. He'll probe deep on lifecycle, state management, how re-renders work, and Presence.

🧠

Continuous learning identity

The Elixir Wizards podcast episode reveals he cares deeply about staying a curious learner. He discussed recommended books (Elixir in Action, Programming Phoenix). Be ready to talk about how you keep sharp — Twitter/X, blogs, hex.pm, ElixirConf videos.

Mike's published work — things he WILL reference

Beacon CMS
Know This
A LiveView-native CMS he's been building at DockYard. It's a headless CMS with content blocks, media, and SEO built entirely in LiveView. He showed it at ElixirConf Roundtable #9. Say: "I've been following Beacon — building a CMS-grade content system in LiveView is architecturally fascinating because of how you handle dynamic template rendering without a compile step."
Flame On! Library
Know This
A flame graph profiler for Elixir Phoenix apps. Integrates with LiveView for live dashboards of function call profiling. Mike built it because :fprof and :eprof outputs are hard to read. Know that flame graphs show call stacks over time — wide = long runtime, deep = many nested calls.
Ironman Library
Know This
A Mix task that automatically adds Credo, Dialyzer, Coveralls, and mix_test_watch to any Elixir project. Born from his Cars.com onboarding work. It's his answer to "how do you get a new team writing good Elixir fast?" — automate the scaffolding of code quality tools.
Ets Library
Know This
A more ergonomic Elixir wrapper around Erlang's ETS tables. He wrote his 2019 blog post "Taming :ets for High-Performance Software" alongside this. The core insight: ETS allows concurrent reads that a GenServer can't, because GenServer serialises everything through a mailbox.

LiveView Mastery

Mike's home turf. This is non-negotiable depth for DockYard. You have strong experience — now refine the edges.

Lifecycle
Components
Presence
Streams
Debugging
Full LiveView lifecycle — know every callback
Mike will probe whether you truly understand when each callback fires, not just that they exist.
elixir — liveview lifecycle
# 1. Static render — server renders HTML before WebSocket connects
def mount(_params, _session, socket) do
  # Called TWICE: once for static render, once after WS connects
  # Use connected?(socket) to guard expensive operations
  if connected?(socket) do
    Phoenix.PubSub.subscribe(MyApp.PubSub, "updates")
  end
  {:ok, assign(socket, loading: true, items: [])}
end

# 2. handle_params — fired after mount AND on LivePatch/LiveRedirect
def handle_params(params, _uri, socket) do
  # Good place for URL-driven state (filters, pagination, sort)
  {:noreply, apply_filters(socket, params)}
end

# 3. handle_event — user interaction from phx-click / phx-submit
def handle_event("save", %{"form" => form_params}, socket) do
  case Accounts.create_user(form_params) do
    {:ok, user} -> {:noreply, push_navigate(socket, to: "/users/#{user.id}")}
    {:error, cs} -> {:noreply, assign(socket, changeset: cs)}
  end
end

# 4. handle_info — process messages (PubSub, GenServer casts, Task results)
def handle_info({:new_message, msg}, socket) do
  {:noreply, update(socket, :messages, &[msg | &1])}
end

# 5. render — only re-runs for changed assigns (diff algorithm)
def render(assigns) do
  ~H"""
  <div>
    <%= for item <- @items do %>
      <.item_component item={item} />
    <% end %>
  </div>
  """
end
⚠ Mike will ask: what triggers a re-render?
Only assigns that change trigger a re-render. LiveView diffs the assigns map. If an assign is the same reference and value, that subtree is skipped. Using update/3 with a function can change a nested field without changing the top-level assign — the diff tracks deeply. This is why :temporary_assigns existed and why Streams replaced it for lists.
assign_async — async data loading without freezing mount
Why it exists
Practical
  • Keeps initial render fast while data loads in the background
  • Avoids blocking mount/3 and tying up the LiveView process
  • Results arrive as messages and update assigns normally (diff-friendly)
What Mike might probe
Say this
  • Use it for I/O: DB reads, HTTP calls, expensive computations
  • The UI should render a loading state based on the async assign
  • Treat it as eventual consistency: don’t assume the data is present on first render
elixir — assign_async pattern
def mount(_params, _session, socket) do
  socket =
    socket
    |> assign(:page_title, "Users")
    |> assign_async(:users, fn ->
      {:ok, %{users: Accounts.list_users()}}
    end)

  {:ok, socket}
end

def render(assigns) do
  ~H"""
  <%= if @users.loading do %>
    <p>Loading…</p>
  <% else %>
    <%= for u <- @users.result.users do %>
      <p><%= u.email %></p>
    <% end %>
  <% end %>
  """
end
LiveComponent vs Function Component
elixir — component types
# FUNCTION COMPONENT — stateless, just a function, no process
defmodule MyAppWeb.CoreComponents do
  use Phoenix.Component

  @doc "Renders a user badge"
  attr :user, :map, required: true
  attr :size, :atom, default: :md

  def user_badge(assigns) do
    ~H"""
    <span class={"badge badge-#{@size}"}><%= @user.name %></span>
    """
  end
end

# LIVE COMPONENT — has its own state, handle_event, update callbacks
defmodule MyAppWeb.SearchLive.SearchBox do
  use MyAppWeb, :live_component

  def render(assigns) do
    ~H"""
    <form phx-change="search" phx-target={@myself}>
      <input name="q" value={@query} />
    </form>
    """
  end

  # phx-target={@myself} — routes event to THIS component, not parent LV
  def handle_event("search", %{"q" => q}, socket) do
    send(self(), {:search_query, q}) # send to parent LV
    {:noreply, assign(socket, query: q)}
  end
end
# KEY: use send(self(), msg) NOT send(parent_pid, msg) to reach parent LV
Rule of thumb: if it needs its own state (search box, modal, form) → LiveComponent. If it's a pure UI element (badge, button, card) → function component. Mike will ask why you chose one over the other in your projects.
Phoenix Presence — track who's online
Mike built Veeps live streaming with Presence for audience tracking. He'll probe this.
elixir — presence in liveview
# 1. Create presence module
defmodule MyAppWeb.Presence do
  use Phoenix.Presence,
    otp_app: :my_app,
    pubsub_server: MyApp.PubSub
end

# 2. In LiveView mount — track the user and subscribe to presence events
def mount(_params, session, socket) do
  if connected?(socket) do
    Phoenix.PubSub.subscribe(MyApp.PubSub, "room:lobby")
    MyAppWeb.Presence.track(self(), "room:lobby", session["user_id"], %{
      name: session["name"],
      joined_at: DateTime.utc_now()
    })
  end
  {:ok, assign(socket, online_users: [])}
end

# 3. Handle presence diffs — fires when anyone joins or leaves
def handle_info(%Phoenix.Socket.Broadcast{event: "presence_diff", payload: diff}, socket) do
  online = MyAppWeb.Presence.list("room:lobby") |> Map.keys()
  {:noreply, assign(socket, online_users: online)}
end
# Presence is CRDT-based — automatically syncs across clustered nodes
LiveView Streams — the modern way to handle large lists
Added in LV 0.18. Replaces :temporary_assigns. Mike will know if you're using the old pattern.
elixir — streams vs old way
# OLD WAY — temporary_assigns reset list after each render
def mount(_, _, socket) do
  {:ok, assign(socket, items: []), temporary_assigns: [items: []]}
end

# NEW WAY — streams (LV 0.18+). Items live in the DOM, not in assigns memory
def mount(_, _, socket) do
  items = Repo.all(Item)
  {:ok, stream(socket, :items, items)}  # Items tracked by DOM id
end

# Insert one item at top — no re-render of existing items
def handle_event("add", params, socket) do
  {:ok, item} = Store.create_item(params)
  {:noreply, stream_insert(socket, :items, item, at: 0)}
end

# Delete without re-rendering the list
def handle_event("delete", %{"id" => id}, socket) do
  item = Repo.get!(Item, id)
  Repo.delete!(item)
  {:noreply, stream_delete(socket, :items, item)}
end

# HEEx template — phx-update="stream" is the key attribute
~H"""
<ul id="items" phx-update="stream">
  <li :for={{dom_id, item} <- @streams.items} id={dom_id}>
    <%= item.name %>
  </li>
</ul>
"""
Why streams? With temporary_assigns, every event sent the whole list to the client. Streams only send the diffs (inserted/deleted DOM nodes). Scales to tens of thousands of rows.
Loading state with streams
Streams keep list data in the DOM, not in assigns — so the simplest pattern is: start with an empty stream + a @loading flag, then reset: true once the data arrives.
heex + elixir — streams + loading state
# Start empty, show loading, then stream(reset: true) when loaded
def mount(_params, _session, socket) do
  socket =
    socket
    |> assign(:loading, true)
    |> stream(:items, [])

  if connected?(socket), do: send(self(), :load_items)
  {:ok, socket}
end

def handle_info(:load_items, socket) do
  items = Repo.all(Item)

  {:noreply,
   socket
   |> stream(:items, items, reset: true)
   |> assign(:loading, false)}
end

~H"""
<%= if @loading do %>
  <p>Loading…</p>
<% end %>

<ul id="items" phx-update="stream">
  <li :for={{dom_id, item} <- @streams.items} id={dom_id}>
    <%= item.name %>
  </li>
</ul>

<%= if !@loading and Enum.empty?(@streams.items) do %>
  <p>No items yet.</p>
<% end %>
"""
Debugging LiveView — Mike's exact technique
This is literally from his 2024 blog post — know it cold
When IO.inspect in the console isn't useful (rendering 50+ list items), put the debug output in the template instead.
heex + elixir — mike's debugging toolkit
<!-- Mike's technique: pretty: true + <pre> tag -->
<pre><%= inspect(@assigns, pretty: true) %></pre>

<!-- Or a single assign -->
<pre><%= inspect(@user, pretty: true) %></pre>

# In Elixir 1.14+ — dbg/1 is even better for pipelines
data
|> transform()
|> dbg()      # prints each step of the pipeline with values
|> save()

# IO.inspect with labels for tracking multiple values
IO.inspect(socket.assigns, label: "socket assigns before event", pretty: true)

# Phoenix.LiveView.send_update to trigger a component re-render manually
send_update(MyAppWeb.ItemComponent, id: "item-1", item: updated_item)
Explain it like I'm in grade 8
What is LiveView, really?

LiveView lets you build pages that update live — live counters, chat, forms that react instantly — without writing JavaScript. Normally the browser runs JS to change what you see. With LiveView, the page keeps a tiny "phone line" open to the server (a WebSocket). When you click something, the server works out what changed and sends back only the bits that changed, and the page updates itself.

Think of it like a friend drawing on a shared whiteboard for you: you say "add a circle," they redraw only the circle — not the whole board — and you never had to learn to draw.

8 questions a DockYard senior would ask
Q1 Walk me through the LiveView lifecycle — why does mount/3 run twice?

mount/3 runs once for the initial static HTTP render (so first paint and SEO work without JS), then again after the WebSocket connects. Guard expensive work (subscriptions, heavy loads) with connected?(socket) so it only runs on the stateful connection. Then handle_params/3 runs (and again on every live_patch), and render/1 re-runs whenever tracked assigns change.

Q2 What actually triggers a re-render, and how are payloads kept small?

Only assigns that change. At compile time LiveView splits the template into static and dynamic parts; it sends the static structure once, then on each update sends only the changed dynamic values by position — the "diff." If an assign's value is unchanged, that subtree is skipped entirely. Don't pull assigns into local variables in a way that defeats change tracking — access @assign directly.

Q3 LiveComponent vs function component — when each?

Function components are stateless — just functions returning HEEx, no process, no state. Use them for presentational reuse. LiveComponents are stateful: own assigns, their own update/2 and handle_event/3, addressed by id. They isolate state and events but still run inside the parent LiveView's process — they are not separate processes. Default to function components unless you truly need encapsulated state.

Q4 How do Streams work and what problem do they solve?

Streams render large or append-only collections without keeping the whole list in socket assigns (which bloats memory and diffs). stream/3 tracks items by DOM id and sends insert/update/delete operations instead of re-diffing the entire list. They replaced :temporary_assigns for lists — perfect for chat logs, feeds, and big tables.

Q5 How do you build real-time multi-user features like a presence list?

PubSub for broadcasting changes: subscribe in mount when connected?, broadcast on events, handle the message in handle_info/2. Phoenix.Presence (PubSub + CRDTs) for who's online: Presence.track in mount, handle the presence_diff in handle_info. Presence merges cleanly across nodes and netsplits.

Q6 How do file uploads work in LiveView?

allow_upload/3 in mount sets constraints (accept, max_entries, max_file_size). The client chunks the file over the channel; you get progress events for free. consume_uploaded_entries/3 in your save handler moves files to permanent storage. With the :external option you can do direct-to-S3 uploads via presigned URLs so bytes never touch your server.

Q7 A LiveView feels sluggish and sends huge diffs — how do you debug it?

Check assigns size first — are you stuffing big structs/lists in? Move lists to streams. Verify change tracking isn't being defeated by template variables. Use liveSocket.enableDebug() in the browser console to watch diffs, and LiveDashboard to inspect the process. Watch for assigns being needlessly reassigned on every event.

Q8 How does LiveView handle disconnects and crashes?

Each connection is a supervised process. On a network drop the JS client auto-reconnects with backoff and re-mounts — so mount must be idempotent and able to rebuild state. If the LiveView process crashes, the supervisor restarts it and the client re-mounts. That's why irreplaceable state lives in the DB / a GenServer / ETS, never only in the socket.

Testing Unit + LiveView

A fast, repeatable workflow: unit tests for business logic, integration tests for LiveView flows, and fixtures/mocks for stable data.

Unit tests
LiveView integration
Data + mocks
Flake killers
What “unit” means in Elixir
Say this
  • Test pure functions: no DB, no network, no time
  • Keep setup tiny; prefer passing explicit inputs
  • Assert on outputs and invariants, not implementation
  • Use describe blocks + clear names
Common unit test targets
High leverage
  • Context functions that don’t need the DB
  • Changeset validation rules
  • Formatting + policy/authorization helpers
  • Parsing and domain logic (pricing, scoring, etc.)
elixir — unit test example (ExUnit)
defmodule MyApp.CheckoutTest do
  use ExUnit.Case, async: true

  alias MyApp.Checkout

  describe "total_cents/1" do
    test "sums line items and applies discounts" do
      cart = %{
        items: [%{price_cents: 500, qty: 2}, %{price_cents: 250, qty: 1}],
        discount_cents: 100
      }

      assert Checkout.total_cents(cart) == 1150
    end
  end
end
Unit testing traps
Q1 “When does a unit test become an integration test?”

The moment you cross a boundary: DB, network, filesystem, time, process registry, or a running endpoint. It’s fine to do — just call it what it is so failures are debuggable.

Q2 “What makes tests slow in Elixir?”

Big fixtures, unnecessary DB usage, sleeping/timeouts, and global/shared state. Keep unit tests pure, and push heavy setup into a small number of integration tests.

What to test in LiveView
Core
  • Mount + auth/redirect behaviour
  • Event handling: click/change/submit
  • URL patches and param handling
  • Server-rendered HTML outcomes (not JS)
Common assertions
Patterns
  • assert has_element?(lv, ...)
  • assert render(lv) =~ "..."
  • assert_patch(lv, ...) for push_patch
  • follow_redirect/3 after submit
elixir — LiveView integration test example
defmodule MyAppWeb.TodoLiveTest do
  use MyAppWeb.ConnCase, async: true

  import Phoenix.LiveViewTest

  test "user can create a todo", %{conn: conn} do
    {:ok, lv, _html} = live(conn, "/todos")

    lv
    |> form("#todo-form", todo: %{title: "Buy milk"})
    |> render_submit()

    assert render(lv) =~ "Buy milk"
  end
end
Integration workflow
Q1 “How do you keep LiveView tests deterministic?”

Control time, avoid random ordering assumptions, and assert on stable UI outcomes. Prefer inserting fixtures directly (DB) over triggering long background flows.

Q2 “What counts as a good LiveView integration test?”

One user story per test: render → interact → assert. Keep it short and focused; test most logic below the UI with unit tests.

Fixtures over factories (usually)
Fast
Prefer a small Fixtures module with helpers like user_fixture/1 that inserts minimal valid data. Keep defaults sane and accept overrides.
Mock boundaries, not internals
Rule
Mock external services behind behaviours (email, payments, HTTP clients). Don’t mock Ecto/Repo — use the sandbox and real queries for DB behaviour.
elixir — minimal fixture helper
defmodule MyApp.AccountsFixtures do
  def user_fixture(attrs \\ %{}) do
    email = Map.get(attrs, :email, "user#{System.unique_integer()}@example.com")
    password = Map.get(attrs, :password, "strong-password")

    {:ok, user} = MyApp.Accounts.register_user(%{email: email, password: password})
    user
  end
end
Top flake causes
Fix first
  • Shared global state (ETS, Registry, Application env)
  • Async tests touching the same resources
  • Time-sensitive assertions (Process.sleep)
  • Random order assumptions (lists without ordering)
Fast diagnostics
Do this
  • Re-run with a fixed seed: mix test --seed 0
  • Run the file repeatedly: mix test path/to/test.exs
  • Disable async for the flakey module temporarily
  • Log the HTML from LiveView failures when needed
One sentence mantra
If the test depends on scheduling or timing, it’s already broken — make it wait on an observable state instead.

CI / CD Shipping Discipline

What runs on every PR, how you deploy safely, and how you roll back when reality disagrees with your plan.

CI: confidence per commit
Core
  • Fast feedback: format, compile, tests, and static checks on every PR
  • Keep it deterministic: pinned deps, reproducible builds, isolated DB
  • Fail loud: treat warnings seriously (--warnings-as-errors)
  • Security basics: secret scanning + dependency audit where possible
CD: safe releases
Interview bait
  • Separate environments (staging/prod), explicit approvals for prod
  • Rollback plan: previous artifact + reversible migrations
  • Reduce blast radius: feature flags, canaries, blue/green
  • Observability gates: metrics/logs/traces + alerting during deploy
github actions — pragmatic Elixir CI
name: CI
on: [push, pull_request]

jobs:
  test:
    runs-on: ubuntu-latest
    services:
      postgres:
        image: postgres:16
        env:
          POSTGRES_PASSWORD: postgres
        ports: ["5432:5432"]
        options: >-
          --health-cmd "pg_isready -U postgres"
          --health-interval 10s
          --health-timeout 5s
          --health-retries 5

    steps:
      - uses: actions/checkout@v4
      - uses: erlef/setup-beam@v1
        with:
          elixir-version: "1.17"
          otp-version: "27"
      - run: mix deps.get
      - run: mix format --check-formatted
      - run: mix compile --warnings-as-errors
      - run: mix test
      - run: mix credo --strict
      - run: mix dialyzer
Common questions
Q1 What belongs in CI vs CD?

CI proves the change is safe to merge: formatting, tests, and static analysis. CD is the controlled process of shipping: build artifacts, approvals, deploy steps, migrations, and rollbacks.

Q2 How do you do zero-downtime deploys with migrations?

Use expand/contract: add nullable columns first, deploy code that writes both, backfill safely, then enforce constraints in a later deploy. Avoid long locks; use concurrent indexes when needed.

Q3 How do you handle secrets?

Never commit them. Use a secret manager or CI secrets store, pass them as env vars at deploy time, and rotate when exposed. Keep least-privilege credentials per environment.

Q4 What’s your rollback strategy?

Roll back the artifact first (known-good release). Make DB changes backward-compatible so rollbacks still run. Use feature flags so you can “turn off” a risky path without redeploying.

OTP & GenServer

The bedrock of Elixir. Your CV mentions OTP trees across all 5 roles — now show depth, not just breadth.

Start here (no jargon)
What is a GenServer, really?

A process in Elixir is like a tiny worker with a mailbox. Messages go into the mailbox, and the process handles them one at a time. A GenServer is just the standard “worker template” for that idea — you write callbacks that say how to handle messages and how to keep state.

Mental model: “a loop that receives messages and returns updated state.” Everything else is naming and convenience.

The 3 moving parts
Memorize
  • Client API: functions other code calls (get(), increment())
  • Server callbacks: handle messages (init, handle_call, handle_cast, handle_info)
  • Supervision: who restarts it when it crashes (start_link + a Supervisor)
A tiny counter GenServer (read this first)
elixir — minimal example
defmodule MyApp.Counter do
  use GenServer

  # ── Client API ──
  def start_link(_opts \\ []) do
    GenServer.start_link(__MODULE__, 0, name: __MODULE__)
  end

  def get(), do: GenServer.call(__MODULE__, :get)
  def increment(), do: GenServer.cast(__MODULE__, :inc)

  # ── Server callbacks ──
  @impl true
  def init(starting_value), do: {:ok, starting_value}

  @impl true
  def handle_call(:get, _from, count) do
    {:reply, count, count}
  end

  @impl true
  def handle_cast(:inc, count) do
    {:noreply, count + 1}
  end
end
How to read this
Flow
  • Counter.get() sends a :get message and waits for a reply (call).
  • Counter.increment() sends :inc and doesn’t wait (cast).
  • Each callback returns the next state (here the state is just a number).
Tip If you remember only one thing

A GenServer is a serialized state machine: one mailbox, one message handled at a time, state updated on each message. That’s why it’s safe: no shared mutable memory.

What’s happening overall
The big idea
Clarity

MyApp.Counter is a stateful process that holds a single number in memory. Any part of your app can call get/0 or increment/0, and they all talk to the same process because it’s registered by name (name: __MODULE__).

Breaking it down
Step-by-step
  • 1) Starting the process: GenServer.start_link(__MODULE__, 0, name: __MODULE__) means “use this module for callbacks, start state at 0, and register the process as MyApp.Counter so callers don’t need the PID”.
  • 2) handle_call (synchronous): the caller waits for a reply. You return {:reply, reply_value, new_state}.
  • 3) handle_cast (asynchronous): the caller does not wait. You return {:noreply, new_state}.
text — call vs cast (core difference)
call                      cast
Caller blocks?   ✅ Yes           ❌ No
Returns value?   ✅ Yes           ❌ No (just :ok)
Timeout?         ✅ 5s default    ❌ No
Use when         Need a result    Fire-and-forget
Risk             Can timeout      Failure is easy to miss
text — visual message flow
# call — synchronous
MyApp.Counter.get()
  │
  ├── sends {:call, :get} ─────────────► GenServer process
  │                                          │
  │◄─────────────── {:reply, 5} ─────────────┘
  │
  returns 5

# cast — asynchronous
MyApp.Counter.increment()
  │
  ├── sends {:cast, :inc} ─────────────► GenServer process
  │
  returns :ok immediately                 (updates state, no reply ever)
elixir — state flow (mental simulation)
# Initial state
init(0)  →  state = 0

increment()  →  handle_cast(:inc, 0)  →  state = 1
increment()  →  handle_cast(:inc, 1)  →  state = 2
increment()  →  handle_cast(:inc, 2)  →  state = 3

get() → handle_call(:get, _, 3) → returns 3 (state stays 3)
One subtle thing
Important
A GenServer is a mailbox. All messages (call, cast, plain send) go into the same mailbox and are processed one at a time. That’s why it’s safe: no races on state.
elixir — safe from 1000 processes
Enum.each(1..1000, fn _ -> MyApp.Counter.increment() end)
elixir — in an Oban/AI context
# Rule of thumb: need a result / confirmation → call
# Don't care about the response → cast
defmodule MyApp.RateLimiter do
  use GenServer

  def consume_token(provider) do
    GenServer.call(__MODULE__, {:consume, provider})
  end

  def record_usage(provider, tokens_used) do
    GenServer.cast(__MODULE__, {:record, provider, tokens_used})
  end
end
If the counter example makes sense, the “full template” below is just the same idea with more patterns (timeouts, handle_info, cleanup, etc.).
The complete GenServer template
elixir — full genserver with all callbacks
defmodule MyApp.RateLimiter do
  use GenServer
  require Logger

  # ── Client API ──────────────────────────────────────────────
  def start_link(opts \\ []) do
    GenServer.start_link(__MODULE__, opts, name: __MODULE__)
  end

  def check_rate(user_id), do: GenServer.call(__MODULE__, {:check, user_id})
  def reset(user_id),     do: GenServer.cast(__MODULE__, {:reset, user_id})

  # ── Server Callbacks ─────────────────────────────────────────
  @impl true
  def init(_opts) do
    # Schedule periodic cleanup of expired rate windows
    schedule_cleanup()
    {:ok, %{}}  # state: %{user_id => {count, window_start}}
  end

  @impl true
  def handle_call({:check, user_id}, _from, state) do
    # call = synchronous, caller blocks until reply
    {allowed?, new_state} = check_and_increment(state, user_id)
    {:reply, allowed?, new_state}
  end

  @impl true
  def handle_cast({:reset, user_id}, state) do
    # cast = async, fire-and-forget, no reply
    {:noreply, Map.delete(state, user_id)}
  end

  @impl true
  def handle_info(:cleanup, state) do
    # handle_info — receives raw messages (timers, PubSub, Process.send_after)
    clean_state = purge_expired(state)
    schedule_cleanup()
    {:noreply, clean_state}
  end

  @impl true
  def terminate(reason, state) do
    # Called before shutdown — persist state if needed
    Logger.info("RateLimiter stopping: #{inspect(reason)}")
    :ok
  end

  defp schedule_cleanup(), do: Process.send_after(self(), :cleanup, :timer.minutes(1))
end
Supervisor strategies
one_for_one
Most common
Only the crashed child restarts. Children are independent. Use for worker pools, caches, or any process that doesn't affect siblings.
one_for_all
Coupled children
All children restart when any crashes. Use when children share state — e.g. a DB connection pool + a cache that must stay in sync with it.
rest_for_one
Sequential deps
Crashed child + all started-after children restart. Use when children have ordered dependencies: child C depends on B which depends on A.
Max restarts
Default: 3 crashes in 5 seconds → supervisor itself crashes → propagates up. This is intentional. BEAM "lets it crash" and surfaces the fault upward until something can truly handle it.
Linking vs Monitoring
elixir
# LINK — bidirectional, crash propagates both ways
# If child crashes, parent crashes too (unless trapping exits)
{:ok, pid} = GenServer.start_link(Worker, [])

# MONITOR — unidirectional, observer gets a message on crash
# Observer does NOT crash. Gets {:DOWN, ref, :process, pid, reason}
ref = Process.monitor(pid)

def handle_info({:DOWN, ^ref, :process, _pid, reason}, state) do
  # React to the crash without dying yourself
  {:noreply, %{state | worker: nil}}
end

# Supervisors use links. LiveView uses monitors for channels.
Story to tell: At your projects using Phoenix Channels (AMI, Uamuzi), the LiveView process monitors the channel — if the socket closes, LiveView gets a :DOWN message. This is why handle_info handles PubSub unsubscribes on disconnect.
Explain it like I'm in grade 8
What are OTP and GenServers?

Elixir runs your program as thousands of tiny independent workers called processes (super lightweight — not OS processes). Each does one job, has its own memory, and talks to others only by passing messages. OTP is the rulebook + toolkit for organizing these workers so that if one crashes, a supervisor notices and restarts it — the whole app keeps running. A GenServer is the standard template for a worker that holds some state and answers messages.

Imagine an office where workers never share desks — they only pass sticky notes. Each has a manager watching; if a worker faints, the manager instantly puts a fresh replacement at the same desk. The office never shuts down over one bad day.

8 questions a DockYard senior would ask
Q1 GenServer.call vs cast — when each?

call is synchronous: the caller blocks for a reply with a timeout (default 5s). Use it for reads or when you need confirmation/backpressure. cast is fire-and-forget — no reply, no backpressure. Default to call; cast silently hides overload because nothing slows the sender down when the server falls behind.

Q2 handle_call vs handle_cast vs handle_info?

handle_call/3 handles synchronous requests (reply with {:reply, val, state}). handle_cast/2 handles async ones. handle_info/2 handles everything else the process receives — raw send, monitor :DOWN, timeouts, PubSub messages. Forgetting a catch-all handle_info clause crashes the server on an unexpected message.

Q3 Link vs monitor?

A link is bidirectional and fatal — if either process dies the other gets an exit signal (and dies too, unless trapping exits). That's how supervision trees propagate failure (start_link). A monitor is one-way and non-fatal — you get a :DOWN message but survive. Use a monitor when you want to know another process died without dying with it.

Q4 Explain the supervision strategies.

:one_for_one — restart only the crashed child. :one_for_all — restart all children (when they depend on each other). :rest_for_one — restart the crashed child and any started after it (ordered deps). Plus restart intensity (max_restarts / max_seconds): crash too often and the supervisor itself gives up and escalates. Children are :permanent, :temporary, or :transient.

Q5 "Let it crash" — what does it actually mean?

Don't write defensive code for every weird state. Code the happy path; if something truly unexpected happens, let the process crash and let the supervisor restart it to a known-good initial state — no corrupt state lingers. You still handle expected errors (like {:error, changeset}) explicitly. "Let it crash" is for the unexpected, not for control flow.

Q6 What goes in init/1, and why avoid heavy work there?

init/1 runs synchronously inside the supervisor's start sequence — a slow init blocks the whole tree from booting. Do minimal setup, then defer heavy work by returning {:ok, state, {:continue, :load}} and doing the load in handle_continue/2. That's the proper tool (the old send(self(), :init) trick is obsolete).

Q7 How do you stop a GenServer becoming a bottleneck?

It processes one message at a time, so a single GenServer serializes everything. If reads dominate, move state to ETS so readers bypass the process entirely (the GenServer owns the table; clients read directly). For parallelism, partition into many processes (e.g. Registry + DynamicSupervisor per entity), or offload work to Task so the server stays responsive.

Q8 Registry, DynamicSupervisor, :via — what are they for?

DynamicSupervisor starts children on demand at runtime (one process per chat room/session). Registry lets you look processes up by an arbitrary key. The {:via, Registry, {MyReg, key}} tuple names a GenServer by a dynamic key so you can call it without tracking its pid. Together they're the standard "process per entity" pattern.

Elixir Tasks

Use Tasks to run work concurrently. The key is supervision, timeouts, and avoiding blocking your important processes.

Explain it like I'm in grade 8
What is a Task?

A Task is a short-lived process that runs a function for you. Think “spawn a helper, do the work, send me the result”.

If a GenServer is a cashier serving one person at a time, a Task is you opening a second checkout lane for a quick job — then closing it when done.

The 3 patterns you actually use
1) One-off async work (simple)
Common
Use Task.async + Task.await when the caller can wait and a crash should take the caller down (linked).
elixir
task = Task.async(fn -> Expensive.compute(input) end)
result = Task.await(task, 5_000)
2) Supervised tasks (production)
Prefer
Prefer Task.Supervisor so tasks are owned by your app’s supervision tree (and you can choose link vs no-link).
elixir
# start a Task.Supervisor in your supervision tree
children = [{Task.Supervisor, name: MyApp.TaskSupervisor}]

# run work without linking to the caller
task =
  Task.Supervisor.async_nolink(MyApp.TaskSupervisor, fn ->
    Expensive.compute(input)
  end)

result = Task.await(task, 5_000)
3) Parallel map (bounded concurrency)
Scales
Use Task.async_stream for fan-out work with limits, timeouts, and backpressure.
elixir
ids
|> Task.async_stream(&Expensive.compute/1,
  max_concurrency: 10,
  timeout: 5_000
)
|> Enum.to_list()
Foot-guns (why Tasks confuse people)
Q1 Why “don’t await inside a GenServer”?

A GenServer processes one message at a time. If you Task.await inside handle_call or handle_cast, you block the mailbox — everything else queues up behind it. Instead, spawn the task and handle the result later with handle_info (or use handle_continue).

Q2 When should you not use a Task?

When the work must not be lost. Tasks are in-memory and disappear if the node dies. For durable background work, use Oban.

Rule of thumb: Tasks are for “concurrency right now”. Oban is for “do it eventually, reliably”.

Elixir Agents

Agents are a tiny wrapper around GenServer for simple state. They shine for small, straightforward in-memory state — and they have clear limits.

Explain it like I'm in grade 8
What is an Agent?

An Agent is “a process that holds a value” with a built-in API to get and update it. It’s basically the simplest possible state server.

The smallest useful Agent
elixir
defmodule MyApp.CounterAgent do
  def start_link(_opts \\ []) do
    Agent.start_link(fn -> 0 end, name: __MODULE__)
  end

  def get(), do: Agent.get(__MODULE__, & &1)

  def increment() do
    Agent.update(__MODULE__, fn count -> count + 1 end)
  end
end
When an Agent is a good fit
Use it
  • Simple state (counters, small caches, feature flags)
  • Test helpers and prototypes
  • When you don’t need a custom message protocol
When an Agent is the wrong tool
Avoid
  • High-throughput state (single process becomes a bottleneck)
  • Complex workflows (use a GenServer with an explicit protocol)
  • Heavy read concurrency (use ETS, owned by a supervised process)
Common confusion
Q1 “Is an Agent different from a GenServer?”

Not really. It’s a convenience wrapper around a GenServer with a fixed protocol (get/update). Pick it for simplicity; switch to a real GenServer when you need richer messaging or better control.

Q2 “What happens if it crashes?”

If you start it under a Supervisor, it restarts — but its in-memory state resets. If the state matters, persist it (DB) or compute it from a source of truth, or use durable queues for workflows.

Ecto Deep Dive

Changesets, multi-tenancy, composable queries. You used this heavily — show the depth.

elixir — ecto changeset pipeline (complete)
defmodule MyApp.Accounts.User do
  use Ecto.Schema
  import Ecto.Changeset

  schema "users" do
    field :email, :string
    field :password, :string, virtual: true  # not persisted
    field :hashed_password, :string
    field :role, Ecto.Enum, values: [:admin, :instructor, :student]
    has_many :courses, MyApp.Courses.Course
    timestamps()
  end

  def registration_changeset(user, attrs, opts \\ []) do
    user
    |> cast(attrs, [:email, :password])      # whitelist fields
    |> validate_required([:email, :password])
    |> validate_email(opts)
    |> validate_password(opts)
  end

  defp validate_email(changeset, opts) do
    changeset
    |> validate_format(:email, ~r/^[^\s]+@[^\s]+$/, message: "must be a valid email")
    |> validate_length(:email, max: 160)
    |> maybe_validate_unique_email(opts)
  end
end

# Ecto.Multi for atomic multi-step transactions
Ecto.Multi.new()
|> Ecto.Multi.insert(:user, user_changeset(attrs))
|> Ecto.Multi.run(:account, fn repo, %{user: user} ->
  repo.insert(account_changeset(%{user_id: user.id}))
end)
|> Repo.transaction()
|> case do
  {:ok, %{user: user, account: account}} -> success(user, account)
  {:error, :user, cs, _changes}          -> handle_user_error(cs)
  {:error, :account, cs, _changes}       -> handle_account_error(cs)
end

Multi-tenancy patterns — from your Podii experience

Row-level (shared DB)
Used at Podii
Every table has a tenant_id column. All queries scope to tenant. Simple but must enforce at query level — easy to leak data if a query forgets the scope. Use a custom Repo wrapper that automatically prepends where tenant_id == current_tenant.
Schema-based (Triplex)
Postgres schemas
Each tenant gets their own Postgres schema (tenant_1.users, tenant_2.users). Triplex library manages this. True data isolation but harder migrations. DockYard clients in regulated sectors (health, finance) often need this.
Explain it like I'm in grade 8
What is Ecto?

Ecto is how Elixir talks to a database. It has four jobs: describe what your data looks like (schemas), safely check and clean data before saving (changesets), build queries in Elixir instead of raw SQL, and run them. The changeset is the star — it's a bouncer that checks every field (right type? required? valid email?) before data is allowed into the database.

Ecto is the careful librarian between you and a giant filing cabinet. You don't rummage in the cabinet yourself — you hand over a filled-out request slip (changeset). They check it for mistakes, and only if it's perfect do they file or fetch your record.

Ecto.Query quicksheet (common asks)
Ordering + pagination
Know this
  • A → Z: order_by: [asc: u.name]; Z → A: order_by: [desc: u.name]
  • Stable paging: add a tie-breaker order_by: [asc: u.name, asc: u.id]
  • Pagination: limit + offset (or keyset paging when perf matters)
The “day-to-day” query tools
Interview bait
  • Filtering: where, or_where, dynamic
  • Joining: join/left_join + assoc/2
  • Aggregates: Repo.aggregate, group_by/having
  • N+1 fixes: preload (and knowing when a join is better)
elixir — Ecto.Query ordering basics
import Ecto.Query

# A → Z (ascending)
from(u in User, order_by: [asc: u.name])
|> Repo.all()

# Z → A (descending)
from(u in User, order_by: [desc: u.name])
|> Repo.all()

# Filter + sort + limit (typical list page)
from(u in User,
  where: ilike(u.email, ^"%@example.com"),
  order_by: [desc: u.inserted_at],
  limit: 50
)
|> Repo.all()

	# Aggregate (count)
	from(u in User, where: u.confirmed_at != nil)
	|> Repo.aggregate(:count, :id)
Joins inner vs left vs right
Inner join (default)
Most common
Returns only rows where both sides match. If there’s no match on the join condition, the left row is excluded.
Left join
Common trap
Returns all rows from the left, plus the matching right rows. If there’s no match, the right-side fields are nil.

Trap: putting right-side filters in where can turn your left join into an inner join. Put those filters in on: when you want to keep unmatched left rows.
Right join
Rare
A right join keeps all rows from the right table. In practice, it’s usually clearer to swap table order and use a left join (same result, easier to read).
elixir — join patterns (Ecto.Query)
import Ecto.Query

# INNER JOIN: only users that have posts
from(u in User,
  join: p in assoc(u, :posts),
  select: {u.id, p.id}
)
|> Repo.all()

# LEFT JOIN: keep all users, even if they have no profile
from(u in User,
  left_join: pr in assoc(u, :profile),
  select: {u.id, pr.id}
)
|> Repo.all()

# LEFT JOIN + filter on the joined table (put the filter in ON to keep left rows)
from(u in User,
  left_join: o in Order,
  on: o.user_id == u.id and o.status == "paid",
  select: {u.id, o.id}
)
|> Repo.all()
8 questions a DockYard senior would ask
Q1 What is a changeset and why is it central?

A changeset captures a set of changes plus validation rules and any errors. cast/4 whitelists allowed fields (stops mass-assignment), validate_* add rules, and constraints (unique_constraint, foreign_key_constraint) defer to the database's own guarantees and turn DB errors into changeset errors. It cleanly separates "what the user sent" from "what's valid to persist," and is reused for insert and update.

Q2 cast vs change?

cast/4 takes external, untrusted params (form strings), casts them to the schema's types, and only permits listed fields. change/2 takes already-trusted, correctly-typed data and applies it directly with no permitting/casting. Forms → cast; internal defaults or computed fields → change.

Q3 How do you prevent N+1 queries?

Use preload — either Repo.preload/2 after fetching, or preload: in the query so Ecto batches a second query (or joins). The N+1 trap is touching an unloaded association inside a loop, firing one query per row. In GraphQL/LiveView contexts, Dataloader batches the same way.

Q4 What is Ecto.Multi and when do you use it?

Multi composes several operations into one atomic transaction with named steps that can depend on earlier results. If any step fails, everything rolls back and you get {:error, failed_step, changeset, changes_so_far} — telling you exactly where it broke. Use it for "create user + profile + audit log" where partial success is unacceptable. Cleaner and far more testable than nested manual rollbacks.

Q5 How do you write composable, reusable queries?

Queries are just data — write functions that take a queryable and return a refined one:

def active(query), do: from u in query, where: u.active

User |> active() |> by_org(id) |> Repo.all()

This keeps query logic DRY and testable, and you compose fragments instead of duplicating where clauses everywhere.

Q6 Explain optimistic locking and handling race conditions.

optimistic_lock/1 adds a version field; on update Ecto checks it matches and increments it — a stale concurrent update raises Ecto.StaleEntryError so you refetch/retry. For uniqueness races, rely on a DB unique index + unique_constraint — never check-then-insert in app code (that's a TOCTOU race). For counters, use atomic update_all with inc:.

Q7 get / get_by / all / one — and the bang variants?

Repo.get/3 fetches by primary key, get_by/3 by clause — returning the struct or nil. Repo.one/2 expects exactly one (raises if more). Repo.all/2 returns a list. Bang versions (get!, one!) raise Ecto.NoResultsError instead of returning nil — handy with action_fallback to turn a missing record into a 404 automatically.

Q8 How do you run migrations safely in production?

Migrations are versioned and run in order — keep them reversible. For zero-downtime: add columns nullable first, backfill in batches, then add constraints — never lock a huge table in one shot. Don't mix schema and data changes in one migration; do data migrations separately so they can't block a deploy. Use concurrent index creation on big tables.

PostgreSQL SQL Tools

psql workflow, EXPLAIN, indexes, and locks. Ecto is the wrapper — Postgres is the truth.

psql muscle memory
Know this
  • \dt list tables, \d users describe
  • \x auto expanded output, \timing on
  • \watch 1 re-run a query every second (great for observing jobs / locks)
  • Use BEGIN; + ROLLBACK; for safe experiments
EXPLAIN checklist
Interview bait
  • Start with EXPLAIN (ANALYZE, BUFFERS) — not guesswork
  • Watch for Seq Scan, huge Sort, and row estimate mismatch
  • Add the right index (often composite, sometimes partial) — but confirm selectivity
  • Fix N+1 at the app layer: preloads, joins, batching
postgres — psql + tuning quicksheet
-- connect (URI form works everywhere)
psql "postgresql://user:pass@localhost:5432/my_app_dev"

-- navigation + output
\conninfo
\dt
\d users
\x auto
\timing on

-- plan + reality
EXPLAIN (ANALYZE, BUFFERS)
SELECT id, email FROM users WHERE email = 'a@b.com';

-- lock triage (what is running / waiting?)
SELECT pid, state, wait_event_type, wait_event, left(query, 120) AS query
FROM pg_stat_activity
WHERE datname = current_database()
ORDER BY state, pid;
Common questions
Q1 “Why is Postgres doing a Seq Scan when there’s an index?”

Common causes: the table is small, the predicate isn’t selective, stats are stale (run ANALYZE), or the query can’t use the index due to casts / functions (lower(email) needs a matching expression index). Always validate with EXPLAIN (ANALYZE, BUFFERS).

Q2 “How do you add an index safely in production?”

Prefer concurrent indexes for large tables: CREATE INDEX CONCURRENTLY .... In Ecto migrations that means concurrently: true and @disable_ddl_transaction true so it doesn’t run inside a transaction.

Q3 “How do you debug locks quickly?”

Start with pg_stat_activity to see who is waiting and what query is running; then inspect pg_locks if you need the exact lock types. Only then consider cancelling a stuck statement (pg_cancel_backend) — killing sessions can cascade.

Q4 “When is raw SQL acceptable with Ecto?”

When the query is genuinely better expressed in SQL (CTEs/window functions), or you’re calling DB-specific features. Keep it localized, parameterized (never string concat), and covered by tests; prefer Ecto query composition for everyday CRUD.

ETS & Performance

Mike authored the Ets library and Flame On! profiler. This is his passion — go deep here.

elixir — ets table types and patterns
# ETS TABLE TYPES — Mike's Ets library wraps all of these
def setup_cache() do
  # :set — unique keys, one value per key (most common)
  :ets.new(:my_cache, [:set, :named_table, :public,
    read_concurrency: true,     # concurrent reads from multiple processes
    write_concurrency: true])  # concurrent writes (use carefully)

  # :ordered_set — keys sorted, supports range queries
  :ets.new(:sorted, [:ordered_set, :named_table, :protected])

  # :bag — allows duplicate keys, each with different values
  :ets.new(:tags, [:bag, :named_table, :public])
end

# PATTERN: GenServer for writes, ETS for reads
# This is Mike's recommended pattern — avoids the GenServer read bottleneck
defmodule MyApp.SessionStore do
  use GenServer

  def start_link(_) do
    GenServer.start_link(__MODULE__, [], name: __MODULE__)
  end

  def init(_) do
    # Create table with GenServer as owner — table dies with GenServer
    :ets.new(:sessions, [:named_table, :set, :public, read_concurrency: true])
    {:ok, %{}}
  end

  # READS bypass GenServer entirely — direct ETS access from any process
  def get_session(token) do
    case :ets.lookup(:sessions, token) do
      [{^token, session}] -> {:ok, session}
      [] -> :error
    end
  end

  # WRITES go through GenServer — serialised, atomic, safe
  def put_session(token, data) do
    GenServer.call(__MODULE__, {:put, token, data})
  end

  def handle_call({:put, token, data}, _, state) do
    :ets.insert(:sessions, {token, data})
    {:reply, :ok, state}
  end
end
When to use ETS
Mike will ask
  • High-read, low-write shared data (session cache, feature flags, config)
  • Many concurrent processes need the same data without waiting
  • GenServer is becoming a bottleneck (all reads serialising through mailbox)
  • Need O(1) key lookup not available in process state map
When NOT to use ETS
Know the limits
  • You need atomic read-modify-write (GenServer handles this naturally)
  • Data needs to survive node restart (ETS is in-memory only)
  • Complex business logic should accompany reads
  • You need distributed access across nodes (use Mnesia or Redis)
Credo + Dialyzer — your biggest gap
Study this
These are NOT on your CV. Mike's Ironman tool auto-adds both to every project. Know them.

Credo — static code analysis. Checks for consistency issues, complexity, naming. Run with mix credo --strict. Common checks: function complexity, TODO comments, unused vars, alias ordering.

Dialyzer — type analysis via success typing. Catches type errors the compiler doesn't. Slow first run (builds PLT), fast after. Use @spec typespecs everywhere so Dialyzer has something to check.
elixir — typespecs for dialyzer
@spec get_user(String.t()) :: {:ok, User.t()} | {:error, :not_found}
def get_user(id) do
  case Repo.get(User, id) do
    nil  -> {:error, :not_found}
    user -> {:ok, user}
  end
end
Explain it like I'm in grade 8
What is ETS, and what is "performance"?

ETS is a super-fast in-memory storage box built into the Erlang VM — like a giant shared dictionary that lives in RAM, not the database. Many processes can read from it at the same time, instantly, without queuing at one worker. People use it for caching and counters. Performance in Elixir is mostly about not turning one process into a traffic jam, and not copying huge chunks of data around.

ETS is a whiteboard on the office wall — anyone can glance at it instantly without waiting in line at someone's desk. The database is the locked archive downstairs: reliable, but slow to walk to every single time.

8 questions a DockYard senior would ask
Q1 ETS vs a GenServer for state — when?

A GenServer serializes access — every read and write queues behind one process, a bottleneck under heavy concurrent reads. ETS lets readers hit memory directly, in parallel. Common pattern: a GenServer owns the table (so it survives), but clients read straight from ETS. Keep state in the GenServer only when access is low-volume or you need strict ordering/coordination.

Q2 Explain the key ETS table options.

Types: :set (unique keys), :ordered_set (sorted, range scans), :bag / :duplicate_bag (multiple values per key). Concurrency: read_concurrency: true (many readers), write_concurrency: true (concurrent writers to different keys). Access: :protected (owner writes, all read — default), :public, :private. Picking these wrong silently kills performance.

Q3 Who owns an ETS table, and what happens when that process dies?

The process that creates it owns it. When the owner dies the table is destroyed — unless ownership is handed off (give_away) or a heir is set. That's why a long-lived supervised GenServer should own it, so a transient crash doesn't wipe your cache. Named tables let other processes reference it by atom.

Q4 How do you build a cache with TTL / expiry in ETS?

Store {key, value, expires_at}. On read, treat expired entries as misses. Sweep periodically with a GenServer timer using select_delete, or delete lazily on access. In production, reach for Cachex or Nebulex — they give TTL, LRU eviction, and stats out of the box instead of reinventing it.

Q5 What's the cost of message passing and large data?

Messages between processes are copied (no shared heap), so passing huge maps/lists repeatedly is expensive. Large binaries (>64 bytes) are refcounted and shared off-heap, which helps. Keep hot data in ETS (read in place) or pass ids instead of payloads. Building giant terms in one process's heap also raises GC pressure.

Q6 How do you find and fix a bottleneck?

Measure first: :telemetry + LiveDashboard, :observer for process/memory counts, and find the process with a huge mailbox — that's your bottleneck. Profile with eprof/fprof, microbenchmark with benchee. Common fixes: move reads to ETS, partition a hot GenServer, batch DB calls, stop copying large terms.

Q7 What are reductions, and how does the scheduler stay fair?

The BEAM is preemptive: each process gets a budget of "reductions" (roughly function calls) before it's paused so others run — one busy process can't starve the rest. That's why Elixir stays responsive under load. Long native calls (NIFs) can break this fairness because they don't yield, which is why dirty schedulers exist.

Q8 When would you NOT use ETS?

When you need durability (ETS is gone on node restart — use the DB / DETS / Mnesia), when data must be consistent across nodes (ETS is node-local — use Redis / a distributed store), or when volume is low and a GenServer is simpler. ETS also has no built-in eviction — unbounded growth is a memory leak, so you always need an expiry strategy.

Oban & Broadway

You've used Oban across 5 projects. Show architectural understanding, not just "I used it."

elixir — oban worker patterns (full example)
defmodule MyApp.Workers.EmailWorker do
  use Oban.Worker,
    queue: :emails,
    max_attempts: 5,
    priority: 0       # 0 = highest priority in queue

  @impl Oban.Worker
  def perform(%Oban.Job{args: %{"user_id" => user_id, "template" => template}}) do
    with {:ok, user} <- Accounts.get_user(user_id),
         {:ok, _}    <- Mailer.send(user, template) do
      :ok
    else
      {:error, :not_found} -> {:cancel, "user deleted — no point retrying"}
      {:error, reason}    -> {:error, reason}  # will retry with backoff
    end
  end
end

# Schedule a job
%{user_id: 42, template: "welcome"}
|> MyApp.Workers.EmailWorker.new()
|> Oban.insert()

# Schedule in the future
%{report_id: 7}
|> ReportWorker.new(scheduled_at: DateTime.add(DateTime.utc_now(), 3600))
|> Oban.insert()

# Unique jobs — prevent duplicate processing (great for AI pipelines)
%{document_id: doc_id}
|> AIAnalysisWorker.new(unique: [period: 300, fields: [:args]])
|> Oban.insert()
Oban vs Broadway — when?
Mike may ask
Oban — job queue backed by PostgreSQL. Jobs are durable (survive crashes), retryable, schedulable. Best for: emails, notifications, AI inference, report generation, anything that can't be lost.

Broadway — data ingestion pipelines from external sources (RabbitMQ, SQS, Kafka, Google Pub/Sub). Best for: processing high-volume event streams, message queues, real-time data ingestion. You used this at Uamuzi with RabbitMQ.

Both together — Broadway consumes from RabbitMQ, dispatches Oban jobs for durable processing. Exactly what Virgil likely does.
Oban in your AI projects
Differentiate yourself
Your three AI projects (Bree AI, NexusScale, CallWisely) all use Oban for AI inference — this is a differentiator. Be ready to explain:
  • Why you don't call AI APIs synchronously in LiveView
  • How you stream AI results back to the LiveView using PubSub
  • How unique: [period: N] prevents duplicate AI job submissions
  • How you handle API rate limits with priority and scheduled_at
Explain it like I'm in grade 8
What are Oban and Broadway?

Some work shouldn't happen while a user waits — sending emails, processing a PDF, calling a slow API. Oban is a background job queue: you hand it a job, it saves it in the database, and workers run it later, retrying if it fails. Broadway is for processing huge streams of incoming data (like messages off a queue) reliably and in parallel. Both are about doing work outside the request — without losing it.

Oban is the office to-do tray backed by a written logbook (the database) — even if the power dies, the list survives and failed tasks get retried. Broadway is a conveyor belt with many hands, pulling items off a delivery truck (a message queue) and processing them in batches without dropping any.

8 questions a DockYard senior would ask
Q1 Why Oban over a plain GenServer or Task?

Oban persists jobs in Postgres, so they survive restarts and crashes — a Task or GenServer-held job is lost if the node dies. Oban also gives retries with backoff, scheduling, uniqueness, rate limiting, per-queue concurrency, and observability. Use a Task only for fire-and-forget work you can afford to lose; use Oban when the job must not be lost.

Q2 How do retries and backoff work?

A worker has max_attempts; on failure (a raise or {:error, _}) Oban reschedules with exponential backoff (overridable via backoff/1). After max attempts it's discarded. Returning {:cancel, reason} stops retrying immediately for permanent failures (e.g. the record was deleted). Make jobs idempotent — they can run more than once.

Q3 How do you guarantee a job runs only once / dedupe?

Use unique job options, e.g. unique: [period: 60, keys: [:user_id]]. Oban blocks inserting a duplicate within the period based on the chosen fields/keys — that's how you avoid enqueuing two "welcome email" jobs for one user. Note this is about insertion, not perfect exactly-once execution — so still keep handlers idempotent.

Q4 How would you design a multi-step pipeline (e.g. process a CV)?

Separate queues per concern (parsing, ai, email) each with its own concurrency, so a slow stage can't starve others. Chain steps by having each worker enqueue the next on success, passing ids (not big payloads) in args. Broadcast progress over PubSub so a LiveView shows status. Each step retries independently; use {:cancel, _} for unrecoverable errors.

Q5 How do you control concurrency and avoid hammering a third-party API?

Queue concurrency limits workers per queue per node. Oban Pro and plugins add global and per-key rate limiting (e.g. per customer). You can also lower max_attempts and lengthen backoff. For strict external limits, a dedicated rate-limited queue or a token-bucket check inside the worker prevents bursts.

Q6 Oban vs Broadway — when each?

Oban: discrete, persisted jobs you enqueue (emails, reports, scheduled tasks tied to your app). Broadway: continuous high-throughput ingestion from a data source (SQS, RabbitMQ, Kafka, GCP PubSub) with built-in batching, backpressure, and concurrency. Pulling from a broker at scale → Broadway. Enqueuing units of deferred work → Oban.

Q7 How does Broadway provide backpressure and batching?

Broadway is built on GenStage: producers only fetch as many messages as downstream demand allows, so a slow processor naturally throttles the producer (backpressure) instead of overflowing memory. Batchers group messages by size/time so you can do bulk operations — one DB insert for 100 rows, one API call — instead of one at a time.

Q8 How do you make background jobs safe and observable in production?

Idempotency first — guard re-runs with unique constraints / upserts. Keep args minimal (ids, not blobs). Watch queue depth, failures, and latency via Oban's telemetry + the Web dashboard. Set sane max_attempts and backoff. Isolate slow/risky work in its own queue so it can't block critical jobs, and alert on growing queues and discarded jobs.

API Design

A senior-friendly checklist for designing JSON APIs: resources, semantics, pagination, idempotency, errors, and production operability.

The core checklist
Shape the API around resources
Default
  • Use nouns and predictable paths: /users, /invoices, /projects/:id
  • Keep “actions” rare; if you need one, make it explicit: POST /invoices/:id/send
  • Prefer stable identifiers (UUIDs) and never expose internal DB assumptions unintentionally
Use HTTP semantics correctly
Signals seniority
  • GET is safe (no writes), POST creates, PATCH partially updates, DELETE removes
  • Status codes: 201 (created), 204 (no content), 404, 409 (conflict/duplicate), 422 (validation), 401/403 (authn/authz)
  • Explicit response headers where useful (e.g. Location on create)
Pagination, filtering, sorting
Scale
  • Prefer cursor pagination for large datasets; include next_cursor and has_more
  • Whitelist filter/sort params: ?status=paid&sort=-inserted_at
  • Set reasonable defaults and hard limits on page size
Example resource: Invoice
Endpoints (shape first)
Concrete
  • GET /invoices (list, cursor pagination)
  • POST /invoices (create)
  • GET /invoices/:id (fetch one)
  • PATCH /invoices/:id (update fields)
  • DELETE /invoices/:id (remove)
elixir — context + controller sketch
# lib/my_app/billing.ex (Context)
defmodule MyApp.Billing do
  alias MyApp.Repo
  alias MyApp.Billing.Invoice

  def list_invoices(opts \\ %{}) do
    # add filters/sort/pagination here (cursor pagination recommended)
    Repo.all(Invoice)
  end

  def get_invoice!(id), do: Repo.get!(Invoice, id)

  def create_invoice(attrs) do
    %Invoice{}
    |> Invoice.changeset(attrs)
    |> Repo.insert()
  end

  def update_invoice(%Invoice{} = invoice, attrs) do
    invoice
    |> Invoice.changeset(attrs)
    |> Repo.update()
  end

  def delete_invoice(%Invoice{} = invoice), do: Repo.delete(invoice)

  def change_invoice(%Invoice{} = invoice, attrs \\ %{}) do
    Invoice.changeset(invoice, attrs)
  end
end

# lib/my_app_web/controllers/invoice_controller.ex (API Controller)
defmodule MyAppWeb.InvoiceController do
  use MyAppWeb, :controller
  alias MyApp.Billing

  def index(conn, params) do
    invoices = Billing.list_invoices(params)
    render(conn, :index, invoices: invoices)
  end

  def show(conn, %{\"id\" => id}) do
    invoice = Billing.get_invoice!(id)
    render(conn, :show, invoice: invoice)
  end

  def create(conn, %{\"data\" => attrs}) do
    with {:ok, invoice} <- Billing.create_invoice(attrs) do
      conn |> put_status(:created) |> render(:show, invoice: invoice)
    end
  end

  def update(conn, %{\"id\" => id, \"data\" => attrs}) do
    invoice = Billing.get_invoice!(id)
    with {:ok, invoice} <- Billing.update_invoice(invoice, attrs) do
      render(conn, :show, invoice: invoice)
    end
  end

  def delete(conn, %{\"id\" => id}) do
    invoice = Billing.get_invoice!(id)
    with {:ok, _} <- Billing.delete_invoice(invoice) do
      send_resp(conn, :no_content, \"\")
    end
  end

  # NOTE: \"new\"/\"edit\" actions are for HTML forms; APIs usually omit them.
end
json — GET /invoices/:id (single)
{
  "data": {
    "id": "inv_01J8QF3F8B9QJYQ8WQH6C2C0W2",
    "status": "paid",
    "currency": "USD",
    "amount_cents": 12900,
    "customer": {
      "id": "cus_01J8QF1P0QKZ8YQ9V3K5D2Y9B4",
      "name": "Acme Inc"
    },
    "issued_at": "2026-05-31T09:12:00Z",
    "paid_at": "2026-05-31T09:15:31Z"
  }
}
json — GET /invoices?status=paid&cursor=... (list)
{
  "data": [
    { "id": "inv_01J8QF3F8B9QJYQ8WQH6C2C0W2", "status": "paid", "amount_cents": 12900 },
    { "id": "inv_01J8QF3Y5Q1M8N6YVZQ9X8V7B1", "status": "paid", "amount_cents": 5400 }
  ],
  "meta": {
    "next_cursor": "eyJpZCI6Imludl8wMUo4UUYzWT...snip",
    "has_more": true
  }
}
json — POST /invoices (create)
{
  "data": {
    "customer_id": "cus_01J8QF1P0QKZ8YQ9V3K5D2Y9B4",
    "currency": "USD",
    "amount_cents": 12900
  }
}
The point is consistency: every response has data; lists add meta; errors use a stable error envelope.
Idempotency & errors (production-safe)
Idempotency rules of thumb
Do this
  • Make retries safe using idempotency keys for creates, unique constraints, and upserts where appropriate
  • For async workflows, return a resource you can poll (e.g. /jobs/:id) instead of blocking the request
Error envelope (consistent)
Strong signal
  • Keep one top-level shape and stable code (machines) + message (humans)
  • For validations, return field-level details (often derived from Ecto changesets) with 422
json — example
{
  "error": {
    "code": "validation_error",
    "message": "Invalid input",
    "details": {
      "email": ["can't be blank"],
      "age": ["must be greater than 0"]
    }
  }
}
Phoenix framing (what to say)
Q1 Where does logic live in a Phoenix API?

Keep controllers thin; push business rules into contexts so they’re testable and reusable. Controllers translate HTTP to context calls and render consistent JSON.

Q2 How do you handle authorization?

Authentication identifies the user (token/session). Authorization is per-resource: “can this user access this invoice?” Enforce checks in contexts so they can’t be bypassed by a different controller.

Q3 How do you keep the API observable?

Add request ids, structured logs, and :telemetry spans around DB and external calls. Emit metrics for latency, error rates, and saturation (queue depth, DB pool usage).

Q4 How do you version without pain?

Prefer additive evolution (new fields) with backward compatibility. When you must break, use a versioned base path (/v1) or a vendor media type, and set a clear deprecation window.

Interview line to use: “I’ll optimize for consistency and evolvability — stable resource shapes, predictable errors, idempotent writes, and cursor pagination — then back it with authz + telemetry so it’s safe in production.”

Search & Vector Databases

When to use Elasticsearch vs vectors, how they work, and the operational gotchas interviewers love to probe.

Elasticsearch
Vector DBs
Sync & Ops
What Elasticsearch is good at
Default
  • Full-text search (BM25), relevance tuning, filters
  • Aggregations (facets, counts, histograms)
  • Near real-time search with fast reads
  • Log/analytics search use cases (when it fits)
Core concepts to say out loud
Interview bait
  • Inverted index + analyzers (tokenization, stemming, lowercasing)
  • Mappings matter (keyword vs text, nested vs object)
  • Shards/replicas drive scaling + availability
  • Refresh interval → “near real-time” visibility, not transactional guarantees
Common questions
Q1 Why is search “inconsistent” right after a write?

ES is near real-time: documents become searchable after a refresh. Writes can be acknowledged before they’re visible to search, so you can observe short windows where a query misses a just-written document.

Q2 How do you paginate ES safely?

Avoid deep from/size paging at scale; use search_after with a stable sort key (and a tiebreaker like _id) to prevent duplicates and reduce cost.

elasticsearch — query + filter sketch
{
  "query": {
    "bool": {
      "must": [{ "match": { "body": "ecto multi tenant" } }],
      "filter": [{ "term": { "status": "published" } }]
    }
  },
  "sort": [{ "inserted_at": "desc" }, { "_id": "asc" }],
  "size": 20
}
What vector databases solve
Modern
  • Semantic search (meaning, not keywords)
  • Similarity matching (dedupe, recommendations)
  • RAG retrieval (docs → chunks → embeddings)
  • Hybrid search (BM25 + vectors) for best relevance
Concepts to know
Signals seniority
  • Embeddings + similarity (cosine/dot/L2)
  • ANN indexes (e.g. HNSW) trade exactness for speed (recall/latency)
  • Chunking strategy matters more than the database
  • Metadata filters + namespaces/tenancy
Common questions
Q1 When would you use Postgres pgvector vs a dedicated vector DB?

pgvector is great when your dataset is modest, you want fewer moving parts, and relational joins/constraints matter. Dedicated vector DBs shine at larger scale, higher QPS, and richer ANN/index controls — especially when vectors are the main workload.

Q2 How do you improve relevance?

Better chunking, better metadata filters, hybrid retrieval (BM25 + vectors), and reranking usually beat “just change the distance metric”.

vectors — minimal data model sketch
documents(id, tenant_id, title, body, inserted_at, ...)
doc_chunks(id, document_id, chunk_index, chunk_text, embedding, metadata_json)

query text → embedding → similarity search (top K) → optional rerank → answer
Source of truth
Rule
Keep Postgres (or your OLTP DB) as the source of truth. Search indexes are derived views: they can be rebuilt; they can be stale; they must be backfilled.
Keeping indexes in sync
Real world
  • Async indexing jobs (Oban) on create/update/delete
  • Backfill + reindex pipelines for schema/mapping changes
  • CDC when needed (logical replication / Debezium-style) to avoid missing events
  • Idempotent indexing by primary key, with retries
Hybrid search pattern (most practical)
BM25 + vectors + rerank
Best of both
Use keyword search to guarantee recall on exact terms (names, IDs), use vectors to capture meaning, then rerank the merged candidates. This prevents semantic matches from burying exact hits.

Live Coding Challenges

Write your answer first. Then reveal. Be honest with yourself — this is where the job is won or lost.

Hard Rate limiter with ETS
Build an Elixir module that rate-limits API calls per user. It should: track request counts per user in a 60-second sliding window using ETS, return {:ok} or {:error, :rate_limited}, and be safe for concurrent access from many processes. A GenServer should own the ETS table.
elixir
defmodule MyApp.RateLimiter do
  use GenServer
  @table :rate_limits
  @limit 100
  @window_ms 60_000

  def start_link(_), do: GenServer.start_link(__MODULE__, [], name: __MODULE__)

  # Direct ETS read — no GenServer call needed
  def check(user_id) do
    now = System.monotonic_time(:millisecond)
    case :ets.lookup(@table, user_id) do
      [{^user_id, count, window_start}] when now - window_start < @window_ms ->
        if count < @limit,
          do: GenServer.call(__MODULE__, {:increment, user_id, count, window_start}),
          else: {:error, :rate_limited}
      _ ->
        GenServer.call(__MODULE__, {:new_window, user_id})
    end
  end

  @impl true
  def init(_) do
    :ets.new(@table, [:named_table, :set, :public, read_concurrency: true])
    {:ok, %{}}
  end

  @impl true
  def handle_call({:increment, uid, count, ws}, _, s) do
    :ets.insert(@table, {uid, count + 1, ws})
    {:reply, :ok, s}
  end

  def handle_call({:new_window, uid}, _, s) do
    :ets.insert(@table, {uid, 1, System.monotonic_time(:millisecond)})
    {:reply, :ok, s}
  end
end
Mike's key check: did you use read_concurrency: true? Did you make the hot path (in-window check) bypass the GenServer? Did you put the ETS table in a GenServer-owned process?
Medium Real-time collaborative counter (LiveView)
Build a LiveView that shows a shared counter — all users on the page see the same count and updates in real time when anyone increments. Include a "how many users are viewing" presence count.
elixir — shared liveview with presence
defmodule MyAppWeb.CounterLive do
  use MyAppWeb, :live_view
  alias MyAppWeb.Presence
  @topic "counter:global"

  @impl true
  def mount(_, session, socket) do
    if connected?(socket) do
      Phoenix.PubSub.subscribe(MyApp.PubSub, @topic)
      Presence.track(self(), @topic, session["user_id"], %{})
    end
    count = MyApp.Counter.get()
    viewers = Presence.list(@topic) |> Enum.count()
    {:ok, assign(socket, count: count, viewers: viewers)}
  end

  @impl true
  def handle_event("increment", _, socket) do
    new_count = MyApp.Counter.increment()
    Phoenix.PubSub.broadcast(MyApp.PubSub, @topic, {:count_updated, new_count})
    {:noreply, assign(socket, count: new_count)}
  end

  @impl true
  def handle_info({:count_updated, count}, socket) do
    {:noreply, assign(socket, count: count)}
  end

  def handle_info(%{event: "presence_diff"}, socket) do
    viewers = Presence.list(@topic) |> Enum.count()
    {:noreply, assign(socket, viewers: viewers)}
  end
end
The key insight: PubSub broadcast to ALL subscribers so they all update. The incrementing user also handles the broadcast message — you could skip the return assign and rely on the broadcast, but it's cleaner to assign immediately (optimistic update).
Conceptual Design an Oban pipeline for AI document processing
A user uploads a CV in Bree AI. The system needs to: parse the PDF, extract text, send to an AI model for analysis, generate a cover letter, and notify the user. Each step can fail independently. How do you design this with Oban?
elixir — oban pipeline with chaining
# Worker 1: Parse PDF → inserts next job on success
defmodule Bree.Workers.ParsePDF do
  use Oban.Worker, queue: :documents, max_attempts: 3

  @impl true
  def perform(%{args: %{"document_id" => doc_id}}) do
    with {:ok, doc}  <- Documents.get(doc_id),
         {:ok, text} <- PDF.extract_text(doc.path),
         {:ok, _}    <- Documents.update(doc, %{extracted_text: text}) do
      # Chain: insert next worker, let THIS worker succeed atomically
      %{"document_id" => doc_id}
      |> Bree.Workers.AIAnalyse.new()
      |> Oban.insert()
      :ok
    end
  end
end

# Worker 2: AI Analysis — higher max_attempts, AI APIs are flaky
defmodule Bree.Workers.AIAnalyse do
  use Oban.Worker, queue: :ai, max_attempts: 5,
    unique: [period: 300, fields: [:args]]  # prevent duplicate AI calls

  @impl true
  def perform(%{args: %{"document_id" => doc_id}}) do
    with {:ok, doc}      <- Documents.get(doc_id),
         {:ok, analysis} <- Claude.analyse_cv(doc.extracted_text),
         {:ok, _}         <- Documents.update(doc, %{ai_analysis: analysis}) do
      # Broadcast to LiveView via PubSub so user sees progress
      Phoenix.PubSub.broadcast(Bree.PubSub, "doc:#{doc_id}", {:analysis_complete, doc_id})
      %{"document_id" => doc_id}
      |> Bree.Workers.GenerateCoverLetter.new()
      |> Oban.insert()
      :ok
    end
  end
end
Key design decisions: separate queues (:documents vs :ai) so AI rate limits don't block PDF parsing. Broadcast to PubSub from workers so LiveView shows real-time progress. Unique jobs prevent duplicate AI calls on retry. Each worker handles its own failure independently.
Tricky Pattern matching & with chains
Rewrite this nested case expression using with, and explain why this is idiomatic Elixir. Then write a version that returns different error messages for each failure point.
elixir — before
def process_order(order_id, user_id) do
  case Orders.get(order_id) do
    {:ok, order} ->
      case Users.get(user_id) do
        {:ok, user} ->
          case Payment.charge(user, order) do
            {:ok, txn} -> {:ok, txn}
            err -> err
          end
        err -> err
      end
    err -> err
  end
end
elixir — after, with detailed errors
def process_order(order_id, user_id) do
  with {:ok, order} <- Orders.get(order_id),
       {:ok, user}  <- Users.get(user_id),
       {:ok, txn}   <- Payment.charge(user, order) do
    {:ok, txn}
  else
    {:error, :order_not_found} -> {:error, "Order #{order_id} does not exist"}
    {:error, :user_not_found}  -> {:error, "User #{user_id} not found"}
    {:error, :insufficient_funds} -> {:error, "Payment failed: insufficient funds"}
    {:error, reason}           -> {:error, "Unexpected: #{inspect(reason)}"}
  end
end
with short-circuits on the first non-matching clause and falls to else. It reads like a sequential recipe: "get the order, get the user, charge payment — and if any step fails, handle it here." The else block matches on the non-matching value, not a fixed error type.

General DSA Questions

10 core prompts a senior engineer should be comfortable with — and a repeatable way to work through each one out loud.

A senior-friendly interview flow

Use the same structure every time:

1) Clarify inputs, outputs, constraints, duplicates, ordering, mutability.

2) Choose an approach (and say why) + expected complexity.

3) Lock invariants (window boundaries, heap contents, recursion contract).

4) Walk one example before coding.

5) Edge cases + tests, then code, then re-check complexity.

10 general DSA questions + how to go through them
Q1 Two Sum: given an array + target, return the indices of two numbers that sum to target.

Explain like I’m 8: You have a bag of number cards. You want two cards that add up to the target number. If you remember what cards you’ve already seen, you can find the partner quickly.

How to go through it (out loud): Ask: “Do we always have exactly one answer? Can I use the same element twice? Do you want indices or values?” Then say: “I’ll use a hash map value → index so each lookup is fast.”

Key idea: When you’re at number x, the number you need is need = target - x. If you’ve seen need before, you’re done.

PSEUDOCODE
map = {}               # value → index
for i from 0 to n-1:
  x = nums[i]
  need = target - x
  if need in map:
    return [map[need], i]
  map[x] = i
return null

Mini tests: [2,7,11,15], 9 → [0,1]; [3,3], 6 → [0,1].

Complexity: O(n) time, O(n) space. Pitfalls: duplicates (decide whether to keep first/last index) and accidentally returning the same index twice.

Q2 Merge Intervals: merge overlapping time ranges.

Explain like I’m 8: Think of appointments on a calendar. If two appointments overlap or touch, you can treat them as one big appointment.

How to go through it (out loud): Ask: “Do endpoints count as overlapping? (Does [1,3] merge with [3,5]?)” Then say: “I’ll sort by start time so overlaps are next to each other.”

Rule: After sorting, if the next interval’s start is ≤ the current interval’s end, they overlap, so we extend the end.

PSEUDOCODE
sort intervals by start
result = []
current = intervals[0]

for each interval in intervals[1:]:
  if interval.start <= current.end:
    current.end = max(current.end, interval.end)
  else:
    append current to result
    current = interval

append current to result
return result

Mini test: [[1,3],[2,6],[8,10],[15,18]] → [[1,6],[8,10],[15,18]].

Complexity: O(n log n) time for sorting, O(n) output space. Pitfalls: forgetting to flush the final interval.

Q3 Longest Substring Without Repeating Characters.

Explain like I’m 8: Put a rubber band around a part of the word. The rubber band can stretch right, but it must never include the same letter twice.

How to go through it (out loud): Say: “I’ll use a sliding window. left is the start of my window, and I move right through the string. I keep where I last saw each character.”

Invariant: The substring from left to right has no repeats.

PSEUDOCODE
last = {}              # char → last index
left = 0
best = 0

for right from 0 to n-1:
  ch = s[right]
  if ch in last:
    left = max(left, last[ch] + 1)
  last[ch] = right
  best = max(best, right - left + 1)

return best

Mini test: "abcabcbb" → 3 (the best is "abc").

Complexity: O(n) time, O(k) space (alphabet size). Pitfalls: moving left backwards or updating the last-seen map in the wrong order.

Q4 Implement an LRU Cache with get and put in O(1).

Explain like I’m 8: Imagine a line of toys. The toy you played with most recently goes to the front. The toy you haven’t touched in a long time goes to the back. If your toy box is full, you throw away the toy at the back.

How to go through it (out loud): Say: “We need O(1) get and put. A hash map gives fast lookup, and a doubly linked list gives fast move-to-front and remove-tail.”

Data structures: Map key → node, and a list with head = most recent, tail = least recent.

PSEUDOCODE
init(capacity):
  map = {}
  list = empty doubly linked list

get(key):
  if key not in map: return -1
  node = map[key]
  list.move_to_front(node)
  return node.value

put(key, value):
  if key in map:
    node = map[key]
    node.value = value
    list.move_to_front(node)
    return

  node = new Node(key, value)
  list.add_to_front(node)
  map[key] = node

  if map.size > capacity:
    old = list.remove_tail()
    delete map[old.key]

Mini walk-through: capacity=2: put(1,1), put(2,2), get(1) (1 becomes most recent), put(3,3) (evicts key 2).

Complexity: O(1) time per op, O(capacity) space. Pitfalls: pointer updates; forgetting to delete evicted keys from the map.

Q5 Binary Search “variants”: first occurrence, last occurrence, or insertion position.

Explain like I’m 8: This is the “guess the number” game. Each time you guess in the middle, you throw away half of the places the answer cannot be.

How to go through it (out loud): Say: “Binary search works when the array is sorted. For ‘first/last’, I’ll search for a boundary using a rule that goes from false to true exactly once.”

Lower bound idea: Find the first index where nums[i] ≥ x.

PSEUDOCODE (LOWER BOUND)
lo = 0
hi = n                 # hi is exclusive
while lo < hi:
  mid = lo + (hi - lo) / 2
  if nums[mid] >= x:
    hi = mid
  else:
    lo = mid + 1
return lo              # first position where nums[i] >= x

Mini tests: [1,2,2,2,3] lower_bound(2) → 1; lower_bound(4) → 5 (insert at end).

Complexity: O(log n) time. Pitfalls: infinite loops (wrong mid update), off-by-one boundaries, and not handling empty arrays.

Q6 Linked List Cycle: detect a cycle and return the node where the cycle starts.

Explain like I’m 8: Imagine a race track. If the path is a circle, a fast runner and a slow runner will eventually meet. If there is no circle, the fast runner runs off the end.

How to go through it (out loud): Say: “I’ll use two pointers: slow moves 1 step, fast moves 2 steps. If they meet, there’s a cycle.”

Finding the start: After they meet, put one pointer back at the head. Move both 1 step at a time. They meet at the cycle entry.

PSEUDOCODE
slow = head
fast = head

# 1) Detect cycle
while fast != null and fast.next != null:
  slow = slow.next
  fast = fast.next.next
  if slow == fast:
    break

if fast == null or fast.next == null:
  return null           # no cycle

# 2) Find entry
ptr1 = head
ptr2 = slow             # meeting point
while ptr1 != ptr2:
  ptr1 = ptr1.next
  ptr2 = ptr2.next
return ptr1

Mini test: 1→2→3→4→2 returns node 2 as the cycle start.

Complexity: O(n) time, O(1) space. Pitfalls: null checks (fast/fast.next) and assuming a cycle always exists.

Q7 Tree Traversals: level-order (BFS) and depth-first (DFS) — when do you use each?

Explain like I’m 8: BFS is like exploring a building floor-by-floor. DFS is like walking down one hallway as far as you can, then coming back.

How to go through it (out loud): Say: “If I need the shortest path in an unweighted graph, BFS is best. If I need to compute something from children to parent (like height), DFS is natural.”

BFS (level order) pseudocode:

PSEUDOCODE (BFS)
queue = [root]
while queue not empty:
  node = queue.pop_front()
  visit(node)
  for each child in node.children:
    queue.push_back(child)

DFS pseudocode (recursive):

PSEUDOCODE (DFS)
dfs(node):
  if node == null: return
  visit(node)
  for each child in node.children:
    dfs(child)

Quick mental model: BFS uses a queue (first-in-first-out). DFS uses the stack (recursion stack or an explicit stack).

Complexity: both are O(n) time; space is O(width) for BFS and O(height) for DFS. Pitfalls: stack depth for skewed trees; forgetting to mark visited for general graphs.

Q8 Top K Frequent Elements.

Explain like I’m 8: Count how many votes each person got, then keep only the top k winners.

How to go through it (out loud): Say: “First I count frequencies. Then I keep a min-heap of size k. The smallest winner sits at the top, so it’s easy to kick out when we find a bigger one.”

PSEUDOCODE (MIN-HEAP SIZE K)
freq = count_map(nums)            # value → count
heap = empty min-heap of (count, value)

for each (value, count) in freq:
  heap.push((count, value))
  if heap.size > k:
    heap.pop_min()

return values in heap

Mini test: [1,1,1,2,2,3], k=2 → [1,2].

Complexity: heap approach O(n log k), bucket approach O(n) expected. Pitfalls: ties; choosing a max-heap and accidentally making it O(n log n).

Q9 Graph Shortest Path: BFS vs Dijkstra — which do you use and why?

Explain like I’m 8: If every road is the same length, you can just spread out step-by-step (BFS). If some roads are longer, you must always pick the currently cheapest route so far (Dijkstra).

How to go through it (out loud): Ask: “Are edge weights all the same?” If yes → BFS. If weights are non-negative and different → Dijkstra with a min-priority queue.

PSEUDOCODE (BFS, UNWEIGHTED)
dist = map with default INF
dist[start] = 0
queue = [start]
while queue not empty:
  u = queue.pop_front()
  for each v in neighbors(u):
    if dist[v] == INF:
      dist[v] = dist[u] + 1
      queue.push_back(v)
return dist
PSEUDOCODE (DIJKSTRA, NON-NEGATIVE WEIGHTS)
dist = map with default INF
dist[start] = 0
pq = min-heap of (dist, node)
pq.push((0, start))

while pq not empty:
  (d, u) = pq.pop_min()
  if d != dist[u]: continue        # stale entry
  for each (v, w) in neighbors(u): # w is weight
    if dist[u] + w < dist[v]:
      dist[v] = dist[u] + w
      pq.push((dist[v], v))
return dist

Complexity: BFS O(V+E), Dijkstra O((V+E) log V) with a heap. Pitfalls: negative weights (needs Bellman–Ford); forgetting to guard against stale heap entries.

Q10 Dynamic Programming: Coin Change (minimum coins to reach an amount).

Explain like I’m 8: To solve a big puzzle, you solve many tiny puzzles first. If you know the best way to make 1, 2, 3… you can build up to the final amount.

How to go through it (out loud): Say: “This is dynamic programming. I’ll define a table where each entry is the best answer for a smaller amount, and then build up.”

State: dp[a] = minimum coins to make amount a.

PSEUDOCODE (BOTTOM-UP)
dp = array of size amount+1 filled with INF
dp[0] = 0

for a from 1 to amount:
  for each coin in coins:
    if a - coin >= 0 and dp[a - coin] != INF:
      dp[a] = min(dp[a], dp[a - coin] + 1)

if dp[amount] == INF: return -1
return dp[amount]

Mini tests: coins [1,2,5], amount 11 → 3 (5+5+1). coins [2], amount 3 → -1.

Complexity: O(A * C) time, O(A) space. Pitfalls: unreachable states (keep infinity), and confusing “min coins” with “count of ways” (different transitions).

20‑Minute Drills

For a 20‑minute interview, they’re testing clarity under time pressure: can you ship a small correct thing and explain your choices.

A realistic 20‑minute flow
Gameplan
  • 2 min — your opener: “what I build, what I’m good at, what I’m aiming for”
  • 10–12 min — one technical drill (build or design)
  • 3–5 min — follow-ups: tradeoffs, edge cases, concurrency, failure modes
  • 1–2 min — your questions
Rapid questions (answer in 20–40s)
Q1 What is an Elixir process, and why is it “safe”?

A process is an isolated worker with its own mailbox and memory. There’s no shared mutable state, so you avoid races; state changes happen by handling one message at a time.

Q2 GenServer vs Task — when would you use each?

GenServer is for state + protocol (a long lived owner of state). Task is for work (short-lived concurrency). If you await in a GenServer, you risk blocking its mailbox.

Q3 ETS vs GenServer state — what’s the tradeoff?

GenServer serializes everything through one mailbox (simple, safe). ETS enables high read concurrency (fast shared table) but needs an owning process and careful design around TTL, consistency, and lifecycle.

Q4 Ecto changeset — what problem does it solve?

Validations + casting + constraints in one place. It turns external input into known-safe data and provides structured errors you can render as 422.

Q5 Why “idempotency” matters in background jobs and APIs?

Retries happen. If the same job/request runs twice, you don’t want duplicate side effects. Use unique constraints, idempotency keys, and “pass ids not blobs”.

Q6 LiveView in one sentence — what’s the model?

A stateful server process renders HTML and pushes diffs over a WebSocket; events go back to the server to update assigns.

Things they might ask you to build (10–15 min)
Build A small GenServer
Counter / in-memory cache / token bucket. Explain state, call vs cast, and how you’d supervise it.
elixir — example
defmodule MyApp.Counter do
  use GenServer

  def start_link(_), do: GenServer.start_link(__MODULE__, 0, name: __MODULE__)
  def get(), do: GenServer.call(__MODULE__, :get)
  def inc(), do: GenServer.cast(__MODULE__, :inc)

  @impl true
  def init(count), do: {:ok, count}

  @impl true
  def handle_call(:get, _from, count), do: {:reply, count, count}

  @impl true
  def handle_cast(:inc, count), do: {:noreply, count + 1}
end
Build Ecto changeset
Write a schema + changeset with validations, then show how you’d return a 422 error envelope.
elixir — example
defmodule MyApp.Accounts.User do
  use Ecto.Schema
  import Ecto.Changeset

  schema "users" do
    field :email, :string
    field :age, :integer
    timestamps()
  end

  def changeset(user, attrs) do
    user
    |> cast(attrs, [:email, :age])
    |> validate_required([:email])
    |> validate_format(:email, ~r/@/)
    |> validate_number(:age, greater_than: 0)
    |> unique_constraint(:email)
  end
end

# controller: on {:error, changeset} -> send 422 with field errors
Build Query + filter
Build a query with filters/sorting and justify cursor vs offset pagination.
elixir — example
import Ecto.Query

def list_invoices(params) do
  status = Map.get(params, "status")

  Invoice
  |> maybe_where_status(status)
  |> order_by([i], desc: i.inserted_at)
  |> limit(50)
  |> Repo.all()
end

defp maybe_where_status(q, nil), do: q
defp maybe_where_status(q, status), do: where(q, [i], i.status == ^status)
Build JSON controller
Implement index/show/create and explain where logic lives (context vs controller).
elixir — example
defmodule MyAppWeb.InvoiceController do
  use MyAppWeb, :controller
  alias MyApp.Billing

  def index(conn, params) do
    invoices = Billing.list_invoices(params)
    render(conn, :index, invoices: invoices)
  end

  def show(conn, %{"id" => id}) do
    invoice = Billing.get_invoice!(id)
    render(conn, :show, invoice: invoice)
  end

  def create(conn, %{"data" => attrs}) do
    with {:ok, invoice} <- Billing.create_invoice(attrs) do
      conn |> put_status(:created) |> render(:show, invoice: invoice)
    end
  end
end
Build Task async stream
Process 100 items with bounded concurrency and a timeout using Task.async_stream.
elixir — example
ids
|> Task.async_stream(&MyApp.Work.process/1,
  max_concurrency: 10,
  timeout: 5_000
)
|> Enum.to_list()
Build A rate limiter
Token bucket or sliding window (GenServer + ETS). Call out idempotency and contention points.
elixir — example (token bucket sketch)
defmodule MyApp.RateLimiter do
  use GenServer
  @table :rate_limits

  def start_link(_), do: GenServer.start_link(__MODULE__, [], name: __MODULE__)
  def allow?(user_id), do: GenServer.call(__MODULE__, {:allow?, user_id})

  @impl true
  def init(_) do
    :ets.new(@table, [:named_table, :set, :public, read_concurrency: true])
    {:ok, %{limit: 10, refill_ms: 1_000}}
  end

  @impl true
  def handle_call({:allow?, user_id}, _from, cfg) do
    # lookup {tokens, last_refill}; refill; decrement if token available
    {:reply, true_or_false, cfg}
  end
end
If they ask you to build something: say your assumptions, implement the happy path, then name 2–3 edge cases + how you’d test it.

Questions to Ask Mike

The best questions reference his actual work. This signals you've done the research and think at his level.

On Beacon CMS — his flagship project
"I've been following Beacon — building a CMS that's entirely LiveView-native is architecturally ambitious. How did you handle dynamic template rendering without a compile step, and what were the hardest design decisions around extensibility for different content types?"
Why it works: References his specific project by name. Asks about a genuinely hard problem (dynamic HEEx rendering without precompilation). Shows architectural thinking, not just "I Googled Beacon."
On teaching Elixir — his Cars.com talk
"Your Cars.com talk about getting 20+ Java and Ruby devs productive in Elixir in under 3 months is fascinating. I led a mentorship programme at Podii — what's the single hardest mindset shift even experienced developers struggle with when moving to Elixir?"
Why it works: References the talk by name. Immediately pivots to your own mentorship experience at Podii — you're not just asking, you're connecting. This becomes a two-way conversation, not an interview.
On Flame On! and profiling philosophy
"Flame On! is a really elegant approach to making BEAM profiling accessible. When you encounter a performance bottleneck in production — what's your actual diagnostic sequence before reaching for profiling tools?"
Why it works: References his library specifically. The follow-up question ("before reaching for profiling") shows you understand that profiling is a last step, not a first step — this is senior-level thinking.
On LiveView Native — DockYard's current bet
"LiveView Native is a fascinating direction for DockYard — using LiveView's server-driven model for native mobile UIs. What's the most surprising constraint you've hit when rendering to a native surface vs the browser?"
Why it works: Shows you're aware of DockYard's current OSS investment beyond just Beacon. Asking about constraints rather than capabilities shows engineering maturity.
On the role itself — practical onboarding
"What does the first 90 days look like for a senior Elixir engineer at DockYard? Do senior engineers typically own a client engagement immediately, or is there a ramp period on existing projects first?"
Why it works: Shows you're thinking about contribution and onboarding velocity, not just passing the interview. DockYard is a consultancy — client context is real.
On OSS culture — important for DockYard
"DockYard has an impressive open source track record — Beacon, Ironman, your Ets library, LiveView Native. Is contributing to OSS something DockYard engineers do on client time, or is it typically personal time?"
Why it works: Shows ecosystem awareness. Signals you're interested in contributing, not just using. The answer will tell you something real about the culture.

Interview Strategy & Stories

The stories you tell matter as much as the code you write. Prepare these specifically.

⚡ Your opener — how to frame your experience

Don't say: "I've been using Elixir for 5 years across multiple companies." Too generic.


Say: "I started using Elixir about 5 years ago, and since then I’ve kept gravitating toward systems where reliability and concurrency matter. I got my early reps at Podii, where I was pushed to read Thinking in Elixir and internalize the functional mindset. From there I built products across different contexts — including an agri-tech startup I pitched across Nigeria, France, and Belgium — then moved to AMI and joined a global engineering team building a learning platform with real-time collaboration. More recently, I’ve worked on products like NexusScale and CallWisely AI, and built ticketing systems that admitted 15k+ people. The common thread is real-time, fault-tolerant architecture and building calm systems under load — which is why DockYard’s work resonates with me."

🧭 The “why Elixir” origin story

Keep it specific: one turning point + one proof point + one ongoing pull.

"I came into Elixir from product work where features moved fast but reliability was always the tax. At Podii, I was basically forced to read Thinking in Elixir — and that changed how I reason about state and systems. The first time it really clicked was shipping real-time flows where OTP supervision and message passing made the system more predictable, not more complex. Since then, I’ve kept choosing Elixir for anything with concurrency, long-running processes, and ‘don’t wake me at 2am’ reliability."

🎯 How to Package Yourself for Mike Binns

The goal: come across as a curious, self-driven human who happens to be a great engineer — not just a résumé of tools.

  1. Lead with curiosity, not just skills. Share a small experiment/tinker you did purely to learn.
    “I got curious about X and built a small thing just to understand how it works…”

  2. Show you care about the ‘why’. Tie projects to impact + what you learned (especially in the hard parts), not just the stack.

  3. Demonstrate you elevate others. Bring a concrete story: mentoring, knowledge sharing, writing docs, improving onboarding, pairing someone through a tough bug.

  4. Be honest about what you don’t know. Say it plainly, then outline your learning/debugging approach (docs, small spike, tests, instrumentation, asking for feedback early).

  5. Show you can work autonomously. Emphasize async communication, self-direction, and accountability in a distributed environment.

  6. Have one technical angle that goes deeper. Be ready to talk about code quality, testability, and debugging — how you keep systems maintainable, not just “how you ship features”.

Angle to emphasize: DockYard’s culture of curiosity, generosity, growth mindset, and autonomous remote work.

Overall impression: genuine, intellectually alive, team-first, and comfortable admitting limits — with a clear plan to learn.
Would you like help preparing specific answers or stories for the interview?

📚 The Mentorship Story — for Mike specifically

Mike's biggest talk was about onboarding teams into Elixir. You led a mentorship programme at Podii HQ — and you learned the hard way that the mindset shift matters more than syntax. Connect these.

"At Podii, I led hands-on Elixir/Phoenix training sessions for interns — code reviews, backend development best practices, and helping them understand OTP patterns not just syntactically but conceptually. The biggest shift I had to guide people through was letting go of mutable state — once they understood that every GenServer callback returns new state rather than mutating old state, the rest of Elixir clicked. I even used Thinking in Elixir as a reference point because it explains that mental model really well. That's something I'd love to continue at DockYard given your team's culture around knowledge sharing."

🔧 The Performance Story — for Mike's ETS/Flame On interest

Prepare a specific performance win. Mike will ask "tell me about a performance problem you solved." Here's your Uamuzi story:

"At Uamuzi, our civic engagement platform was handling high-volume message queues through Broadway from RabbitMQ. We hit a bottleneck where the GenServer holding the rate-limit state was serialising all incoming requests — everything was waiting for the mailbox. I introduced ETS with read_concurrency: true for the hot read path, keeping the GenServer only for writes. We paired that with Cachex for a secondary TTL-based cache layer. The result was a significant reduction in P95 latency because concurrent processes could now read rate-limit data simultaneously without queuing."

🌍 Knowledge sharing + OSS — your proof points

Mike cares about teaching and community. This is where you show it with concrete artifacts.

"I genuinely enjoy sharing what I learn. I create video content to help people get into Elixir, I write articles on Medium, and I give talks when I can. I’ve been involved with ElixirConf Africa, and I host a podcast where I interview tech leaders about engineering decision-making. On the OSS side, I’ve published Hex packages focused mostly on payment integrations — the kind of glue code that saves teams time and makes systems more consistent. I’ve found that teaching and packaging are two sides of the same thing: you only really understand a tool once you can explain it and shape it into something others can use."

🧠 If you go blank on a live coding question

Say out loud: "Let me think through the data model first before writing code." Then write the data structure in a comment. Mike values engineers who design before they type — this signals seniority.


If you're uncertain about a specific API: "I'd check the docs on the exact function signature for :ets.select, but conceptually here's what I'd do..." — showing you know the concept is more important than memorising the exact arity.

Study Plan

Prioritised prep — assuming the interview is within 1–2 weeks. Track your progress.

Days 1–2 · Highest priority
Credo + Dialyzer + Typespecs
Install Credo and Dialyzer in a local project. Run mix credo --strict and fix every warning. Write @spec typespecs on 10 functions. Understand what Dialyzer catches that the compiler misses. This is the biggest gap and Mike's Ironman tool is literally a setup script for these.
Days 2–3 · Second priority
LiveView Streams + assign_async
Build a small LiveView with a stream of 100+ items. Add, delete, update individual items. Then add assign_async for loading data asynchronously with a loading state. The goal: be able to write this from memory without docs.
Days 3–4 · Third priority
Testing: ExUnit + Mox + LiveViewTest
Testing is completely absent from your CV. Write tests for one of your existing LiveViews using Phoenix.LiveViewTest. Write a module with a behaviour and mock it with Mox. Have code-level examples ready.
Days 4–5 · Fourth priority
ETS deep practice
Build the GenServer-owned ETS cache pattern from memory. Understand read_concurrency, write_concurrency, table ownership, and inheritance. Know all four table types cold. This overlaps with your Uamuzi experience — solidify it.
Days 5–6 · Fifth priority
Read Beacon + LiveView Native docs
Read the Beacon CMS README and at least one blog post Mike wrote about it. Skim the LiveView Native GitHub and understand what it does architecturally. You don't need to build with it — just understand the "what and why" for the conversation.
Day 7 · Mock session
Full mock interview
Do a timed coding session: 45 minutes, no docs, write the rate limiter from memory. Then do the Presence + counter LiveView. Then practice your opener story out loud. Time yourself. Being articulate under pressure is its own skill.

Track your prep

  • Install Credo + Dialyzer in a project and run them
  • Write @spec typespecs for 10 functions, run Dialyzer
  • Build a LiveView using stream() and stream_insert()
  • Build a LiveView using assign_async with loading state
  • Write LiveViewTest tests for a LiveView (mount, event, info)
  • Write a module with a behaviour and mock it with Mox
  • Build the GenServer-owned ETS cache pattern from memory
  • Build the Presence-tracked LiveView from memory
  • Build the Oban job chain pipeline (parse → AI → notify)
  • Read Beacon CMS README and Mike's blog posts
  • Read LiveView Native GitHub — understand what it does
  • Prepare your 5 stories (opener, mentorship, performance, OSS, AI systems)
  • Prepare your 5 questions to ask Mike
  • Do 45-minute no-docs timed coding session
  • Open a public GitHub repo with any Elixir utility code