ADR-004: Database Constraint Enforcement for Section Enrollment Concurrency
Date: 2026-02-19 Status: Accepted
Context
During peak registration windows, many students simultaneously attempt to enroll in popular course sections. Section.capacity must never be exceeded — over-enrollment is a correctness failure with direct consequences for students and instructors.
A naive application-level approach reads the current enrolled_count, compares it to capacity, and then inserts an Enrollment row if space is available. This is a classic time-of-check / time-of-use (TOCTOU) race condition: two concurrent requests can both read enrolled_count = 24 against a capacity = 25, both conclude a seat is available, and both INSERT — resulting in 26 enrolled students.
The solution must be correct under concurrent load without introducing excessive lock contention that degrades performance during the registration peak.
Decision
Capacity enforcement is delegated to the database layer via a CHECK constraint or AFTER INSERT trigger on Enrollment.
The implementation approach for Curriculab/db:
Section.enrolled_countis a derived integer maintained by a trigger (or computed via a subquery in aCHECK). It is not a raw writeable column.- An
AFTER INSERTtrigger onEnrollmentincrementsenrolled_counton the parentSectionrow within the same transaction. - A
CHECKconstraint (or the trigger itself) validatesenrolled_count <= capacityand raises an exception if the constraint is violated. - PostgreSQL's MVCC ensures that two concurrent transactions incrementing
enrolled_counton the sameSectionrow will serialize — one will see the committed value from the other and either succeed or fail the constraint cleanly. - The API layer catches the constraint violation exception and returns an HTTP 409 (Conflict) with a user-facing "section is full" message — not a generic 500 error.
The UNIQUE (student_id, section_id) constraint on Enrollment independently prevents duplicate enrollments.
Consequences
Positive
- Correctness guaranteed at the database layer regardless of application server count or deployment topology.
- No explicit application-level locks or coordination required.
- Scales naturally: each concurrent INSERT either commits or fails; there is no shared in-memory lock to contend over.
- The constraint failure is a clear, catchable exception with a specific error code — easy for the API to translate into a business-logic response.
SELECT ... FOR UPDATEremains available as an escalation path (see Alternatives) if the trigger-based approach shows contention under profiling.
Negative
- Constraint violations must be handled as expected business events in the API layer, not just error conditions. All enrollment code paths must catch and translate the specific PostgreSQL exception.
- Testing correctness requires concurrent load simulation — unit tests that run serially will not expose race conditions.
- If
enrolled_countis stored (not purely calculated on read), the trigger must be carefully tested to ensure it fires correctly on INSERT, on DELETE (drop), and on status changes (e.g.,Enrollment.completion_statuschanging fromenrolledtowithdrawn).
Neutral
- The
UNIQUE (student_id, section_id)constraint onEnrollmenthandles the duplicate-registration case independently and does not interact with capacity enforcement.
Alternatives Considered
Naive check-then-insert (application layer)
Read enrolled_count, compare to capacity, insert if space available. Subject to TOCTOU race conditions under any concurrency. Do not use.
SELECT ... FOR UPDATE on the Section row
The API acquires a row-level exclusive lock on Section before checking capacity and inserting Enrollment. Guarantees correctness by serializing all concurrent enrollments for a given section. The tradeoff: the lock is held for the duration of the transaction, meaning all concurrent enrollment attempts for the same section queue behind each other. Under peak registration for a high-demand section, this can create a lock-wait queue. This approach is retained as a fallback if the constraint-based approach proves insufficient under profiling.
Distributed lock (e.g., Redis SETNX)
Acquire an external lock keyed on section_id before enrolling. Adds a Redis dependency and distributed lock complexity (lock expiry, crash recovery). Rejected as overengineering when the database can enforce the constraint directly.
Serializable transaction isolation
Run all enrollment transactions at ISOLATION LEVEL SERIALIZABLE. PostgreSQL's SSI would detect the write-write conflict and abort one of the concurrent transactions. Correct, but applies globally and adds overhead to all transactions, not just enrollment. Rejected in favour of targeted constraint enforcement.
Application-layer semaphore / in-process lock Acquire a per-section semaphore in the API process. Correct only for single-server deployments; fails when multiple API server instances run concurrently. Rejected.