DEV Community

Aleksander Wons
Aleksander Wons

Posted on

A better way to handle constants

This is a copy of an old post on my private blog from June 2022.

Introduction

I think we have all seen it and done it before (at least I have):

if ($transaction->getStatus() === 'completed') {
  ...
}
Enter fullscreen mode Exit fullscreen mode

That is usually the first thing you may come up with. The transaction object has a property called _status_, and this property is a string. On the code level, it will look something like this:

class Transaction
{
    private string $status;
}
Enter fullscreen mode Exit fullscreen mode

That seems fine until we realize we have another place in our source code where we need to compare the status to that specific value. The first improvement we could make is to extract the actual constant into the _Transaction_ class and then reference it later.

class Transaction
{
    public const STATUS_COMPLETED;
}

if ($transaction->getStatus() === Transaction::STATUS_COMPLETED {
    ...
}
Enter fullscreen mode Exit fullscreen mode

That's already better. We no longer have to copy/paste the same string value repeatedly—a big win.

What's wrong with this implementation and how to make it better

Let's stop here for a moment and consider where we make the comparison. Let's say this is a payment transaction, and we have a piece of code that will allow an action if that transaction is completed. Let's see how this might look in code:

class SomeService
{
    public function foo(Order $roder): void {
        if ($order->getPaymentTransaction->getStatus() === Transaction::STATUS_COMPLETED) {
             ...
        }
    }

    public function bar(Order $order): void {
        if ($order->getPaymentTransaction->getStatus() === Transaction::STATUS_COMPLETED) {
             ...
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

We can already notice something interesting in this small piece of code. Who knows how to tell if a transaction is in a specific state? Transaction itself? Nope. Our service. And you can probably see where I'm going with this. Before you know, there are at least a few places in our code where we have the same comparison:

if ($transaction->getStatus() === Transaction::STATUS_COMPLETED) {
    ...
}
Enter fullscreen mode Exit fullscreen mode

And since our transaction can have various states and we might have actions associated with various states of the transaction, we will do similar comparisons all over our codebase.

One could argue that this is merely a harmless duplicated code. It might not be a big deal, but let me give you my take on it.

How to tell if a transaction is in a specific state is anyone's business but the transaction itself. Why would we even expect a service or another object to be able to decide how to interpret the transaction status? I believe this is about the separation of concerns. While a service or another object needs to be able to tell if a transaction is in a specific state or not is important, it's the transaction that needs to give an answer to that question. And this moves us toward the following versions of the code:

if ($transaction->getStatus()->isCompleted()) {
    ...
}
Enter fullscreen mode Exit fullscreen mode

With this simple change, we took away the unnecessary noise from our code and made the status string (and actually the whole reasoning about its state) an implementation detail that is irrelevant from the perspective of the caller. We could implement this the following way:

class TransactionStatus
{
    public const STATUS_INITIATED = 'initiated';
    public const STATUS_COMPLETED = 'completed';
    private const VALID_VALUES = [
        self::STATUS_INITIATED,
        self::STATUS_COMPLETED,
    ];

    public function __construct(private string $value)
    {
        $this->ensureIsValid($value);
    }

    public function isCompleted(): bool
    {
        return $this->value === self::STATUS_COMPLETED
    }

    private function ensureIsValid(string $value): void {
        if (!in_array($value, self::VALID_VALUES, true)) {
            throw new \InvalidArgumentException(
                sprintf(
                  'Invalid transaction status [%s]. Must be one of [%s]',
                  $value,
                  implode(',', self::VALID_VALUES)
                )
            );
        }
    }
}

class Transaction
{
    private TransactionStatus $status;

    public function getStatus(): TransactionStatus
    {
        return $this->status;
    }
}
Enter fullscreen mode Exit fullscreen mode

If we were on PHP >= 8.1 already, we could make the implementation a bit simpler by using an enum:

enum TransactionStatus: string
{
    case INITIATED = 'initiated';
    case COMPLETED = 'completed';

    public function isCompleted(): bool {
        return match($this) {
          self::COMPLETED => true,
          default => false  
        };
    }
}
Enter fullscreen mode Exit fullscreen mode

So we solved the problem of getting the actual status. But how about creating a new state object? It would be good to keep the knowledge about the actual string representation of a status to a bare minimum. We could be tempted to use the constant as an argument for the constructor:‌

// PHP<8.1
$transactionStatus = new TransactionStatus(TransactionStatus::COMPLETED);

// PHP≥8.1
$transactionStatus = TransactionStatus::COMPLETED;
Enter fullscreen mode Exit fullscreen mode

That will technically work, but why does the caller need to know which value is correct? Didn't we want to make this an implementation detail? We can solve this by adding a static factory that will create the appropriate instance for us:

// PHP<8.1
class TransactionStatus
{
    private STATUS_COMPLETED = 'completed'; //notice that the constant can now be private because we won't have a single reference to it anywhere in code

    public static function asCompleted(): self
    {
        return new self(self::STATUS_COMPLETED);
    }
}

//PHP≥8.1
enum TransactionStatus: string
{
    case COMPLETED = 'completed';

    public static function asCompleted(): self
    {
        return self::COMPLETED;
    }
}
Enter fullscreen mode Exit fullscreen mode

Sending data over the wire

We completely hid the actual implementation of the status behind function calls. But you might have some reservations here: what about sending data over a wire? Agree. We need solutions for that problem as well.

We will have to either serialize the status or re-create it from a string to send data over the wire. To map the status into a string, we can do the following:

// PHP<8.1
$transactionStatus = new TransactionStatus($request['transaction']['status']);

// PHP≥8.1
$transactionStatus = TransactionStatus::from($request['transaction']['status']);
Enter fullscreen mode Exit fullscreen mode

In both cases, our object can only contain a value that we support. This is good because we don't want to have any surprises there. And since we don't care what status will get instantiated, we can pass the incoming string into the constructor or the from method.

Mapping into a string is also simple:

// PHP<8.1
class TransactionStatus
{
    private string $value;

    public function getValue(): string
    {
        return $this->value;
    }
}

// PHP≥8.1
$transactionStatus->value; // this property is read-only
Enter fullscreen mode Exit fullscreen mode

Summery

As we can see, there is nothing that would stop us from storing related constants as value objects. They are easier to maintain and less error-prone than simple strings or constants loosely gathered in one class. With a value object, we can encapsulate all the irrelevant implementation details. Here, once again how the whole class would look like for PHP 8 with a regular class:

class TransactionStatus
{
    private const STATUS_INITIATED = 'initiated';
    private const STATUS_COMPLETED = 'completed';
    private const VALID_VALUES = [
        self::STATUS_INITIATED,
        self::STATUS_COMPLETED
    ];

    public function __construct(private string $value)
    {
        $this->ensureIsValid($value);
    }

    public function isInitialized(): bool
    {
        return $this->value === self::STATUS_INITIATED;
    }

    public function isCompleted(): bool
    {
        return $this->value === self::STATUS_COMPLETED;
    }

    public function getValue(): string
    {
        return $this->value;
    }

    public static function asInitiated(): self
    {
        return new self(self::STATUS_INITIATED);
    }

    public static function asCompleted(): self
    {
        return new self(self::STATUS_COMPLETED);
    }

    private function ensureIsValid(string $value): void
    {
        if (!in_array($value, self::VALID_VALUES, true)) {
            throw new \InvalidArgumentException(
                sprintf(
                    'Invalid status [%s]. Must be one of [%s]',
                    $value,
                    implode(',', self::VALID_VALUES)
                )
            );
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

And here is what it would look like if we could use PHP 8.1 with an enum type:

enum TransactionStatus: string
{
    case INITIATED = 'initiated';
    case COMPLETED = 'completed';

    public function isCompleted(): bool
    {
        return match ($this) {
            self::COMPLETED => true,
            default => false
        };
    }

    public function isInitialized(): bool
    {
        return match ($this) {
            self::INITIATED => true,
            default => false
        };
    }

    public static function asInitiated(): self
    {
        return self::INITIATED;
    }

    public static function asCompleted(): self
    {
        return self::COMPLETED;
    }
}
Enter fullscreen mode Exit fullscreen mode

As we can see, the implementation is much simpler if we have PHP at least 8.1 at our disposal. But still easy to implement in PHP 7 and 8.

There is one more topic that needs to be covered here: persistence. Since this is a lengthy topic, stay tuned for the next post about it!

Top comments (0)