When Your Join Table Needs Its Own Logic: Choosing has_many :through in Rails
Choose has_many :through when your many-to-many relationship carries data or behavior—like appointment dates, statuses, or notifications. The join model becomes a first-class Active Record object with validations, callbacks, and scopes, keeping domain logic where it belongs.
05 Sept 2025, 12:43 UTC

The Problem: Many-to-Many That Carries Data
You're modeling a scheduling system. Physicians see patients through appointments. Each appointment has a date, a room number, and a status. A simple has_and_belongs_to_many between Physician and Patient gives you the connection, but where do you put the appointment metadata? You could stuff it into a join table with extra columns, but then you lose validations, callbacks, and the ability to treat the relationship as a first-class domain object.
Takeaway: Use has_many :through when the relationship itself carries behavior or data that deserves its own model. Reserve has_and_belongs_to_many for pure, metadata-free connections.
Why the Join Model Matters
has_many :through introduces an explicit join model—here, Appointment—that sits between Physician and Patient. Unlike a bare join table, Appointment is a full Active Record class. You can add validations (validates :scheduled_at, presence: true), callbacks (after_create :notify_patient), scopes (scope :upcoming, -> { where('scheduled_at > ?', Time.current) }), and custom methods without polluting either parent model.
This separation keeps your domain clean. Physician doesn't need to know about room assignments. Patient doesn't need to know about cancellation policies. The logic lives where it belongs: on the relationship itself.
Concrete Setup: Physician, Appointment, Patient
Run these generators (requires Rails 7+ and a database configured):
rails g model Physician name:string
rails g model Patient name:string
rails g model Appointment physician:references patient:references scheduled_at:datetime room:string status:string
rails db:migrate
Then wire the associations:
# app/models/physician.rb
class Physician < ApplicationRecord
has_many :appointments
has_many :patients, through: :appointments
end
# app/models/patient.rb
class Patient < ApplicationRecord
has_many :appointments
has_many :physicians, through: :appointments
end
# app/models/appointment.rb
class Appointment < ApplicationRecord
belongs_to :physician
belongs_to :patient
validates :scheduled_at, presence: true
validates :status, inclusion: { in: %w[scheduled completed cancelled] }
scope :upcoming, -> { where('scheduled_at > ?', Time.current).where(status: 'scheduled') }
end
Now physician.appointments returns the join records (Appointment instances), and physician.patients returns the target Patient records via the join. You can filter patients by appointment attributes:
# Patients with upcoming appointments for this physician
physician.patients.joins(:appointments).merge(Appointment.upcoming)
Renaming with source for Domain Clarity
Suppose your join model is called Subscription linking User to Plan, but in your domain users "follow" plans. The source option lets you keep the association name readable:
class User < ApplicationRecord
has_many :subscriptions
has_many :followed_plans, through: :subscriptions, source: :plan
end
Now user.followed_plans reads naturally, while the underlying join remains Subscription. Without source, Rails would look for a followed_plan association on Subscription and raise ActiveRecord::AssociationReflection errors.
Trade-offs and Limitations
- Boilerplate for simple cases. If you truly only need a many-to-many link with zero metadata,
has_and_belongs_to_manyavoids an extra model, migration, and file. Don't reach forhas_many :throughby default. - N+1 query risk. Calling
physician.patients.map { |p| p.appointments.first.room }fires a query per patient. Usephysician.patients.includes(:appointments)orphysician.appointments.includes(:patient)to eager-load. - Indirect deletion behavior.
physician.patients.destroy(patient)deletes theAppointmentjoin record, not thePatient. This is usually what you want, but it surprises developers expectingdestroyon a collection to remove the target object.
Verify It Works
In a Rails console (rails c), run:
physician = Physician.create!(name: "Dr. Smith")
patient = Patient.create!(name: "Jane Doe")
appointment = physician.appointments.create!(patient: patient, scheduled_at: 1.day.from_now, room: "101", status: "scheduled")
physician.appointments.count # => 1 (join records)
physician.patients.count # => 1 (target records)
physician.patients.joins(:appointments).merge(Appointment.upcoming).count # => 1
If the counts match expectations, the association chain is wired correctly. Add an invalid appointment (scheduled_at: nil) and confirm appointment.valid? returns false—proof that join-model validations run.
Closing Checklist
- Does the relationship need its own attributes, validations, or callbacks? →
has_many :through. - Is it a pure link with zero metadata? →
has_and_belongs_to_many(or a simple join table). - Are you using
sourceto rename the target association? Verify the join model actually defines thesourceassociation name. - Anywhere you iterate the
throughcollection, add.includes(:join_model)or.includes(:target)to avoid N+1.
Start with the simplest association that fits. Upgrade to has_many :through when the relationship earns its own responsibilities.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.