Skip to content

Module 02 — The Ruby DSL: Building ECharts Options in Ruby

What We’re Building

In Module 01 we passed a raw Ruby hash to Components::Chart and it worked. That approach scales poorly — raw hashes offer no type safety, no reuse, and no testability. This module introduces the architecture that carries us through the rest of the series:

  • A thin Ruby DSL in app/lib/chart/ for building ECharts option hashes
  • A service pattern in app/services/stats/ for transforming raw ActiveRecord data
  • Chart-specific Phlex components in Components::Charts::* that wire the two together
  • Unit tests for all of the above in plain Ruby

Formatting — axis labels, tooltip presentation, number units — is deliberately absent from this module. Naked charts first; formatting in Module 03.

By the end, the controller will do nothing but fetch raw data. All transformation, configuration, and rendering logic lives where it belongs.

By the end of this module we’ll be able to write code like:

1
2
3
4
5
6
# app/controllers/charts_controller.rb
class ChartsController < ApplicationController
  def demo
    @readings = GdpReading.ordered
  end
end
<%# app/views/charts/demo.html.erb %>
<h1 class="text-2xl font-bold mb-6">GDP by Industry</h1>

<%= render Components::Charts::GdpByIndustry.new(readings: @readings) %>

which will create a chart like this (and with no javascript at all) :

module_02_chart.png


2.1 — Design Principles

Three distinct layers, three distinct responsibilities.

Controller              — fetches raw ActiveRecord data, nothing else
Stats::*                — transforms raw data into clean domain structures
Components::Charts::*   — builds chart options from clean data, renders HTML

The service layer is chart-agnostic.

A Stats::* module does not know what its output will be used for. The same service can feed a chart component, a PDF generator, an API response, or a CSV export. It transforms data — full stop.

Chart-specific logic belongs in the chart component.

Converting clean domain data into ECharts series, axis labels, and colour assignments is chart-specific. It lives in the component, not the service.

Services are modules, not classes.

Data transformation services are procedural — they take input, return output, maintain no state. A class adds nothing here. We use extend self to make the module directly callable:

1
Stats::GdpByIndustry.call(readings)

This pattern was articulated clearly by Dave Thomas: when you find yourself writing SomeClass.new(params).call, ask whether the class is doing any real object work — managing state across method calls, responding to messages over time. If the answer is no, a module with extend self is more honest about what the code actually is.

Check out Dave’s work at: Stop Abusing Classes and Start writing Ruby (stop using classes). San Francisco Ruby Conference 2025

The DSL wraps what benefits from Ruby. Everything else stays as hashes.

app/lib/chart/ contains reusable machinery that knows nothing about your application data. These objects serialise via #to_h and are testable in isolation.

Plain hashes are welcome.

Chart::Options accepts either DSL objects or plain hashes for every option. Plain hashes pass through unchanged; DSL objects have #to_h called. This keeps call sites clean for simple cases while leaving explicit objects available when needed.


2.2 — File Structure

app/
  lib/
    chart/
      options.rb
      title.rb
      tooltip.rb
      axis.rb
      series/
        base.rb
        line.rb
        bar.rb
        scatter.rb
        pie.rb
  services/
    stats/
      gdp_by_industry.rb
  views/
    components/
      chart.rb            ← updated base class
      charts/
        gdp_by_industry.rb

Autoloading

app/lib is not autoloaded by default. Add it to both autoload and eager load paths:

1
2
3
# config/application.rb
config.autoload_paths  << Rails.root.join("app/lib")
config.eager_load_paths << Rails.root.join("app/lib")

Run rails zeitwerk:check to confirm all constants resolve correctly before proceeding.

app/services is autoloaded automatically — no configuration needed.

Namespace note: Data is a Ruby 3.2+ built-in class. Using Services::Data as a namespace causes constant lookup conflicts with Zeitwerk. Use Stats:: instead.

Constant references inside components

When referencing Chart:: or Stats:: constants from inside Components::Charts::*, use the :: prefix to force top-level constant lookup:

1
2
3
::Chart::Options.new(...)
::Chart::Series::Line.new(...)
::Stats::GdpByIndustry.call(...)

Without ::, Ruby walks up the nesting chain and looks for Components::Charts::Chart::Options first, which does not exist.


2.3 — The DSL Objects

Chart::Title

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
# app/lib/chart/title.rb
module Chart
  class Title
    def initialize(text:, subtext: nil, left: "auto")
      @text    = text
      @subtext = subtext
      @left    = left
    end

    def to_h
      h = { text: @text, left: @left }
      h[:subtext] = @subtext if @subtext
      h
    end
  end
end

Chart::Tooltip

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
# app/lib/chart/tooltip.rb
module Chart
  class Tooltip
    def initialize(trigger: "axis", formatter: nil, **extra)
      @trigger   = trigger
      @formatter = formatter
      @extra     = extra
    end

    def to_h
      h = { trigger: @trigger, **@extra }
      h[:formatter] = @formatter if @formatter
      h
    end
  end
