Namespacing models in Rails is a common pattern for organizing larger applications. You group related models under a shared module, keep your directory structure clean, and avoid naming collisions as the codebase grows. The problem is that Rails does not handle namespaced models and their associations gracefully out of the box. Every association between namespaced models requires you to manually specify class_name, foreign_key, and often inverse_of. For a few models, this is manageable. For a dozen or more, it becomes a source of repetitive boilerplate and subtle bugs.
This post walks through the pain points of namespaced models in Rails, then introduces a concern called Namespaceable that automates the tedious parts. We will use a simplified clinic management application as our running example.
The Example Application
We are building an application to manage a network of medical clinics. The domain model looks like this:
# models/clinic.rb
Clinic
has_many doctors
has_many appointments # (namespaced under Clinic)
has_many invoices # (namespaced under Billing)
# models/doctor.rb
Doctor
belongs_to clinic
belongs_to invoice # (namespaced under Billing, optional)
# models/clinic/appointment.rb
Clinic::Appointment
belongs_to clinic
belongs_to invoice # (namespaced under Billing)
# models/billing/invoice.rb
Billing::Invoice
belongs_to clinic
has_many doctors
has_one appointment # (namespaced under Clinic)A few notes on the domain: each appointment belongs to exactly one clinic (no cross-clinic scheduling). Each appointment generates one invoice, so the invoice belongs to the appointment's clinic as well. Doctors have an optional reference to their most recent billing invoice for credentialing purposes.
The Problem with Namespaced Models
Rails expects a direct mapping between a model's class name and its table name. When you namespace a model, that mapping breaks. A Clinic::Appointment class will look for an appointments table by default, not clinic_appointments. You have to fix this yourself.
Fixing Table Names
There are two approaches. The first is setting the table name explicitly on each model:
# app/models/clinic/appointment.rb
class Clinic::Appointment < ApplicationRecord
self.table_name = 'clinic_appointments'
endThe second is defining the namespace module separately with a table_name_prefix. This is what the Rails model generator produces when you run rails generate model Clinic::Appointment:
# app/models/clinic.rb (module definition)
module Clinic
def self.table_name_prefix
'clinic_'
end
end# app/models/clinic/appointment.rb
class Clinic::Appointment < ApplicationRecord
endThe module approach is cleaner for multiple models in the same namespace. The drawback is that the module file lives at app/models/clinic.rb, which conflicts if you also have a Clinic model at that same path. You can work around this by placing your model inside its own namespace (Clinic::Clinic) or by pluralizing the namespace (Clinics::Appointment), but both options force awkward naming compromises.
For nested namespaces, you need to set the prefix at each level. A Billing::Invoice::LineItem model would need Billing with self.table_name_prefix = 'billing_' and Billing::Invoice with self.table_name_prefix = 'billing_invoice_' to produce the expected billing_invoice_line_items table name.
Fixing Associations
Table names are the first hurdle. Associations are the bigger one. Every association that crosses a namespace boundary requires you to specify :class_name so Rails knows which model to load, and :foreign_key so it knows which column to use. For many associations, you also need :inverse_of to make bidirectional relationships work correctly.
Here is what the example models look like with all of that wiring done manually:
# models/clinic.rb
class Clinic < ApplicationRecord
has_many :doctors, dependent: :restrict_with_error
has_many :appointments, class_name: "Clinic::Appointment", dependent: :destroy
has_many :invoices, class_name: "Billing::Invoice", dependent: :destroy
end
# models/doctor.rb
class Doctor < ApplicationRecord
belongs_to :clinic
belongs_to :invoice, class_name: "Billing::Invoice",
foreign_key: "invoice_id", optional: true
end
# models/clinic/appointment.rb
class Clinic::Appointment < ApplicationRecord
self.table_name = "clinic_appointments"
belongs_to :clinic
belongs_to :invoice, class_name: "Billing::Invoice", foreign_key: "invoice_id"
end
# models/billing/invoice.rb
class Billing::Invoice < ApplicationRecord
self.table_name = "billing_invoices"
belongs_to :clinic
has_many :doctors, dependent: :nullify
has_one :appointment, class_name: "Clinic::Appointment",
dependent: :restrict_with_error
endThis works, but it is verbose and repetitive. Every namespaced association needs the same set of options. The class_name and foreign_key values follow predictable patterns based on the model's namespace and class name, yet you have to type them out every time. In a real application with dozens of namespaced models, this becomes a maintenance burden and a source of copy-paste errors.
There is also a collision risk to consider. In this example, we are using just the class name (without the module) for foreign keys, so Billing::Invoice uses invoice_id. That works until you introduce a Legal::Invoice model, at which point two models compete for the same invoice_id column. Your approach to foreign key naming depends on your application, but either way, you are making the same decision and writing the same boilerplate for every association.
Introducing Namespaceable
We wanted a concern that could look at a namespaced model and its associations and figure out the correct table_name, class_name, foreign_key, and inverse_of values automatically. The result is Namespaceable, a concern that replaces standard association definitions with a single namespaced method call.
The method takes an association type, the associated class name, and any standard association options you would normally pass. It determines the rest from the namespace structure:
# Example usage:
namespaced :belongs_to, 'Billing::Invoice', optional: true
namespaced :has_one, 'Clinic::Appointment', dependent: :restrict_with_error
namespaced :has_many, 'Billing::Invoice', dependent: :destroy
namespaced({ belongs_to: :invoice }, 'Billing::Invoice')Any options you specify explicitly take precedence over the concern's automatic determinations, so you can always override special cases.
The Concern
# frozen_string_literal: true
module Namespaceable
extend ActiveSupport::Concern
included do
def self.table_name
_table_name(self)
end
def self.namespaced(association, association_class, namespace: nil, **options)
association_namespace = namespace ? "#{namespace}::" : ''
association_class_name = "#{association_namespace}#{association_class}"
_namespaced(association, association_class_name, **options)
end
def self._namespaced(association, association_class_name, **association_options)
association_type, association_name = _association(association, association_class_name)
association_class = association_class_name.constantize
association_as = association_options[:as]&.to_s
namespaceable_options = {
class_name: association_class_name,
foreign_key: _foreign_key(association_type, association_name, association_as),
inverse_of: _inverse_of(association_type, association_class, association_as)
}
# Use association class name for :source_type if :source is present
namespaceable_options[:source_type] = association_class_name if association_options[:source]
# :has_and_belongs_to_many does not support :inverse_of
if association_type == :has_and_belongs_to_many
namespaceable_options.delete(:inverse_of)
end
__send__(
association_type,
association_name.to_sym,
**namespaceable_options.merge(association_options)
)
end
def self._association(association, association_class_name)
return association.to_a.flatten if association.is_a? Hash
association_type = association
association_name = _name_of_class(association_class_name)
if %i[has_many has_and_belongs_to_many].include?(association_type)
association_name = association_name.pluralize
end
[association_type, association_name]
end
def self._foreign_key(association_type, association_name, association_as)
foreign_key =
if association_as
association_as
elsif %i[has_many has_one].include?(association_type)
_name_of_class(name)
else
association_name
end
"#{foreign_key}_id".to_sym
end
def self._inverse_of(association_type, association_class, association_as)
inverse_of = (association_as || _name_of_class(name)).singularize
if association_type == :belongs_to &&
_inverse_type(association_class) == :has_many
inverse_of = inverse_of.pluralize
end
inverse_of.to_sym
end
def self._inverse_type(association_class)
inverse_associations = association_class.reflect_on_all_associations
inverse_association = inverse_associations.find do |assoc|
assoc.options[:class_name] == name
end
inverse_association&.macro
end
def self._name_of_class(class_name)
class_name.split('::').last.underscore
end
def self._table_name(klass)
modular_name = klass.name.split('::').map(&:underscore)
modular_name.join('_').pluralize
end
end
endHere is what the concern does at each step:
Table name resolution. When a model includes the concern, table_name is overridden to join all namespace segments with underscores and pluralize. Clinic::Appointment becomes clinic_appointments. Billing::Invoice::LineItem would become billing_invoice_line_items.
Association type and name. The _association method extracts the class name without the module, converts it to snake_case, and pluralizes it for has_many and has_and_belongs_to_many associations. You can also pass a hash to set a custom association name (e.g., { belongs_to: :invoice } instead of letting it default to :billing_invoice).
Foreign key determination. For belongs_to associations, the foreign key is derived from the association name. For has_many and has_one, it is derived from the including model's class name. Polymorphic associations use the :as option.
Inverse resolution. The concern looks at the associated model's existing associations to determine whether the inverse is a has_many or has_one, and pluralizes the :inverse_of value accordingly. This depends on the inverse association having a :class_name option set, which will be present automatically if both sides use the namespaced method.
The Models After Namespaceable
With the concern in place, the same models become significantly cleaner:
# models/clinic.rb
class Clinic < ApplicationRecord
include Namespaceable
has_many :doctors, dependent: :restrict_with_error
namespaced :has_many, "Clinic::Appointment", dependent: :destroy
namespaced :has_many, "Billing::Invoice", dependent: :destroy
end
# models/doctor.rb
class Doctor < ApplicationRecord
include Namespaceable
belongs_to :clinic
namespaced :belongs_to, "Billing::Invoice", optional: true
end
# models/clinic/appointment.rb
class Clinic::Appointment < ApplicationRecord
include Namespaceable
belongs_to :clinic, inverse_of: :appointments
namespaced({ belongs_to: :invoice }, "Billing::Invoice")
end
# models/billing/invoice.rb
class Billing::Invoice < ApplicationRecord
include Namespaceable
belongs_to :clinic, inverse_of: :invoices
namespaced :has_many, "Doctor", dependent: :nullify
namespaced :has_one, "Clinic::Appointment", dependent: :restrict_with_error
endThe self.table_name declarations are gone. The class_name and foreign_key options are gone. The concern handles all of it. Where you do need to override something (like the inverse_of on the non-namespaced belongs_to :clinic associations), you still can, and your explicit options take precedence.
Non-namespaced associations (like has_many :doctors on Clinic) continue to work with standard Rails syntax. You only use the namespaced method for associations that cross namespace boundaries.
Caveats and Future Direction
The concern works well for the common cases, but it has limitations worth knowing about.
Foreign key naming strategy is baked in. The current implementation uses just the class name (without the module) for foreign keys, so Billing::Invoice produces invoice_id rather than billing_invoice_id. Switching between these strategies is not yet configurable. If your application needs full module-qualified foreign keys to avoid collisions, you would need to modify the _foreign_key method (the code comments indicate where).
Inverse resolution has a dependency. The automatic inverse_of determination relies on the associated model already having a :class_name option on its side of the relationship. This happens naturally if both models use the namespaced method, but if only one side does, you may need to specify inverse_of explicitly.
No error handling yet. The concern does not validate its inputs or provide helpful error messages if something goes wrong. Invalid class names, circular references, or mismatched association types will surface as standard Ruby or ActiveRecord errors rather than descriptive messages from the concern itself.
Migrations still need manual attention. The concern handles the model layer, but your migrations still need to account for namespaced table and column names. For example, add_reference :doctor, :invoice, foreign_key: { to_table: :billing_invoices } instead of the simpler add_reference :doctor, :billing_invoice, foreign_key: true.
We are planning to turn this into a gem with configurable foreign key strategies, proper error handling, and a test suite. If you are dealing with namespaced models in your own Rails application and want to try the concern in its current form, the code above is ready to drop into app/models/concerns/namespaceable.rb.
If you are working on a Rails application and running into complexity with namespacing, architecture, or model design, we can help. Learn more about our web development services or get in touch to talk about your project.


