A few tips for making your code easier to reason about
It's been a long time since the last post! In previous posts, we already discussed some strategies for making our code easier to reason about by looking at Functional Programming concepts and integrating them in our own workflow. In this post, we go at the problem from a more traditional perspective and look at a few small things that can make our lives a lot easier (as well as the lives of the other people that need to check out our code)!
π π ββοΈ π Make invalid state impossible to represent
Let's say we are building an app that lets people order coffee from a local coffee shop. They pick the product they want to order from a list, and they have to put in the number of items they want to order (the quantity) per product that they picked.
You could write something like
$cart->add(new ProductId('001-esspresso'), 3);
If you wanted to buy three Espressos.
The problem with this, is that we can easily pass a faulty value to that function:
$cart->add(new ProductId('001-esspresso'), 0);
or
$cart->add(new ProductId('001-esspresso'), -2);
or even
$cart->add(new ProductId('001-esspresso'), 3.14);
This can become a problem later on, for instance when we are calculating the price of the order. This means that our method needs to guard against faulty input values here, but it's impossible to know from the outside of the method if it does it or not. This will throw an exception:
$cart->add(new ProductId('001-esspresso'), -2);
But you don't know that. You need to go and look inside that method to know for sure. Also, you don't know when it will throw...
PHP's type system could be of help in some cases (e.g. to prevent the float value, we could typehint for an integer), but in this case, we're missing a concept: Quantity
which is a Value Object that represents the exact value that we need, a positive integer that's bigger than zero.
The method now only accepts instances of that object, which means it doesn't need to do any additional checks.
public function add(ProductId $id, Quantity $quantity): void
{
// ...
}
What's more, every time we pass a Quantity
around, or when we are passed one, it's guaranteed to be a verified correct value:
final class Quantity
{
private $quantity;
public function __construct(int $quantity): Quantity
{
$this->assertBiggerThanZero($quantity);
$this->quantity = $quantity;
}
private function assertBiggerThanZero(int $quantity): void
{
if ($quantity <= 0) {
throw new InvalidArgumentException('Quantity should be bigger than zero');
}
}
}
π€ β‘οΈ π Using annotation and static analysis to our advantage
It's good to know that the Quantity
object is a Value Object, because this makes it easy to reason about: it behaves like a value, it's immutable. Knowing this, you can forget about passing by reference and other headaches. It's even better if you can let other developers know:
/**
* @immutable
*/
final class Quantity
{
// ...
}
You can get even more out of it, if you use psalm. You can let psalm guard this property for you: if someone would add a method to the Quantity
class that made it mutable, your tests would start failing:
/**
* @psalm-immutable
*/
final class Quantity
{
// ...
}
At this point, you can be pretty sure that no mutation is going to happen. You can also annotate functions/methods like this to let others (and psalm) know that the function is "pure":
/**
* @psalm-pure
*/
public function addOneFreeCoffee(Quantity $quantity): Quantity
{
return new Quantity($quantity->toInt() + 1);
}
This annotation makes it impossible to do anything in the body of this method that changes state, or even generates output. It becomes a lot easier to reason about this function: if you give it input a
, it will always return output b
. It won't magically pull out some random value, or a record from a database. It's transparent.
β β‘οΈ π Your tests are domain expectations
Tests are often used as "a way to make sure our code works". While I don't disagree with that, I think good tests are way more valuable than that. If you're working in a Test-Driven manner, they provide confidence and flow during development. They help you to do safe refactors. But also, they document your code. Let's look at an example, would you rather find this:
/**
* @test
*/
public function shippingIsFreeWhenYouOrderThreeOrMoreProductsAtLeastOneBigLatte()
{
$cart = new Cart();
$cart->add(new ProductId('001-esspresso'), new Quantity(2));
$cart->add(new ProductId('002-lungo'), new Quantity(3));
$cart->add(new ProductId('003-latte-big'), new Quantity(1));
$cart->checkout();
$this->assertTrue($cart->freeShipping())
}
or this:
/**
* @test
*/
public function shippingIsFreeWhenYouOrderThreeOrMoreProductsAtLeastOneBigLatte()
{
$cart = new Cart();
$cart = $this->givenTheCartContainsThreeOrMoreProducts($cart);
$cart = $this->andTheCartContainsOneOrMoreBigLattes($cart);
$cart = $this->whenTheCartIsCheckedOut($cart);
$this->thenShippingShouldBeFree($cart);
}
When you look at the first example, the domain rule is expressed in the name of the test only, the test code itself doesn't help you in understanding the actual rule that we're testing. You can see how the Cart
is used, but you'll have a harder time finding out or validating what the actual rule is that's being tested. In the second example, there's no chance of missing the domain rule. The domain language is used to express the problem as if you were talking about it. It's a bit harder to see how the Cart
is used, but it's easy to click through to the implementation of those methods to see the actual implementations.
Conclusion
Using three simple concepts, we can give our brains a bit of rest when looking at the code because they don't need to keep as much information in "working memory" to understand it. I hope these tips will help you to make your code easier to grasp for yourself and your coworkers! Hope to see you for the next post!