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.
The solution
Wrap the concept in its own class. A small PhoneNumber object owns parsing, validation, country-code logic, and display formatting. Every other piece of code uses the object instead of touching the string directly.
- The phone-number rules live in one file
- Adding a new country code changes one place
- Tests for phone-number behavior live where the logic does
class PhoneNumber
def initialize(raw)
@raw = raw
raise ArgumentError, "Invalid phone" unless valid?
end
def to_e164
cleaned = @raw.gsub(/[\s\-()]/, "")
cleaned.start_with?("+") ? cleaned : "+33#{cleaned.delete_prefix("0")}"
end
def display
to_e164.gsub(/(\+\d{2})(\d{2})(\d{2})(\d{2})(\d{2})(\d{2})/, '\1 \2 \3 \4 \5 \6')
end
private
def valid?
@raw =~ /\A\+?[\d\s\-()]+\z/
end
end
# Now every caller goes through it:
phone = PhoneNumber.new(user.phone_number)
SmsService.send(phone.to_e164, body)
phone.display # for the UI The principle at play — Value objects
The fancy name for this anti-pattern is "primitive obsession" — using language primitives (strings, integers, booleans) to represent concepts that actually have their own rules. Phone numbers, email addresses, money amounts, postal addresses, durations: they're all things that look like primitives in your database but really aren't.
When the same kind of value needs the same kind of behavior in many places, that's a hint that it deserves a class of its own. The class becomes the single home for everything you ever need to do with that concept — validation, formatting, conversion, comparison. Other code stops needing to know how the concept works internally; it just uses the object.
The hardest part is noticing which strings in your code are really concepts in disguise. Anything you find yourself validating, formatting, or parsing in more than one place is a candidate.