end

The **extra pattern is the escape hatch — any ECharts tooltip option the DSL does not model can be passed through directly.


Chart::Axis

One class handles both X and Y axes.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
# app/lib/chart/axis.rb
module Chart
  class Axis
    def initialize(type:, data: nil, name: nil, **extra)
      @type  = type
      @data  = data
      @name  = name
      @extra = extra
    end

    def to_h
      h = { type: @type, **@extra }
      h[:data] = @data if @data
      h[:name] = @name if @name
      h
    end

    def self.category(data:, **opts) = new(type: "category", data: data, **opts)
    def self.value(**opts)           = new(type: "value", **opts)
    def self.time(**opts)            = new(type: "time", **opts)
    def self.log(**opts)             = new(type: "log", **opts)
  end
end

Chart::Series::Base

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
# app/lib/chart/series/base.rb
module Chart
  module Series
    class Base
      def initialize(name: nil, data: [], **extra)
        @name  = name
        @data  = data
        @extra = extra
      end

      def to_h
        h = { type: series_type, data: @data, **@extra }
        h[:name] = @name if @name
        h
      end

      private

      def series_type
        raise NotImplementedError, "#{self.class} must define series_type"
      end
    end
  end
end

Chart::Series::Line

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
# app/lib/chart/series/line.rb
module Chart
  module Series
    class Line < Base
      def initialize(smooth: false, area: false, **opts)
        super(**opts)
        @smooth = smooth
        @area   = area
      end

      def to_h
        h = super
        h[:smooth]    = true if @smooth
        h[:areaStyle] = {} if @area
        h
      end

      private

      def series_type = "line"
    end
  end
end

Chart::Series::Bar

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
# app/lib/chart/series/bar.rb
module Chart
  module Series
    class Bar < Base
      def initialize(stack: nil, **opts)
        super(**opts)
        @stack = stack
      end

      def to_h
        h = super
        h[:stack] = @stack if @stack
        h
      end

      private

      def series_type = "bar"
    end
  end
end

Chart::Series::Scatter

1
2
3
4
5
6
7
8
9
# app/lib/chart/series/scatter.rb
module Chart
  module Series
    class Scatter < Base
      private
      def series_type = "scatter"
    end
  end
end

Chart::Series::Pie

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
# app/lib/chart/series/pie.rb
module Chart
  module Series
    class Pie < Base
      def initialize(radius: "75%", center: ["50%", "50%"], **opts)
        super(**opts)
        @radius = radius
        @center = center
      end

      def to_h
        super.merge(radius: @radius, center: @center)
      end

      private

      def series_type = "pie"
    end
  end
end

Chart::Options

The top-level builder. Accepts DSL objects or plain hashes interchangeably. Owns serialisation.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
# app/lib/chart/options.rb
module Chart
  class Options
    def initialize(
      title:     nil,
      tooltip:   nil,
      x_axis:    nil,
      y_axis:    nil,
      series:    [],
      legend:    nil,
      grid:      nil,
      color:     nil,
      animation: false,
      **extra
    )
      @title     = title
      @tooltip   = tooltip
      @x_axis    = x_axis
      @y_axis    = y_axis
      @series    = Array(series)
      @legend    = legend
      @grid      = grid
      @color     = color
      @animation = animation
      @extra     = extra
    end

    def to_h
      h = {
        animation: @animation,
        series:    @series.map { |s| resolve(s) }
      }
      h[:color]   = @color           if @color
      h[:title]   = resolve(@title)   if @title
      h[:tooltip] = resolve(@tooltip) if @tooltip
      h[:xAxis]   = resolve(@x_axis)  if @x_axis
      h[:yAxis]   = resolve(@y_axis)  if @y_axis
      h[:legend]  = resolve(@legend)  if @legend
      h[:grid]    = resolve(@grid)    if @grid
      h.merge!(@extra)
      h
    end

    def to_json(*)
      to_h.to_json
    end

    private

    # DSL objects call to_h; plain hashes pass through unchanged.
    def resolve(value)
      value.respond_to?(:to_h) ? value.to_h : value
    end
  end
end

2.4 — The Service Layer

Services live in app/services/stats/. Each is a module with extend self — directly callable, no instantiation. A service knows about your ActiveRecord models and returns clean domain data. It has no knowledge of charts, PDFs, or any consumer.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
# app/services/stats/gdp_by_industry.rb
module Stats
  module GdpByIndustry
    extend self

    # Returns { industry_name => [{ year:, quarter:, value: }, ...] }
    # Consumers decide what to do with this structure.
    def call(readings)
      readings
        .group_by(&:industry)
        .transform_values do |rows|
          rows.map { |r| { year: r.year, quarter: r.quarter, value: r.value_billions.to_f } }
        end
    end
  end
