Building BandTools: Protects From Direct Destruction Concern
17 July 2026
In the previous BandTools post I wrote about a controller concern that protects sensitive actions. This time the focus is on the model layer: a small Rails concern that stops certain records from being destroyed in the wrong way.
In BandTools, every user owns a handful of records that are inseparable from the account itself. Subscribe, confirmation, archive and unsubscribe pages are modelled with single-table inheritance (STI): one pages database table, a base Page model, and subclasses that share that table while behaving as distinct types. Rails stores the subclass name in a type column so ArchivePage, ConfirmationPage, SubscribePage and UnsubscribePage can all be loaded from the same rows:

There is also a ConfirmationEmail that customises the message sent when someone joins a mailing list. Each of these is a singleton for the account: one page of each type, and one confirmation email. That makes a direct destroy especially costly. You are not removing one of many interchangeable rows; you are removing the only copy while the account is still active. These records should disappear only when the user does.
Rails makes the happy path easy. On User you declare the associations with dependent: :destroy:
has_one :confirmation_email, dependent: :destroy
has_many :pages, dependent: :destroy
Destroy the user and the pages and confirmation email go with them. The problem is the unhappy path: a direct call such as page.destroy or confirmation_email.destroy from a console session, a misplaced controller action, or a future refactor. Without an extra guard, that call succeeds and leaves the account in an inconsistent state.
ProtectsFromDirectDestruction closes that gap. The entire concern is a dozen lines:
module ProtectsFromDirectDestruction
extend ActiveSupport::Concern
included do
before_destroy :prevent_direct_destroy_unless_association
end
private
def prevent_direct_destroy_unless_association
return if destroyed_by_association.present?
errors.add(:base, "#{self.class.name} cannot be deleted directly.")
throw(:abort)
end
end
When the module is mixed in, it registers a before_destroy callback. That callback asks a simple question: is this destroy happening because a parent association asked for it?
Rails answers that question via destroyed_by_association. When a parent with dependent: :destroy tears down its children, Active Record sets that attribute on each child to the association reflection that triggered the cascade. A direct call to destroy leaves it blank. The callback treats a present value as permission to proceed, and anything else as a refusal.
Refusal uses the standard Active Record abort pattern: add an error to the base of the record, then throw(:abort). That cancels the destroy transaction, leaves destroy returning a falsey result, and keeps the row in the database. Callers that use destroy! will see an exception instead, which is usually what you want in a console or a test when something unexpected tried to delete a protected record.
Using the concern is a one-liner on each protected model:
class Page < ApplicationRecord
include ProtectsFromDirectDestruction
belongs_to :user
# ...
end
class ConfirmationEmail < ApplicationRecord
include ProtectsFromDirectDestruction
belongs_to :user
# ...
end
Because Page uses STI, a ConfirmationPage, SubscribePage and the rest all inherit the protection from the base class. That shows up in the error message: because self.class.name is the STI subclass, a failed destroy reports “ConfirmationPage cannot be deleted directly.” rather than a generic Page message. That makes debugging in the console a little clearer.
The behaviour is covered by a pair of tests. The first asserts that calling destroy on a page does not change the Page count and that the expected error is present. The second destroys the owning user and asserts that the page is gone:
test "prevents direct destruction of page" do
assert_no_difference "Page.count" do
result = @page.destroy
assert_not result
end
assert_includes @page.errors[:base], "ConfirmationPage cannot be deleted directly."
end
test "allows destruction via user association" do
@user.destroy
assert_nil Page.find_by(id: @page.id)
end
That second test is the important counterpart. A guard that blocked every destroy would be worse than no guard at all, because account deletion would leave orphaned pages and confirmation emails behind. The destroyed_by_association check is what keeps parent-driven cleanup working while still rejecting the direct call.
I like this pattern because it encodes an invariant in the model rather than relying on every caller to remember the rule. Controllers, jobs, and console experiments can all call destroy; the protected models refuse unless the parent association is doing the work. For records that only make sense as part of a larger aggregate, that is exactly the behaviour you want.