Hanami 2.0

Hanami 2.0 was released in November 2022.
It was the perfect timing for me, since at the time, I was planning to build a small side project.
I’ve never tried Hanami 1.x but read a lot about it, so when 2.0 was released I decided to give it a try.
It was such an amazing journey that I decided to share my opinion about the brand-new framework.

About the project

To provide some context let’s talk a bit about my project itself.

We use tons of open source gems everyday. Thanks to the hard work of their authors, we can build our apps faster.
I believe that we owe them some help. One of the ways to repay our debt, is to contribute to their software.
That’s where I had the idea for ShinyGems – a place, where all issues with “help wanted” tag, for the most popular gems, are gathered together.
The app was pretty straightforward to build and consists of two parts:

  • background processing – periodically executed workers to pull data from RubyGems and GitHub
  • web – pretty simple UI to display data we already have

The app is already live, you can see it here: shinygems.dev.


Source code is available on GitHub: kukicola/shiny_gems

First impressions

Hanami handles a lot of things differently than Rails. The first thing that catches your eye is widely used dependency container.
In my opinion, dependency injection is an underrated pattern in the Ruby world
(if you would like to learn more about DI, check out my article).
Thanks to Hanami’s solution, we have clear, loosely coupled dependencies.

Another thing, that may be surprising at first, is that you won’t see any controllers.
In Hanami, each action is a separate class with a handle method, which takes request and response arguments.
Out of the box we have parameter validation with type coercion.
At first, I was afraid that it’ll lead me to a huge number of almost-empty classes but after creating few endpoints I started to notice the value of this approach.
Each action is extremely clean – we have dependencies defined at the top, params validation in the middle and finally the handle method fully focused on endpoints logic.
This approach solves one of the Rails problems – huge controllers with multiple actions and private methods, often used by only some of the endpoints.

Example:

module Web
  module Actions
    module Gems
      class Index < Web::Action
        include Deps["repositories.gems_repository"]

        before :validate_params!

        DEFAULT_PARAMS = {
          page: 1,
          sort_by: "downloads"
        }.freeze

        SORTING_DIRECTIONS = ["name", "stars", "downloads", "issues_count", "recent_issues"].freeze

        params do
          optional(:page).filled(:integer)
          optional(:sort_by).filled(:string, included_in?: SORTING_DIRECTIONS)
        end

        def handle(request, response)
          params = DEFAULT_PARAMS.merge(request.params.to_h)

          result = gems_repository.index(page: params[:page], order: params[:sort_by])
          response[:gems] = result.to_a
          response[:pager] = result.pager
          response[:sort_by] = params[:sort_by]
        end
      end
    end
  end
end

Views are not included in 2.0 (will be included in 2.1) but I decided to use hanami/view directly from the main branch.
Since some concepts may change before the release I won’t dive deep into it.
Let me just mention my two favorite things:

  • View is a class, not just a template. There is a place for view-related logic.
  • Automatic object decoration (using classes called parts)

I honestly can’t wait for the release.

ROM

Persistent layer isn’t included in 2.0 as well but you can find instructions on how to integrate rom-rb
with hanami in getting started guide (it’ll be built-in in 2.1).
As you can expect, it’s also different than Active Record.

The problem with models in Rails is that they tend to quickly become “master” objects containing huge amount of responsibilities.
Especially, they are responsible for both persistence and business logic.
ROM separates those layers. You’ll use repositories (on top of relations, mappers and commands) to communicate with the DB.
Instead of models, you’ll have entities – pure data in form of hash, struct or any custom object.

How many times have you seen long chains of Active Record methods scattered across controllers, services, etc.?
With repositories, you can extract it and fully focus on your business logic by working on plain data.
What is more, if we combine it with dependency injection, it’s easy to test things like services or actions without touching the database.

Sample repository:

module Processing
  module Repositories
    class GemsRepository < ROM::Repository[:gems]
      include Deps[container: "persistence.rom"]

      commands :create, update: :by_pk, delete: :by_pk
      auto_struct true

      def by_id(id, with: nil)
        query = gems.by_pk(id)
        query = query.combine(with) if with
        query.one
      end

      def pluck_ids_for_hour(hour)
        gems.where(Sequel.lit("id % 24 = ?", hour)).pluck(:id)
      end

      def pluck_name_by_list(items)
        gems.where(name: items).pluck(:name)
      end

      def replace_repo(old_id, new_id)
        gems.where(repo_id: old_id).update(repo_id: new_id)
      end
    end
  end
end

ROM is a really huge topic, so if you’d like to learn more, take a look at official website.

I have to admit I had a lot of problems with rom-factory (it’s like factory_bot for ROM) but I hope it’ll get better in the future.

Slices

It’s time for my favorite part – Slices. They allow you to split your application into smaller parts/modules.
For example, you can split it by features and keep the directory structure based on them, not class type
(feature/[actions/services/etc]/some_class.rb instead of [actions/services/etc]/feature/some_class.rb).
The closest Rails solution, that I could compare it to, would be engines.

Each slice has a separate dependency container, you can define which classes should be exposed outside the slice.
When used properly they can help you keep your code organized and isolated.

What is more, you can define which slices should be loaded using an environment variable.
In ShinyGems, I have two slices – “web” and “processing”.
“Processing” is loaded in sidekiq process. It contains all the workers and gems required to pull data from APIs.
“Web”, as the name suggests, is loaded in web process. It contains all my actions and views.
I don’t need workers or gems like octokit here so, thanks to slices, they are not loaded, keeping the memory usage low.

Directory tree of ShinyGems slices:

slices
├── processing
│   ├── config
│   ├── repositories
│   ├── services
│   └── workers
└── web
    ├── actions
    ├── assets
    ├── lib
    ├── repositories
    ├── services
    ├── templates
    └── views

Compared to Rails

I hope you don’t expect me to answer the question if Hanami is better than Rails.
As always – it depends. It’s different.
You won’t be able to build apps in Hanami as fast as in Rails.
Although, Hanami encourages you to write cleaner and easier-to-test code,
which will be painless to maintain.

A breath of fresh air

In Hanami guides, you can see that is built for maintainable, secure, faster and testable Ruby applications.
Looks like the authors did their job perfectly:

  • maintainable – slices, no fat models or controllers – check!
  • secure – CSRF protection, CSP, parameters validations etc. out of the box – check!
  • faster – it’s blazingly fast (for example, ShinyGems homepage response takes 10-15ms) – check!
  • testable – separation between DB and application layers, DI – check!

I really enjoyed building ShinyGems with Hanami. Even with some hiccups, I felt like I’m learning something new every day.
I would recommend you to try it even if you’re strongly attached to Rails.
It’s always worth to see different approaches, gain fresh perspective.

If you’ll like to take a look at the real-world Hanami app I remind you that
ShinyGems is open source: kukicola/shiny_gems.

Read More