A hanami slice in a Rails app

I am trying to put together an example of using a slice in a Rails app. This code creates a minimal reproduction:

rails new hanami_sample
cd hanami_sample

echo '
gem "hanami", "~> 2.2"
gem "dry-operation"
' >> Gemfile

bundle



cd tmp
bundle exec hanami new SportsballHanami
cd sportsball_hanami
bundle exec hanami generate slice HanamiPredictor
mv slices ../..

cd ../..


echo '# typed: ignore

require "dry/operation"

module SportsballHanami
  class App < Hanami::App
  end

  class Action
  end
  class Operation < Dry::Operation
  end
  class View
  end
end


Hanami.boot

HanamiPredictor::Slice.boot

p Hanami.app.slices[:hanami_predictor].keys

' > config/initializers/load_hanami.rb


bin/rails runner 'puts "Hello, world!"'

Execution fails with

.local/share/mise/installs/ruby/3.2.0/lib/ruby/gems/3.2.0/gems/hanami-2.2.1/lib/hanami/extensions/operation.rb:29:in `configure_for_slice': uninitialized constant SportsballHanami::Deps (NameError)

          include slice.namespace::Deps["db.rom"]
                                 ^^^^^^

If I do not make Operation inherit from < Dry::Operation, things work. I have been unable to find where Deps is defined and why it is not available here…

Does anyone here quickly see what is amiss?

Why am I doing this? For the last part of the final chapter in Gradual Modularization for… by Stephan Hagemann [PDF/iPad/Kindle]

Thanks!

Ooh, this is exciting! Thanks for sharing this here, @stephan (and welcome to the forum!). I’ll look at this in more detail tomorrow and hopefully get an answer to you.

Hi @stephan!

I don’t think we’ve fully considered all the implications of using a Hanami::Slice like this, but it’s something we want to support since creating standalone slices is a perfect way to incrementally adopt Hanami in an existing Rails app. Thanks for trying this out and reporting your trouble as well.

Using this branch should fix this problem: Only auto-inject db.rom when hanami-db is bundled by timriley · Pull Request #1490 · hanami/hanami · GitHub

FWIW Deps is set here, but this error is on inheriting from Dry::Operation when the class is defined, so it errors out before booting/preparing: hanami/lib/hanami/slice.rb at 2535d26c49a5ecd0c0958a186ca15f0e87da6317 · hanami/hanami · GitHub

Note that once you correct this problem, you’ll run into a Zeitwerk loading error (because both Hanami and Rails are trying to load app/, I believe). Digging into that now and will report back with how to fix that.

Indeed, it looks like you can add a prepare_container block to the App class definition to keep Hanami out of app/ and to prevent Zeitwerk from raising a conflicting_directory error.

  class App < Hanami::App
    prepare_container do |container|
      container.autoloader.ignore("app")
    end
  end

We should document this, or perhaps check to see if any other Zeitwerk loaders have app/ as a root so we can avoid this situation in the future.

Please keep us updated on any other configuration tweaks you have to make, I’d love to document them and streamline using Hanami with other Ruby frameworks.

Hey again @stephan! So here’s a way past your (initially reported) error. In config/initializers/load_hanami.rb:

require "hanami"
require "dry/operation"

module SportsballHanami
  class App < Hanami::App
  end
  class Action
  end
  class View
  end
end

Hanami.boot

# Define base operation class after booting Hanami
module SportsballHanami
  class Operation < Dry::Operation
  end
end

p Hanami.app.slices[:hanami_predictor].keys

The problem here is to do with Hanami’s operation extension, which enhances Dry::Operation to leverage facilities provided by a full Hanami app. Specifically, it’s this line that is the issue. It runs when the class is defined, and it expects the Hanami app/slice to have already been prepared, because it’s looking for slice.namespace::Deps. This is fine in a multi-file app, because you prepare the app/slice before loading other files. But in a single-file Hanami setup like you’re building here, you were defining your operation class before the Hanami was prepared, which is why the operation extension couldn’t find the Deps mixin.

The simple workaround here is to define that class after booting the Hanami app.

There’s a more thorough fix, though, which is to take a different approach in operation extension code, with it looking up container-based dependencies at the time of calling .new instead of needing to locate the container (via the Deps mixin at the time of class definition). You can see an example of this approach in our action extension. I thought we might’ve discovered a simpler approach for the operation extension, but your experiments have made it clear the other way is more flexible.

I hope this explanation is helpful!

The current arrangement of that initializer code is definitely a hack, and I would like to work with you to make it as clean as possible. The first step would be fixing that operation extension.

By the way, I noticed your action and view classes didn’t inherit from Hanami::Action and Hanami::View. Was that intentional, or just an oversight?

Now I did mention initial error up the top of my reply, because after I made that slight fix, something else came up. When running the rails runner command again, I saw this:

❯ bin/rails runner 'puts "Hello, world!"'
["inflector", "logger", "notifications", "rack.monitor"]
/Users/tim/.local/share/mise/installs/ruby/3.4.1/lib/ruby/gems/3.4.0/gems/zeitwerk-2.7.1/lib/zeitwerk/loader.rb:574:in 'block (3 levels) in Zeitwerk::Loader#raise_if_conflicting_directory': loader (Zeitwerk::Error)

#<Zeitwerk::Loader:0x000000011d4d6688
 @autoloaded_dirs=[],
 @autoloads={},
 @collapse_dirs=#<Set: {}>,
 @collapse_glob_patterns=#<Set: {}>,
 @dirs_autoload_monitor=#<Monitor:0x000000011d5d3bd0>,
 @eager_load_exclusions=#<Set: {}>,
 @eager_loaded=false,
 @ignored_glob_patterns=
  #<Set:
   {"/Users/tim/Source/scratch/hanami_rails_sample/lib/assets",
    "/Users/tim/Source/scratch/hanami_rails_sample/lib/tasks"}>,
 @ignored_paths=
  #<Set: {"/Users/tim/Source/scratch/hanami_rails_sample/lib/tasks"}>,
 @inflector=Rails::Autoloaders::Inflector,
 @initialized_at=2025-02-13 14:22:35.124353 +1100,
 @logger=nil,
 @mutex=#<Thread::Mutex:0x000000011d5d3bf8>,
 @namespace_dirs={},
 @on_load_callbacks={},
 @on_setup_callbacks=[],
 @on_unload_callbacks={},
 @reloading_enabled=false,
 @roots={"/Users/tim/Source/scratch/hanami_rails_sample/lib" => Object},
 @setup=false,
 @shadowed_files=#<Set: {}>,
 @tag="rails.main",
 @to_unload={}>


wants to manage directory /Users/tim/Source/scratch/hanami_rails_sample/app/controllers, which is already managed by

#<Zeitwerk::Loader:0x0000000100dbac80
 @autoloaded_dirs=[],
 @autoloads=
  {"/Users/tim/Source/scratch/hanami_rails_sample/app/controllers" =>
    #<Zeitwerk::Cref:0x000000011de32b00
     @cname=:Controllers,
     @mod=SportsballHanami,
     @path="SportsballHanami::Controllers">,
   "/Users/tim/Source/scratch/hanami_rails_sample/app/helpers" =>
    #<Zeitwerk::Cref:0x000000011de32808
     @cname=:Helpers,
     @mod=SportsballHanami,
     @path="SportsballHanami::Helpers">,
   "/Users/tim/Source/scratch/hanami_rails_sample/app/jobs" =>
    #<Zeitwerk::Cref:0x000000011de32308
     @cname=:Jobs,
     @mod=SportsballHanami,
     @path="SportsballHanami::Jobs">,
   "/Users/tim/Source/scratch/hanami_rails_sample/app/mailers" =>
    #<Zeitwerk::Cref:0x000000011de31fc0
     @cname=:Mailers,
     @mod=SportsballHanami,
     @path="SportsballHanami::Mailers">,
   "/Users/tim/Source/scratch/hanami_rails_sample/app/models" =>
    #<Zeitwerk::Cref:0x000000011de31c78
     @cname=:Models,
     @mod=SportsballHanami,
     @path="SportsballHanami::Models">},
 @collapse_dirs=#<Set: {}>,
 @collapse_glob_patterns=#<Set: {}>,
 @dirs_autoload_monitor=#<Monitor:0x000000011dc9bf80>,
 @eager_load_exclusions=#<Set: {}>,
 @eager_loaded=false,
 @ignored_glob_patterns=#<Set: {}>,
 @ignored_paths=#<Set: {}>,
 @inflector=
  #<Dry::System::Plugins::Zeitwerk::CompatInflector:0x000000011de37510
   @config=
    #<Dry::Configurable::Config values={namespace_separator: ".", autoloader: #<Zeitwerk::Loader:0x0000000100dbac80 ...>, component_dirs: #<Dry::System::Config::ComponentDirs:0x000000011ddb0cb8 @dirs={"app" => #<Dry::System::Config::ComponentDir:0x000000011de1adc0 @__config__=#<Dry::Configurable::Config values={namespaces: #<Dry::System::Config::Namespaces namespaces={nil => #<Dry::System::Config::Namespace path=nil key=nil const="sportsball_hanami">}>, auto_register: #<Proc:0x000000011de19600 /Users/tim/.local/share/mise/installs/ruby/3.4.1/lib/ruby/gems/3.4.0/gems/hanami-2.2.1/lib/hanami/app.rb:142 (lambda)>, loader: #<Class:0x000000011ddf71e0>, add_to_load_path: false, instance: nil, memoize: false}>, @path="app">}, @defaults=#<Dry::System::Config::ComponentDir:0x000000011ddb0ba0 @__config__=#<Dry::Configurable::Config values={loader: Dry::System::Loader::Autoloading, add_to_load_path: false, auto_register: true, instance: nil, memoize: false, namespaces: #<Dry::System::Config::Namespaces namespaces={}>}>, @path=nil>>, name: :sportsball_hanami, root: #<Pathname:/Users/tim/Source/scratch/hanami_rails_sample>, provider_registrar: #<Class:0x000000011ddf8ea0>, provider_dirs: ["config/providers"], registrations_dir: "config/registrations", env: :development, inflector: #<Dry::Inflector>, resolver: #<Dry::Core::Container::Resolver:0x000000011dbd1d48>, registry: #<Dry::Core::Container::Registry:0x000000011dbd12f8 @_mutex=#<Thread::Mutex:0x000000011dbd1280>, @factory=#<Dry::Core::Container::Item::Factory:0x000000011ddb37b0>>, exports: nil, auto_registrar: Dry::System::AutoRegistrar, manifest_registrar: Dry::System::ManifestRegistrar, importer: Dry::System::Importer}>>,
 @initialized_at=2025-02-13 14:22:35.212627 +1100,
 @logger=nil,
 @mutex=#<Thread::Mutex:0x000000011dc9bfa8>,
 @namespace_dirs=
  {"SportsballHanami::Controllers" =>
    ["/Users/tim/Source/scratch/hanami_rails_sample/app/controllers"],
   "SportsballHanami::Helpers" =>
    ["/Users/tim/Source/scratch/hanami_rails_sample/app/helpers"],
   "SportsballHanami::Jobs" =>
    ["/Users/tim/Source/scratch/hanami_rails_sample/app/jobs"],
   "SportsballHanami::Mailers" =>
    ["/Users/tim/Source/scratch/hanami_rails_sample/app/mailers"],
   "SportsballHanami::Models" =>
    ["/Users/tim/Source/scratch/hanami_rails_sample/app/models"]},
 @on_load_callbacks={},
 @on_setup_callbacks=[],
 @on_unload_callbacks={},
 @reloading_enabled=false,
 @roots=
  {"/Users/tim/Source/scratch/hanami_rails_sample/app" => SportsballHanami},
 @setup=true,
 @shadowed_files=#<Set: {}>,
 @tag="e67976",
 @to_unload={}>


	from /Users/tim/.local/share/mise/installs/ruby/3.4.1/lib/ruby/gems/3.4.0/gems/zeitwerk-2.7.1/lib/zeitwerk/loader.rb:568:in 'Hash#each_key'
	from /Users/tim/.local/share/mise/installs/ruby/3.4.1/lib/ruby/gems/3.4.0/gems/zeitwerk-2.7.1/lib/zeitwerk/loader.rb:568:in 'block (2 levels) in Zeitwerk::Loader#raise_if_conflicting_directory'
	from /Users/tim/.local/share/mise/installs/ruby/3.4.1/lib/ruby/gems/3.4.0/gems/zeitwerk-2.7.1/lib/zeitwerk/loader.rb:564:in 'Array#each'
	from /Users/tim/.local/share/mise/installs/ruby/3.4.1/lib/ruby/gems/3.4.0/gems/zeitwerk-2.7.1/lib/zeitwerk/loader.rb:564:in 'block in Zeitwerk::Loader#raise_if_conflicting_directory'
	from /Users/tim/.local/share/mise/installs/ruby/3.4.1/lib/ruby/gems/3.4.0/gems/zeitwerk-2.7.1/lib/zeitwerk/loader.rb:561:in 'Thread::Mutex#synchronize'
	from /Users/tim/.local/share/mise/installs/ruby/3.4.1/lib/ruby/gems/3.4.0/gems/zeitwerk-2.7.1/lib/zeitwerk/loader.rb:561:in 'Zeitwerk::Loader#raise_if_conflicting_directory'
	from /Users/tim/.local/share/mise/installs/ruby/3.4.1/lib/ruby/gems/3.4.0/gems/zeitwerk-2.7.1/lib/zeitwerk/loader/config.rb:122:in 'Zeitwerk::Loader::Config#push_dir'
	from /Users/tim/.local/share/mise/installs/ruby/3.4.1/lib/ruby/gems/3.4.0/gems/railties-8.0.1/lib/rails/application/finisher.rb:30:in 'block (2 levels) in <module:Finisher>'
	from /Users/tim/.local/share/mise/installs/ruby/3.4.1/lib/ruby/gems/3.4.0/gems/railties-8.0.1/lib/rails/application/finisher.rb:25:in 'Array#each'
	from /Users/tim/.local/share/mise/installs/ruby/3.4.1/lib/ruby/gems/3.4.0/gems/railties-8.0.1/lib/rails/application/finisher.rb:25:in 'block in <module:Finisher>'
	from /Users/tim/.local/share/mise/installs/ruby/3.4.1/lib/ruby/gems/3.4.0/gems/railties-8.0.1/lib/rails/initializable.rb:32:in 'BasicObject#instance_exec'
	from /Users/tim/.local/share/mise/installs/ruby/3.4.1/lib/ruby/gems/3.4.0/gems/railties-8.0.1/lib/rails/initializable.rb:32:in 'Rails::Initializable::Initializer#run'
	from /Users/tim/.local/share/mise/installs/ruby/3.4.1/lib/ruby/gems/3.4.0/gems/railties-8.0.1/lib/rails/initializable.rb:61:in 'block in Rails::Initializable#run_initializers'
	from /Users/tim/.local/share/mise/installs/ruby/3.4.1/lib/ruby/3.4.0/tsort.rb:231:in 'block in TSort.tsort_each'
	from /Users/tim/.local/share/mise/installs/ruby/3.4.1/lib/ruby/3.4.0/tsort.rb:353:in 'block (2 levels) in TSort.each_strongly_connected_component'
	from /Users/tim/.local/share/mise/installs/ruby/3.4.1/lib/ruby/3.4.0/tsort.rb:434:in 'TSort.each_strongly_connected_component_from'
	from /Users/tim/.local/share/mise/installs/ruby/3.4.1/lib/ruby/3.4.0/tsort.rb:352:in 'block in TSort.each_strongly_connected_component'
	from /Users/tim/.local/share/mise/installs/ruby/3.4.1/lib/ruby/3.4.0/tsort.rb:350:in 'Rails::Initializable::Collection#each'
	from /Users/tim/.local/share/mise/installs/ruby/3.4.1/lib/ruby/3.4.0/tsort.rb:350:in 'Method#call'
	from /Users/tim/.local/share/mise/installs/ruby/3.4.1/lib/ruby/3.4.0/tsort.rb:350:in 'TSort.each_strongly_connected_component'
	from /Users/tim/.local/share/mise/installs/ruby/3.4.1/lib/ruby/3.4.0/tsort.rb:229:in 'TSort.tsort_each'
	from /Users/tim/.local/share/mise/installs/ruby/3.4.1/lib/ruby/3.4.0/tsort.rb:208:in 'TSort#tsort_each'
	from /Users/tim/.local/share/mise/installs/ruby/3.4.1/lib/ruby/gems/3.4.0/gems/railties-8.0.1/lib/rails/initializable.rb:60:in 'Rails::Initializable#run_initializers'
	from /Users/tim/.local/share/mise/installs/ruby/3.4.1/lib/ruby/gems/3.4.0/gems/railties-8.0.1/lib/rails/application.rb:440:in 'Rails::Application#initialize!'
	from /Users/tim/Source/scratch/hanami_rails_sample/config/environment.rb:5:in '<main>'
	from /Users/tim/.local/share/mise/installs/ruby/3.4.1/lib/ruby/3.4.0/bundled_gems.rb:82:in 'Kernel.require'
	from /Users/tim/.local/share/mise/installs/ruby/3.4.1/lib/ruby/3.4.0/bundled_gems.rb:82:in 'block (2 levels) in Kernel#replace_require'
	from /Users/tim/.local/share/mise/installs/ruby/3.4.1/lib/ruby/gems/3.4.0/gems/bootsnap-1.18.4/lib/bootsnap/load_path_cache/core_ext/kernel_require.rb:30:in 'Kernel#require'
	from /Users/tim/.local/share/mise/installs/ruby/3.4.1/lib/ruby/gems/3.4.0/gems/zeitwerk-2.7.1/lib/zeitwerk/core_ext/kernel.rb:34:in 'Kernel#require'
	from /Users/tim/.local/share/mise/installs/ruby/3.4.1/lib/ruby/gems/3.4.0/gems/railties-8.0.1/lib/rails/application.rb:416:in 'Rails::Application#require_environment!'
	from /Users/tim/.local/share/mise/installs/ruby/3.4.1/lib/ruby/gems/3.4.0/gems/railties-8.0.1/lib/rails/command/actions.rb:20:in 'Rails::Command::Actions#boot_application!'
	from /Users/tim/.local/share/mise/installs/ruby/3.4.1/lib/ruby/gems/3.4.0/gems/railties-8.0.1/lib/rails/commands/runner/runner_command.rb:30:in 'Rails::Command::RunnerCommand#perform'
	from /Users/tim/.local/share/mise/installs/ruby/3.4.1/lib/ruby/gems/3.4.0/gems/thor-1.3.2/lib/thor/command.rb:28:in 'Thor::Command#run'
	from /Users/tim/.local/share/mise/installs/ruby/3.4.1/lib/ruby/gems/3.4.0/gems/thor-1.3.2/lib/thor/invocation.rb:127:in 'Thor::Invocation#invoke_command'
	from /Users/tim/.local/share/mise/installs/ruby/3.4.1/lib/ruby/gems/3.4.0/gems/railties-8.0.1/lib/rails/command/base.rb:178:in 'Rails::Command::Base#invoke_command'
	from /Users/tim/.local/share/mise/installs/ruby/3.4.1/lib/ruby/gems/3.4.0/gems/thor-1.3.2/lib/thor.rb:538:in 'Thor.dispatch'
	from /Users/tim/.local/share/mise/installs/ruby/3.4.1/lib/ruby/gems/3.4.0/gems/railties-8.0.1/lib/rails/command/base.rb:73:in 'Rails::Command::Base.perform'
	from /Users/tim/.local/share/mise/installs/ruby/3.4.1/lib/ruby/gems/3.4.0/gems/railties-8.0.1/lib/rails/command.rb:65:in 'block in Rails::Command.invoke'
	from /Users/tim/.local/share/mise/installs/ruby/3.4.1/lib/ruby/gems/3.4.0/gems/railties-8.0.1/lib/rails/command.rb:143:in 'Rails::Command.with_argv'
	from /Users/tim/.local/share/mise/installs/ruby/3.4.1/lib/ruby/gems/3.4.0/gems/railties-8.0.1/lib/rails/command.rb:63:in 'Rails::Command.invoke'
	from /Users/tim/.local/share/mise/installs/ruby/3.4.1/lib/ruby/gems/3.4.0/gems/railties-8.0.1/lib/rails/commands.rb:18:in '<main>'
	from /Users/tim/.local/share/mise/installs/ruby/3.4.1/lib/ruby/3.4.0/bundled_gems.rb:82:in 'Kernel.require'
	from /Users/tim/.local/share/mise/installs/ruby/3.4.1/lib/ruby/3.4.0/bundled_gems.rb:82:in 'block (2 levels) in Kernel#replace_require'
	from /Users/tim/.local/share/mise/installs/ruby/3.4.1/lib/ruby/gems/3.4.0/gems/bootsnap-1.18.4/lib/bootsnap/load_path_cache/core_ext/kernel_require.rb:30:in 'Kernel#require'
	from bin/rails:4:in '<main>'

tl;dr we have conflicting Zeitwerk loaders - both Hanami and Rails have loaders and Hanami’s loader are attempting to manage the same root directory.

And I see that @cllns has just given you a way to fix this, so I might stop typing now and just post this :laughing:

Thanks for digging that PR back up, @cllns. I’ll see if I can resume work on it tomorrow.

Thank you both for all the pointers and of course for the branch! I can confirm that the minimal sample works with that and that I get my larger sample to work also.

I had not yet subclassed action and view because I was leery of adding more dependencies and thus more things to work through and I am not using either in my example.

Here is the next version I have come up with:

rm -rf hanami_sample

rails new hanami_sample
cd hanami_sample

echo '
gem "hanami", github: "hanami/hanami", branch: "fix-error-in-operation-extension-for-no-db-apps"
gem "hanami-controller", "~> 2.2"
gem "hanami-view", "~> 2.2"
gem "dry-operation"
' >> Gemfile

bundle



cd tmp
bundle exec hanami new SportsballHanami
cd sportsball_hanami
bundle exec hanami generate slice HanamiPredictor
mv slices ../..

cd ../..


echo '# typed: ignore

require "dry/monads"
require "dry/operation"
require "hanami/action"
require "hanami/view"

module SportsballHanami
  class App < Hanami::App
    prepare_container do |container|
      container.autoloader.ignore("app")
    end
  end

  class Action < Hanami::Action
    include Dry::Monads[:result]
  end
  class Operation < Dry::Operation
  end
  class View < Hanami::View
  end
end


Hanami.boot

HanamiPredictor::Slice.boot

p Hanami.app.slices[:hanami_predictor].keys

' > config/initializers/load_hanami.rb


bin/rails runner 'puts "Hello, world!"'

This fails with

/Users/stephan/.local/share/mise/installs/ruby/3.2.0/lib/ruby/gems/3.2.0/bundler/gems/hanami-27a002cd91c2/lib/hanami/provider/source.rb:8:in `initialize': missing keyword: :slice (ArgumentError)
	from /Users/stephan/.local/share/mise/installs/ruby/3.2.0/lib/ruby/gems/3.2.0/gems/dry-configurable-1.3.0/lib/dry/configurable/instance_methods.rb:17:in `initialize'
	from /Users/stephan/.local/share/mise/installs/ruby/3.2.0/lib/ruby/gems/3.2.0/gems/dry-system-1.2.2/lib/dry/system/provider.rb:140:in `new'
...

The loading problems go away if I remove the inheritance from < Hanami::View. Once removed, the script passes.

Ah. So while my solution of using that branch works for this use-case, it’s only really because you’re not using hanami-db, not working around the underlying issue of referencing classes before the app is booted.

Tim’s fix is more in-depth, explains the core of the issue, and that approach also fixes this issue with Hanami::View as well.

So, if you remove the github source from the Gemfile, then change your config/initializers/load_hanami.rb file to:

# typed: ignore

require "dry/monads"
require "dry/operation"
require "hanami/action"
require "hanami/view"

module SportsballHanami
  class App < Hanami::App
    prepare_container do |container|
      container.autoloader.ignore("app")
    end
  end

  class Action < Hanami::Action
    include Dry::Monads[:result]
  end
end

Hanami.boot

module SportsballHanami
  class View < Hanami::View
  end

  class Operation < Dry::Operation
  end
end

HanamiPredictor::Slice.boot

p Hanami.app.slices[:hanami_predictor].keys

That works for me. I’d probably throw the Action in the second section too, to keep the base classes together.

Thanks again! I can confirm too that this works. I am now having an issue with a test for the operation and will post a minimal example in a new topic: Trouble testing the result of an operation