One moment a project can get on an unwanted road is when new business rules pop in. Even more if this happens on a tight deadline. You just throw some innocent if statements, and that’s how hell can unleash…
Later, other rules will be requested by business. Each will have at least one if statement and maybe interactions with some services. Then one day you notice yourself tangled in the code you’d prefer to never see.
There are ways to implement these rules fast and efficient, avoiding most of those undesired if statements. This is my situation:
- I have a Song resource and a User resource
- A Song can be restricted to a User based on multiple conditions
- If a Song is restricted to a User, we have to let them know why, and this can be for reasons like:
- Song is not released yet
- Song was retired by the record company
- User is from a country where we don’t have rights to offer the Song
- User has a plan which doesn’t allow him to play the Song
- and so on
The “what” doesn’t matter, the “how” is the key. And business folks can change their mind any time. They want a new rule fast, or cancel one, or change it.
I was for sure determined to not “if” up all those things. Instead, I thought of each rule as an independently and very small component, which can be added, removed, or changed any time.
Because a Song can be restricted to the User, lets name these rules “restrictions”. Each restriction has its own different implementation, and we need to get its result (the common part for all restrictions), so I started with a contract and restrictions classes. Naming a restriction is important, as by its name everyone should understand its requirements. So choose names like SongIsNotReady, UserPlanNotAcceptedForSong, instead of SongRestriction, UserRestriction.
interface RestrictionInterface { /** * @return bool */ public function isRestricted(); } class SongIsNotReady implements RestrictionInterface { /** * @return bool */ public function isRestricted() { } } class UserPlanNotAcceptedForSong implements RestrictionInterface { /** * @return bool */ public function isRestricted() { } }
Now I’m gonna iterate all the restrictions and get the first for which business requirements are not met (in my case, the order of defining the restrictions was important, and I needed just one). I’m using a service which receives the ordered restrictions list, iterates them, calls the isRestricted method for each one of them, and throws an exception if it gets a true value. The thrown exception has the restriction class itself attached. It’s less important how you implement the service, as long as you break restrictions into small units.
class SongRestrictionService { /** * @var array */ private $restrictions; /** * @param array $restrictions */ public function __construct(array $restrictions) { $this->restrictions = $restrictions; } /** * @throws RestrictionException */ public function findRestriction() { /** @var RestrictionInterface $restriction */ foreach ($this->restrictions as $restriction) { if ($restriction->isRestricted()) { $e = new RestrictionException('Song restriction was met'); $e->setRestriction($restriction); throw $e; } } } } class RestrictionException extends \Exception { /** * @var RestrictionInterface */ private $restriction; /** * @param RestrictionInterface */ public function setRestriction(RestrictionInterface $restriction) { $this->restriction = $restriction; } /** * @return RestrictionInterface */ public function getRestriction() { return $this->restriction; } }
And when you want to use the service:
$rules = [ new SongIsNotReady(), new UserPlanNotAcceptedForSong(), ]; $rulesService = new SongRestrictionService($rules); try { $rulesService->findRestriction(); } catch (RestrictionException $e) { // If you need a key for translation or just to pass it forward, // do it here, not inside the restriction service $ruleKey = get_class($e->getRestriction()); }
Of course, each restriction needs some data (Song, User) to work with. See the full restrictions service example on GitHub. Use a dependency injection container.
Image if all the above had been written as:
$allowUserToAccessSong = true; if ($song->getStatus() == 'not_ready' || $user->getPlan() == 'free') { $allowUserToAccessSong = false; } // Then many another restrictions come up, using other services. $anotherService1 = new AnotherService1(); $anotherService2 = new AnotherService2(); if ($anotherService1->getSomething() == 1 && $anotherService2->getSomething() > 3) {}
If context doesn’t allow a clean service to be developed, you can at least put restrictions into an array of clojures. Later it’s gonna be easier to break the whole thing into classes.
Generally speaking, avoid if statements. When one comes to your mind, ask yourself if you can avoid it. I don’t mean turn all of them into services like the one I described, just think very well if it can be about multiple conditions for one result and choose the best method which will allow you to maintain and extend.