When deletion of a parent record should be blocked by one or more conditions on the same child table, declare a has_many association per condition with its own scope and dependent: :restrict_with_error, then declare the plain dependent: :destroy association last.
The pattern
The example below uses two scoped associations to keep things readable, but the same approach works with one, three, or any number of independent restrictions.
class Account < ApplicationRecord
has_many :active_subscriptions,
-> { where(status: :active) },
class_name: 'Subscription',
dependent: :restrict_with_error
has_many :recently_canceled_subscriptions,
-> { where(status: :canceled, canceled_at: 30.days.ago..) },
class_name: 'Subscription',
dependent: :restrict_with_error
has_many :subscriptions, dependent: :destroy
end
dependent: :restrict_with_error and dependent: :destroy both install before_destroy callbacks, and Rails fires them in declaration order. The restricting associations are declared first, so their checks run while the table is still intact. Only once every check passes does dependent: :destroy clear the rest.
account = Account.create!(name: 'Globex')
account.subscriptions.create!(status: :active)
account.subscriptions.create!(status: :canceled, canceled_at: 2.days.ago)
account.subscriptions.create!(status: :expired)
With an active subscription present, the first restrict check fires and aborts before the others run:
account.destroy!
# => raises ActiveRecord::RecordNotDestroyed: Failed to destroy the record
account.errors[:base]
# => ["Cannot delete record because dependent active subscriptions exist"]
Once nothing restricts the deletion, dependent: :destroy removes whatever is left:
account.active_subscriptions.destroy_all
account.recently_canceled_subscriptions.destroy_all
account.destroy!
# => deletes the account and its remaining (expired) subscription
Declaration order is the whole trick
Declaring the unscoped association first breaks it:
has_many :subscriptions, dependent: :destroy # declared first: WRONG
Its callback fires before the restrict checks and deletes the very rows they were meant to find. Every check then runs against an empty table, passes, and the parent is deleted. Keep the restricting associations above the dependent: :destroy.
One catch: restrictions through a join
This only works when the restricting association can be declared first. A has_many :through cannot: Rails raises ActiveRecord::HasManyThroughOrderError unless the through association is declared after the association it rides on. That association carries the dependent: :destroy, so it clears the join rows before the restrict check sees them. Restrict over the join table's own foreign key instead of going through: it:
has_many :unbilled_line_items,
-> { joins(:charge).where(charges: { billed: false }) },
class_name: 'LineItem',
inverse_of: :account,
dependent: :restrict_with_error
has_many :line_items, dependent: :destroy
Propagating the restriction up to a grandparent
If a grandparent (e.g. Organization) owns the parent through dependent: :destroy, the restriction propagates automatically. When the grandparent is destroyed, Rails calls destroy on each child. The child's restrict_with_error adds an error and aborts. The aborted child rolls back the surrounding transaction, and the grandparent is preserved.
class Organization < ApplicationRecord
has_many :accounts, dependent: :destroy
end
organization = organizations(:big_co)
account = organization.accounts.create!(...)
account.subscriptions.create!(status: :active)
organization.destroy
assert Organization.exists?(organization.id)
assert_includes organization.accounts.first.errors[:base], 'Cannot delete record because dependent active subscriptions exist'
The grandparent does not need its own restrict_with_error declaration. Forwarding through dependent: :destroy is sufficient, because the child's abort halts the whole destroy chain.
Two things to watch out for:
- The grandparent must use
dependent: :destroy, notdependent: :delete_all. The latter issues a singleDELETEand skips child callbacks entirely, so the restriction never runs. - If the grandparent runs a
before_destroyof its own that clears child rows ahead of the dependent chain, the restriction is bypassed at the grandparent level too.