ruby 62 lines · 3 tabs

Validating Coupon Codes with a Custom EachValidator in Rails

Shared by codesnips Sep 2026
3 tabs
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
3 files · ruby Explain with highlit

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

Share this code

Here's the card — post it anywhere.

Validating Coupon Codes with a Custom EachValidator in Rails — share card
Link copied