end

The return value is a plain Ruby hash. No ECharts concepts. No chart series. Just data.

Note .to_f on value_billions — ActiveRecord returns BigDecimal from the database. Calling .to_f ensures ECharts receives a plain Ruby float, which serialises to an unquoted JSON number. Without this, values arrive as quoted strings and ECharts renders them incorrectly.


2.5 — The Updated Base Component

Update Components::Chart so subclasses provide their own options via chart_options:

  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
# app/views/components/chart.rb
module Components
  class Chart < Components::Base
    prop :height,   String, default: -> { "400px" }
    prop :html,     Hash,   default: -> { {} }
    prop :controls, Array,  default: -> { [] }
    prop :stacked,  _Any,   default: -> { false }

    def view_template
      div(
        class: "relative p-2 rounded-lg bg-white border border-neutral-200",
        data:  { chart_target: "wrapper" },
        **@html
      ) do
        render_controls if @controls.any?
        div(
          data: {
            controller:                "chart",
            chart_options_value:       chart_options.to_json,
            chart_stackable_value:     @controls.include?(:stack),
            chart_stacked_value:       @stacked,
            chart_downloadable_value:  @controls.include?(:download),
            chart_themeable_value:     @controls.include?(:theme)
          },
          style: "height: #{@height}; width: 100%;",
          "data-chart-mount": true
        )
      end
    end

    private

    # Subclasses override this to return a Chart::Options instance.
    def chart_options
      ::Chart::Options.new
    end

    def render_controls
      div(class: "absolute top-2 right-2 flex items-center gap-1 z-10") do
        stack_button    if @controls.include?(:stack)
        theme_button    if @controls.include?(:theme)
        download_button if @controls.include?(:download)
      end
    end

    def btn_class
      "p-1 rounded text-neutral-500 hover:text-neutral-900 hover:bg-neutral-100 " \
      "focus:outline-none focus:ring-2 focus:ring-neutral-300"
    end

    def stack_button
      button(
        class: btn_class,
        title: "Toggle stacked",
        data: {
          chart_target: "stackBtn",
          action:       "click->chart#toggleStack"
        }
      ) { raw stack_icon }
    end

    def theme_button
      button(
        class: btn_class,
        title: "Toggle theme",
        data: {
          chart_target: "themeBtn",
          action:       "click->chart#toggleTheme"
        }
      ) { raw moon_icon }
    end

    def download_button
      div(class: "relative") do
        button(
          class: btn_class,
          title: "Download chart",
          data: { action: "click->chart#toggleDownloadMenu" }
        ) { raw download_icon }

        div(
          class: "hidden absolute right-0 top-7 bg-white border border-neutral-200 " \
                 "rounded shadow-md text-sm z-20",
          data: { chart_target: "downloadMenu" }
        ) do
          button(
            class: "block w-full px-4 py-2 text-left hover:bg-neutral-50",
            data: { action: "click->chart#download", chart_format_param: "svg" }
          ) { "SVG" }
          button(
            class: "block w-full px-4 py-2 text-left hover:bg-neutral-50",
            data: { action: "click->chart#download", chart_format_param: "png" }
          ) { "PNG" }
        end
      end
    end

    def stack_icon
      %(<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="none" ) +
      %(viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">) +
      %(<rect x="3" y="3" width="8" height="8" rx="1"/>) +
      %(<rect x="13" y="3" width="8" height="8" rx="1"/>) +
      %(<rect x="3" y="13" width="8" height="8" rx="1"/>) +
      %(<rect x="13" y="13" width="8" height="8" rx="1"/></svg>)
    end

    def moon_icon
      %(<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="none" ) +
      %(viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">) +
      %(<path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"/></svg>)
    end

    def download_icon
      %(<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="none" ) +
      %(viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">) +
      %(<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/>) +
      %(<polyline points="7 10 12 15 17 10"/>) +
      %(<line x1="12" y1="15" x2="12" y2="3"/></svg>)
    end
  end
end

Components::Chart no longer has an options prop — subclasses build their own options internally via chart_options.


2.6 — The Chart Component

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
# app/views/components/charts/gdp_by_industry.rb
module Components
  module Charts
    class GdpByIndustry < Components::Chart
      prop :readings, _Any, default: -> { [] }

      private

      def chart_options
        data = ::Stats::GdpByIndustry.call(@readings)

        ::Chart::Options.new(
          tooltip: { trigger: "axis" },
          legend:  { type: "scroll", bottom: 5 },
          x_axis:  { type: "category", data: x_labels(data) },
          y_axis:  { type: "value" },
          grid:    { bottom: 60, left: 8, right: 8, containLabel: true },
          series:  build_series(data)
        )
      end

      def build_series(data)
        data.map do |industry, rows|
          ::Chart::Series::Line.new(
            name: industry,
            data: rows.map { |r| r[:value].round(1) }
          )
        end
      end

      def x_labels(data)
        data.values.first&.map { |r| "#{r[:year]} Q#{r[:quarter]}" } || []
      end
    end
  end
end

Plain hashes are used for tooltip, legend, x_axis, y_axis, and grid — the common case. Chart::Series::Line is used explicitly because we want smooth: and area: options available. This is the coercion pattern in practice — brevity by default, explicit DSL objects when warranted.


2.7 — Controller and View

1
2
3
4
5
6
7
8
9
# app/controllers/charts_controller.rb
class ChartsController < ApplicationController
  def index
  end

  def gdp
    @readings = GdpReading.ordered
  end
end
<%# app/views/charts/gdp.html.erb %>
<h1 class="text-2xl font-bold mb-6">GDP by Industry</h1>

<%= render Components::Charts::GdpByIndustry.new(readings: @readings) %>

The controller knows nothing about chart options. The view knows nothing about data transformation. Each layer does exactly one thing.


2.8 — The Escape Hatch

The DSL is intentionally incomplete. Pass plain hashes for anything it does not model:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
::Chart::Options.new(
  series: [
    ::Chart::Series::Line.new(name: "GDP", data: gdp_data),
    # Raw hash for a type the DSL does not model yet
    { type: "effectScatter", data: highlights, symbolSize: 20 }
  ],
  # Complex features passed through directly
  dataZoom: [
    { type: "inside" },
    { type: "slider", bottom: 40 }
  ],
  visualMap: { min: 0, max: 100, inRange: { color: ["#e0f3f8", "#313695"] } }
)

You never need to wait for the DSL to support an ECharts feature.


2.9 — Testing

Testing the Service

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
# test/services/stats/gdp_by_industry_test.rb
class Stats::GdpByIndustryTest < ActiveSupport::TestCase
  test "groups readings by industry" do
    readings = [
      GdpReading.new(industry: "Mining",      year: 2022, quarter: 1, value_billions: 46.3),
      GdpReading.new(industry: "Mining",      year: 2022, quarter: 2, value_billions: 48.7),
      GdpReading.new(industry: "Agriculture", year: 2022, quarter: 1, value_billions: 11.8)
    ]
    result = Stats::GdpByIndustry.call(readings)

    assert_equal 2, result.keys.length
    assert_includes result.keys, "Mining"
    assert_includes result.keys, "Agriculture"
  end

  test "returns floats not BigDecimal" do
    readings = [GdpReading.new(industry: "Mining", year: 2022, quarter: 1, value_billions: 46.3)]
    result   = Stats::GdpByIndustry.call(readings)

    assert_instance_of Float, result["Mining"].first[:value]
  end
end

Testing the DSL

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
# test/lib/chart/options_test.rb
class Chart::OptionsTest < ActiveSupport::TestCase
  test "line series serialises correctly" do
    series = ::Chart::Series::Line.new(name: "Mining", data: [36.8, 38.1], smooth: true)
    h      = series.to_h

    assert_equal "line",       h[:type]
    assert_equal "Mining",     h[:name]
    assert_equal [36.8, 38.1], h[:data]
    assert_equal true,         h[:smooth]
  end

  test "plain hash series passes through unchanged" do
    raw     = { type: "gauge", data: [{ value: 85 }] }
    options = ::Chart::Options.new(series: [raw])

    assert_includes options.to_h[:series], raw
  end

  test "animation defaults to false" do
    assert_equal false, ::Chart::Options.new(series: []).to_h[:animation]
  end

  test "area series includes areaStyle" do
    series = ::Chart::Series::Line.new(name: "GDP", data: [1, 2], area: true)
    assert_includes series.to_h.keys, :areaStyle
  end
end

Run with:

1
rails test test/services test/lib

Fast, focused, no browser, no JavaScript.


2.10 — Module Summary

The three layers and their locations:

Layer Location Responsibility
DSL app/lib/chart/ Reusable option builders and serialisation
Service app/services/stats/ Transform ActiveRecord data into clean domain structures
Component app/views/components/charts/ Call service, build options, render HTML

The DSL objects:

Object Purpose
Chart::Options Top-level builder, owns serialisation, supports color: palette name
Chart::Title Title and subtitle
Chart::Tooltip Tooltip trigger
Chart::Axis X and Y axes with convenience constructors
Chart::Series::Line Line and area series
Chart::Series::Bar Bar and stacked bar series
Chart::Series::Scatter Scatter series
Chart::Series::Pie Pie and donut series

The rule: Services return data. Components build charts. The DSL builds option hashes. None of these know about each other’s concerns. Formatting is handled separately — see Module 03.


Next: Module 03 — Formatters and the JavaScript Bridge