Skip to content
 
 

Repository files navigation

Mastery

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.

Overview

  • 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 the mastered list and stops being asked.
  • Each user response is recorded as an independent Response struct (not nested inside Quiz/Question), so it can be easily logged or persisted.

Installation

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"}
  ]
end

Documentation can be generated with ExDoc and published on HexDocs. Once published, the docs can be found at https://hexdocs.pm/mastery.

Data model

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.

Mastery.Core.Template

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.

Mastery.Core.Question

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

Mastery.Core.Response

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.

Mastery.Core.Quiz

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:

  1. Starts in templates.
  2. When selected to be asked, it moves from templates to used.
  3. If the answer is correct, its counter in record is incremented; once it reaches the mastery value, the template moves to mastered.
  4. If the answer is incorrect, its counter in record is reset to 0.
  5. Once all templates in templates are exhausted (all are in used without being mastered), used moves back to templates to start a new question cycle.

Quick example (iex -S mix)

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/right are picked at random on each select_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 increments quiz.record for that template; two correct answers in a row (since mastery: 2) move it into quiz.mastered.

Boundary example: QuizManager GenServer (iex -S mix)

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.

Boundary example: QuizSession GenServer (iex -S mix)

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.

Top-level API: Mastery module (iex -S mix)

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_fields

Each 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 session

take_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)
# []

Run with multiple users (iex -S mix)

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

Build 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_fields

Start a session per user:

user1 = Mastery.take_quiz title, email1
user2 = Mastery.take_quiz title, email2
Mastery.select_question user1

Each 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.start at this point to visually inspect the two running QuizSession processes and their internal state. See Using :observer on this setup below if you want to try it.

Worker example: Proctor — schedule timed quizzes (iex -S mix)

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.

Notifications: Proctor start/stop events (iex -S mix)

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

Persistence example: MasteryPersistence (iex -S mix)

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 via MasteryPersistence.record_response/2 (wired up in config/dev.exs).
  • MasteryPersistence.report(title) returns a %{email => count} map read straight from the responses table.

Repeat Mastery.answer_question(name, "another_answer") a few times to accumulate more rows and watch the count go up in report/1.

Using :observer on this setup

: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 yet

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

Errata: fixes vs. the book's official source code

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.

  1. Validator.check_field/3 silently drops earlier validation errors (lib/mastery/boundary/validator.ex)

    Book (p. 118): defp check_field(:ok, _errors, _field_name), do: :ok

    Whenever a field passes validation, its result (:ok) was returned as-is, discarding whatever errors had already accumulated from earlier fields in the same require/optional pipeline — and it crashes with ArgumentError (: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.

  2. Mastery.build_quiz/1 / add_template/2 matched on the wrong success value (lib/mastery.ex)

    Book (p. 122): with :ok <- QuizValidator.errors(fields), ...

    QuizValidator.errors/1 and TemplateValidator.errors/1 return 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 :ok by coincidence.

    Fixed: with [] <- QuizValidator.errors(fields), ... (same for TemplateValidator.errors/1).

  3. Mastery.schedule_quiz/4 had 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) since schedule_quiz/4 was copied from the book independently of the earlier fix. Symptom: schedule_quiz/4 always returned [] and silently never scheduled anything, since the first with clause 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.

Project structure

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.

Diagrams by chapter

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.

Recurring design patterns worth reusing

A few things repeat across chapters and are the most transferable ideas for future projects:

  • Functional core / imperative shell (ch. 3–5 → 6+): Core structs and their functions never touch a process, a database, or the network — everything effectful lives in Boundary. The classDiagram/sequenceDiagram split (ch. 3 vs. ch. 4) mirrors this: data shape first, behavior second.
  • Named process, one singleton vs. dynamic, many ephemeral (ch. 6–7): QuizManager and Proctor are long-lived named GenServers (one per app); QuizSession is short-lived, started per {title, email} via DynamicSupervisor, restart: :temporary because it's meant to die when the quiz is mastered or force-ended.
  • Registry + :via for 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): Proctor never polls; it always computes exactly how long until the next event and lets handle_info(:timeout) do the work, chained with Process.send_after/3 for further-out events.
  • Inject the side effect, don't hardcode it (ch. 9 & 10, same shape twice): both persistence_fn and notify_pid let Boundary modules 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.

About

Mastery: an Elixir/OTP learning project built while working through Designing Elixir Systems with OTP. A quiz engine with a pure functional core, a GenServer boundary layer, dynamic supervision via Registry, a self-scheduling Proctor for timed quizzes, and Ecto-backed persistence as a poncho app — with per-chapter Mermaid architecture diagrams.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages