Most teams building education software start with a users table, a courses table, and a join table between them. That works for exactly one demo. It breaks the first time someone asks: what happens when a student drops a class three days before the refund deadline but two days after the add/drop deadline? Generic CRUD has no answer, because it never modeled enrollment as anything other than a row existing or not.
Enrollment is not a subscription. A subscription has one lifecycle: active, past due, canceled. Enrollment has a lifecycle nested inside a term, nested inside a program, with financial consequences that change depending on the exact date a status changes and rules that vary by institution, by course, and sometimes by student cohort. Waitlists, seat caps, prerequisite holds, and financial holds all sit on top of that lifecycle and can block a transition even when the student clicks the button correctly.
We haven't shipped a student information system ourselves, but the failure pattern is one we recognize from every domain where a status field secretly carries money and compliance with it — billing systems, licensing platforms, insurance claims. The fix is the same shape every time: stop modeling the relationship and start modeling the state machine that governs it.
Why the foreign-key model breaks on contact with reality
A student_courses join table answers one question well: is this student in this course, yes or no. It cannot answer any of these, all of which a registrar asks in the first month of any real deployment:
- Was the student enrolled on the day attendance was taken, even though they dropped later?
- Did the drop happen before or after the refund deadline for that specific term?
- Is the student on the roster for grading purposes but excluded from the tuition invoice because of a late-add fee waiver?
- Can this student re-add after a withdrawal without instructor override?
- Does a financial hold block a *new* enrollment while leaving *existing* enrollments untouched?
Every one of those questions requires knowing not just the current state but the state on a specific date, and the reason the state changed. A boolean or a single foreign key throws that history away the moment it's overwritten.
Model enrollment as a state machine bound to a term
The fix is to treat each enrollment as its own row with its own lifecycle, scoped to a specific section within a specific term, and to log every status transition instead of mutating a single field in place. A minimal version looks like this:
create table enrollments (
id uuid primary key,
student_id uuid not null,
section_id uuid not null,
term_id uuid not null,
status text not null check (status in
('waitlisted','enrolled','dropped','withdrawn','completed')),
effective_at timestamptz not null,
reason text,
created_by uuid not null
);
create table enrollment_events (
id uuid primary key,
enrollment_id uuid references enrollments(id),
from_status text,
to_status text not null,
occurred_at timestamptz not null,
actor_id uuid not null
);
The enrollment_events table is the part teams skip and then regret. Refund policy, attendance reporting, and financial-aid audits all ask "what was true as of date X," not "what is true now." Without an event log you're reconstructing that answer from application logs or, worse, from memory.
Term boundaries change the meaning of every status
The same dropped status means something different depending on which side of the refund deadline it falls on. A drop before the deadline usually voids the charge entirely. A drop after it converts to a withdrawal that keeps the charge and may still count against a grade-point average. If your schema stores only the status and not the term's deadline configuration at the time of the transition, you can't answer a billing dispute six months later — the deadline may have since changed for the next term.
Money follows enrollment status, not a billing cycle
Generic subscription billing charges on a calendar cycle: every 30 days, charge the card. Education billing charges on enrollment events: charge when the student's status moves to enrolled, refund a percentage based on how many days elapsed since that transition relative to the term's own calendar, and hold future charges if a financial hold is active regardless of what the billing cycle says. We've built the underlying primitive for this kind of state-driven charge — entitlement gating and metered billing tied to status changes rather than clock ticks — in non-education platforms, and the mechanism transfers directly: a status transition is the trigger, not a cron job.
The practical rule: never let a payment processor's subscription object be the source of truth for whether a student is enrolled. Let the enrollment state machine be the source of truth, and have it emit billing events. If the two ever disagree, the registrar's rules win, because that's what a refund dispute or an accreditation audit will check.
Waitlists and seat caps are concurrency problems, not UI features
A section with 30 seats and a waitlist looks like a simple counter until two students drop at the same moment two others try to register from the waitlist. Handling that correctly means the seat count itself needs the same transactional discipline you'd give an inventory count in retail — decrement on confirmed enrollment, never on form submission, and hold the seat only for a short, expiring window while payment or hold-clearance completes. We've dealt with the identical problem shape in retail inventory, where overselling a seat is the same bug as overselling a SKU: two writers reading a stale count and both committing.
A decision rule that holds up in practice: promote from the waitlist inside the same transaction that processes the drop, using a row lock on the section, not a background job that polls every few minutes. Polling introduces a window where the seat looks open to two students at once.
Holds, guardians, and the identity problem generic auth doesn't solve
Financial holds, academic holds, and immunization holds each block different actions — a financial hold blocks new registration but not viewing grades; an academic hold blocks registration but not dropping a class already in progress. Modeling holds as a single is_blocked flag on the student record forces every hold to behave identically, which is wrong for at least one of them on day one.
Identity gets harder again once guardians enter the picture below a certain grade level: one person needs read access to grades and billing without ever being able to trigger an enrollment action themselves. That's a role and scope problem, not an authentication problem, and it's worth designing the permission model before the enrollment workflow, not after — the same way we'd design tenant and role scoping on any multi-tenant platform before writing the first feature against it.
When the simple model is actually fine
None of this matters for a single self-paced course with no term calendar, no waitlist, and no refund window beyond a flat 14-day policy. If a student either has access to the content or doesn't, a boolean is correct and a state machine is over-engineering. The complexity above earns its cost only when at least two of these are true: there's a term calendar with deadlines, seats are capped, money changes hands on a schedule tied to status rather than a subscription clock, or a third party — a funder, an accreditor, a guardian — needs an audit trail of who was enrolled on what date. Build the state machine when those conditions show up, not before.
If you're scoping a platform where enrollment, billing, and reporting all have to agree with each other under audit, that's a design conversation worth having before the first schema migration. Talk to us about how we'd approach it, or look at how we've structured state-driven billing and multi-tenant data on platforms outside education where the same rules applied.
Frequently asked
Should enrollment status live in the billing system or the student information system?
The student information system's state machine should be the source of truth. Billing should subscribe to enrollment status changes and react to them, not the other way around. If billing drives status, refunds and registrar rules will eventually disagree with what the payment processor thinks happened.
How do we handle a student enrolled in the same course twice across different terms?
Scope every enrollment row to a specific term and section, never just to the course. Two enrollments in the same course across different terms are two independent rows with independent status histories, deadlines, and charges — treating them as one record is what causes retake and repeat-grade bugs.
Do we need an event log for enrollment status, or is a single status column enough?
You need the event log if any refund, attendance, or compliance rule depends on the date a status changed, not just the current status. That covers most real institutions. Skip it only for content with no deadlines and no third-party reporting requirement.
What's the safest way to handle waitlist promotion without overselling seats?
Promote from the waitlist inside the same database transaction that processes a drop, using a row lock on the section's seat count. Background jobs that poll on an interval create a window where two students can both believe a seat is theirs.