Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

Autoload paths and nested services classes crash in Ruby

I've multiple issues to load / require classes under my app/services folder in a Rails 5 project and I'm starting to give up on this issue.

First of all and to be clear, services/ are simple PORO classes I use throughout my project to abstract most of the business logic from the controllers, models, etc.

The tree looks like this

app/
 services/
  my_service/
    base.rb
    funny_name.rb
  my_service.rb  
models/
 funny_name.rb

Failure #1

First, when I tried to use MyService.const_get('FunnyName') it got FunnyName from my models directory. It does not seem to have the same behavior when I do MyService::FunnyName directly though, in most of my tests and changes this was working fine, it's odd.

I realised Rails config.autoload_paths does not load things recursively ; it would makes sense that the first FunnyName to be catch is the models/funny_name.rb because it's definitely loaded but not the other.

That's ok, let's find a workaround. I added this to my application.rb :

config.autoload_paths += Dir[Rails.root.join('app', 'services', '**/')]

Which will add all the subdirectories of services into config.autoload_paths. Apparently it's not recommended to write things like that since Rails 5 ; but the idea does look right to me.

Failure #2

Now, when I start my application it crashes and output something like this

Unable to autoload constant Base, expected /.../backend/app/services/my_service/base.rb to define it (LoadError)

Names were changed but it's the matching path from the tree I wrote previously

The thing is, base.rb is defined in the exact file the error leads me, which contains something like

class MyService
  class Base
  end
end

Poor solution

So I try other workaround, lots of them, nothing ever works. So I end up totally removing the autoload_paths and add this directly in the application.rb

Dir[Rails.root.join('app', 'services', '**', '*.rb')].each { |file| require file }

Now the base.rb is correctly loaded, the MyService.const_get('FunnyName') will actually return the correct class and everything works, but it's a disgusting workaround. Also, it has yet not been tested in production but it might create problems depending the environment.

Requiring the whole tree from the application.rb sounds like a bad idea and I don't think it can be kept this way.

What's the cleanest way to add custom services/ directory in Rails ? It contains multiple subdirectories and classes with simple names which are also present in other parts of the app (models, base.rb, etc.)

How do you avoid confusing the autoload_paths ? Is there something else I don't know which could do the trick ? Why did base.rb even crash here ?

like image 905
Laurent Avatar asked Jul 28 '18 00:07

Laurent


2 Answers

Working solution

After deeper investigation and attempts, I realised that I had to eager_load the services to avoid getting wrong constants when calling meta functionalities such as const_get('MyClassWithModelName').

But here's is the thing : the classic eager_load_paths won't work because for some reason those classes will apparently be loaded before the entire core of Rails is initialized, and simple class names such as Base will actually be mixed up with the core, therefore make everything crash.

Some could say "then rename Base into something else" but should I change a class name wrapped into a namespace because Rails tell me to ? I don't think so. Class names should be kept simple, and what I do inside a custom namespace is no concern of Rails.

I had to think it through and write down my own hook of Rails configuration. We load the core and all its functionalities and then service/ recursively.

On a side note, it won't add any weight to the production environment, and it's very convenient for development.

Code to add

Place this in config/environment/development.rb and all other environment you want to eager load without Rails class conflicts (such as test.rb in my case)

# we eager load all services and subdirectories after Rails itself has been initializer
# why not use `eager_load_paths` or `autoload_paths` ? it makes conflict with the Rails core classes
# here we do eager them the same way but afterwards so it never crashes or has conflicts.
# see `initializers/after_eager_load_paths.rb` for more details
config.after_eager_load_paths = Dir[Rails.root.join('app', 'services', '**/')]

Then create a new file initializers/after_eager_load_paths.rb containing this

# this is a customized eager load system
# after Rails has been initialized and if the `after_eager_load_paths` contains something
# we will go through the directories recursively and eager load all ruby files
# this is to avoid constant mismatch on startup with `autoload_paths` or `eager_load_paths`
# it also prevent any autoload failure dû to deep recursive folders with subclasses
# which have similar name to top level constants.
Rails.application.configure do
  if config.respond_to?(:after_eager_load_paths) && config.after_eager_load_paths.instance_of?(Array)
    config.after_initialize do
      config.after_eager_load_paths.each do |path|
        Dir["#{path}/*.rb"].each { |file| require file }
      end
    end
  end
end

Works like a charm. You can also change require by load if you need it.

like image 100
Laurent Avatar answered Oct 27 '22 12:10

Laurent


When I do this (which is in all of my projects), it looks something like this:

app
 |- services
 |   |- sub_service
 |   |   |- service_base.rb
 |   |   |- useful_service.rb     
 |   |- service_base.rb

I put all common method definitions in app/services/service_base.rb:

app/services/service_base.rb

class ServiceBase

  attr_accessor *%w(
    args
  ).freeze

  class < self 

    def call(args={})
      new(args).call
    end

  end

    def initialize(args)
      @args = args
    end

end

I put any methods common to the sub_services in app/services/sub_service/service_base.rb:

app/services/sub_service/service_base.rb

class SubService::ServiceBase < ServiceBase

    def call

    end

  private

    def a_subservice_method
    end

end

And then any unique methods in useful_service:

app/services/sub_service/useful_service.rb

class SubService::UsefulService < SubService::ServiceBase

    def call
      a_subservice_method
      a_useful_service_method
    end

  private

    def a_useful_service_method
    end

end

Then, I can do something like:

SubService::UsefulService.call(some: :args)
like image 21
jvillian Avatar answered Oct 27 '22 11:10

jvillian