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.
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.
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.

OTP & GenServer

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

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.

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.

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.

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.

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.

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 4 years across multiple companies." Too generic.


Say: "I've spent the last 4 years building real-time, multi-tenant systems in production — at AMI building a learning management system with Phoenix Channels for live collaborative sessions, at Virgil building consultancy tools for global markets with Oban-backed AI processing pipelines, and on my own products like CallWisely where I'm building enterprise-grade telephony AI on Elixir with OTP supervision for 24/7 uptime. The common thread is real-time, fault-tolerant architecture — which is why DockYard's work resonates with me so much."

📚 The Mentorship Story — for Mike specifically

Mike's biggest talk was about onboarding teams into Elixir. You led a mentorship programme at Podii HQ. 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. 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."

🌍 The OSS Gap — how to address it

You have no published Hex packages. Mike has several. Here's how to handle this honestly:

"I haven't published any Hex packages yet — my OSS involvement has been more indirect: I've submitted issues and PRs against libraries I use, and I've built internal tools at Virgil that I'm planning to extract into shareable packages. Watching your work on Ironman and Ets has actually inspired me to approach this more intentionally. I'm planning to open-source [specific internal tool] once I've validated the API design."


Pro tip: Before the interview, open a GitHub repo with even one small Elixir utility library. Having anything public is far better than nothing.

🧠 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