Rails application namespace
Imagine this — you're working on an application that imports structured text documents. It might be a blog engine that fetches author's previous posts from Medium. Let's assume these are the markdown files. You've exported them from the former blogging software and they're present on the local filesystem.
In order to make them available in the application, you may start with a simple code like below. The FileImport
walks over directory of markdown files. Reads each of them, processing with Parser
to recover their structure with associated metadata. Finally each of them becomes a persisted Entry
in the application.
class FileImport
def initialize
@parser = Parser.new
end
def call(path)
Dir.glob("#{path}/*.md") do |path|
parsed_result = @parser.call(File.read(path))
Entry.create(
name: File.basename(path, ".md"),
content: parsed_result.content,
title: parsed_result.metadata.fetch(:title),
)
end
end
end
So far so good. You even have a working unit test in case of future regressions. You've also heard a bit about mutation testing with mutant
and decide to finally try this thing. It can eliminate lots of dead and redundant code that Copilot is generating.
ENV["RAILS_ENV"] ||= "test"
require_relative "../config/environment"
require "rails/test_help"
require "mutant/minitest/coverage"
class FileImportTest < ActiveSupport::TestCase
cover FileImport
def file_import = FileImport.new
def entry = sample_imported_entry
def test_happy_path
file_import.call(File.join(__dir__, "fixtures/posts"))
assert_equal 1, Entry.count
assert_equal entry, Entry.last
end
end
You set things up, run the tests and instead a moment of joy it fails unexpectedly. The error message says:
Parser is not a module (TypeError)
What the heck?
What happened here was the name conflict among two different constants available in the application. On one hand we've defined our very own markdown Parser
, as a class in the application. On the other hand, we've added a gem dependency to our application — mutant
, which transitively depends on a parser
gem. This gem defines Parser
as a module. A const cannot be both a class and a module, thus the error we've experienced.
Once you figure out it is about name conflict, a quick and pragmatic solution is to rename our Parser
. Perhaps MarkdownParser
to be more specific. There, problem solved.
At least it should work until we add, directly or indirectly, some kind of markdown_parser
gem dependency... 😉
Can we do better?
Custom application namespace
Another approach to such name conflicts is having a specific application namespace. Then nest application code in that namespace, hoping that none of the dependencies will use that single const name — here it is KakaDudu
.
module KakaDudu
class Parser
def call
# ...
end
end
end
In fact, when we generate a new Rails app, it already gives us a top-level namespace. Look at your config/application.rb
to check it out.
require_relative "boot"
require "rails/all"
# Require the gems listed in Gemfile, including any gems
# you've limited to :test, :development, or :production.
Bundler.require(*Rails.groups)
module KakaDudu
class Application < Rails::Application
# Initialize configuration defaults for originally generated Rails version.
config.load_defaults 7.1
# Configuration for the application, engines, and railties goes here.
#
# These settings can be overridden in specific environments using the files
# in config/environments, which are processed later.
#
# config.time_zone = "Central Time (US & Canada)"
# config.eager_load_paths << Rails.root.join("extras")
end
end
That generated namespace is not immediately useful though. Adding the namespace to a Parser
, living in app/lib/parser.rb
would mean moving it to app/lib/kaka_dudu/parser.rb
. This the expectation of the zeitwerk
code loader, which modern Rails versions use. We'd have to follow that pattern for every existing entity — FileImport
, Entry
. And the future ones. And for controllers, helpers... the list goes on.
Could zeitwerk
help us here?
Root namespace for directory
Luckily the gift of Xavier keeps [giving]((https://github.com/fxn/zeitwerk?tab=readme-ov-file#root-directories-and-root-namespaces). We can associate code loading root with a desired namespace. This instructs the loader what namespace is expected from a path which doesn't include one. The KakaDudu::Parser
can live in app/lib/parser.rb
. So can the others without nesting them under kaka_dudu/
in a filesystem tree.
module KakaDudu
class Application < Rails::Application
config.load_defaults 7.1
Dir["#{root}/app/*"].each do |path|
Rails.autoloaders.main.push_dir(path, namespace: KakaDudu)
end
end
end
I've recently learned this feature got even better in Rails. Kudos to Xavier once again!
But does it have any drawbacks when applied so widely to all app/
subdirectories?
Framework classes and namespaces
Rails is a framework built on string manipulation. It follows particular conventions to avoid excessive configuration. Adding a namespace to entities influenced by the framework changes their string representation. Let's examine this piece by piece what to expect — from models, controllers, views and helpers.
Model
I'd have expected that ActiveRecord models need an explicit table name — not to resolve it to name-spaced table kaka_dudu_entries
. This wasn't the case and all worked well here without changes.
module KakaDudu
class Entry < ApplicationRecord
# self.table_name = "entries" # not needed
end
end
Controller
Controllers cannot be considered outside routing layer. Adding a class namespace requires wrapping associated routes with a block — namespace
or scope
. While namespace
would prefix routes with kaka_dudu/
, using scope
lands us where we want to be — with public routes unchanged.
module KakaDudu
class RootController < ApplicationController
def index
headers["Link"] = %Q_<#{micropub_url}>; rel="micropub"_
end
end
end
Rails.application.routes.draw do
get "up" => "rails/health#show", :as => :rails_health_check
scope module: "kaka_dudu" do
root "root#index"
end
end
View
Controller names also affect view paths — where the action is supposed to find corresponding template to render. I had not yet found a solution other than nesting on the filesystem tree under a name-spaced directory. Same had to be done for layouts, although a bit differently — app/views/layouts
is the root.
app/views/
├── kaka_dudu
│ └── root
│ └── index.html.erb
└── layouts
└── kaka_dudu
└── application.html.erb
Helper
Every application starts with the ApplicationHelper
module. With coarse-grained approach, where all app/*
residents get a namespace, this creates a conflict originating from the AbstractController::Helpers::Resolution
module — precisely from the helper_modules_from_paths
logic.
In short — the app/**/*_helper.rb
is scanned and the names of the helper modules are extracted. For a name-spaced KakaDudu::ApplicationHelper
living in app/helpers/application_helper.rb
that would be "application"
as a prefix
. Next this prefix
goes through "#{prefix.camemlize}Helper".constantize
.
As you can imagine, this blows with NameError: uninitialized constant ApplicationHelper
error. We don't have a non-namespace helper anymore.
The solutions vary:
not having helper modules and making use of
helper_method
.not including all helpers by default – letting it them to be resolved automatically based on a controller name (for example
KakaDudu::ApplicationHelper
forKakaDudu::ApplicationController
) or explicitly stating which helper module to use withhelper
class method.config.action_controller.include_all_helpers = false
Conclusion
I'm pretty happy how little there is to be tweaked with modern Rails and an application namespace to go together. I can consciously live with its limitations and a few rough edges here and there.
Happy hacking!