Domain-Driven Design e gestione degli utenti in Laravel

Il Domain-Driven Design (DDD) è un approccio alla progettazione del software che pone il dominio di business al centro dello sviluppo. In un framework come Laravel, che per convenzione favorisce un'organizzazione del codice di tipo MVC con modelli Eloquent attivi, adottare DDD richiede un cambio di mentalità: separare con chiarezza la logica di dominio dall'infrastruttura, dall'applicazione e dalla presentazione. In questo articolo vedremo come applicare i principi del DDD alla gestione degli utenti, uno dei contesti più comuni e al tempo stesso più sottovalutati in ogni applicazione web.

Perché applicare il DDD alla gestione degli utenti

La gestione degli utenti viene spesso trattata come un problema risolto: si genera uno scaffold, si usano le classi di autenticazione fornite dal framework e si prosegue con il resto dell'applicazione. Tuttavia, non appena i requisiti diventano non banali — registrazione con conferma email, ruoli e permessi, profili estesi, politiche di password, blocco account, audit log — la logica tende a disperdersi tra controller, modelli Eloquent, service e middleware, rendendo il codice fragile e difficile da testare.

Il DDD propone di isolare questa logica in un bounded context ben definito, in cui l'entità User non è un semplice record di database ma un oggetto ricco di comportamento, con invarianti esplicite e un linguaggio ubiquo condiviso con gli esperti di dominio.

Struttura delle cartelle

Laravel non impone una struttura particolare oltre a quella di default, quindi siamo liberi di organizzare il codice secondo le esigenze del DDD. Una possibile disposizione è la seguente:

app/
  Modules/
    UserManagement/
      Domain/
        Model/
          User.php
          UserId.php
          Email.php
          HashedPassword.php
        Repository/
          UserRepository.php
        Event/
          UserRegistered.php
        Exception/
          EmailAlreadyTaken.php
      Application/
        Command/
          RegisterUserCommand.php
          RegisterUserHandler.php
        Query/
          FindUserByEmailQuery.php
      Infrastructure/
        Persistence/
          EloquentUserRepository.php
          UserEloquentModel.php
      Presentation/
        Http/
          Controllers/
            RegisterUserController.php
          Requests/
            RegisterUserRequest.php

I quattro livelli — Domain, Application, Infrastructure, Presentation — riflettono la classica architettura a strati del DDD. Il livello di dominio non dipende da nessun altro livello, mentre l'infrastruttura implementa le interfacce dichiarate nel dominio.

Value Object: Email e password

Nel DDD i concetti che non hanno un'identità propria ma sono definiti dai loro attributi vengono modellati come value object. Email e password sono candidati ideali: un'email è valida o non lo è, e la sua validazione non dipende dal contesto.

<?php

declare(strict_types=1);

namespace App\Modules\UserManagement\Domain\Model;

use InvalidArgumentException;

final class Email
{
    private string $value;

    public function __construct(string $value)
    {
        // Normalizzazione: l'email viene sempre salvata in minuscolo
        $normalized = strtolower(trim($value));

        if (!filter_var($normalized, FILTER_VALIDATE_EMAIL)) {
            throw new InvalidArgumentException("Invalid email address: {$value}");
        }

        $this->value = $normalized;
    }

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

    public function equals(Email $other): bool
    {
        return $this->value === $other->value;
    }
}

La password richiede un trattamento analogo, ma con un accorgimento: non dobbiamo mai esporre la password in chiaro. Modelliamo quindi un value object che rappresenti una password già sottoposta ad hashing.

<?php

declare(strict_types=1);

namespace App\Modules\UserManagement\Domain\Model;

use InvalidArgumentException;

final class HashedPassword
{
    private string $hash;

    private function __construct(string $hash)
    {
        $this->hash = $hash;
    }

    public static function fromPlainText(string $plain): self
    {
        // Applichiamo una politica minima sulla lunghezza
        if (strlen($plain) < 8) {
            throw new InvalidArgumentException('Password must be at least 8 characters long');
        }

        return new self(password_hash($plain, PASSWORD_BCRYPT));
    }

