ruby · 5 min read
Functional programming in Ruby: keep the export, lose the mutation
Build a tested order-to-CSV pipeline with map, select and sum. Check input ownership, quoting, invalid rows and the limits of lazy evaluation.
An order export needs a list of names, quantities and totals. The interesting failure is not the arithmetic. It is discovering that formatting the export also changed the strings another part of the application was still using.
A small functional core gives that boundary a name: accept values, return new values, and leave the caller's objects alone. Ruby supports this style without replacing classes or building an abstraction around every block. This experiment turns a handful of order rows into CSV, then tests the assumptions that make the pipeline safe to reuse.
Start with a contract, not a chain
Each input row is a Hash with three symbol keys: name, quantity and unit_cents. Names must be nonblank strings. Quantity and price must be nonnegative integers. The example rejects the string "2" as a quantity instead of quietly converting it. Parsing an HTTP form or a CSV import belongs at a different boundary, where malformed text can receive a useful error message.
Money stays in integer cents. This avoids introducing floating-point rounding into this particular multiplication; it does not solve taxes, exchange rates or currencies with different minor-unit rules. Those would require additional policy and tests.
The normalizer returns a Data record with a stripped, frozen name. Stripping creates a new string, so freezing that result does not freeze the caller's string. Quantity and cents are integers. That limited object graph is easy to reason about. A generic recursive freezer would have to answer harder questions about cycles and shared references.
Each collection operation has one job
The transformation and calculation are separate:
def lines(rows) rows.map { |raw| normalize(raw) }.select { |line| line.quantity.positive? }.freezeend
def total_cents(lines) lines.sum { |line| line.quantity * line.unit_cents }endThe pipeline normalizes every row before discarding zero-quantity lines. That order is intentional. An invalid name on a zero-quantity row still indicates bad input; filtering first would conceal it. If the product needs to ignore those rows entirely, reverse that decision explicitly and update the tests.
Map produces the records. Select applies the export policy. Sum calculates a scalar. These are different jobs even though they fit comfortably on a few lines. Ruby's Enumerable reference describes their contracts; none of them guarantees that the objects inside a returned collection are independent copies.
The export array is frozen to stop accidental append or removal. The records and names are also frozen. That makes this specific result stable. It would be inaccurate to generalize that property to any array produced by map.
Serialize the fields, not your assumptions
A field can contain a comma, a quote or a newline. Joining fields with commas cannot distinguish those characters from the structure of the file:
def to_csv(lines) CSV.generate do |csv| csv << %w[name quantity total_cents] lines.each { |line| csv << [line.name, line.quantity, line.quantity * line.unit_cents] } endendThe test suite parses the generated CSV back and compares the exact fields, including a name containing all three troublesome characters. This checks the format rather than a hand-written guess at quoting. The CSV library owns that serialization rule.
The method returns a string. Writing it to disk, choosing a download filename and setting an HTTP content type remain outside the transformation. This separation gives tests a simple observable result and gives the application control over side effects.
CSV syntax correctness is not spreadsheet security. If a later workflow opens untrusted cells in a spreadsheet, formula interpretation needs its own policy. This lab verifies quoting and round trips, not that separate behavior.
Make the ownership test hostile
One test passes a frozen input hash and frozen name. Another changes the original mutable name after normalization. The output must remain unchanged in both cases. Those tests would catch an implementation that trims with strip! or retains the caller's string and merely freezes the record.
The suite also contains a deliberate counterexample: freeze an outer array, then append to an inner array. The inner mutation succeeds. Data has the same important boundary: immutable member assignment does not make every referenced object immutable.
This is why the example's narrow value contract matters more than the presence of a freeze call. Ownership should be established at construction, not inferred from the name of a container.
Lazy traversal changes what gets inspected
The downloadable example also offers first_positive. It uses a lazy map/select/take chain over an Enumerator. An instrumented source records which quantities were visited. Asking for one positive row visits zero and two, then stops; the later rows remain untouched.
That result demonstrates bounded consumption, not a universal memory improvement. Returning CSV as one string still allocates the complete output. Starting with File.readlines would already have loaded a whole file before lazy traversal began. Use a streaming source when the requirement is streaming input.
It also changes validation scope: an invalid row after the requested prefix will not be seen. Choose eager validation when the entire batch must be accepted or rejected.
Run the experiment
Download the complete Ruby example and tests. The recorded environment is Ruby 3.3.2, CSV 3.3.5 and Minitest 6.0.6; the script activates those gem versions. Install either exact gem version if your Ruby does not already have it.
ruby example.rb --seed 42The local run passed 8 tests and 27 assertions. It covers empty totals, invalid rows, input ownership, CSV round trips and lazy consumption. It does not read a database or measure throughput.
The useful habit is to ask two questions of each stage: which values does it return, and which objects may it change? Once those answers are explicit, map and select become readable expressions of the work. For the next complication—stopping a multi-stage calculation after a validation error—see the Result pipeline experiment.
Found a mistake or tried a different approach?
Send Alex a note ↗