« back published by @mmartin_joo on January 12, 2022

Domain-Driven Design with Laravel - Value Objects

What Is Domain-Driven Design or DDD?

Domain-Driven Design is a software development approach that tries to bring the business language and the source code as close as possible.

I think that's the most simple yet correct definition of DDD. To be a little bit more specific it achieves this by making every important "stuff" a first-class citizen. If "Change invoice status" or "Increase order item quantity" are two sentences that your business people say a lot then you probably should have

  • ChangeInvoiceStatus
  • IncreaseOrderItemQuantity

classes somewhere in your application. That's one example of being a first-class citizen.

Now let's say you're working on some server management / monitoring application. Maybe a commercial one, or some internal tool for your own projects. What is the single most important "stuff" in this application? In my opinion, it's the server status. If it says "Healthy" everyone is happy, but if it says "Down" you know you're about to have a hard day. We know it's important but still, it's just a string in our database table. It's the 15th attribute on our Server model. And we're changing it from "Healthy" to "Deploying" in the 873rd line of some random Command or Job, or in a Model somewhere, or even worse in a Controller. Instead of having these string values and changing them, we can have classes like:

  • ServerStatus/Healthy
  • ServerStatus/Down
  • ServerStatus/Deploying

We can also have transitions like:

  • HealthyToDeploying

So this is what I mean by making things, first-class citizens. This is what Domain-Driven Design is all about (and a little bit more, but now this is the important thing).

What Is a Value Object in the Domain-Driven Design World?

What is the most annoying thing when you're working with legacy code? Yeah there's a lot of annoying thing, so I show my favorite one:

public function doSomething($data)
{
    // 954 lines of code here
}

public function getList($data)
{
    // 673 lines of code here
}

Of course there are no type hints but the great names suggest us that $data is an array in both cases. My next question is: what's inside $data? Of course we don't know that. I have to go scan 673 loc to get my answers. Ohh and 412 lines later it turns out that we also PUT something into $data. Great!

Originally PHP arrays tried to solve every problems at once: queues, stacks, lists, hash maps, trees. Everything. But with its weak type system it's very hard to maintain these kind of methods.

Domain-Driven Design's answer to these problems: Value Objects.

Value Object is a very simple class that contains mostly (but not only) scalar data. So it's a wrapper class that holds together related information. Let's see an example:

class Percent
{
    public function __construct(private readonly ?float $value)
    {
    }

    public static function from(?float $value): self
    {
        return new static($value);
    }

    public function format(string $defaultValue = ''): string
    {
        if ($this->value === null) {
            return $defaultValue;
        }

        return number_format($this->value * 100, 2) . '%';
    }
}

This is a Value Object that represents a percentage. As you can see it also enforces some "rules", like:

  • We always use two decimal places (or X decimal places from a config).
  • By default we represent a NULL value as an empty string.

The second rule is arbitrary and you can override it, but the first one is mandatory in this example. I use this class mostly (but not only) in HTTP Resources:

class HoldingResource extends JsonResource
{
    public function toArray($request)
    {
        return [
            'id' => $this->id,
            'ticker' => $this->stock->ticker,
            'averageCost' => Money::from($this->average_cost)->format(),
            'quantity' => Decimal::from($this->quantity)->format(),
            'investedCapital' => Money::from($this->invested_capital)->format(),
            'marketValue' => Money::from($this->market_value)->format(),
            'yield' => Percent::from($this->yield)->format(),
            'yieldOnCost' => Percent::from($this->yield_on_cost)->format(),
        ];
    }
}

When you take a look at this array you know immediately the type of every key.

Okay that's an easy example. Let's see something that more similar to the getList($data) above. We need to accept date filter from the FE and filter the results based on these. I'm sure you know some projects where you have a lot of date filters but in each place the developer named the filters different, for example:

// In the Product model
public function getProducts(array $filters)
{
    // startDate and endDate are strings
    // But in the wrong timezone and format
    $filters['startDate'];
    $filters['endDate'];
}

// In the Order model
public function getOrders(array $filters)
{
    // start and end are Carbon objects
    // But time is set to 00:00:00 and in fact you need them to filter
    $filters['start'];
    $filters['end'];
}

