ConventionalExtensions allows splitting up class definitions based on convention, similar to ActiveSupport::Concern's use.
The entry point is to call load_extensions right after a class is originally defined:
# lib/post.rb
class Post < SomeSuperclass
load_extensions # Loads every Ruby file found with `lib/post/extensions/*.rb`.
endSince the loading above happens after the Post constant has been defined, we can reopen Post in an extension:
# lib/post/extensions/mailroom.rb
class Post # <- Post is reopened here, so there's no superclass mismatch error
def mailroom
puts "you've got mail"
end
endNow, Post.new.mailroom works and Post.instance_method(:mailroom).source_location points to the extension file and line.
Since we're reopening Post we can also define class methods directly:
# lib/post/extensions/cool.rb
class Post
def self.cool
puts "really cool"
end
endNow, Post.cool works and Post.method(:cool).source_location points to the extension file and line.
Note, any class method macro extensions are now available within the top-level Post definition too:
# lib/post.rb
class Post < SomeSuperclass
load_extensions # Loads the `cool` extension…
cool # …and now we can invoke the class method macro.
endConventionalExtensions also supports implicit class reopening by automatically using Post.class_eval so you can skip class Post, like so:
# lib/post/extensions/mailroom.rb
def mailroom
puts "you've got mail"
endWith this, Post.new.mailroom still works and Post.instance_method(:mailroom).source_location points to the extension file and line.
In case you need to have more fine grained control over the loading, you can call load_extensions or load_extension from within an extension:
# lib/post/extensions/mailroom.rb
load_extension :named
named :sup # We're depending on the `named` class method macro from the `named` extension, and hoisting the loading.
def mailroom
…
end
# lib/post/extensions/named.rb
def self.named(key)
puts key
endWhether extensions use explicit or implicit class reopening, # frozen_string_literal: true is supported.
In case you're setting up a base class, where you're expecting subclasses to use extensions, you can do:
class BaseClass
extend ConventionalExtensions.load_on_inherited # This calls `load_extensions` automatically in the `inherited` hook.
end
class Subclass < BaseClass
# No need to write `load_extensions` here, it's called already.
endThis works for Active Record too, and you can add this to your ApplicationRecord:
# app/models/application_record.rb
class ApplicationRecord < ActiveRecord::Base
extend ConventionalExtensions.load_on_inherited
endNote that you lose support for calling load_extensions manually because of the implementation, so it's all or nothing.
Typically, when writing an app domain model with ActiveSupport::Concern your object graph looks like this:
# app/models/post.rb
class Post < ApplicationRecord
include Cool, Mailroom
end
# app/models/post/cool.rb
module Post::Cool
extend ActiveSupport::Concern
class_methods do
def cool
puts "really cool"
end
end
end
# app/models/post/mailroom.rb
module Post::Mailroom
extend ActiveSupport::Concern
included do
belongs_to :creator, class_name: "User"
end
def mailroom
puts "you've got mail"
end
endBoth Post::Cool and Post::Mailroom are immediately loaded (via Zeitwerk's file naming conventions) & included. Most often these concern modules are never referred to again, so they're practically implicit modules, yet defined with tricky DSL.
With ConventionalExtensions you'd write this instead:
# app/models/post.rb
class Post < ApplicationRecord
load_extensions # Loads every Ruby file found with `app/models/post/extensions/*.rb`.
end
# app/models/post/extensions/cool.rb
class Post
def self.cool
puts "really cool"
end
end
# app/models/post/extensions/mailroom.rb
class Post
belongs_to :creator, class_name: "User"
def mailroom
puts "you've got mail"
end
endThere are places where concerns are more suited:
- Multi-model concerns in
app/models/concerns, you'd need modules to help with that. - Needing to include multiple levels of modules and have them all inserted directly on the base class, concerns have this built in, but ConventionalExtensions can't support that. It's a rare use case nonetheless.
If you're using Zeitwerk to eager load your app or lib code, you may need to ignore the nested extensions folders so Zeitwerk won't expect them to contain code following its naming conventions.
We handle this for Rails through Rails.autoloaders.main.ignore "**/extensions/**.rb". Feel free to use this ignore call on your Zeitwerk loader.
Install the gem and add to the application's Gemfile by executing:
$ bundle add conventional_extensions
If bundler is not being used to manage dependencies, install the gem by executing:
$ gem install conventional_extensions
After checking out the repo, run bin/setup to install dependencies. Then, run rake test to run the tests. You can also run bin/console for an interactive prompt that will allow you to experiment.
To install this gem onto your local machine, run bundle exec rake install. To release a new version, update the version number in version.rb, and then run bundle exec rake release, which will create a git tag for the version, push git commits and the created tag, and push the .gem file to rubygems.org.
Bug reports and pull requests are welcome on GitHub at https://github.com/kaspth/conventional_extensions.
The gem is available as open source under the terms of the MIT License.