    public static function fromHash(string $hash): self
    {
        return new self($hash);
    }

    public function matches(string $plain): bool
    {
        return password_verify($plain, $this->hash);
    }

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

Si noti l'uso di due costruttori statici nominati: fromPlainText viene utilizzato quando si crea una nuova password, mentre fromHash è usato dal repository per ricostruire l'oggetto a partire dal database.

L'identità dell'utente

Anche l'identificatore dell'utente merita di essere un value object. Utilizzare un UUID generato a livello di dominio consente di assegnare un'identità all'utente prima ancora che sia persistito, un vantaggio notevole quando si lavora con eventi di dominio.

<?php

declare(strict_types=1);

namespace App\Modules\UserManagement\Domain\Model;

use Ramsey\Uuid\Uuid;

final class UserId
{
    private string $value;

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

    public static function generate(): self
    {
        return new self(Uuid::uuid4()->toString());
    }

    public static function fromString(string $value): self
    {
        return new self($value);
    }

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

    public function equals(UserId $other): bool
    {
        return $this->value === $other->value;
    }
}

L'entità User

L'entità User è l'aggregato principale del nostro bounded context. A differenza di un modello Eloquent, non estende alcuna classe del framework e non conosce il database. Espone metodi che rappresentano azioni di dominio significative, non semplici setter.

<?php

declare(strict_types=1);

namespace App\Modules\UserManagement\Domain\Model;

use App\Modules\UserManagement\Domain\Event\UserRegistered;
use DateTimeImmutable;

final class User
{
    private UserId $id;
    private Email $email;
    private HashedPassword $password;
    private string $name;
    private ?DateTimeImmutable $emailVerifiedAt;
    private array $recordedEvents = [];

    private function __construct(
        UserId $id,
        Email $email,
        HashedPassword $password,
        string $name,
        ?DateTimeImmutable $emailVerifiedAt = null
    ) {
        $this->id = $id;
        $this->email = $email;
        $this->password = $password;
        $this->name = $name;
        $this->emailVerifiedAt = $emailVerifiedAt;
    }

    public static function register(
        UserId $id,
        Email $email,
        HashedPassword $password,
        string $name
    ): self {
        $user = new self($id, $email, $password, $name);

        // Registriamo un evento di dominio: l'utente si è appena iscritto
        $user->recordedEvents[] = new UserRegistered($id, $email, new DateTimeImmutable());

        return $user;
    }

    public static function restore(
        UserId $id,
        Email $email,
        HashedPassword $password,
        string $name,
        ?DateTimeImmutable $emailVerifiedAt
    ): self {
        return new self($id, $email, $password, $name, $emailVerifiedAt);
    }

    public function verifyEmail(): void
    {
        if ($this->emailVerifiedAt !== null) {
            return;
        }

        $this->emailVerifiedAt = new DateTimeImmutable();
    }

    public function changePassword(HashedPassword $newPassword): void
    {
        $this->password = $newPassword;
    }

    public function id(): UserId
    {
        return $this->id;
    }

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

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

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

    public function emailVerifiedAt(): ?DateTimeImmutable
    {
        return $this->emailVerifiedAt;
    }

    public function releaseEvents(): array
    {
        $events = $this->recordedEvents;
        $this->recordedEvents = [];
        return $events;
    }
}

Il metodo factory register è l'unico modo per creare un nuovo utente; restore è riservato al repository per ricostruire un utente dal database. Questa separazione impedisce che il codice applicativo possa creare utenti bypassando le regole di dominio.

Il repository come interfaccia di dominio

Nel DDD il repository appartiene al dominio come interfaccia, mentre la sua implementazione concreta vive nel livello di infrastruttura. Questo consente di mantenere il dominio indipendente dal meccanismo di persistenza.

<?php

declare(strict_types=1);

namespace App\Modules\UserManagement\Domain\Repository;

use App\Modules\UserManagement\Domain\Model\Email;
use App\Modules\UserManagement\Domain\Model\User;
use App\Modules\UserManagement\Domain\Model\UserId;

interface UserRepository
{
    public function nextIdentity(): UserId;

