All lessons

Spot the Tax · Card 17 of 20

A phone number is more than a string

Why the same phone-number rules end up duplicated across User, Contact, SmsService, and the decorator.

The code

What will this cost you in six months?

class User < ApplicationRecord
  validates :phone_number, format: { with: /\A\+?[\d\s\-()]+\z/ }
end

class Contact < ApplicationRecord
  validates :phone_number, format: { with: /\A\+?[\d\s\-()]+\z/ }
end

class SmsService
  def self.send(phone_number, body)
    cleaned = phone_number.gsub(/[\s\-()]/, "").gsub(/\A0/, "+33")
    Twilio.send(cleaned, body)
  end
end

class UserDecorator
  def display_phone
    user.phone_number.gsub(/(\d{2})(\d{2})(\d{2})(\d{2})(\d{2})/, '\1 \2 \3 \4 \5')
  end
end

The problem

A phone number isn't really a string — it's a concept that has its own validation rules, its own normalization (E.164 prefix), and its own display format. By representing it as a string, you push all of those rules to wherever the string is used. The same regex appears in User and Contact validations. The "add the country code" logic lives in SmsService. The display formatting lives in the decorator. Adding support for a new country code means hunting for every place a phone number is touched.

Take a moment. Before revealing, think about how you'd give the phone-number concept a single home, so the rules live in one file and every other piece of code just uses it.