What are Data Transfer Objects?

A data transfer object (DTO) is a simple class that holds the data and methods required to perform a specific action.

Application Setup

Let’s imagine we have an app with a users table structured like this:

column_namedata_typeis_nullable
idbigint unsignedNO
first_namevarchar(255)YES
last_namevarchar(255)YES
usernamevarchar(255)YES
emailvarchar(255)NO
passwordvarchar(255)NO
created_attimestampNO
updated_attimestampNO

As you might have noticed, first_name, last_name, and username can be nullable. This is a common situation where a user can choose to provide their full name or just an alias.

Using an array

Very often devs default to using a generic array; the appeal is that it’s quick and easy to set up but with time, we realize that they can become inflexible and cumbersome to work with once requirements evolve. Consider this example:

class UserService
{
    public function create(
        array $userData,
    ): User {
        return User::create([
            'first_name' => $userData['first_name'] ?? null,
            'last_name' => $userData['last_name'] ?? null,
            'username' => $userData['username'] ?? null,
            'email' => $userData['email'],
            'password' => Hash::make($userData['password']),
        ]);
    }
}

We receive an array through $userData, and we have to assume that the structure consists of first_name, last_name, username, email, and password. However, as we get more coding experience, we learn that assumption is our worst enemy. An array will never guarantee that the data will always be provided in this structure. This forces us to use the null coalescing operator to make sure no warnings are thrown since first_name, last_name, and username can be null and may not be provided.

Let’s see an example of how this could be used:

class UserCreationDataFormatter
{
    public function __construct(
        private array $userData,
    ) {}

    public function format(): array
    {
        return [
            'first_name' => $this->userData['first_name'] ?? null,
            'last_name' => $this->userData['last_name'] ?? null,
            'username' => $this->userData['username'] ?? null,
            'email' => $this->userData['email'],
            'password' => $this->userData['password'],
        ];
    }
}

class UserCreationValidator
{
    public function __construct(
        private array $userData,
    ) {}

    public function validate(): array
    {
        $userDataFormatted = (new UserCreationDataFormatter($this->userData))
            ->format();

        if (isDomainBanned($userDataFormatted['email'])) {
            throw new RuntimeException('Error');
        }

        return $userDataFormatted;
    }
}

class UserController
{
    public function create(
        UserCreateRequest $request,
    ): Response {
        $userDataValidated = (new UserCreationValidator($request->validated()))
            ->validate();

        (new UserService)->create($userDataValidated);

        return response('User was created', 201);
    }
}

Let’s delve into how we pass data received from a request across multiple classes, where all classes need to assume an array structure. Now, let’s introduce a change in logic: we allow users to select their country. You might think it’s straightforward, but it requires careful consideration. Imagine this change was introduced after a long period of time; you might not recall all the details and where this code is used.

Starting from the UserService, we begin modifying how we create a user. We realize that this code is only used by the controller, which receives data from the UserCreationValidator class, which in turn receives data from the UserCreationDataFormatter. Nothing inherently wrong here, but it’s easy to overlook potential mistakes that could lead to bugs.

Additionally, we need to consider that this code might be used elsewhere. Whenever we need a user record, we’ll have to check what’s required, which could be for a user, an order, an invoice, or a product. The idea is to ensure that we have all the necessary information before proceeding.

Using a DTO

Now, let’s introduce our DTO to this scenario and explore ways to enhance our code.

We’ve achieved a typed solution, where our IDE informs us about the optional and mandatory fields. Additionally, we’ve utilized an enum for the Country, eliminating the need to manually consider variations like “au,” “AU,” or “Australia.”

enum Country: string
{
    case US = 'US';
    case UK = 'UK';
    case AU = 'AU';
}

readonly class UserCreateDTO
{
    public function __construct(
        private ?string $firstName,
        private ?string $lastName,
        private ?string $username,
        private ?Country $country,
        private string $email,
        private string $password
    ) {}

    public function getFirstName(): ?string
    {
        return $this->firstName;
    }

    public function getLastName(): ?string
    {
        return $this->lastName;
    }

    public function getUsername(): ?string
    {
        return $this->username;
    }

    public function getEmail(): string
    {
        return $this->email;
    }

    public function getCountry(): ?Country
    {
        return $this->country ?? Country::AU;
    }

    public function getPassword(): string
    {
        return $this->password;
    }

    public function getHashedPassword(): string
    {
        return Hash::make($this->getPassword());
    }
}

class UserService
{
    public function create(
        UserCreateDTO $userData,
    ): User {
        return User::create([
            'first_name' => $userData->getFirstName(),
            'last_name' => $userData->getLastName(),
            'username' => $userData->getUsername(),
            'country' => $userData->getCountry()->value,
            'email' => $userData->getEmail(),
            'password' => $userData->getHashedPassword(),
        ]);
    }
}

class UserCreationValidator
{
    public function __construct(
        private UserCreateDTO $userData,
    ) {}

    public function validate(): void
    {
        if (isDomainBanned($this->userData->getEmail())) {
            throw new RuntimeException('Error');
        }
    }
}

class UserController
{
    public function create(
        UserCreateRequest $request,
    ): Response {
        $userData = new UserCreateDTO(
            firstName: $request->get('first_name'),
            lastName: $request->get('last_name'),
            username: $request->get('username'),
            country: $request->get('country'),
            email: $request->get('email'),
            password: $request->get('password'),
        );

        (new UserCreationValidator($userData))
            ->validate();

        (new UserService)->create($userData);

        return response('User was created', 201);
    }
}