Academic-Year Scoping
Why every school record belongs to a school year, how that year travels with each request, and why a lock — not the current year — decides whether a write is allowed.
Almost every record in this system belongs to a school year. An enrollment is for a particular year. So is a grade, an exam, a report card, a term fee.
That sounds obvious, but it has a consequence you have to handle in every feature you build: when someone asks for "the enrollments", the system has to know which year they mean. This page explains how it knows.
The problem, concretely
It is October 2026. A teacher opens the gradebook.
- Most of the time they want this year's grades.
- But sometimes they need last year's — a student transferred in, and they want to see their history.
- And an admin might still be finishing a report from a year that already ended.
So the system cannot just assume "always show the current year". Staff legitimately move between years, and the software has to let them.
The solution: the year is sent with every request, as an ordinary field called campusAcademicYearId. The frontend says which year it is asking about, every time. Nothing is assumed.
Three walls, and only one is your job
Before any query reaches the database, three separate filters narrow it down. Two happen automatically. The third is the one you have to remember.
school_idBaseRepository. Answers: which school?500, never a wide read403campusAcademicYearId400 — never a silent all-years readWhy is the year the manual one? Because the other two never change during a request. You are always one user, in one school, with one set of campus permissions — so the system can apply those silently. But the year genuinely changes from screen to screen, so only the request itself knows it.
The safety net is that the field is required. If you forget to send it, validation rejects the request with a 400. It never quietly falls back to "all years" — which would show a teacher four years of grades at once.
What a "campus academic year" actually is
Here is the part that trips people up. Records do not point at a year like "2025/2026". They point at one campus running one year.
Three tables, three jobs:
| Table | What it represents |
|---|---|
academic_year | The year as a concept — "2025/2026". One row for the whole platform. |
campus_x_academic_year | One campus's run of that year. Its own start and end dates, its own lock. This is what records point at. |
campus_term | A term inside one campus's year, so a term is automatically specific to both. |
Why not just point at the year? Because campuses run the same year differently. Lugazi might still be entering results for 2024/2025 while Kampala has finished and sealed it. If records pointed at the shared year, one campus finishing would affect the other.
You will see this called 'the cay'
In code and in conversation, campus_x_academic_year is shortened to cay. If someone says "pass the cay id", they mean campusAcademicYearId.
What decides whether a write is allowed
Each campus year has two separate flags, and they are constantly confused:
| Flag | What it means | Does it block writes? |
|---|---|---|
isCurrent | "This is the year the app shows by default" | No. It is a display pointer, nothing more. |
lockedAt | "This year is sealed — the records are final" | Yes. This is the only write gate. |
So a teacher can still correct last year's grades while looking at this year's screen — right up until an admin locks last year. Locking is a deliberate, explicit act.
Never gate a write on isCurrent
It is tempting to write "only allow edits if this is the current year". That is wrong, and it breaks a real workflow: staff routinely finish last year's paperwork in the first weeks of a new year. Check lockedAt.
Which year gets checked
There is a subtlety here worth slowing down for.
When you create something, the year comes from the request — that is the year the new record will belong to.
When you change something that already exists, the check is against that record's own year, not whatever year the user happens to be viewing. Otherwise someone viewing an unlocked 2026 could edit a locked 2025 record just by having the right screen open.
The mental model
Five questions, five answers. If you remember this table you have the whole concept.
| Question | Answer | Who enforces it |
|---|---|---|
| Which year is this request about? | The campusAcademicYearId it carries | A required DTO field, checked by validation |
| Is this write allowed? | Only if that year is unlocked | CampusAcademicYearScopeService.assertNotLocked() |
| Which year does a change check against? | The record's own year, not the viewer's | The service loads the record first |
| Is a year past, current, or future? | Compare its order number to the current year's | Worked out on read — never stored as a flag |
| Can a year be deleted? | Not while any record still points at it | CAMPUS_YEAR_RESOURCES → 409 listing what is attached |
That last row is worth noting: there is no stored isPast or isFuture. Those were deliberately removed, because a stored flag drifts out of date the moment a new year starts. Ordering is compared instead.
Where to go next
Request Scoping
How to require the year on a DTO, the one sanctioned exception, and the service that gates writes.
Locking and Lifecycle
Creating, starting and locking years — and what protects a year from deletion.
Scoped Entities
Which entities are year-scoped, which deliberately are not, and children that inherit it.
Multi-Tenancy
The school wall — the automatic layer this one sits on top of.