I am building a CTF challenge tracker in Hanami 3 with my Rails habits left switched on, to find out where they stop working. The first thing I did was reach for the model generator.

$ hanami generate --help

Commands:
  hanami generate action NAME
  hanami generate component NAME
  hanami generate mailer NAME
  hanami generate migration NAME
  hanami generate operation NAME
  hanami generate part NAME
  hanami generate provider NAME
  hanami generate relation NAME
  hanami generate repo NAME
  hanami generate slice NAME
  hanami generate struct NAME
  hanami generate view NAME

No model. No scaffold. My honest first reaction was to look for a plugin, which was the wrong reaction. The list is not missing anything. It is telling me that what I call a model is four jobs sharing one name, and I have to pick.

The relation is the table

module CtfTracker
  module Relations
    class Challenges < CtfTracker::DB::Relation
      schema :challenges, infer: true
    end
  end
end

That is the whole file. infer: true reads the columns from the database, so the relation knows the shape of challenges without me repeating it. This is the queryable object: it speaks SQL, it composes, and it is the thing that knows how to fetch rows.

What it is not is the object I pass around my application. I only touch it from inside a repo.

The repo is the list of questions I actually ask

This is where I felt the difference most. The generated repo is an empty class body, and the base class hands out no CRUD at all. My first spec failed on undefined method 'create', which surprised me more than it should have.

So I wrote the four things this app needs:

def all
  challenges.to_a
end

def find(id)
  challenges.by_pk(id).one
end

def create(attributes)
  challenges.changeset(:create, attributes).map(:add_timestamps).commit
end

def mark_solved(id, solved_at)
  challenges
    .by_pk(id)
    .changeset(:update, solved: true, solved_at: solved_at)
    .map(:touch)
    .commit
end

I want to be straight about the trade rather than sell it. Writing all by hand for the tenth table is going to feel like ceremony, and on a small app the Rails version is plainly less typing.

What I get is that this file is the complete inventory of what the application asks of the challenges table. Nobody can reach past it. In Rails, a model’s query surface is the entire relational algebra, permanently available from anywhere, which is how you end up finding a three-table join inside a view partial at eleven at night. I have done that archaeology. Here the surface is four methods, and adding a fifth is a decision somebody makes on purpose.

The struct is what comes back, and it has no file

This is the one that caught me out, pleasantly. app/structs/ contains nothing but a .keep. I never generated a struct. And yet:

class:      CtfTracker::Structs::Challenge
a ROM::Struct? true
attributes: id, name, category, difficulty, points, solved, created_at, updated_at, solved_at
has a name setter? false

ROM builds the class at runtime from the relation’s schema. You only write a struct file when you want to add behaviour to it.

Look at the last line, because that is the part that rearranges your habits. The object has no setters. challenge.name = "x" does not exist, so challenge.save cannot exist either. The single most familiar gesture in Rails, mutate then persist, is not available on the thing you are holding.

Changes go through the repo, as a changeset, describing what should become true. The row is data, the change is a separate act, and the object I hand to a view cannot secretly write to the database.

The operation is where the decisions live

Solving a challenge has two rules that are neither SQL nor HTTP: you cannot solve one that does not exist, and you cannot solve one twice.

def call(id)
  challenge = step find(id)
  step ensure_unsolved(challenge)

  challenge_repo.mark_solved(challenge.id, Time.now)
end

Each step returns a success or a failure, successes are unwrapped, and the first failure short-circuits the rest. That behaviour comes from dry-operation, which hanami new already put in the Gemfile. The two rules here are two small private methods returning Failure(:not_found) and Failure(:already_solved).

The operation, the two repo methods behind it and the action that calls it are all in the app tagged 02-four-objects.

The filesystem detail here is worth more than it looks. The generator put this in app/challenges/solve.rb, not app/operations/challenges/solve.rb. Relations, repos, structs, actions and views all live in directories named after their technical layer. Business logic lives in a directory named after what it is about. In Rails this would be a service object, under whatever convention that particular codebase settled on. Here it is where the framework puts it.

What the four objects buy

Here is the action that finishes the job:

def handle(request, response)
  case solve.call(request.params[:id])
  in Success(_challenge)
    response.redirect_to "/challenges"
  in Failure(:already_solved)
    response.status = 409
  in Failure(:not_found)
    response.status = 404
  end
end

There is no business logic in there. Not because I was disciplined, but because there was nowhere to put it: the action has no model to call methods on, and the struct it would receive has no methods that change anything.

That is the pattern I keep meeting in this framework. Rails gives you one generous object and trusts you to keep the layers apart. Hanami hands you four narrow ones and removes the option. Whether that is worth the extra files depends on how many times you have watched the generous version go wrong.

This app has one table and four routes, so I am asking the question from the easiest possible position. Ask me again when it has thirty tables.

Comments, questions, or just a reaction?

Send an email to ~bounga/bounga.org-discuss@lists.sr.ht. It is public and archived, so other readers can follow along and answer too.