    public function save(User $user): void;

    public function findById(UserId $id): ?User;

    public function findByEmail(Email $email): ?User;

    public function existsByEmail(Email $email): bool;
}

Implementazione Eloquent del repository

Nell'infrastruttura possiamo tranquillamente usare Eloquent, ma solo come dettaglio implementativo. Il modello Eloquent diventa un data mapper interno al repository e non viene mai esposto al dominio.

<?php

declare(strict_types=1);

namespace App\Modules\UserManagement\Infrastructure\Persistence;

use App\Modules\UserManagement\Domain\Model\Email;
use App\Modules\UserManagement\Domain\Model\HashedPassword;
use App\Modules\UserManagement\Domain\Model\User;
use App\Modules\UserManagement\Domain\Model\UserId;
use App\Modules\UserManagement\Domain\Repository\UserRepository;
use DateTimeImmutable;

final class EloquentUserRepository implements UserRepository
{
    public function nextIdentity(): UserId
    {
        return UserId::generate();
    }

    public function save(User $user): void
    {
        // Usiamo updateOrCreate per gestire sia creazione che aggiornamento
        UserEloquentModel::updateOrCreate(
            ['id' => $user->id()->value()],
            [
                'email' => $user->email()->value(),
                'password' => $user->password()->value(),
                'name' => $user->name(),
                'email_verified_at' => $user->emailVerifiedAt()?->format('Y-m-d H:i:s'),
            ]
        );
    }

    public function findById(UserId $id): ?User
    {
        $record = UserEloquentModel::find($id->value());

        return $record ? $this->toDomain($record) : null;
    }

    public function findByEmail(Email $email): ?User
    {
        $record = UserEloquentModel::where('email', $email->value())->first();

        return $record ? $this->toDomain($record) : null;
    }

    public function existsByEmail(Email $email): bool
    {
        return UserEloquentModel::where('email', $email->value())->exists();
    }

    private function toDomain(UserEloquentModel $record): User
    {
        // Ricostruzione dell'aggregato a partire dai dati grezzi
        return User::restore(
            UserId::fromString($record->id),
            new Email($record->email),
            HashedPassword::fromHash($record->password),
            $record->name,
            $record->email_verified_at
                ? new DateTimeImmutable($record->email_verified_at)
                : null
        );
    }
}

Comando e handler per la registrazione

Il livello applicativo orchestra le operazioni coordinando dominio e infrastruttura. Adottiamo il pattern Command Bus: un comando rappresenta un'intenzione, un handler ne esegue la logica.

<?php

declare(strict_types=1);

namespace App\Modules\UserManagement\Application\Command;

final class RegisterUserCommand
{
    public function __construct(
        public readonly string $email,
        public readonly string $plainPassword,
        public readonly string $name
    ) {
    }
}
<?php

declare(strict_types=1);

namespace App\Modules\UserManagement\Application\Command;

use App\Modules\UserManagement\Domain\Exception\EmailAlreadyTaken;
use App\Modules\UserManagement\Domain\Model\Email;
use App\Modules\UserManagement\Domain\Model\HashedPassword;
use App\Modules\UserManagement\Domain\Model\User;
use App\Modules\UserManagement\Domain\Repository\UserRepository;
use Illuminate\Contracts\Events\Dispatcher;

final class RegisterUserHandler
{
    public function __construct(
        private readonly UserRepository $users,
        private readonly Dispatcher $events
    ) {
    }

