Features such as streaks and milestones are derived from user activity. If every activity request updates every derived field synchronously, one new feature expands the critical write path and old data becomes difficult to recalculate.
I start with durable facts:
type ActivityRecorded = {
eventId: string;
userId: string;
activityType: "lesson_completed" | "reflection_added";
occurredAt: string;
};
Consumers project those facts into views optimized for reading: current streak, longest streak, earned milestones, or daily summaries.
Define time semantics first#
A streak depends on a calendar boundary. Decide whether days use UTC or the user's timezone, and what happens when that timezone changes. Convert an event to a canonical activity day before updating the projection.
Late events require a rule too. You may recalculate a small date window or enqueue a complete rebuild. Silently ignoring them makes the projection permanently wrong.
Apply events idempotently#
Store processed event IDs or use an atomic update whose natural key prevents repeated credit. A retry of the same activity must not increment a streak twice or award a milestone twice.
Keep milestone awards unique by {userId, milestoneKey}. The notification announcing the milestone should use that award ID as part of its own idempotency key.
Preserve a rebuild path#
A projection is disposable only if its source facts are durable. When rules change from “one activity per day” to “one qualifying lesson per day,” rebuild the derived state from history and compare it before replacing the live view.
Version projection rules when different interpretations may coexist during migration. Record enough metadata to explain which version produced the current result.
Return honest freshness#
Asynchronous projection means a write response may precede an updated streak. The UI can show an optimistic state, poll briefly, or display a pending indicator. Choose based on the user experience rather than pretending the update is immediate.
Derived projections separate the fact that happened from the changing ways a product interprets it. Durable events, explicit time rules, idempotent updates, and rebuildability make that separation safe.