Problem
School Rating (the public 0-5 average shown to users) is currently computed by aggregating SchoolComments.AverageRate live, per request:
Rating = t.SchoolComments.Any() ? t.SchoolComments.Average(c => c.AverageRate) : (double?)null
This re-aggregates over the SchoolComments table for every school on every list/detail request — a real performance cost as the comments table grows.
PR #512 attempted to fix this by making Rating a column written by the weekly UpdateSchoolScoreAsync ranking job instead. That introduces its own problems:
Rating goes stale for up to a week between job runs (and reads null for every school immediately after the migration deploys, until the job next runs).
HasRatingSpecification's hasRating filter becomes dependent on job freshness instead of reflecting actual comment data.
- The job's change-detection
WHERE clause can't correctly reset Rating back to NULL if a school's only comment(s) are removed (NULL <> NULL never matches).
- It re-couples
Rating and the internal ranking signal, which were deliberately separated on 2026-07-10 (see docs/business/school-scoring-analysis.md).
Proposed solution
Add two denormalized counter columns to School:
RatingSum (double) — running sum of SchoolComments.AverageRate
RatingCount (int) — running count of comments
Update these atomically (single UPDATE Schools SET RatingSum = RatingSum + @rate, RatingCount = RatingCount + 1 WHERE Id = @schoolId, not read-modify-write) in SchoolService.CreateSchoolCommentAsync — currently the only path that inserts a SchoolComment row.
Do not store a derived Rating column. Compute RatingSum / RatingCount (null when RatingCount == 0) in the presentation/service layer wherever Rating is currently projected. This keeps a single source of truth and makes the value always consistent — no batch lag, no stale-null bug.
For the weekly ranking job (UpdateSchoolScoreAsync), read RatingSum/RatingCount directly off the Schools row instead of re-aggregating SchoolComments via the CommentAgg CTE — removes a join/aggregation from the job entirely and keeps its input as fresh as the last comment write.
Scope / affected files
src/Domain/Entity/School.cs — add RatingSum, RatingCount
- New EF migration (add columns + one-time backfill via
SUM/COUNT ... GROUP BY SchoolId over existing SchoolComments)
src/Application/Service/SchoolService.cs — CreateSchoolCommentAsync (atomic increment), GetSchoolsListAsync/GetSchoolAsync/GetSchoolsAsync (compute Rating from counters), UpdateSchoolScoreAsync (drop CommentAgg, read counters directly)
src/Domain/Specification/School/HasRatingSpecification.cs — filter on RatingCount > 0 (unchanged semantics, still accurate)
Out of scope / known future risk
There's currently no update/delete path for SchoolComment. If one is added later, it must apply the matching delta to RatingSum/RatingCount or the counters will drift from reality — flag this at the increment call site.
Relation to PR #512
This replaces #512's approach; #512 should not be merged as-is.
Problem
School
Rating(the public 0-5 average shown to users) is currently computed by aggregatingSchoolComments.AverageRatelive, per request:This re-aggregates over the
SchoolCommentstable for every school on every list/detail request — a real performance cost as the comments table grows.PR #512 attempted to fix this by making
Ratinga column written by the weeklyUpdateSchoolScoreAsyncranking job instead. That introduces its own problems:Ratinggoes stale for up to a week between job runs (and readsnullfor every school immediately after the migration deploys, until the job next runs).HasRatingSpecification'shasRatingfilter becomes dependent on job freshness instead of reflecting actual comment data.WHEREclause can't correctly resetRatingback toNULLif a school's only comment(s) are removed (NULL <> NULLnever matches).Ratingand the internal ranking signal, which were deliberately separated on 2026-07-10 (seedocs/business/school-scoring-analysis.md).Proposed solution
Add two denormalized counter columns to
School:RatingSum(double) — running sum ofSchoolComments.AverageRateRatingCount(int) — running count of commentsUpdate these atomically (single
UPDATE Schools SET RatingSum = RatingSum + @rate, RatingCount = RatingCount + 1 WHERE Id = @schoolId, not read-modify-write) inSchoolService.CreateSchoolCommentAsync— currently the only path that inserts aSchoolCommentrow.Do not store a derived
Ratingcolumn. ComputeRatingSum / RatingCount(null whenRatingCount == 0) in the presentation/service layer whereverRatingis currently projected. This keeps a single source of truth and makes the value always consistent — no batch lag, no stale-null bug.For the weekly ranking job (
UpdateSchoolScoreAsync), readRatingSum/RatingCountdirectly off theSchoolsrow instead of re-aggregatingSchoolCommentsvia theCommentAggCTE — removes a join/aggregation from the job entirely and keeps its input as fresh as the last comment write.Scope / affected files
src/Domain/Entity/School.cs— addRatingSum,RatingCountSUM/COUNT ... GROUP BY SchoolIdover existingSchoolComments)src/Application/Service/SchoolService.cs—CreateSchoolCommentAsync(atomic increment),GetSchoolsListAsync/GetSchoolAsync/GetSchoolsAsync(computeRatingfrom counters),UpdateSchoolScoreAsync(dropCommentAgg, read counters directly)src/Domain/Specification/School/HasRatingSpecification.cs— filter onRatingCount > 0(unchanged semantics, still accurate)Out of scope / known future risk
There's currently no update/delete path for
SchoolComment. If one is added later, it must apply the matching delta toRatingSum/RatingCountor the counters will drift from reality — flag this at the increment call site.Relation to PR #512
This replaces #512's approach; #512 should not be merged as-is.