Terminal tree output showing a Rails app/models directory with namespaced subdirectories — clinic/ containing appointment.rb, patient.rb, and provider.rb, and billing/ with invoice.rb — followed by the Ruby class definition Clinic::Appointment < ApplicationRecord, illustrating how directory structure maps to module namespacing in Rails

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'
end

The 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
end

The 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
end

This 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
end

Here 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
end

The 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.

Related Posts

AWS logo centered over dark blue stylized map of Europe with concentric radar-style rings emanating from Germany, representing the AWS European Sovereign Cloud infrastructure launch for EU data sovereignty and GDPR compliance
January 26, 2026 • Frank Valcarcel

AWS Launches European Sovereign Cloud

AWS launched a physically separate cloud infrastructure in Europe with EU-only governance, zero US dependencies, and over 90 services. Here is what organizations in healthcare, finance, and government need to know about the sovereign cloud and how to evaluate it for their compliance strategy.

A conceptual illustration shows a chat bubble icon at the center of a complex maze, representing the challenges of evaluating Large Language Models for commercial applications. The intricate blue-tinted labyrinth symbolizes the many considerations Cuttlesoft navigates when implementing AI solutions in enterprise software - from API integration and cost management to security compliance. This visual metaphor captures the complexity of choosing the right LLM technology for custom software development across healthcare, finance, and enterprise sectors. The centered message icon highlights Cuttlesoft's focus on practical communication AI applications while the maze's structure suggests the methodical evaluation process used to select appropriate AI tools and frameworks for client solutions.
September 12, 2024 • Frank Valcarcel

Benchmarking AI: Evaluating Large Language Models (LLMs)

Large Language Models like GPT-4 are revolutionizing AI, but their power demands rigorous assessment. How do we ensure these marvels perform as intended? Welcome to the crucial world of LLM evaluation.

Let's work together

Tell us about your project and how Cuttlesoft can help. Schedule a consultation with one of our experts today.

Contact Us