One Analytics Engine, Not One Per Feature: Designing a Polymorphic Tracking System
Jul 2026 · 9 min read
The agent portal on the property platform I architected needed analytics — views, inquiries, performance metrics — for property listings. That shipped first, scoped to properties, which is a reasonable way to ship a first version. Then the user-facing side added "user ads" — room-wanted and flatmate-wanted posts — and those needed the exact same analytics: views, inquiries, a performance dashboard. The obvious move was copying the property analytics service, renaming a few fields, and shipping a second one. The obvious move was also the wrong one, because it produces two analytics systems that will drift apart the moment either gets a new metric and someone forgets to port it to the other.
Views and inquiries aren't concepts that belong to properties
The reframe: "a view" and "an inquiry" aren't concepts that belong to properties specifically. They belong to anything a user can look at and express interest in. Modeling them polymorphically means a view event doesn't reference a propertyId — it references a generic targetType + targetId pair:
interface ViewEvent {
targetType: "property" | "userAd" | string;
targetId: string;
viewerType: "anonymous" | "registered" | "agent";
source: "search" | "direct" | "social" | "referral";
// ...engagement fields, TTL'd after 90 days
}The same event model, the same aggregation service, and the same dashboard math work for properties, user ads, or whatever the platform adds next that needs a "how many views did this get" answer — without writing a single new line of tracking logic.
Thin adapters are where domain logic lives, and nowhere else
The agent-side and user-ad-side services that consume this core are deliberately thin. Their job is translating "give me this agent's dashboard" into a call against the shared core with targetType: "property", and translating the generic result back into whatever shape that app's frontend expects. That's the only place domain-specific logic is allowed to live — which fields the agent dashboard surfaces versus which the user-ad dashboard surfaces. The actual counting, aggregation, and time-series math never gets duplicated, because there's only one place it's implemented at all.
Tracking as a middleware concern, not a call-site concern
The auto-tracking middleware fires asynchronously on any route that renders a property or user ad, and infers context itself — anonymous versus registered versus agent viewer, referral source — rather than requiring every route handler to remember to call trackView() manually. Analytics that depend on every call site remembering to instrument itself are analytics that quietly go missing the first time someone adds a new way to view a property — a new route, a new client, a new entry point nobody thought to check against the tracking checklist. Making tracking a cross-cutting middleware concern instead of a per-call-site responsibility removes that entire failure mode instead of relying on discipline to prevent it.
Retention baked into the schema, not into someone's memory
View events auto-expire after 90 days, inquiry events after a year — both via MongoDB TTL indexes declared directly on the model, not a cron job someone has to remember to run or a manual cleanup script that quietly stops being invoked after the person who wrote it changes teams. The data-retention policy is enforced by the exact same mechanism that stores the data, which means it can't silently stop working the way an external cleanup process can — there's no separate system to forget about.
Replacing a system safely means not deleting the old one immediately
The prior per-feature analytics models were deprecated in place, not deleted, while the unified system took over. "Replace the analytics system" and "delete the old models right away" are two separate decisions, and treating them as one removes your own ability to compare old numbers against new ones during rollout, or to roll back cleanly if the new aggregation math turns out to have a bug the old system didn't. Keeping the deprecated path around, clearly marked, costs almost nothing and buys a real safety net during exactly the period a migration is most likely to reveal a problem.
The pattern to recognize next time
The generalizable signal here isn't "build analytics this way." It's noticing the moment you're about to duplicate a cross-cutting concern — tracking, analytics, notifications, audit logging, anything that fundamentally asks "what happened to this thing" regardless of what kind of thing it is — across a second domain. That moment is the signal to extract a polymorphic core with thin adapters, not to copy the existing implementation and rename a few fields. The shape recurs: a shared engine, a generic reference instead of a typed foreign key, non-blocking capture, and a retention policy that lives in the schema instead of in a script.