    public function handle(RegisterUserCommand $command): string
    {
        $email = new Email($command->email);

        // Verifica dell'unicità a livello di dominio
        if ($this->users->existsByEmail($email)) {
            throw new EmailAlreadyTaken($email);
        }

        $user = User::register(
            $this->users->nextIdentity(),
            $email,
            HashedPassword::fromPlainText($command->plainPassword),
            $command->name
        );

        $this->users->save($user);

        // Rilasciamo gli eventi di dominio accumulati nell'aggregato
        foreach ($user->releaseEvents() as $event) {
            $this->events->dispatch($event);
        }

        return $user->id()->value();
    }
}

Binding nel service provider

Per far sì che l'interfaccia UserRepository venga risolta con l'implementazione Eloquent, registriamo il binding in un service provider dedicato.

<?php

declare(strict_types=1);

namespace App\Providers;

use App\Modules\UserManagement\Domain\Repository\UserRepository;
use App\Modules\UserManagement\Infrastructure\Persistence\EloquentUserRepository;
use Illuminate\Support\ServiceProvider;

final class UserManagementServiceProvider extends ServiceProvider
{
    public function register(): void
    {
        $this->app->bind(UserRepository::class, EloquentUserRepository::class);
    }
}

Il controller come sottile adattatore HTTP

Nel livello di presentazione, il controller si limita a tradurre la richiesta HTTP in un comando e a gestire la risposta. Non contiene logica di dominio.

<?php

declare(strict_types=1);

namespace App\Modules\UserManagement\Presentation\Http\Controllers;

use App\Modules\UserManagement\Application\Command\RegisterUserCommand;
use App\Modules\UserManagement\Application\Command\RegisterUserHandler;
use App\Modules\UserManagement\Domain\Exception\EmailAlreadyTaken;
use App\Modules\UserManagement\Presentation\Http\Requests\RegisterUserRequest;
use Illuminate\Http\JsonResponse;

final class RegisterUserController
{
    public function __construct(private readonly RegisterUserHandler $handler)
    {
    }

    public function __invoke(RegisterUserRequest $request): JsonResponse
    {
        try {
            $userId = $this->handler->handle(new RegisterUserCommand(
                email: $request->input('email'),
                plainPassword: $request->input('password'),
                name: $request->input('name')
            ));
        } catch (EmailAlreadyTaken $exception) {
            // Trasformazione dell'eccezione di dominio in risposta HTTP
            return response()->json(['error' => $exception->getMessage()], 409);
        }

        return response()->json(['id' => $userId], 201);
    }
}

Eventi di dominio

Gli eventi di dominio rappresentano fatti accaduti nel passato e sono il meccanismo privilegiato per comunicare con altri bounded context senza creare accoppiamento diretto. L'evento UserRegistered può essere ascoltato da un listener che invia l'email di benvenuto, da un altro che crea un profilo di default, da un terzo che aggiorna un sistema di analytics — il tutto senza modificare l'handler della registrazione.

<?php

declare(strict_types=1);

namespace App\Modules\UserManagement\Domain\Event;

use App\Modules\UserManagement\Domain\Model\Email;
use App\Modules\UserManagement\Domain\Model\UserId;
use DateTimeImmutable;

final class UserRegistered
{
    public function __construct(
        public readonly UserId $userId,
        public readonly Email $email,
        public readonly DateTimeImmutable $occurredOn
    ) {
    }
}

Considerazioni finali

Applicare il DDD alla gestione degli utenti in Laravel comporta più codice e una curva di apprendimento più ripida rispetto allo scaffold tradizionale. Il vantaggio, però, emerge con chiarezza quando il dominio cresce: le regole di business vivono in un unico posto, sono testabili in isolamento senza toccare il database, e il codice rimane leggibile anche dopo mesi di evoluzione. Il framework diventa un dettaglio, non il protagonista, e questo è forse l'insegnamento più importante del Domain-Driven Design.

Non tutte le applicazioni richiedono questo livello di rigore: per un semplice CRUD l'approccio Eloquent-first di Laravel resta imbattibile per velocità di sviluppo. Ma quando il dominio ha una reale complessità di business, separare con disciplina i livelli ripaga ogni singola riga di codice in più.