Laravel Tips: Just Enough Domain Driven Design using Pest Architecture Tests

Alejandro Vélez-Calderón
Curology Tech
Published in
6 min readAug 9, 2023

--

At Laracon US 2023, Nuno Maduro unveiled some updates to Architecture tests in the Pest testing framework. We can use these to enforce simple boundaries between modules in a modular monolith.

Photo by Joe Pohle on Unsplash

What is a modular monolith?

The modular monolith approach allows you create domain specific boundaries that separate different components of your application from each other.

Modern frameworks like Laravel, or Rails provide the bare-bones structure to get your project going quickly, but as collaborators increase, so too does the complexity. Over time, allowing different business domains to interact with each other ad-hoc will blur the lines of your application and make it difficult to make changes.

In the beginning of a company, it may not be a good idea to draw these boundaries, as you’ll be defining your business needs and attempting to deliver value quickly to your stakeholders. Once your company starts to take shape, it is highly suggested you group your code into Domains or Modules — hence the term Modular Monolith.

A modular monolith can also define the interfaces that can then be transitioned into microservices. This transition is often smoother and less risky compared to transforming a traditional monolithic codebase into microservices.

Why modularize?

If we allow domains to manage data that does not belong to them, we will discover two main issues down the road:

  1. It will become harder to make changes because achieving one business result will require changes across the system.
  2. Innocuous changes will ripple into unrelated parts of our application.

For example, let’s look at a simple interface that updates a user’s name.

The User Service is a level of abstraction away from the users table.

The user service encapsulates any business logic needed to be performed like capitalizing a user’s name, or ensuring only alphabetical characters are used. It’s also the only place in our application that knows how to store a user’s name as it relates to the users table.

Now imagine a code base where this interface doesn’t exist, and any place in our application can update a user’s name.

No interface into the users table.

Let’s say our company would like to store an addressee name separately from a user’s first and last name. In our modularized code base, we only need to update one interface, but in our second example this change needs to be done in three separate places.

If your team is small this might not be big deal, but if we extrapolate this problem to a team of 50–100 developers, and multiple teams that own different parts of our system it can become very cumbersome to make changes.

Enforcing Boundaries

Modularizing a codebase can be as simple as creating new directories and moving files accordingly, but segmenting our code is only half the battle we also need supporting tools to enforce those boundaries.

There’s nothing preventing engineers from reaching across boundaries and importing classes that they are not supposed to. In modern IDEs they may even import the wrong class by mistake!

If we’ve defined a domain, but consumers still decide to create their own ways around it then our interface is weakened and over time we will find ourselves in an even more confusing code base e.g. “I thought we modularized this why is it still so hard to make changes?”.

A class circumvents our interface, and updates the users table itself.

We can monitor every PR that comes through our team’s domain, but this is prone to mistakes and takes time and effort. This is where Pest’s Architecture tests can help!

A Modular Monolith in Laravel

We’ll be describing the sample repository here https://github.com/alejandrovelez7/modular-laravel where we’ve showcased how to use Pest tests to enforce domain boundaries.

This repository has the following directory structure:

app
↳ Domains
↳ Sales
↳ Contracts
↳ DataTransferObjects
SalesService.php
Support
↳ Contracts
↳ DataTransferObjects
SupportService.php

Both the SalesService.php and the SupportService.php are interfaces, which means they define the API into each domain.

For example, the SalesService looks like:

<?php

declare(strict_types=1);

namespace App\Domains\Sales\Contracts;

use App\Domains\Sales\Contracts\DataTransferObjects\Customer;
use App\Domains\Sales\Contracts\DataTransferObjects\Opportunity;

interface SalesService
{
public function createOpportunity(): Opportunity;

public function getOpportunity(int $opportunityId): Opportunity;

public function getCustomer(int $customerId): Customer;
}

Consumers of this service should not care about the implementation details of the individual functions, but they must be able to import the return types and any custom exceptions that our service may throw.

The return type for our functions above are Data Transfer Objects that are also available to import from the Contracts directory.

The Customer DTO for example, looks like:

<?php

namespace App\Domains\Sales\Contracts\DataTransferObjects;

use Spatie\LaravelData\Data;

class Customer extends Data
{
public int $id;

public string $name;
}

By the way, shoutout to the Laravel Data package from the great people at Spatie!

At this point, we’ve built a very simple but effective modular monolith. Each directory inside the Domains directory corresponds to a module, and we’re only exposing the minimum amount of classes consumers need to interact across domains. We’ve also established a convention that domains should only communicate across boundaries via the Contracts directory and each domain’s services.

Assuming you’ve configured a service provider (example here), consumers can call your service across the application using:

<?php

resolve(SalesService::class)->getCustomer(1); // Woohoo!

The Problem Manifested

Despite your best efforts, you’ve discovered newer engineers have been inadvertently skipping your interface. They found there was a useful Model query in the Support domain, and they’ve been using it directly instead.

Again, nothing in our project prevents engineers from reaching across domains. This is where Pest can help!

Using Pest’s architecture tests we can simply write:

<?php

test('Only the support domain can use support models')
->expect('App\Domains\Support\Models')
->toOnlyBeUsedIn('App\Domains\Support');

Now if another engineer inadvertently circumvents our interface, Pest will kindly remind them not to overreach.

I’m hoping Pest adds globs to their DSL soon, as it would help simplify these tests even more, but this is already pretty light work!

Internal Boundaries

In general, within a domain the Contracts directory should be self-contained. If we find ourselves using private classes, it can be confusing to consumers, e.g. “can I use this class that is imported into the DTO?”.

We can enforce this boundary succinctly with:

<?php

test('contracts is isolated')
->expect('App\Domains\Support\Contracts')
->toUseNothing()
->ignoring('Spatie\LaravelData\Data')
->ignoring('Spatie\LaravelData\Attributes\DataCollectionOf');

Now we’re keeping our Contracts nice and simple!

Takeaways

This is only a small example of what it may look like to implement just enough domain driven design (thanks Sam Newman!) in a Laravel application using Pest. Keep an eye out on that sample repo, as we’ll try to keep it up to date as Pest continues to evolve. 😃

I hope the Pest DSL continues to adds niceties to these types of tests, because they can add a lot of clarity and structure! If we can define our codebase’s structure through tests it can speed up onboarding of new developers, and free up our subject matter experts to focus on new and more exciting opportunities. I think we could also take this one step further and generate modules through an artisan command that has set conventions supported by Pest tests. Would this be a useful package? Let us know what you think!

Also, if you’d like to connect hit me up on LinkedIn!

--

--

Alejandro Vélez-Calderón
Curology Tech

Senior Software Engineer @ Curology from Bayamón, Puerto Rico