Spot the Tax · Card 7 of 20
Adding a variant shouldn't mean editing every file
Why a case statement on notification.kind ends up scattered across half your codebase.
The code
What will this cost you in six months?
class NotificationDispatcher
def deliver(notification)
case notification.kind
when "email"
Mailer.with(to: notification.recipient).send_now
when "sms"
Twilio.send(notification.recipient, notification.body)
when "push"
Firebase.push(notification.device_token, notification.body)
end
end
end The problem
The case statement on notification.kind lives here, but it almost never lives here only. The same case tends to show up in the delivery report, the analytics CSV export, the user's notification settings page, and probably the per-channel error handler too — anywhere in the app that needs to act differently depending on the kind. So when the team decides to add Slack as a fourth channel, you have to track down every one of those files and add a new when "slack" branch. Miss one, and that channel quietly misbehaves in that part of the app.
Take a moment. Before revealing, think about how you'd let new notification kinds be added without having to edit the dispatcher (and the report, and the settings page) every time.
The solution
Replace the scattered case statements with a single registry that maps each kind to a handler class. Anywhere in the code that needs to dispatch on the kind goes through the registry. Adding Slack now means writing a new SlackHandler class and adding one line to the registry, and none of the existing files need to change.
- Adding a new kind is one new file plus one line in the registry
- The mapping
kind → handlerlives in one place - The dispatcher reads the same regardless of how many kinds exist
module NotificationKind
ALL = {
"email" => EmailHandler,
"sms" => SmsHandler,
"push" => PushHandler
}.freeze
def self.get(kind)
ALL[kind]
end
end
class NotificationDispatcher
def deliver(notification)
NotificationKind.get(notification.kind).new(notification).deliver
end
end The principle at play — Open for extension, closed for modification
The Open/Closed Principle says that the parts of your codebase you've already written should be "closed" — you shouldn't have to keep editing them every time you add a new variant of something. Instead, your code should be "open" to extension, meaning new functionality can be added by writing new code rather than going back and rewriting existing code.
When you have a case statement on a string field, every value of that string is essentially baked into the structure of the file you wrote. Adding a new value means going back into the file and modifying it. And because the same case tends to appear in multiple places (anywhere that needs to act differently per kind), one new value usually means several modifications.
The fix is to give the concept its own home. The registry holds the mapping in one place, and every other piece of code dispatches through it instead of writing its own case statement. New variants are added by creating new files and registering them, without touching any of the existing dispatch sites.