Skip to content

Replace live/batch Rating calculation with denormalized RatingSum/RatingCount counters on School #519

Description

@gamadev1

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.csCreateSchoolCommentAsync (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.

Metadata

Metadata

Assignees

Labels

No labels
No labels

Type

No type

Projects

Status
Todo

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions