Elixir from the ground up

Putting it together: a sales report

A capstone that uses most of the course: records as maps, a module of named work, a group by town, a fold for each group, money formatted to two places, and a report printed in a chosen order.

Part 12 of 12

Advanced22 min readBack to Courses

The shape of the program

The data is a list of orders, and each order is a map: an item, a town, a quantity and a price. The work is a module of small named functions — one that counts units, one that values a group, one that turns a number into money, one that decides how a row reads. The list is the data and the module is what the program does with it, which is the ordinary division of labour in Elixir. The module has no state and the data has no behaviour; the functions take a list and answer a value, which makes each one testable on its own.

Notice how little the functions do. units adds up quantities and value adds up quantities multiplied by prices, and neither of them knows anything about towns or rows. Small functions with one job each are what make the grouping stage at the bottom short enough to read at a glance.

Grouping, folding, and money

Enum.group_by(orders, fn o -> o.town end) returns a map from each town to the list of orders from that town. That map is then sorted by town, because a map has no order worth relying on, and each pair is turned into a line: the town, the units counted, and the value folded to a single number with Enum.reduce. The sort happens before the row is built, so the report order is decided by the town name rather than by whatever order the map chose to hand its pairs over.

The last piece is money. Elixir has no formatting for currency, so the total comes out of a small helper: :erlang.float_to_binary(value, decimals: 2) gives the text 249.00 rather than 249.0. It lives on the Erlang side of the runtime, which is always present — one of the quiet gifts of a language that runs on another language. The helper is the only place that knows the format, so changing the currency or the number of places is a change in one function rather than in every row.

Where a real project goes next

The orders would come from somewhere — a file, a database, a queue — and each of those is one more module that turns something outside into this list of maps. The report would be tested, which for a functional program means calling the functions with known lists and checking the answers. Neither step asks for a new idea: another module, another function, the same patterns. The input module would turn a file or a query into the same list of maps, and the report would not need to know where the rows came from.

The habit worth keeping is the one at the top of this chapter. Name each step, keep it small, and let the shape of the data decide how the code is written. A report is a fold and a group and a sort, and once you can see them, so is almost everything else.

The whole capstone, one page

defmodule Shop do
  def money(v), do: :erlang.float_to_binary(v, decimals: 2)

  def units(list), do: Enum.reduce(list, 0, &qty/2)
  def value(list), do: Enum.reduce(list, 0.0, &cost/2)

  def row(town, list) do
    total = money(value(list))
    Enum.join([town, to_string(units(list)), total], "  ")
  end

  def report(orders) do
    orders
    |> Enum.group_by(fn o -> o.town end)
    |> Enum.sort_by(fn {town, _} -> town end)
    |> Enum.map(fn {town, list} -> row(town, list) end)
  end

  defp qty(order, total), do: total + order.qty
  defp cost(order, total), do: total + order.qty * order.price
end

orders = [
  %{item: "desk", town: "Leeds", qty: 1, price: 249.0},
  %{item: "map", town: "Paris", qty: 3, price: 19.0},
  %{item: "lamp", town: "Paris", qty: 2, price: 24.5}
]

Enum.each(Shop.report(orders), fn line -> IO.puts(line) end)
IO.puts("orders #{Enum.count(orders)}")
IO.puts("total  #{Shop.money(Shop.value(orders))}")

Three orders, two towns, one page

Run it and check both rows against the orders by hand. Then add a fourth order in a third town, print the average order value, and add a row that names the best-selling item.

Output
Click "Run" to execute your code

A capstone is a reunion, not an exam: maps, modules, group_by, reduce, pattern matching in a pipe and a default-free function head are all things earlier chapters taught. If a line looks unfamiliar, the chapter it came from is one page back.

A report is a pipeline of questions

Each stage answers one question and hands a smaller, more useful value to the next. Grouping answers "which orders belong together"; the fold answers "what is this group worth"; the sort answers "which group first". Writing the stages separately keeps each one testable with a small list you can check by hand.

Money is the stage that needs care. A float is close to the value you typed, not exact, so 0.1 + 0.2 is not 0.3 and a total can end in a long tail of digits. Formatting at the edge — :erlang.float_to_binary(value, decimals: 2) — hides the tail from the reader without pretending the arithmetic was exact.

Integer division with / gives a float, so a quantity that should stay whole needs div. A map printed without sorting has no reliable order, and two runs of a report can look different for no reason. And formatting each price before adding them loses cents; add the numbers first and format the total once.

Try these next

  • Add a fourth order and check the town total by hand.
  • Sort the report by value instead of by town and compare the two readings.
  • Add an average-per-order line, using div or a rounded float.

A second report, by item

Run it and check each row against the orders. Then add a fifth order for an existing item, change the sort so the cheapest row comes first, and print the average value of an order.

Output
Click "Run" to execute your code

Check what you learned

One question at a time. A wrong answer explains itself — take another go.

Part 1 of 5

What does Enum.group_by(orders, fn o -> o.town end) give back?