class Coupon < ApplicationRecord
before_validation :normalize_code
validates :code,
coupon_code: { min_length: 6 },
uniqueness: { case_sensitive: false }
validates :percent_off, numericality: { in: 1..100 }
def redeemable?
expires_at.nil? || expires_at.future?
end
def redeem!
raise ActiveRecord::RecordInvalid, self unless redeemable?
increment!(:redemptions_count)
rescue ActiveRecord::RecordNotUnique
errors.add(:code, :taken)
raise ActiveRecord::RecordInvalid, self
end
private
def normalize_code
self.code = code.to_s.strip.upcase.presence
end
end
class AddCouponCodes < ActiveRecord::Migration[7.1]
def change
create_table :coupons do |t|
t.string :code, null: false
t.integer :percent_off, null: false, default: 0
t.datetime :expires_at
t.integer :redemptions_count, null: false, default: 0
t.timestamps
end
# Case-insensitive uniqueness enforced at the database level.
add_index :coupons, "lower(code)", unique: true, name: "index_coupons_on_lower_code"
end
end
class CouponCodeValidator < ActiveModel::EachValidator
FORMAT = /\A[A-Z0-9]+(?:-[A-Z0-9]+)*\z/
def validate_each(record, attribute, value)
if value.blank?
record.errors.add(attribute, :blank)
return
end
min_length = options.fetch(:min_length, 4)
if value.length < min_length
record.errors.add(attribute, :too_short, count: min_length)
end
unless FORMAT.match?(value)
record.errors.add(attribute, options[:message] || "must be uppercase letters, digits, and dashes")
end
end
end
This snippet shows how to validate a coupon code in Rails using a reusable custom ActiveModel::EachValidator combined with a database-level uniqueness guarantee. The pattern separates two concerns that are often conflated: format/shape validation, which belongs in application code and produces friendly error messages, and true uniqueness, which can only be enforced reliably at the database with a unique index.
In add_coupon_codes migration, the code column is added null: false and backed by a functional unique index on lower(code). A functional index is what makes case-insensitive uniqueness real: without it, SAVE10 and save10 would be treated as distinct. The expires_at column supports the domain logic in the model. Doing this in the schema means the constraint holds even under concurrent inserts, where two requests both pass the in-memory validation and race to insert.
CouponCodeValidator is the heart of the format check. As an EachValidator, it receives the record, the attribute, and the value for whatever attribute declares validates :code, coupon_code: true. It rejects blank or malformed values against FORMAT (uppercase letters, digits, and dashes), and it honors a configurable options[:min_length] so the same validator can be tuned per model. Errors are attached with record.errors.add, which integrates with the standard error-rendering flow.
Coupon model wires everything together. before_validation :normalize_code upcases and strips the value so the stored form is canonical and matches the functional index. It declares the custom coupon_code validator alongside uniqueness: { case_sensitive: false }. That Rails-level uniqueness check gives a clean validation error in the common case, but it is not a substitute for the index — it can still lose a race. The rescue ActiveRecord::RecordNotUnique in redeem! is the safety net: if the database rejects a duplicate, the code re-raises a validation error rather than a raw exception. redeemable? centralizes the expiry rule.
The trade-off is a small amount of duplication between the model validation and the DB constraint, which is deliberate: the app layer gives good UX, the DB layer guarantees correctness. Reach for this whenever a value must be both well-formed and truly unique.
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.