class Category < ApplicationRecord
has_many :products, dependent: :restrict_with_error
has_many :subcategories,
class_name: "Category",
foreign_key: :parent_id,
dependent: :restrict_with_error
belongs_to :parent, class_name: "Category", optional: true
validates :name, presence: true, uniqueness: { scope: :parent_id }
def deletable?
products.none? && subcategories.none?
end
end
class Product < ApplicationRecord
belongs_to :category
has_many :line_items, dependent: :restrict_with_error
has_many :orders, through: :line_items
validates :name, presence: true
validates :sku, presence: true, uniqueness: true
scope :discontinued, -> { where(active: false) }
def sold_before?
line_items.exists?
end
end
class CategoriesController < ApplicationController
before_action :set_category, only: %i[show destroy]
def destroy
if @category.destroy
redirect_to categories_path, notice: "Category deleted."
else
redirect_back(
fallback_location: categories_path,
alert: @category.errors.full_messages.to_sentence
)
end
end
private
def set_category
@category = Category.find(params[:id])
end
end
class AddCategoryForeignKeys < ActiveRecord::Migration[7.1]
def change
add_reference :products, :category, null: false, index: true
add_foreign_key :products, :categories, on_delete: :restrict
add_column :categories, :parent_id, :bigint
add_index :categories, :parent_id
add_foreign_key :categories, :categories,
column: :parent_id,
on_delete: :restrict
add_foreign_key :line_items, :products, on_delete: :restrict
end
end
This snippet demonstrates a defensive pattern for handling deletions in a Rails application where destroying a parent record could strand its children. Instead of silently cascading a destroy (which can wipe out large trees of data) or blindly deleting and later hitting a database foreign-key violation, the models declare dependent: :restrict_with_error so ActiveRecord refuses the deletion and attaches a friendly error message when related rows still exist.
In Category model, the association has_many :products, dependent: :restrict_with_error tells ActiveRecord to check for dependent Product rows before running the destroy. If any exist, the callback halts the transaction and adds an error to the record's base attribute rather than raising an exception. This differs from :restrict_with_exception, which raises ActiveRecord::DeleteRestrictionError — the _with_error variant is friendlier for user-facing flows because it flows through the normal validation error channel. Note that the check counts existing children, so it does not depend on records loaded in memory.
In Product model, a second layer is shown: dependent: :restrict_with_error on line_items means a product cannot be removed while it appears on any historical order, protecting order integrity. This keeps the domain rule close to the data.
The CategoriesController shows how the pattern pays off in a controller. Because destroy returns false instead of raising, the action can branch cleanly: on success it redirects, and on failure it reads @category.errors.full_messages to render an explanation. There is no rescue block cluttering the flow.
The schema migration reinforces the app-level guard with a real database-level foreign_key constraint. This belt-and-suspenders approach matters: dependent: :restrict_with_error only protects deletions going through ActiveRecord callbacks, while a database foreign key stops orphaning from raw SQL, delete_all, or concurrent races. The trade-off is that restrict-style deletions require callers to clean up children first, so it suits reference data (categories, tags, users with audit trails) more than ephemeral records. When cascading truly is desired, dependent: :destroy remains the right tool.
Related snips
class CommentsController < ApplicationController
before_action :set_post
def create
@comment = @post.comments.build(comment_params)
System test: asserting Turbo Stream responses
class Post < ApplicationRecord
belongs_to :author, class_name: 'User'
has_many :comments, dependent: :destroy
scope :published, -> { where.not(published_at: nil).where('published_at <= ?', Time.current) }
scope :draft, -> { where(published_at: nil) }
ActiveRecord scopes for reusable query logic
module Api
module V1
class UsersController < BaseController
def show
user = User.includes(:profile).find(params[:id])
ETags for conditional requests and caching
class PostsController < ApplicationController
def index
@posts = Post.includes(:author)
.order(created_at: :desc)
.page(params[:page])
.per(10)
Turbo Frames: infinite scroll with lazy-loading frame
package dbutil
import (
"context"
"github.com/jackc/pgconn"
Retry Postgres serialization failures with bounded attempts
require "csv"
class PeopleCsvStream
include Enumerable
HEADERS = %w[id full_name email signed_up_at plan].freeze
Resilient CSV Export as a Streamed Response
Share this code
Here's the card — post it anywhere.