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.