// In the Invoice model
public function getInvoices(array $filters)
{
    // from_date and to_date are DateTime objects
    // But from_time and end_time are relative timestamps from the
    // start_date and end_date that contains the time in seconds
    $filters['from_date'];
    $filters['from_time'];
    $filters['to_date'];
    $filters['to_time'];        
}

// In the Customers model
public function getCustomers(array $filters)
{
    // You know that it's not gonna be a CarbonInterval
    // But it's another array with timestamps
    $filters['interval'];
}

So different array keys, different date and time formats, sometimes nested arrays, sometimes different_casing and so on. The whole thing is a mess. I bet you've worked on code like this.

Let's make date filters great again! First we can define our own start and end dates:

class StartDate
{
    public Carbon $date;

    public function __construct(Carbon $date)
    {
        $this->date = $date->startOfDay();
    }

    public static function fromString(string $date): self
    {
        return new static(Carbon::parse($date));
    }

    public function __toString(): string
    {
        return $this->date->format('Y-m-d H:i:s');
    }
}

In this example I don't have to worry about time only date. So in this case I can enforce that start date means the start of a day. I have a __toString() because I want to use this class in Eloquent where statements. As you can see I can also enforce the format here. Similarly I have an EndDate class:

class EndDate
{
    public Carbon $date;

    public function __construct(Carbon $date)
    {
        $this->date = $date->endOfDay();
    }

    public static function fromString(string $date): self
    {
        return new static(Carbon::parse($date));
    }

    public function __toString(): string
    {
        return $this->date->format('Y-m-d H:i:s');
    }
}

Now we can construct a new DateFilter class from these two classes:

class DateFilter
{
    public function __construct(public StartDate $startDate, public EndDate $endDate)
    {
    }

    public static function fromCarbons(Carbon $startDate, Carbon $endDate): self
    {
        return new static(
            StartDate::fromString($startDate->toString()),
            EndDate::fromString($endDate->toString())
        );
    }
}

As you can see I love the idea of factory methods. In this case I have one that creates the class from Carbon objects.

The next is to create a DateFilter value object in the request:

class GetHoldingDividendsRequest extends FormRequest
{
    public function authorize()
    {
        return $this->getHolding()->user()->is($this->user());
    }

    public function getHolding(): Holding
    {
        return $this->route('holding');
    }

    public function getDateFilter(): ?DateFilter
    {
        if ($this->input('filter.startDate')) {
            return new DateFilter(
                StartDate::fromString($this->input('filter.startDate')),
                EndDate::fromString($this->input('filter.endDate')),
            );
        }

        return null;
    }

    public function rules()
    {
        return [
            'filter' => 'nullable|sometimes|array',
            'filter.startDate' => 'date|required_with:endDate',
            'filter.endDate' => 'date|required_with:startDate',
        ];
    }
}

Now I can use the DateFilter class in any query or scope:

public function wherePayDateBetween(?DateFilter $dates): self
{
    if ($dates) {
        return $this->whereBetween(
            'pay_date', 
            [$dates->startDate, $dates->endDate]
        );
    }

    return $this;
}

This is why the StartDate and EndDate overwrites the __toString() method. It can be used in a where expression.

We can also leverage the fromCarbons() factory like this:

public function thisWeek(User $user): float
{
    $dates = DateFilter::fromCarbons(now()->startOfWeek(), now()->endOfWeek());
    return $this->sumByDate($dates, $user);
}

public function thisMonth(User $user): float
{
    $dates = DateFilter::fromCarbons(now()->startOfMonth(), now()->endOfMonth());
    return $this->sumByDate($dates, $user);
}

I think using Value Objects as a container or wrapper around values is a very great way to make your code more maintainable.

The Benefits of Value Objects

  • Everything is type-hinted. No more getProduct($data) where you don't know what's gonna happen
  • Auto-completion. Your IDE knows that there is a startDate property of type StartDate on the DateFilter class.
  • High level code. I think it's much better to look at these methods.
  • Enforces some basic rules about these values and formats.
  • First-class citizens. Now you have simple and visible classes in your ValueObjects directory so every developer should use them and will know how your application represents a percentage for example.
Domain-Driven Design with Laravel