The problem as it appears over time
Analytics is rarely a problem in the early life of a Rails application.
At first, it’s a query in a controller. Then a bit of aggregation in a service. Maybe a chart that embeds some SQL because it was faster at the time. Nothing feels wrong - in fact, it often feels flexible. You can answer new questions quickly, and the code is close to where it’s used.
As the application grows, teams often try to improve things by introducing structure. A PORO per chart appears. Each report gets its own class. Logic moves out of controllers and into “analytics objects” that feel more intentional and easier to test.
This helps - briefly.
Over time, these objects begin to mirror the same underlying problems. Queries are duplicated with small variations. Filtering and aggregation logic diverge subtly between charts. Business rules become scattered across multiple classes with no shared vocabulary. The system looks organised, but the relationships between analytics concepts remain implicit.
In long-lived systems, this becomes risky.
Analytics code turns into something teams are hesitant to touch - not because it’s inherently complex, but because its structure makes the impact of change hard to reason about. Onboarding slows down. Small reporting changes feel larger than they should. What once felt flexible now feels fragile.
The issue isn’t that analytics exists. It’s that, in many Rails applications, it never gets a clear architectural home.
Why This Becomes Dangerous in Long-Lived Systems
In a long-lived Rails application, analytics rarely breaks loudly. Instead, it degrades quietly.
As analytics logic spreads across controllers, services, and chart-specific POROs, the system accumulates assumptions that aren’t written down anywhere. Filters behave slightly differently depending on where they’re applied. Aggregations are re-implemented with small inconsistencies. Over time, analytics becomes less a coherent part of the system and more a collection of loosely related behaviours.
The real cost shows up when something needs to change.
A new business question sounds simple, but requires touching multiple classes. A small tweak to a report risks breaking another chart that happens to reuse the same query in a different form. Developers become cautious - not because the change is difficult, but because the blast radius is unclear.
This uncertainty increases cognitive load. New team members have to learn not just how analytics works, but where it might be hiding. Context lives in people’s heads rather than in the structure of the system. Analytics slowly turns into “that part of the codebase” that only a few people feel comfortable modifying.
Ironically, many of these systems were built to be flexible.
But flexibility without clear boundaries tends to age poorly. What once allowed rapid iteration now creates hesitation, duplication, and accidental coupling. The system hasn’t become rigid - it has become fragile.
In practice, this often leads teams to avoid improving analytics altogether, or to work around existing structures instead of fixing them. At that point, the problem is no longer about charts or reports. It’s about architecture.
The Core Question
At this point, it’s tempting to reach for tools.
A new reporting library. A dashboard builder. A BI layer. Something that promises to “solve analytics” by adding capabilities on top of what already exists.
But the problems described above aren’t really about missing features.
They’re about ownership, boundaries, and structure.
The question isn’t how to build charts faster, or how to write more expressive queries. It’s a more fundamental design question:
Where should analytics logic live in a Rails application that’s expected to evolve over time?
Not as an implementation detail, and not as a collection of convenience methods - but as a part of the system with clear responsibilities and a clear place to live.
This isn’t a question about frontends or visualisation libraries. It’s not even a question about SQL versus ActiveRecord. Those decisions matter, but they sit downstream of a more basic concern: how analytics fits into the overall shape of the application.
Until that question is answered explicitly, analytics tends to remain incidental. It exists everywhere and nowhere at the same time. And in long-lived systems, that ambiguity becomes expensive.
A Different Way to Think About Analytics
If analytics is going to survive in a long-lived Rails application, it needs to stop being treated as a side effect of querying data.
It needs a shape.
One useful way to think about analytics is to separate it into a small number of distinct responsibilities - each with a clear purpose and a clear place to live.
At a high level, analytics work usually answers four different questions:
What data exists and is available for analysis?
Where does that data come from?
How is it queried, filtered, and aggregated?
How is it presented or consumed?
In many Rails applications, these concerns are collapsed into one place - often a controller action, a service object, or a PORO created “just for this chart.” That collapse is what makes analytics hard to reason about and harder to change.
Instead, imagine treating each of these concerns explicitly.
Separate the What from the How
The first shift is conceptual.
There is a difference between describing a dataset and querying it.
A dataset answers questions like:
What does this data represent?
What is it called in the domain?
Is it stable enough for charts, reports, or dashboards to depend on?
This description changes far less frequently than the queries built on top of it.
By naming and modelling datasets explicitly, you give analytics a stable anchor - something other parts of the system can reference without knowing how the data is fetched or calculated.
Make Data Sources Explicit
Next, separate where the data comes from from everything else.
Sometimes data comes from ActiveRecord models.
Sometimes from database views.
Sometimes from external APIs.
Sometimes from computed or in-memory sources.
In many codebases, this distinction is implicit and scattered. The source is “whatever the query happens to hit.”
Making data sources explicit - even when they’re just thin wrappers around existing models - creates a boundary. It gives you a place to say: this is the authoritative source for this analytical data.
That boundary is what allows analytics logic to evolve without constantly rewriting consumers.
Treat Querying as a Strategy, Not an Accident
Filtering, grouping, and aggregation are not incidental details. They’re behaviour.
When query logic is embedded directly inside controllers, services, or chart POROs, it becomes tightly coupled to its immediate use case. Reuse is accidental. Testing is awkward. Changes ripple unpredictably.
When querying logic has no explicit home, it still exists - it is just harder to see and harder to change.
Treating querying as a strategy - something that can vary independently - makes it possible to support different data sources, different querying mechanisms, and different performance trade-offs without reshaping the entire system.
This doesn’t mean abstracting everything behind clever interfaces. It means giving querying logic a clear role and a clear boundary.
Let Presentation Be the Last Step
Finally, presentation should consume analytics - not define it.
Charts, reports, and dashboards are views over analytics data. They should describe what they want (grouping, measures, filters, labels), not how the data is fetched or calculated.
When presentation concerns are kept at the edge, analytics becomes easier to test, easier to reuse, and easier to expose through different frontends - whether that’s a charting library, an admin UI, or an API.
Taken together, this model does something important:
It turns analytics from scattered behaviour into a small, explicit subsystem with its own vocabulary and boundaries.
Not heavier.
Not more abstract.
Just clearer.
This is usually where teams reach for structure - but not yet for architecture.
The “One PORO per Chart” Pattern - and Why It Breaks Down
A very common response to messy analytics code is to “clean it up” by introducing Plain Old Ruby Objects.
You end up with things like:
SalesByRegionChart
ActiveUsersOverTime
RevenuePerCustomerReport
Each class encapsulates the query, the aggregation, and the formatting needed for a single chart or report. Compared to SQL in controllers, this feels like a big improvement.
And to be fair - it often is an improvement.
This pattern usually appears when a team cares about code quality but doesn’t yet have a shared mental model for analytics as a system. POROs are familiar, flexible, and easy to introduce incrementally.
But over time, cracks start to appear.
Why It Feels Right at First
The one-PORO-per-chart approach works initially because:
Each chart has a clear owner in the codebase
Logic is no longer embedded in controllers
Queries are testable in isolation
Naming things feels like progress
For a while, analytics feels under control.
The problem isn’t that this pattern is wrong. The problem is that it doesn’t scale with the questions the business starts asking.
Where the Pattern Starts to Strain
As analytics grows, you start to see:
Slight variations of the same query copied across multiple POROs
The same filters implemented in subtly different ways
Aggregation logic drifting over time
Formatting concerns leaking into query objects
No single place to answer: “What analytics datasets does this system actually have?”
At this point, change becomes risky.
A small tweak to a definition - say, what “active user” means - requires hunting through multiple classes, each tightly coupled to its own presentation needs.
The code is organised, but the system is not.
The Hidden Cost: No Stable Analytics Vocabulary
Perhaps the biggest limitation of this pattern is that it never establishes a stable vocabulary for analytics.
Each chart PORO defines its own implicit dataset.
Each report re-describes what data it cares about.
Nothing is persisted, named, or shared at the domain level.
That makes it difficult to:
Reuse analytics across charts and reports
Expose analytics through admin tools or APIs
Version or migrate analytics definitions
Reason about analytics independently of the UI
The code answers how to build this chart, but not what analytics capabilities the system provides.
When POROs Stop Being Enough
Eventually, teams hit a point where:
Analytics needs to be configurable, not hard-coded
Non-developers need visibility into what exists
Frontends want structured data, not bespoke hashes
Analytics logic must survive refactors and rewrites
At that point, adding another PORO doesn’t really solve the problem. It just adds another place where knowledge lives.
What’s missing isn’t another class - it’s a clearer separation of responsibilities and a more explicit model for analytics itself.
Treating Analytics as a First-Class Domain
Long-lived Rails applications tend to stabilise around a few core domains.
We invest time modelling things like:
users
accounts
permissions
billing
workflows
We give them names, persistence, constraints, and clear ownership.
Analytics, by contrast, is often treated as something derived - important, but secondary. It lives in queries, helpers, or one-off objects because it’s “just reporting.”
But analytics isn’t incidental.
Analytics encodes how the business understands itself.
Definitions like active user, conversion, or revenue are not implementation details. They are business concepts, and they change over time. When those concepts aren’t modelled explicitly, they drift - silently and dangerously.
What First-Class Actually Means
Treating analytics as a first-class domain doesn’t mean building a BI tool inside Rails.
It means a few concrete things:
Analytics concepts have names
Those names refer to persisted definitions
Queries are built from those definitions, not the other way around
Presentation is a consumer, not an owner
In other words, analytics becomes something the application knows about, not just something it happens to compute.
Stability Enables Change
This may sound counterintuitive, but making analytics more explicit usually makes systems more flexible.
When datasets are named and stable:
charts can change without redefining data
reports can evolve without duplicating logic
performance strategies can be swapped without touching consumers
definitions can be versioned instead of rewritten
The system gains leverage because fewer parts are responsible for knowing everything.
A Familiar Rails Pattern, Applied Consistently
None of this is foreign to Rails developers.
We already do this elsewhere:
Controllers don’t own business rules
Views don’t own persistence
Models don’t own presentation
Analytics just hasn’t traditionally been given the same treatment.
Once you apply the same architectural discipline - clear responsibilities, explicit boundaries, stable concepts - analytics stops feeling like a mess of special cases and starts behaving like part of the system.
Analytics That Survives the Second Rewrite
Most Rails applications can ship analytics.
The harder problem is keeping analytics correct, understandable, and changeable once the application has been around for a few years.
That’s usually when teams discover that:
definitions have drifted
logic is duplicated in subtle ways
no one is quite sure which chart is “right”
changes feel risky even when they shouldn’t be
These problems aren’t caused by a lack of tooling. They’re caused by analytics never being given a clear place in the architecture.
When analytics is treated as a first-class domain - with explicit concepts, stable definitions, and clear boundaries - it becomes easier to reason about and safer to evolve. Not because it’s abstracted, but because it’s named.
This way of thinking is what led me to build AnalyticsPlane - a framework designed to make these boundaries explicit and durable in real Rails applications.
If you’ve felt the pain of analytics code that grew organically and then hardened into something brittle, you’re not alone. And you don’t need to solve it all at once.
Start by asking a simpler question: Where should analytics logic live in a long-lived Rails app?
Sometimes, the answer isn’t “a better query” - it’s a better place for the concept to exist.
A Final Thought
None of this is intended as a universal solution. It’s one way of giving analytics a clearer place in applications that are expected to evolve over time.
I’m interested in how others think about these trade-offs, particularly in long-lived Rails systems:
Where does this kind of explicit structure start to feel like over-engineering in your experience?
At what point in a Rails app’s life would you expect these boundaries to become worth the cost?
What analytics responsibilities would you be most tempted to collapse - and why?
Top comments (0)