tva
← Insights

Eine Prüfungsvorbereitungs-App mit Tausenden von Fragen aufbauen: Architekturentscheidungen

The problem with most exam prep apps is not the question content – it is the architecture around it. A few hundred questions stored in a flat JSON file works fine for a single certification in a single market. But in reality, once you add regional variants, multiple difficulty tiers, question types that differ by exam body, and a leaderboard that users across time zones are competing on simultaneously, the choices you made in week one start breaking things in week eight.

We built an exam prep app spanning multiple professional certifications across several regions. The architecture decisions we made – particularly around data modeling, Swift Package Manager modularity, the Supabase backend, and offline behavior – were not obvious at the outset. Some of them changed significantly between the first prototype and the version that shipped.

Das Datenmodellierungsproblem

The naive model for an exam question is a table with a prompt, four answer options, and a correct answer index. That works until you need to support questions with images, questions with code blocks, questions with variable numbers of options, or questions where multiple answers are correct. Changing the schema after you have content loaded is expensive, so it is worth designing for the full content envelope upfront.

The schema we landed on stores questions with a type field (single-choice, multi-choice, ordering, code-snippet) and a JSONB options column that holds the answer set. This gives each question type the flexibility to carry the data it needs without requiring a separate table per type. The correct answers are stored as a JSONB array of indices rather than a single integer, which handles multi-correct questions without a schema change.

Questions belong to domains, which belong to exams, which belong to regions. This four-level hierarchy sounds like over-engineering until you have to support the same certification body issuing different syllabi for different markets, or a single app serving both Japanese and German certification tracks with overlapping content. The hierarchy makes it possible to query “all questions for this exam in this region at this difficulty level” with a single parameterized query rather than application-level filtering.

Difficulty is stored as an integer (1–5) rather than an enum. Enums require migrations when you want to add a level; integers don’t. The front end maps integers to labels.

Swift Package Manager für modulare Architektur

A monolithic Xcode target for a codebase of this size creates a specific kind of problem: build times grow, and it becomes difficult to enforce boundaries between layers. A networking module should not have access to UI components. The question engine should not care whether it is running inside an iOS app or a test harness. Swift Package Manager makes these boundaries explicit and enforced by the compiler.

The package graph we settled on has four main packages:

  • QuestionEngine – Pure logic: question selection, answer evaluation, session scoring, spaced repetition scheduling. No UIKit, no SwiftUI, no Supabase dependency. Fully testable in isolation.
  • ExamData – Data models and Supabase client wrappers. Defines the Question, Exam, UserProgress, and LeaderboardEntry types. Imports the Supabase Swift client.
  • OfflineStore – Core Data stack and sync logic. Depends on ExamData for type definitions. Handles the local cache and change tracking for sync.
  • AppUI – SwiftUI views and view models. Imports QuestionEngine and ExamData. Has no direct knowledge of OfflineStore.

The main app target composes all four packages. This structure means you can run the full QuestionEngine test suite without spinning up a simulator, because the package has no platform dependencies. It also means that when the Supabase Swift client had a breaking change in a minor version, the blast radius was contained to ExamData – we updated one package, the rest compiled unchanged.

The cost is that SPM package resolution and caching can be temperamental in Xcode, particularly after clean builds. Having a Makefile target that runs xcodebuild -resolvePackageDependencies before CI builds eliminates most of those surprises.

Das Supabase-Backend

Supabase was the right choice for this backend for two reasons: Row Level Security handles multi-tenant data access without application-layer guards, and the real-time subscription system makes leaderboard updates straightforward. The schema is entirely PostgreSQL, which means the full range of query capabilities is available when building the reporting layer.

User progress is stored per-question, per-session. Each row records the question ID, the answer given, whether it was correct, the time taken, and the session timestamp. This is more granular than most apps need, but it makes spaced repetition scheduling possible: the algorithm has the full history to work with, not just a running score. Aggregate views over this table drive the dashboard statistics without requiring the application to compute them.

Row Level Security policies enforce that users can only read and write their own progress rows. The questions, exams, domains, and regions tables are publicly readable – no authentication required for content access, which simplifies the offline sync case. Only the user-specific tables require an authenticated session.

One thing we got wrong initially: trying to use Supabase Realtime for question content sync. Realtime is designed for small, high-frequency row changes, not for syncing thousands of rows of relatively static content. We replaced the Realtime subscription on the questions table with a scheduled polling job that checks a content_updated_at timestamp on the exam record and downloads a delta only when content has changed. This is less elegant but far more reliable at the scale of the content library.

Offline-First-Design

An exam prep app that requires a network connection is not useful to the exam candidate who is studying on a flight. Offline-first is not optional here – it is the primary use case.

The approach we took is content-push-on-wifi combined with a local Core Data cache that is the primary data source for the question engine. When the app launches with a network connection, it checks whether the exam content has been updated since the last sync. If it has, it downloads the delta and writes it to Core Data. If there is no network connection, it reads from Core Data without complaint. The user’s progress is written locally first and synced to Supabase when a connection is available.

The sync conflict model is simple by design: the server is authoritative for question content, and the client is authoritative for user progress. There are no bidirectional content edits that could conflict. User progress rows are append-only – a completed session is never updated, only new sessions are added – which means the sync logic is a straightforward “upload rows with timestamps newer than the last successful sync.”

One detail that matters: the offline store should indicate its freshness to the user. A banner that says “Content last updated 3 days ago” sets appropriate expectations. Silently serving stale content when new questions have been added to the exam pool creates user confusion when their scores differ from peers who synced more recently.

Bestenlisten unter gleichzeitiger Last

Leaderboards are straightforward to read but surprisingly subtle to write correctly under concurrent updates. The naive approach – maintaining a score column per user that is updated after each session – creates write contention when many users finish sessions simultaneously, and it is vulnerable to race conditions where two concurrent updates overwrite each other.

The approach that worked is to treat the leaderboard as a read model derived from the session history, not a maintained score column. A PostgreSQL materialized view aggregates session scores per user on a scheduled refresh. The refresh runs every few minutes rather than on every write, which means the leaderboard is eventually consistent rather than real-time – an acceptable trade-off for a study app. Users see their ranking updated within a few minutes of completing a session, which meets every use case except live competitive exam events.

For competitive events where real-time rankings matter, we use a separate Redis-backed counter outside Supabase. But for the standard study flow, the materialized view approach is simpler, cheaper, and correct under any concurrent write load.

Was wir anders machen würden

The JSONB options column for answer data was the right call, but we under-specified the schema for the options objects early on. Different developers added slightly different fields over time – some options had an explanation field, some had an imageUrl, some had neither. Without a validated shape enforced at insertion time, the content grew inconsistent. Adding a Zod schema on the TypeScript side of the admin tool and a CHECK constraint on the Postgres column would have caught this earlier. Enforce the shape of JSONB data at the boundary, not after the fact.

The SPM package boundary between AppUI and OfflineStore was too strict. Several SwiftUI views needed to know whether they were rendering from cached data or live data, and that information lived in OfflineStore. We ended up passing a protocol through AppUI that OfflineStore could satisfy – which is the right abstraction – but it would have been cleaner to design that protocol from the start rather than retrofitting it.

Verwandte Beiträge

Weitere Artikel