DockYard Interview x Battle Strategy
Interview prep sections
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.
Your strongest selling points for DockYard
Strengths & Gaps
Honest analysis of your CV against what DockYard expects at senior level. Focus your energy on the red items.
- 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
- 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
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
:fprof and
:eprof outputs are hard to read. Know that flame
graphs show call stacks over time — wide = long runtime, deep =
many nested calls.
LiveView Mastery
Mike's home turf. This is non-negotiable depth for DockYard. You have strong experience — now refine the edges.
# 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
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
- Keeps initial render fast while data loads in the background
-
Avoids blocking
mount/3and tying up the LiveView process - Results arrive as messages and update assigns normally (diff-friendly)
- 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
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
# 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
# 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
:temporary_assigns. Mike
will know if you're using the old pattern.
# 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> """
@loading flag, then reset: true once
the data arrives.
# 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 %>
"""
IO.inspect in the console isn't useful
(rendering 50+ list items), put the debug output
in the template instead.
<!-- 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)
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.
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.
- Test pure functions: no DB, no network, no time
- Keep setup tiny; prefer passing explicit inputs
- Assert on outputs and invariants, not implementation
- Use
describeblocks + clear names
- Context functions that don’t need the DB
- Changeset validation rules
- Formatting + policy/authorization helpers
- Parsing and domain logic (pricing, scoring, etc.)
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
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.
- Mount + auth/redirect behaviour
- Event handling: click/change/submit
- URL patches and param handling
- Server-rendered HTML outcomes (not JS)
assert has_element?(lv, ...)assert render(lv) =~ "..."assert_patch(lv, ...)forpush_patchfollow_redirect/3after submit
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
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 module with helpers like
user_fixture/1 that inserts minimal valid data.
Keep defaults sane and accept overrides.
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
- Shared global state (ETS, Registry, Application env)
- Async tests touching the same resources
- Time-sensitive assertions (
Process.sleep) - Random order assumptions (lists without ordering)
- 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
CI / CD Shipping Discipline
What runs on every PR, how you deploy safely, and how you roll back when reality disagrees with your plan.
- 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
- 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
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
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.
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.
-
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)
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
-
Counter.get()sends a:getmessage and waits for a reply (call). -
Counter.increment()sends:incand 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.
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__).
-
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 asMyApp.Counterso 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}.
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
# 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)
# 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)
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.
Enum.each(1..1000, fn _ -> MyApp.Counter.increment() end)
# 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
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
# 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.
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.
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.
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.
Task.async + Task.await when the
caller can wait and a crash should take the caller down (linked).
task = Task.async(fn -> Expensive.compute(input) end) result = Task.await(task, 5_000)
Task.Supervisor so tasks are owned by your
app’s supervision tree (and you can choose link vs no-link).
# 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)
Task.async_stream for fan-out work with limits,
timeouts, and backpressure.
ids |> Task.async_stream(&Expensive.compute/1, max_concurrency: 10, timeout: 5_000 ) |> Enum.to_list()
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.
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.
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.
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
- Simple state (counters, small caches, feature flags)
- Test helpers and prototypes
- When you don’t need a custom message protocol
- 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)
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.
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
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.
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.
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.
-
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)
-
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)
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)
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.
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()
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.
\dtlist tables,\d usersdescribe\x autoexpanded output,\timing on-
\watch 1re-run a query every second (great for observing jobs / locks) -
Use
BEGIN;+ROLLBACK;for safe experiments
-
Start with
EXPLAIN (ANALYZE, BUFFERS)— not guesswork -
Watch for
Seq Scan, hugeSort, 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
-- 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;
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.
# 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
- 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
- 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 — 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.
@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
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.
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."
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()
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.
- 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
priorityandscheduled_at
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.
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.
-
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
-
GETis safe (no writes),POSTcreates,PATCHpartially updates,DELETEremoves -
Status codes:
201(created),204(no content),404,409(conflict/duplicate),422(validation),401/403(authn/authz) -
Explicit response headers where useful (e.g.
Locationon create)
-
Prefer cursor pagination for large datasets; include
next_cursorandhas_more -
Whitelist filter/sort params:
?status=paid&sort=-inserted_at - Set reasonable defaults and hard limits on page size
GET /invoices(list, cursor pagination)POST /invoices(create)GET /invoices/:id(fetch one)PATCH /invoices/:id(update fields)DELETE /invoices/:id(remove)
# 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
{
"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"
}
}
{
"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
}
}
{
"data": {
"customer_id": "cus_01J8QF1P0QKZ8YQ9V3K5D2Y9B4",
"currency": "USD",
"amount_cents": 12900
}
}
data;
lists add meta; errors use a stable error
envelope.
- 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
-
Keep one top-level shape and stable
code(machines) +message(humans) -
For validations, return field-level details (often derived
from Ecto changesets) with
422
{
"error": {
"code": "validation_error",
"message": "Invalid input",
"details": {
"email": ["can't be blank"],
"age": ["must be greater than 0"]
}
}
}
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.
Search & Vector Databases
When to use Elasticsearch vs vectors, how they work, and the operational gotchas interviewers love to probe.
- 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)
- 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
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.
{
"query": {
"bool": {
"must": [{ "match": { "body": "ecto multi tenant" } }],
"filter": [{ "term": { "status": "published" } }]
}
},
"sort": [{ "inserted_at": "desc" }, { "_id": "asc" }],
"size": 20
}
- Semantic search (meaning, not keywords)
- Similarity matching (dedupe, recommendations)
- RAG retrieval (docs → chunks → embeddings)
- Hybrid search (BM25 + vectors) for best relevance
- 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
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”.
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
- 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
Live Coding Challenges
Write your answer first. Then reveal. Be honest with yourself — this is where the job is won or lost.
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
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?
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
# 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
with, and
explain why this is idiomatic Elixir. Then write a version that
returns different error messages for each failure point.
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
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.
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.
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.
- 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
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.
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
422 error envelope.
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
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)
index/show/create and explain where logic
lives (context vs 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
end
Task.async_stream.
ids |> Task.async_stream(&MyApp.Work.process/1, max_concurrency: 10, timeout: 5_000 ) |> Enum.to_list()
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
Questions to Ask Mike
The best questions reference his actual work. This signals you've done the research and think at his level.
Interview Strategy & Stories
The stories you tell matter as much as the code you write. Prepare these specifically.
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."
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."
The goal: come across as a curious, self-driven human who happens to be a great engineer — not just a résumé of tools.
-
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…” -
Show you care about the ‘why’. Tie projects to impact + what you learned (especially in the hard parts), not just the stack.
-
Demonstrate you elevate others. Bring a concrete story: mentoring, knowledge sharing, writing docs, improving onboarding, pairing someone through a tough bug.
-
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).
-
Show you can work autonomously. Emphasize async communication, self-direction, and accountability in a distributed environment.
-
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?
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."
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."
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."
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.
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.
assign_async for
loading data asynchronously with a loading state. The goal: be
able to write this from memory without docs.
Phoenix.LiveViewTest.
Write a module with a behaviour and mock it with Mox. Have
code-level examples ready.
read_concurrency,
write_concurrency, table ownership, and inheritance.
Know all four table types cold. This overlaps with your Uamuzi
experience — solidify it.
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