My friends and family are under attack in Ukraine. 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:

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.

Learn about more topics