Donate to protect them directly or help international organizations.
Null Parameters
Let's say that we want to create a discount in the database. It can apply to certain products, or be valid during certain times.
public function insertDiscount(
string $name,
int $amountInCents,
int $productId = null,
DateTimeImmutable $startDate = null,
DateTimeImmutable $endDate = null
) : int
First, there are too many parameters. Second, the majority are optional. Methods like this are unintuitive and hard to maintain, especially as the number of parameters tends to grow over time. Code like this is hard to reason about:
$this->insertDiscount('My discount', 100, null, null, new DateTimeImmutable('2022-01-01'));
The Missing Design
Several questions come immediately to mind:
- If the
productId
is null, will it apply to nothing or everything? - If we specify either the
startDate
orendDate
, do we have to specify both? What are the consequences of only specifying one of them? - What happens when we later need to create discounts based on payment method (credit card, e-wallet, loyalty points)? Do we just keep appending countless parameters?
These questions are usually a sign that the problem was approached from the technical side, and not clearly understood before implementation.
Minimum Viable Discount
What is the simplest possible discount that we can have? If we drop all the optional parameters, we can represent discounts using a small class:
final class Discount
{
public function __construct(
public readonly string $name,
public readonly int $amountInCents
) {}
}
The code using this class is a bit more intuitive to read:
$this->insertDiscount(
new Discount('My discount', 50)
);
Global Discounts
Without any constraints, does the discount apply to everything or nothing? After a conversation with the business people, we found out that a discount starts out as global, meaning that it applies to everything. We can clarify this by making the constructor private and adding a named constructor:
final class Discount
{
private function __construct(
//...
) {}
public static function createGlobal(
string $name,
int $amountInCents
):self {
return new self($name, $amountInCents);
}
}
We then use the class like this:
$this->insertDiscount(
Discount::createGlobal('My discount', 50)
);
Upon instantiation, it is now clear that the discount is global. We took this hidden knowledge and made it obvious in the code. The next person reading it will have one fewer question.
Additional Constraints
To add the other constraints, we can have methods for each:
final class Discount
{
public function constrainByProduct(int $productId): self
{
//...
}
public function constrainByStartDate(DateTimeImmutable $startDate): self
{
//...
}
public function constrainByDateRange(DateTimeImmutable $startDate, DateTimeImmutable $endDate): self
{
//...
}
}
There are two methods to work with dates. This makes it clear that you can either supply the starDate
or both dates, but not just the endDate
. Another bit of knowledge captured in the code to avoid questions.
The code that uses this class has become much easier to reason about:
$this->insertDiscount(
Discount::createGlobal('My discount', 50)
->constrainByProduct($productId)
->constrainByStartDate(new DateTimeImmutable('now'))
);
It may be more verbose, but clarity can be the difference between taking 10 minutes to understand and fix code as opposed to an entire day. It can also avoid bugs, because it is no longer possible to specify an endDate
without the startDate
, which could have led to all kinds of problems down the line.
We used the word constrain
on purpose. This reinforces the fact that by default, a discount allows everything, and that we can later constrain it.
Adding New Constraints
Adding new constraints no longer requires changing an existing method. We instead add another method:
final class Discount
{
public function constrainByPaymentMethod(PaymentMethod $paymentMethod)
{
//...
}
}
The PaymentMethod
is our own class, which can be an enum. Using it becomes straightforward:
$discount->constrainByPaymentMethod(PaymentMethod::Debit);
Conclusion
This class prevents a developer from misusing the parameters, so they can get discounts to work more quickly and without unnecessary debugging. It also makes the use cases so obvious that no documentation or explanation is required. Onboarding new developers becomes easier than with a long list of optional parameters, since they can now figure everything out quickly on their own, without digging around for information.