Practice project based on the book Designing Elixir Systems with OTP. Mastery is a quiz generator: templates produce questions, users answer with responses, and the system tracks progress until the user masters each category of questions.
- A template defines how a question is generated (text, random values to substitute) and how to validate the correct answer.
- A quiz groups templates by category and asks them one at a time, keeping track of consecutive correct answers per template.
- After answering correctly a set number of times in a row (
mastery, defaults to 3), a template moves to themasteredlist and stops being asked. - Each user response is recorded as an independent
Responsestruct (not nested insideQuiz/Question), so it can be easily logged or persisted.
If available in Hex, the package can be
installed by adding mastery to your list of dependencies in mix.exs:
def deps do
[
{:mastery, "~> 0.1.0"}
]
endDocumentation can be generated with ExDoc and published on HexDocs. Once published, the docs can be found at https://hexdocs.pm/mastery.
All structures live under lib/mastery/core/ as structs. They are
immutable: every domain operation (generating a question, recording a
response, advancing the quiz) produces a new version of the struct instead
of mutating it.
File: lib/mastery/core/template.ex
Defines a question template: how questions are generated, what values each substitution can take, and how to validate the answer.
| Field | Type | Description |
|---|---|---|
name |
atom |
Identifying name of the template. |
category |
atom |
Groups templates of the same family (e.g. :addition). |
instructions |
String.t |
Text shown to the user explaining how to answer. |
raw |
String.t |
Template source code before compilation, e.g. "<%= left %> + <%= right %>". |
compiled |
Macro.t (compiled EEx) |
Compiled version of raw, generated with EEx.compile_string/1, ready to render the question. |
generators |
%{atom => list | function} |
One generator per substitution in the template. Can be a list (a random element is picked) or a function. |
checker |
(substitutions, String.t -> boolean) |
Function that, given the substitutions and the user's answer, determines whether the answer is correct. |
Template.new/1 builds the struct from a keyword list, requiring the
:raw key and computing :compiled automatically via
EEx.compile_string/1.
File: lib/mastery/core/question.ex
Represents a concrete question, already generated from a template.
| Field | Type | Description |
|---|---|---|
asked |
String.t |
Final question text shown to the user, e.g. "3 + 2". |
substitutions |
%{atom => any} |
Concrete values chosen for each substitution in the template, e.g. %{left: 3, right: 2}. |
template |
Mastery.Core.Template.t |
The template that generated this question (kept in full so the answer can be validated later). |
File: lib/mastery/core/response.ex
Represents a user's answer to a question. It is deliberately kept "flat"
(without nesting the full Quiz/Question) so it's easy to print, log, or
persist.
| Field | Type | Description |
|---|---|---|
quiz_title |
atom |
Title of the quiz the answered question belongs to. |
template_name |
atom |
Name of the template that generated the question. |
to |
String.t |
Text of the question being answered (equivalent to question.asked). |
email |
String.t |
Email of the user answering, used as the user identifier. |
answer |
String.t |
The answer given by the user. |
correct |
boolean |
Result of applying the template's checker to the answer. |
timestamp |
DateTime.t |
Moment the response was recorded (DateTime.utc_now/0). |
Response.new/3 receives the quiz, the email, and the answer, and
builds the struct by reading quiz.current_question to fill in the rest of
the fields and computing correct by invoking the template's checker.
File: lib/mastery/core/quiz.ex
The main aggregate: controls the full lifecycle of a quiz, from the pool of available templates to the user's progress.
| Field | Type | Default | Description |
|---|---|---|---|
title |
atom |
nil |
Title of the quiz. |
mastery |
integer |
3 |
Number of consecutive correct answers needed to consider a template mastered. |
templates |
%{String.t => [Template.t]} |
%{} |
Available templates, grouped by category; the pool from which questions are picked. |
used |
[Template.t] |
[] |
Templates already used in the current cycle but not yet mastered; they move back to templates once exhausted. |
current_question |
Question.t | nil |
nil |
Question currently being shown to the user. |
last_response |
Response.t | nil |
nil |
Last response recorded by the user. |
record |
%{atom => integer} |
%{} |
Streak of consecutive correct answers per template name. |
mastered |
[Template.t] |
[] |
Templates the user has already mastered and are no longer asked. |
Template state flow within a quiz:
- Starts in
templates. - When selected to be asked, it moves from
templatestoused. - If the answer is correct, its counter in
recordis incremented; once it reaches themasteryvalue, the template moves tomastered. - If the answer is incorrect, its counter in
recordis reset to 0. - Once all templates in
templatesare exhausted (all are inusedwithout being mastered),usedmoves back totemplatesto start a new question cycle.
The current core (Template, Quiz, Response) is already usable directly
from an IEx session, without any process/UI layer yet. Start it with:
iex -S mix
and paste the following to build a quiz, add a template, get a question and answer it:
alias Mastery.Core.{Template, Quiz, Response}
generator = %{ left: [1, 2], right: [1, 2] }
checker = fn(sub, answer) ->
sub[:left] + sub[:right] == String.to_integer(answer)
end
quiz = Quiz.new(title: :addition, mastery: 2) \
|> Quiz.add_template(
name: :single_digit_addition,
category: :addition,
instructions: "Add the numbers",
raw: "<%= @left %> + <%= @right %>",
generators: generator,
checker: checker ) \
|> Quiz.select_question
quiz.current_question.asked
email = "jill@example.com"
response = Response.new(quiz, email, "0")
quiz = Quiz.answer_question(quiz, response)
quiz.record
# ----
quiz = Quiz.select_question(quiz)
quiz.current_question.asked
response = Response.new quiz, email, "3"
quiz = Quiz.answer_question(quiz, response)
quiz.record
left/rightare picked at random on eachselect_question, so the question shown ("1 + 1","2 + 2", ...) and whether"0"/"3"count as correct answers will vary between runs — this snippet is meant for exploring the API interactively, not as a deterministic example. A correct answer incrementsquiz.recordfor that template; two correct answers in a row (sincemastery: 2) move it intoquiz.mastered.
Mastery.Boundary.QuizManager (lib/mastery/boundary/quiz_manager.ex)
is a GenServer that wraps the pure Quiz core in a process: it holds a map
of quizzes (keyed by title) as its state and exposes a small API
(build_quiz/1, add_template/2, lookup_quiz_by_title/1) instead of
requiring the caller to thread a %Quiz{} struct through every call by hand.
Mastery.Examples.Math (lib/mastery/examples/math.ex)
provides a ready-made single-digit addition template/quiz, handy for trying
this out without writing fixture data yourself.
Start iex -S mix and run:
alias Mastery.Examples.Math
alias Mastery.Boundary.QuizManager
{:ok, _pid} = GenServer.start_link(QuizManager, %{}, name: QuizManager)
QuizManager.build_quiz(title: :quiz)
QuizManager.add_template(:quiz, Math.template_fields())
QuizManager.lookup_quiz_by_title(:quiz)The last call returns the %Mastery.Core.Quiz{} built up so far, with the
addition template already added to its templates map. Starting the
GenServer under the name QuizManager means there's a single, well-known
process you can call into from anywhere (e.g. a future web layer) without
having to pass a PID around.
While QuizManager is the single, well-known process that holds the catalog
of quizzes, Mastery.Boundary.QuizSession
(lib/mastery/boundary/quiz_session.ex)
is a separate GenServer started per user, per quiz attempt — its state
is just {quiz, email}, so each person taking a quiz gets their own isolated
process tracking their own progress.
Start iex -S mix and run:
alias Mastery.Boundary.QuizSession
alias Mastery.Examples.Math
{:ok, session} = GenServer.start_link(QuizSession, {Math.quiz(), "mathy@example.com"})
QuizSession.select_question(session)That returns the question text, e.g. "1 + 6" — the exact numbers are
random each run (Math's generators pick any digit 0-9), so don't expect
to see the same question twice.
Answer it, then keep going:
QuizSession.answer_question(session, "7")
QuizSession.select_question(session)
QuizSession.answer_question(session, "some_answer")answer_question/2 replies with {next_question_text, was_previous_answer_correct?}
as long as there are more questions to ask. Since Math.quiz/0 sets
mastery: 2, once you get two answers right in a row for the same template,
there's nothing left to ask (the template moved to mastered and there's
only one template in this example quiz), so the next answer_question/2 call
replies :finished instead — and the session process stops itself.
Mastery (lib/mastery.ex) is the public entry point of the
library: it wraps QuizManager and QuizSession behind plain functions
(build_quiz/1, add_template/2, take_quiz/2, select_question/1,
answer_question/2), so callers never see a GenServer.call, a raw PID
shape, or any custom struct — just data in, data out.
QuizManager no longer needs to be started by hand either — it's part of
the supervision tree in lib/mastery/application.ex,
so it's already running as soon as the app boots.
Start iex -S mix and run:
alias Mastery.Examples.Math
Mastery.build_quiz Math.quiz_fields
Mastery.add_template Math.quiz.title, Math.template_fieldsEach call replies :ok once the quiz/template fields pass validation
(QuizValidator/TemplateValidator) and the QuizManager accepts them.
Now take the quiz:
session = Mastery.take_quiz Math.quiz.title, "mathy@email.com"
Mastery.select_question sessiontake_quiz/2 returns a {title, email} tuple — not a PID; that tuple is
the process's name, looked up on demand through a Registry (see the
QuizSession lifecycle changes below). select_question/1 returns the
question text, e.g. "8 + 7" (random, like in the QuizSession example
above). Answer it:
Mastery.answer_question session, "wrong"
Mastery.answer_question session, "14"
Mastery.answer_question session, "2"Same reply shape as QuizSession.answer_question/2:
{next_question_text, was_previous_answer_correct?} while the quiz is
still going, and :finished once mastery consecutive correct answers
are reached (Math.quiz/0 uses mastery: 2). Once finished, the session
process stops itself — and its Registry entry disappears with it:
Registry.lookup(Mastery.Registry.QuizSession, session)
# []Since take_quiz/2 now starts each session under a DynamicSupervisor
and names it via a {title, email} Registry entry instead of a PID
(see lib/mastery/boundary/quiz_session.ex),
nothing stops multiple users from taking the same quiz at the same
time — each gets an isolated process, tracked by name instead of by a
PID you'd otherwise have to keep passing around.
Start iex -S mix and run:
alias Mastery.Examples.Math
email1 = "mathter_of_the_universe@example.com"
email2 = "mam_math@example.com"
title = Math.quiz.titleBuild the quiz and add the template — same as always, and note that
QuizManager is already running, no explicit start needed:
Mastery.build_quiz Math.quiz_fields
Mastery.add_template title, Math.template_fieldsStart a session per user:
user1 = Mastery.take_quiz title, email1
user2 = Mastery.take_quiz title, email2
Mastery.select_question user1Each take_quiz/2 call returns its own {title, email} tuple
(user1/user2 here), and select_question/1 returns a question, e.g.
"5 + 2". Now drive both sessions independently, interleaved:
Mastery.answer_question user1, "7"
Mastery.select_question user2
Mastery.answer_question user1, "8"
Mastery.answer_question user2, "3"
Mastery.answer_question user2, "5"With mastery: 2, each user needs two correct answers in a row before
their session finishes — user1 and user2 progress completely
independently of each other. Once a user finishes, their session process
stops itself, which you can confirm via the Registry:
Registry.lookup(Mastery.Registry.QuizSession, user1)
# []The book's version of this walkthrough also opens
:observer.startat this point to visually inspect the two runningQuizSessionprocesses and their internal state. See Using:observeron this setup below if you want to try it.
Mastery.Boundary.Proctor (lib/mastery/boundary/proctor.ex)
is the worker layer on top of everything built so far: a GenServer that
holds a queue of quizzes-to-be, sorted by start time, and uses OTP's
handle_call/timeout mechanism to wake itself up exactly when the next
quiz should start — no polling. When a quiz's end time arrives, the
Proctor removes it from QuizManager and force-stops every active
QuizSession for it.
Start iex -S mix and run:
alias Mastery.Examples.Math
alias Mastery.Boundary.QuizSession
now = DateTime.utc_now()
five_seconds_from_now = DateTime.add(now, 5)
one_minute_from_now = DateTime.add(now, 60)Set up the timing window: the quiz will become available in 5 seconds and close a minute after that. Now schedule it:
Mastery.schedule_quiz(
Math.quiz_fields(),
[Math.template_fields()],
five_seconds_from_now,
one_minute_from_now
)Replies :ok once the quiz/template fields pass validation — same
QuizValidator/TemplateValidator checks as build_quiz/1/add_template/2,
just run up front here since there's no QuizManager interaction yet (the
quiz doesn't exist there until the Proctor builds it). At this point
nothing is in QuizManager yet; the Proctor is just holding the
scheduled entry and waiting.
Wait at least 5 seconds (the start_at you picked above) before the
next step — the Proctor's internal timeout needs to fire and actually
call QuizManager.build_quiz/1 + add_template/2 for you. Then:
Mastery.take_quiz(Math.quiz_fields().title, "james@graysoftinc.com")If you don't wait, this returns nil — not because anything is broken,
but because the quiz genuinely isn't built yet at that point in time.
Once it works, confirm the session is tracked:
QuizSession.active_sessions_for(Math.quiz_fields().title)
# [{:simple_addition, "james@graysoftinc.com"}]active_sessions_for/1 asks the DynamicSupervisor for every running
QuizSession child, then filters the Registry keys down to the ones
matching this quiz's title — this is exactly what the Proctor calls
internally (via end_sessions/1) to force-stop every session for a quiz
once its end_at time is reached.
Rather than writing tests with sleeps and timing-dependent polling to check
whether the Proctor started/stopped a quiz, schedule_quiz/5
(lib/mastery.ex) takes an optional notify_pid — when
given, the Proctor send/2s that process a message when it starts the
quiz and another when it stops it, so callers (tests, or any other process)
can receive them instead of guessing at timing.
Start iex -S mix and run:
alias Mastery.Examples.Math
now = DateTime.utc_now()
five_seconds_from_now = DateTime.add(now, 5)
one_minute_from_now = DateTime.add(now, 60)
Mastery.schedule_quiz(
Math.quiz_fields(),
[Math.template_fields()],
five_seconds_from_now,
one_minute_from_now,
self()
)Same as the earlier Proctor example — start_at 5 seconds out, end_at a
minute after that — except this time we pass self() as notify_pid. Once
start_at arrives you'll see the same Logger.info line as before, and the
call itself returns :ok:
10:23:48.562 [info] Starting quiz simple_addition...
:ok
Take the quiz like before:
Mastery.take_quiz(Math.quiz_fields().title, "james@graysoftinc.com")
# {:simple_addition, "james@graysoftinc.com"}Wait for end_at to pass (a minute, per the setup above) — you'll see the
"Stopped quiz ..." log line once it does. Now check your mailbox:
receive do message -> message end
# {:started, :simple_addition}
receive do message -> message end
# {:stopped, :simple_addition}Both notifications are there, in order — {:started, title} sent from
Proctor.start_quiz/2 the moment it builds the quiz, and
{:stopped, title} sent from the {:end_quiz, ...} handler right before it
tears every active QuizSession down. This is what makes it possible to
test the Proctor's scheduling behavior deterministically (assert_receive
instead of Process.sleep + polling).
mastery_persistence (mastery_persistence/) is a
separate poncho project — a path dependency of mastery, not an umbrella
app — that persists every Response to PostgreSQL via Ecto. Mastery
stays decoupled from it: QuizSession.answer_question/3 accepts an
optional persistence_fn callback, and Mastery.answer_question/3
defaults it to whatever config :mastery, :persistence_fn points at
(config/dev.exs sets it to &MasteryPersistence.record_response/2), so
the core domain never references MasteryPersistence directly.
Requires PostgreSQL running (see
mastery_persistence/README.md for the
Docker Compose setup and how to create/migrate the databases). Start
iex -S mix and run:
title = :basic_addition
:ok = Mastery.build_quiz(%{title: title})
template_fields = [
name: :single_digit_addition,
category: :addition,
instructions: "Add the numbers",
raw: "<%= @left %> + <%= @right %>",
generators: %{left: [1, 2, 3], right: [1, 2, 3]},
checker: fn subs, answer ->
to_string(Keyword.fetch!(subs, :left) + Keyword.fetch!(subs, :right)) == String.trim(answer)
end
]
:ok = Mastery.add_template(title, template_fields)
email = "your_email@example.com"
name = Mastery.take_quiz(title, email)
question = Mastery.select_question(name)
# prints something like "1 + 2" — adjust the answer to what you see
Mastery.answer_question(name, "999")
# check what got persisted in Postgres
MasteryPersistence.report(title)What to expect at each step:
Mastery.select_question(name)returns the question text as-is (e.g."1 + 2").Mastery.answer_question(name, "999")returns{next_question_text, was_previous_answer_correct?}— and along the way persists the previous response viaMasteryPersistence.record_response/2(wired up inconfig/dev.exs).MasteryPersistence.report(title)returns a%{email => count}map read straight from theresponsestable.
Repeat Mastery.answer_question(name, "another_answer") a few times to
accumulate more rows and watch the count go up in report/1.
:observer/:wx ship with Erlang/OTP but aren't on Mix's code path by
default — only applications explicitly listed in mix.exs are. Fixed in
mix.exs by adding them as :dev-only extra applications:
defp extra_applications(:dev), do: [:logger, :observer, :wx, :runtime_tools]
defp extra_applications(_), do: [:logger]Even with that, on this machine (Fedora 44, GTK 3.24.52, KDE/Wayland)
:observer.start prints a harmless-looking GTK warning
(wx: GTK: State 0 for context ... doesn't match state 128 set via gtk_style_context_set_state()) and the window doesn't actually render —
a known compatibility issue between wx and GTK3 3.24.4x+. Workaround
that reliably makes the window appear:
iex(1)> :observer.start
# nothing visible yetPress Ctrl+C (opens Erlang's BREAK menu), then c to continue. The
window shows up right after. Forcing XWayland
(GDK_BACKEND=x11 iex -S mix) alone did not fix it on its own — the
Ctrl+C/c step was still required.
Bugs found while building this project, all present in the book's own
printed source (Designing Elixir Systems with OTP) and confirmed
against the official code.zip bundle as well — not a transcription
mistake on this side. They're fixed here rather than reproduced verbatim.
-
Validator.check_field/3silently drops earlier validation errors (lib/mastery/boundary/validator.ex)Book (p. 118):
defp check_field(:ok, _errors, _field_name), do: :okWhenever a field passes validation, its result (
:ok) was returned as-is, discarding whatever errors had already accumulated from earlier fields in the samerequire/optionalpipeline — and it crashes withArgumentError(:ok ++ [...]) if a field after that one turns out invalid.Fixed:
defp check_field(:ok, errors, _field_name), do: errors— passes the accumulator through instead of replacing it. -
Mastery.build_quiz/1/add_template/2matched on the wrong success value (lib/mastery.ex)Book (p. 122):
with :ok <- QuizValidator.errors(fields), ...QuizValidator.errors/1andTemplateValidator.errors/1return a list (empty on success), never the atom:ok. This only appeared to work in the book because bug #1 above turned a fully-valid run into:okby coincidence.Fixed:
with [] <- QuizValidator.errors(fields), ...(same forTemplateValidator.errors/1). -
Mastery.schedule_quiz/4had the same wrong-success-value bug as #2 (lib/mastery.ex)Book:
with :ok <- QuizValidator.errors(quiz), true <- Enum.all?(templates, &(:ok == TemplateValidator.errors(&1))), ...— same mismatch as #2, just introduced later (chapter 8) sinceschedule_quiz/4was copied from the book independently of the earlier fix. Symptom:schedule_quiz/4always returned[]and silently never scheduled anything, since the firstwithclause never matched.Fixed:
with [] <- QuizValidator.errors(quiz), true <- Enum.all?(templates, &([] == TemplateValidator.errors(&1))), ....
These interact: #2 and #3 only surface once #1 is fixed (the pipeline's
success value changes from :ok to []). Fixing them together keeps the
iex walkthroughs above working exactly like the book describes, while
making QuizValidator/TemplateValidator actually report every
validation error instead of just the last one checked.
lib/
├── mastery.ex # Entry point / public API
├── mastery/
│ ├── application.ex # OTP application supervisor
│ └── core/ # Data and domain logic layer (pure, no side effects)
│ ├── template.ex
│ ├── question.ex
│ ├── response.ex
│ └── quiz.ex
The core layer is deliberately kept free of side effects (no IO, no
processes): it's just data and pure functions. OTP process orchestration
(servers, supervision, persistence) will be added in layers on top of this
functional core.
Mermaid diagrams tracking how this project evolved, one book chapter at a time. Each file reflects the code as it existed at the end of that chapter — not later additions — so earlier diagrams intentionally go stale in specific, documented ways (see each file's notes).
| Chapter | Diagram | What it captures |
|---|---|---|
| 3 | chapter-03-class-diagram.md | classDiagram of the four core structs (Template, Question, Quiz, Response) — fields only, no behavior yet. |
| 4 | chapter-04-sequence-diagram.md | Full quiz cycle as pure function calls within one process: build → add template → select question → answer → mastery check. |
| 5 | — | Tests only added, no lib/ changes — no new diagram. |
| 6 | chapter-06-architecture-diagram.md | Layered architecture: the Boundary layer appears (QuizManager, QuizSession GenServers + validators) between Mastery and Core. Application still starts nothing. |
| 6 | chapter-06-sequence-diagram.md | Same quiz cycle, now crossing process boundaries via GenServer.call/start_link. |
| 7 | chapter-07-supervision-diagram.md | Real supervision tree: QuizManager + Registry + DynamicSupervisor wired into Application. |
| 7 | chapter-07-sequence-diagram.md | Quiz cycle updated: sessions addressed by {title, email} name via Registry/:via, not by pid. |
| 8 | chapter-08-supervision-diagram.md | Proctor added to the supervision tree, alongside QuizManager. |
| 8 | chapter-08-scheduling-sequence-diagram.md | Timed quiz lifecycle driven by Proctor's self-messages (Process.send_after/3, GenServer reply-timeouts as a priority queue). |
| 9 | chapter-09-architecture-diagram.md | mastery_persistence introduced as an independent poncho app, coupled to mastery only via a config-injected function. |
| 9 | chapter-09-persistence-sequence-diagram.md | answer_question flow: the domain update runs as a continuation inside MasteryPersistence's Repo.transaction/1. |
| 10 | chapter-10-scheduling-notifications-sequence-diagram.md | Proctor gains an opt-in notify_pid — same "inject the side effect" pattern as chapter 9's persistence_fn, applied to scheduling events. |
A few things repeat across chapters and are the most transferable ideas for future projects:
- Functional core / imperative shell (ch. 3–5 → 6+):
Corestructs and their functions never touch a process, a database, or the network — everything effectful lives inBoundary. TheclassDiagram/sequenceDiagramsplit (ch. 3 vs. ch. 4) mirrors this: data shape first, behavior second. - Named process, one singleton vs. dynamic, many ephemeral (ch. 6–7):
QuizManagerandProctorare long-lived named GenServers (one per app);QuizSessionis short-lived, started per{title, email}viaDynamicSupervisor,restart: :temporarybecause it's meant to die when the quiz is mastered or force-ended. Registry+:viafor meaningful process identity (ch. 7): clients hold{title, email}, never a raw pid — the pid is an implementation detail resolved on every call.- GenServer reply-timeout as a self-scheduling priority queue (ch. 8):
Proctornever polls; it always computes exactly how long until the next event and letshandle_info(:timeout)do the work, chained withProcess.send_after/3for further-out events. - Inject the side effect, don't hardcode it (ch. 9 & 10, same shape twice): both
persistence_fnandnotify_pidletBoundarymodules stay ignorant of what consumes their output (a DB write, a test's mailbox, nothing) — config or a caller-supplied value decides, not the module itself.