Appendix A — Understanding the Service Layer
The chart modules move quickly through the service layer — a call method is
introduced, the data comes out shaped correctly, and we move on to the chart.
This appendix slows down and examines what the services are actually doing.
The patterns here — group_by, transform_values, each_with_object,
aggregation, pivot — appear throughout Rails applications. Understanding them
makes the services readable rather than mysterious.
A.1 — The extend self Pattern
Every service in this series uses extend self:
|
|
Without extend self, calling Stats::LabourForce.call(readings) would raise
NoMethodError — call would be an instance method with no instance to call it on.
extend self makes every method defined in the module into both an instance method
and a module-level method. This means call can be invoked directly on the module:
|
|
The alternative is a class with def self.call:
|
|
Both work. extend self on a module is preferred here because it signals that
this is procedural code — it takes input and returns output, maintains no state,
and has no lifecycle. A class implies an object with identity and state; a module
with extend self implies a namespace for functions. The intent is clearer.
A.2 — The Core Transformation Pattern
Most services follow the same shape:
|
|
Step 1 — group_by
group_by takes an enumerable and returns a Hash where keys are the return value
of the block and values are arrays of elements that produced that key:
|
|
The &:industry shorthand is equivalent to { |r| r.industry }. Each array
contains all readings for that industry across all years and quarters.
Step 2 — transform_values
transform_values maps over the values of a Hash, leaving the keys unchanged:
|
|
Each array of ActiveRecord objects is mapped to an array of plain Ruby hashes.
The result is a Hash of Array of Hash — nested but regular, easy to iterate
in a chart component.
Why plain hashes?
Returning ActiveRecord objects from a service ties the consumer to the model interface. If a column is renamed, every consumer breaks. Plain hashes with symbolic keys are stable, serialisable, and testable without a database.
A.3 — The BigDecimal.to_f Rule
Every service calls .to_f on decimal database columns:
|
|
Without .to_f, Rails returns a BigDecimal object from the database. BigDecimal
serialises to a quoted JSON string:
|
|
ECharts expects unquoted numbers. Quoted strings are silently treated as zero or
cause incorrect rendering. .to_f converts BigDecimal to a plain Ruby Float
which serialises to an unquoted JSON number.
This is a consistent rule: always call .to_f on any decimal column before
including it in a service result.
A.4 — The Labour Force Service: Pivoting Rows
The Labour Force service is more complex than the GDP service because the data arrives in a different shape. Let us dissect it in detail.
The raw data shape
The labour_force_readings table has one row per state per month, with separate
columns for each measure:
state year month employed_thousands unemployed_thousands participation_rate unemployment_rate
NSW 2012 1 3421.3 198.4 62.1 5.5
NSW 2012 2 3418.7 196.1 62.0 5.4
VIC 2012 1 2891.2 161.3 64.3 5.3
...What we want
For charts we want annual averages per state — one hash per year, not one hash per month:
|
|
The transformation
|
|
Step 1 — group_by(&:state)
Groups all readings by state name. Result:
|
|
Step 2 — group_by(&:year) inside transform_values
For each state’s readings, groups again by year. This is a nested group_by —
perfectly legal in Ruby and a common pattern:
|
|
Step 3 — map over the year groups
group_by returns a Hash. Calling map on a Hash yields [key, value] pairs —
destructured here as |year, rows|. Each iteration produces one hash for one year.
Step 4 — avg helper
|
|
r.send(attr) calls the method named by attr on each reading — equivalent to
r.employed_thousands when attr is :employed_thousands. This avoids writing
four separate average calculations.
.reject(&:zero?) excludes zero values from the average. A missing month is stored
as zero in the database — including it would drag the annual average down
incorrectly. This is a domain decision: treat zero as “no data”, not as “zero
employed persons”.
Step 5 — sort_by { |r| r[:year] }
group_by does not guarantee key order. Sorting by year ensures the array is in
chronological order before it reaches the chart component.
A.5 — The Business Composition Service: A Richer Example
The Stats::BusinessComposition service is the most interesting in the series.
It demonstrates several patterns not seen in the other services.
|
|
Step 1 — Finding the latest quarter
|
|
We need to identify the most recent year + quarter combination. The naive
approach — readings.order(year: :desc, quarter: :desc).first — works but
requires sorting the entire dataset.
The sort key trick is simpler: multiply year by 10 and add quarter. Since quarters are 1–4, this produces a unique integer for each year/quarter combination:
2024 Q1 → 2024 * 10 + 1 = 20241
2024 Q2 → 2024 * 10 + 2 = 20242
2024 Q3 → 2024 * 10 + 3 = 20243
2024 Q4 → 2024 * 10 + 4 = 20244The maximum of these values corresponds to the latest quarter. Calling
readings.maximum(:year) and readings.maximum(:quarter) separately is safe
because the latest year always contains the latest quarter — the dataset has no
gaps.
Note: This trick works because quarters are 1–4, a single digit. It would fail for months (1–12) —
2024 * 10 + 12 = 20252is less than2024 * 10 + 9 = 20249… wait, no:20252 > 20249so it actually works for months too. But for clarity, useyear * 100 + monthfor monthly data to make the intent obvious.
Step 2 — Selecting the latest quarter’s rows
|
|
select filters the array to rows matching the latest quarter. The same sort key
applied to each row identifies matches efficiently without string comparison.
Note that readings here is an ActiveRecord relation — calling select with a
block loads all records into memory and filters in Ruby. For large tables, a
database WHERE clause would be more efficient. Our dataset is small enough that
in-memory filtering is fine and keeps the service independent of ActiveRecord
query syntax.
Step 3 — Rejecting totals and zeros
|
|
The ABS data includes a “Total” row that sums all industries. Including it in a pie chart would create a slice larger than all others combined — visually nonsensical. We reject it explicitly by name.
We also reject zero values — some industry/quarter combinations have no data reported, stored as zero. Including them produces zero-value slices in the chart.
Step 4 — Sorting descending by sales
|
|
sort_by { |r| -value } sorts descending — the negative sign reverses the natural
ascending order. Sorting largest-first means the pie chart renders the dominant
industries first, which ECharts places at the top-right of the chart by default.
Step 5 — Computing the total
|
|
total is computed after filtering — it is the sum of the industries we are
actually displaying, not the sum of all industries including “Total”. This ensures
percentages add to 100%.
Step 6 — Building the result
|
|
Each reading becomes a plain hash with three keys. share is the percentage of
total — computed here in Ruby rather than left to ECharts. This means the share
is available for:
- Custom tooltip formatters (which receive the raw
value, not the ECharts-calculatedpercent) - Testing in plain Ruby
- Any non-chart consumer of this service (a table, a CSV export, an API response)
ECharts calculates its own percent for label templates ({d}) — but the ECharts
percentage may differ slightly from ours due to floating point rounding. Consistent
rounding in the service prevents discrepancies between the tooltip and the label.
A.6 — Testing Services
Services are the most testable layer in the stack. No database, no browser, no Rails — just Ruby objects in and Ruby hashes out.
Testing Stats::LabourForce
|
|
Testing Stats::BusinessComposition
|
|
Run all service tests:
|
|
No database seed required — the tests use Model.new to build objects in memory.
This makes the test suite fast and independent of data quality in the seed fixtures.
A.7 — Patterns Summary
| Pattern | Where used | What it does |
|---|---|---|
extend self |
All services | Makes module methods callable directly |
group_by |
GDP, Labour Force | Groups rows by a key into a Hash of Arrays |
transform_values |
GDP, Labour Force | Maps over Hash values, preserving keys |
each_with_object |
National Accounts | Builds a Hash by accumulating into an object |
r.send(attr) |
Labour Force avg |
Dynamic method dispatch — avoids repetition |
.reject(&:zero?) |
Labour Force avg |
Excludes missing data stored as zero |
year * 10 + quarter |
Business Composition | Sort key for year/quarter combinations |
.to_f on decimals |
All services | Prevents BigDecimal JSON serialisation issues |
Pre-computing share |
Business Composition | Available for tests and non-chart consumers |
Hash intersection & |
National Accounts | Aligns parallel series on common periods |
pluck with block |
National Accounts | Retrieves specific columns without full objects |
A.8 — The National Accounts Gauges Service: Parallel Series and each_with_object
The Stats::NationalAccountsGauges service introduces two new patterns: building
a Hash from a flat array using each_with_object, and aligning multiple series
on a shared set of periods using Ruby’s array intersection operator.
|
|
Step 3 — each_with_object in detail
This is the most interesting pattern in the service. Let us break it down:
|
|
pluck with multiple columns returns an array of arrays:
|
|
We want to turn this into a Hash keyed by [year, quarter]:
|
|
each_with_object({}) is the right tool. It takes an accumulator object —
here an empty Hash {} — and passes it alongside each element into the block.
The accumulator persists across all iterations and is returned at the end:
|
|
The block parameters need explaining. each_with_object yields two things:
the current element and the accumulator. Since each element is itself an array
[year, quarter, value], Ruby destructures it automatically when you write
|(year, quarter, value), h| — the inner parentheses destructure the element,
h receives the accumulator.
Without destructuring it would look like:
|
|
Both are equivalent — the destructured form is more concise.
Why each_with_object rather than map + to_h?
The map + to_h alternative:
|
|
This also works. each_with_object is preferred when the transformation isn’t
a simple 1-to-1 mapping — for example if you needed to conditionally add multiple
keys, or merge into an existing Hash. For this case either is fine; each_with_object
is used here because it makes the intent explicit: we are building a Hash, one entry
at a time.
Why a compound key [year, quarter]?
Using [year, quarter] as the Hash key preserves both dimensions without string
formatting. The key [2000, 1] is unambiguous and easy to reconstruct. Compare
with a string key "2000-Q1" — that works but requires parsing to extract the
year and quarter back out. Array keys avoid this round-trip.
Step 1 — Array intersection &
|
|
Array#& returns the intersection — elements present in both arrays, in the
order they appear in the left operand:
|
|
Applied to Hash keys, it finds the periods present in all three series. If the ABS data has a quarter for GDP but not for saving ratio (which can happen near the current release date when some series update before others), the intersection silently excludes it. No nil values, no array length mismatches.
Step 2 — Building parallel arrays
|
|
By mapping over the same periods array for each series, we guarantee parallel
arrays — gdp[0], saving[0], and trade[0] all correspond to periods[0].
The slider in Module 08 relies on this — index i into any array gives the
correct value for that period.
This is a deliberate structural choice. An alternative would be an array of period objects:
|
|
That works too and keeps each period’s data together. The parallel arrays approach is chosen here because it maps more directly to what the JavaScript slider needs: separate arrays it can index into independently.
More services will be added to this appendix as the series progresses.