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ù.