Gestione delle eccezioni nel Domain-Driven Design
La gestione delle eccezioni nel Domain-Driven Design (DDD) è uno degli aspetti più delicati dell'intera architettura applicativa. Non si tratta semplicemente di intercettare errori e restituire messaggi all'utente, ma di modellare con cura le condizioni eccezionali del dominio, distinguendole nettamente dagli errori tecnici e infrastrutturali. Una corretta strategia di gestione delle eccezioni rende il codice più espressivo, più manutenibile e, soprattutto, più aderente al linguaggio ubiquo che caratterizza il dominio applicativo.
In questo articolo affronteremo in modo approfondito le diverse categorie di eccezioni che si incontrano in un'applicazione strutturata secondo i principi del DDD, le strategie per modellarle correttamente, l'utilizzo dei pattern alternativi come Result e Either, e infine la traduzione delle eccezioni di dominio in risposte HTTP nel contesto delle architetture esagonali e a porte e adattatori.
La natura delle eccezioni nel dominio
Prima di scrivere una sola riga di codice, è fondamentale comprendere che nel DDD esistono almeno tre categorie distinte di eccezioni: le eccezioni di dominio, le eccezioni applicative e le eccezioni infrastrutturali. Ognuna di queste categorie ha origini, semantiche e modalità di gestione profondamente diverse.
Le eccezioni di dominio rappresentano violazioni di regole di business o invarianti del modello. Quando un cliente tenta di effettuare un acquisto superando il proprio credito disponibile, oppure quando si cerca di confermare un ordine già confermato, ci troviamo di fronte a situazioni che il dominio stesso considera anomale e che devono essere espresse attraverso il linguaggio ubiquo. Queste eccezioni non sono errori del programmatore né problemi tecnici: sono parte integrante del modello concettuale.
Le eccezioni applicative, invece, sorgono al livello degli use case o degli application service. Tipicamente segnalano problemi di orchestrazione, come l'assenza di un'entità richiesta o la violazione di una precondizione di un caso d'uso. Pur essendo legate alla logica applicativa, non appartengono al cuore del dominio.
Le eccezioni infrastrutturali, infine, sono quelle che provengono dalle dipendenze esterne: database non raggiungibili, servizi REST in timeout, errori di serializzazione. Queste non devono mai contaminare il modello di dominio e vanno tradotte ai confini dell'architettura.
Modellare le eccezioni di dominio
Il primo passo per una gestione corretta consiste nel creare gerarchie di eccezioni che rispecchino la struttura concettuale del dominio. Vediamo un esempio in PHP, ispirato a un classico contesto di e-commerce.
<?php
namespace App\Domain\Order\Exception;
abstract class DomainException extends \RuntimeException
{
// Classe base per tutte le eccezioni di dominio
protected string $errorCode;
public function __construct(string $message, string $errorCode, ?\Throwable $previous = null)
{
parent::__construct($message, 0, $previous);
$this->errorCode = $errorCode;
}
public function getErrorCode(): string
{
return $this->errorCode;
}
}
La classe astratta DomainException funge da radice per tutta la gerarchia. Notiamo che estende RuntimeException e non Exception: questa scelta non è casuale. Le eccezioni di dominio rappresentano condizioni che il chiamante non è tenuto a gestire obbligatoriamente in ogni punto, ma che vengono raccolte e tradotte a livelli architetturali più alti. Il campo errorCode è un identificatore stabile che può essere utilizzato per la traduzione in messaggi localizzati o per il logging strutturato.
Vediamo ora come si specializza questa classe per un caso concreto.
<?php
namespace App\Domain\Order\Exception;
final class OrderAlreadyConfirmedException extends DomainException
{
// Eccezione sollevata quando si tenta di confermare un ordine già confermato
public static function withId(string $orderId): self
{
return new self(
sprintf('Order with id "%s" has already been confirmed.', $orderId),
'order.already_confirmed'
);
}
}
L'uso di un factory method statico (withId) è una pratica eccellente: rende il codice chiamante più espressivo e centralizza la costruzione del messaggio. Inoltre, la classe è dichiarata final perché rappresenta una condizione di errore atomica e non destinata a essere ulteriormente specializzata.
Sollevare eccezioni nei modelli di dominio
Vediamo ora come queste eccezioni vengono utilizzate all'interno di un'entità del dominio. Il principio fondamentale è che le invarianti devono essere protette dai metodi del modello stesso, mai dal codice chiamante.
<?php
namespace App\Domain\Order;
use App\Domain\Order\Exception\OrderAlreadyConfirmedException;
use App\Domain\Order\Exception\EmptyOrderException;
final class Order
{
private OrderId $id;
private OrderStatus $status;
private array $items;
public function __construct(OrderId $id)
{
$this->id = $id;
$this->status = OrderStatus::draft();
$this->items = [];
}
public function confirm(): void
{
// Verifica delle invarianti prima della transizione di stato
if ($this->status->isConfirmed()) {
throw OrderAlreadyConfirmedException::withId($this->id->value());
}
if (empty($this->items)) {
throw EmptyOrderException::withId($this->id->value());
}
$this->status = OrderStatus::confirmed();
}
}
Osserviamo come il metodo confirm() verifica due invarianti distinte prima di procedere con la transizione di stato. L'ordine delle verifiche è importante: solleviamo per prima l'eccezione che rappresenta la condizione più grave o più probabile, in modo da non eseguire controlli inutili. Questo pattern, noto come guard clause, mantiene il corpo dei metodi piatto e leggibile.
Eccezioni applicative e use case
Salendo di un livello, troviamo le eccezioni applicative. Queste si manifestano tipicamente nei command handler e nei query handler, e segnalano problemi nell'orchestrazione del caso d'uso.
<?php
namespace App\Application\Order\Exception;
abstract class ApplicationException extends \RuntimeException
{
protected string $errorCode;
public function __construct(string $message, string $errorCode)
{
parent::__construct($message);
$this->errorCode = $errorCode;
}
public function getErrorCode(): string
{
return $this->errorCode;
}
}
final class OrderNotFoundException extends ApplicationException
{
// Eccezione sollevata quando un ordine richiesto non viene trovato
public static function withId(string $orderId): self
{
return new self(
sprintf('Order with id "%s" was not found.', $orderId),
'order.not_found'
);
}
}
È importante notare la differenza semantica: OrderNotFoundException non è un'eccezione di dominio, perché l'assenza di un ordine non viola alcuna invariante del modello. È piuttosto una condizione operativa che il caso d'uso deve gestire.
<?php
namespace App\Application\Order\Command;
use App\Application\Order\Exception\OrderNotFoundException;
use App\Domain\Order\OrderRepositoryInterface;
final class ConfirmOrderHandler
{
public function __construct(
private OrderRepositoryInterface $repository
) {}
public function __invoke(ConfirmOrderCommand $command): void
{
// Recupero dell'aggregato dal repository
$order = $this->repository->findById($command->orderId());
if ($order === null) {
throw OrderNotFoundException::withId($command->orderId()->value());
}
// Il dominio si occupa di proteggere le proprie invarianti
$order->confirm();
$this->repository->save($order);
}
}
Il command handler delega completamente la verifica delle invarianti all'aggregato. Il suo compito si limita al recupero, all'invocazione del metodo di dominio e alla persistenza. Questa separazione netta tra responsabilità applicative e di dominio è uno dei principi cardine del DDD.
Il pattern Result come alternativa alle eccezioni
Le eccezioni sono uno strumento potente, ma presentano anche svantaggi: interrompono il flusso normale del programma, sono costose in termini di performance e possono rendere il control flow difficile da seguire. Per questo motivo, molti progetti DDD moderni adottano il pattern Result, ispirato a linguaggi funzionali come Rust o Haskell.
Vediamo un'implementazione in Java, particolarmente adatta a questo tipo di approccio grazie al supporto per generici e record.
package com.example.shared;
import java.util.function.Function;
public sealed interface Result<T, E> permits Result.Success, Result.Failure {
// Implementazione di successo
record Success<T, E>(T value) implements Result<T, E> {
@Override
public boolean isSuccess() {
return true;
}
@Override
public T getValue() {
return value;
}
@Override
public E getError() {
throw new IllegalStateException("Success has no error");
}
}
// Implementazione di fallimento
record Failure<T, E>(E error) implements Result<T, E> {
@Override
public boolean isSuccess() {
return false;
}
@Override
public T getValue() {
throw new IllegalStateException("Failure has no value");
}
@Override
public E getError() {
return error;
}
}
boolean isSuccess();
T getValue();
E getError();
static <T, E> Result<T, E> success(T value) {
return new Success<>(value);
}
static <T, E> Result<T, E> failure(E error) {
return new Failure<>(error);
}
default <U> Result<U, E> map(Function<T, U> mapper) {
// Trasforma il valore in caso di successo, propaga l'errore altrimenti
if (isSuccess()) {
return Result.success(mapper.apply(getValue()));
}
return Result.failure(getError());
}
default <U> Result<U, E> flatMap(Function<T, Result<U, E>> mapper) {
// Concatena operazioni che possono a loro volta fallire
if (isSuccess()) {
return mapper.apply(getValue());
}
return Result.failure(getError());
}
}
L'utilizzo delle sealed interfaces introdotte in Java 17 permette di esprimere in modo elegante l'esaustività dei due casi possibili. Il compilatore stesso ci garantisce che ogni Result sia o un Success o un Failure, escludendo qualsiasi altra possibilità.
Vediamo come si traduce un command handler precedentemente basato su eccezioni in una versione che utilizza Result.
package com.example.order.application;
import com.example.order.domain.Order;
import com.example.order.domain.OrderId;
import com.example.order.domain.OrderRepository;
import com.example.shared.Result;
public final class ConfirmOrderHandler {
private final OrderRepository repository;
public ConfirmOrderHandler(OrderRepository repository) {
this.repository = repository;
}
public Result<Void, OrderError> handle(ConfirmOrderCommand command) {
// Recupero dell'aggregato senza sollevare eccezioni
var maybeOrder = repository.findById(command.orderId());
if (maybeOrder.isEmpty()) {
return Result.failure(OrderError.notFound(command.orderId()));
}
Order order = maybeOrder.get();
// Il metodo confirm restituisce a sua volta un Result
return order.confirm()
.map(confirmedOrder -> {
repository.save(confirmedOrder);
return null;
});
}
}
Notiamo come l'uso del Result rende esplicito nel tipo di ritorno il fatto che l'operazione può fallire. Il chiamante è obbligato a gestire entrambi i casi, eliminando la possibilità di dimenticare un blocco catch. Tuttavia, questo stile richiede una maggiore disciplina e può appesantire il codice quando le operazioni concatenate sono molte.
Quando preferire eccezioni e quando preferire Result
Non esiste una risposta univoca a questa domanda. Tuttavia, alcune linee guida empiriche possono aiutare nella decisione. Le eccezioni sono preferibili quando l'errore è veramente eccezionale, cioè quando si verifica raramente e rappresenta una condizione che il chiamante normale non dovrebbe gestire. Sono inoltre più idiomatiche in linguaggi come PHP, Python e Java tradizionale, dove la cultura del linguaggio le supporta nativamente.
Il pattern Result, invece, brilla quando gli errori fanno parte del flusso normale dell'applicazione, come nelle validazioni o nelle operazioni che possono legittimamente fallire per ragioni di business. È particolarmente potente nei domini dove la composizione funzionale è dominante, come nei microservizi reattivi o nelle pipeline di elaborazione dati.
Traduzione delle eccezioni ai confini dell'architettura
Indipendentemente dall'approccio scelto, le eccezioni di dominio non devono mai trapelare oltre i confini dell'architettura esagonale. Questo significa che il livello di presentazione, tipicamente un controller HTTP, deve tradurre ogni eccezione di dominio in una risposta appropriata.
<?php
namespace App\Infrastructure\Http\Controller;
use App\Application\Order\Command\ConfirmOrderCommand;
use App\Application\Order\Command\ConfirmOrderHandler;
use App\Application\Order\Exception\OrderNotFoundException;
use App\Domain\Order\Exception\OrderAlreadyConfirmedException;
use App\Domain\Order\Exception\EmptyOrderException;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
final class ConfirmOrderController
{
public function __construct(
private ConfirmOrderHandler $handler
) {}
public function __invoke(Request $request, string $orderId): JsonResponse
{
try {
$command = new ConfirmOrderCommand($orderId);
($this->handler)($command);
return new JsonResponse(['status' => 'confirmed'], Response::HTTP_OK);
} catch (OrderNotFoundException $e) {
// Errore applicativo: la risorsa non esiste
return new JsonResponse([
'error' => $e->getErrorCode(),
'message' => $e->getMessage(),
], Response::HTTP_NOT_FOUND);
} catch (OrderAlreadyConfirmedException | EmptyOrderException $e) {
// Violazioni di invarianti di dominio: conflitto sullo stato
return new JsonResponse([
'error' => $e->getErrorCode(),
'message' => $e->getMessage(),
], Response::HTTP_CONFLICT);
}
}
}
La mappatura tra eccezioni e codici di stato HTTP segue convenzioni semantiche precise. Un 404 Not Found è appropriato per risorse mancanti, mentre un 409 Conflict esprime correttamente il fatto che lo stato corrente della risorsa impedisce l'operazione richiesta. Le violazioni di validazione, invece, vengono tipicamente mappate su 422 Unprocessable Entity.
Gestione centralizzata tramite exception handler
Nei framework moderni è spesso preferibile centralizzare la traduzione delle eccezioni in un componente dedicato, anziché ripetere blocchi try-catch in ogni controller. In Symfony, ad esempio, si può utilizzare un EventSubscriber.
<?php
namespace App\Infrastructure\Http\EventSubscriber;
use App\Application\Order\Exception\OrderNotFoundException;
use App\Domain\Order\Exception\DomainException;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Event\ExceptionEvent;
use Symfony\Component\HttpKernel\KernelEvents;
final class ExceptionSubscriber implements EventSubscriberInterface
{
public static function getSubscribedEvents(): array
{
return [
KernelEvents::EXCEPTION => ['onKernelException', 10],
];
}
public function onKernelException(ExceptionEvent $event): void
{
$exception = $event->getThrowable();
// Mappatura delle eccezioni note in risposte HTTP
$response = match (true) {
$exception instanceof OrderNotFoundException => new JsonResponse([
'error' => $exception->getErrorCode(),
'message' => $exception->getMessage(),
], Response::HTTP_NOT_FOUND),
$exception instanceof DomainException => new JsonResponse([
'error' => $exception->getErrorCode(),
'message' => $exception->getMessage(),
], Response::HTTP_CONFLICT),
default => null,
};
if ($response !== null) {
$event->setResponse($response);
}
}
}
Questa soluzione mantiene i controller estremamente snelli e concentra in un unico punto la logica di traduzione. È inoltre facilmente estensibile: aggiungere una nuova eccezione richiede solo una modifica al subscriber, senza toccare il codice applicativo.
Logging e tracciabilità delle eccezioni
Un aspetto spesso trascurato è il logging differenziato. Le eccezioni di dominio non sono bug né incidenti tecnici: sono parte del flusso applicativo. Loggarle a livello error con stack trace completi inquina i log e genera falsi allarmi nei sistemi di monitoring. Le eccezioni infrastrutturali, al contrario, meritano la massima attenzione e devono produrre alert immediati.
<?php
namespace App\Infrastructure\Http\EventSubscriber;
use App\Application\Exception\ApplicationException;
use App\Domain\Exception\DomainException;
use Psr\Log\LoggerInterface;
use Symfony\Component\HttpKernel\Event\ExceptionEvent;
final class ExceptionLoggerSubscriber
{
public function __construct(
private LoggerInterface $logger
) {}
public function onKernelException(ExceptionEvent $event): void
{
$exception = $event->getThrowable();
// Logging con livelli differenziati per categoria
match (true) {
$exception instanceof DomainException => $this->logger->info(
'Domain rule violation',
['error_code' => $exception->getErrorCode()]
),
$exception instanceof ApplicationException => $this->logger->warning(
'Application error',
['error_code' => $exception->getErrorCode()]
),
default => $this->logger->error(
'Unexpected error',
['exception' => $exception]
),
};
}
}
Il livello info per le eccezioni di dominio riflette correttamente la loro natura: sono eventi attesi e parte del comportamento normale del sistema. Il livello warning per le eccezioni applicative segnala situazioni meritevoli di attenzione ma non critiche. Solo le eccezioni inattese, che indicano bug o problemi infrastrutturali, raggiungono il livello error.
Eccezioni nei value object
Un capitolo a parte meritano le eccezioni sollevate dai value object durante la loro costruzione. I value object devono essere sempre validi: non esiste un'istanza di EmailAddress con un valore non valido. Questa garanzia, nota come always-valid, viene tipicamente protetta sollevando eccezioni nel costruttore.
<?php
namespace App\Domain\Shared\ValueObject;
use App\Domain\Shared\Exception\InvalidEmailAddressException;
final class EmailAddress
{
private string $value;
public function __construct(string $value)
{
// Validazione invariante: ogni istanza è sempre valida
if (!filter_var($value, FILTER_VALIDATE_EMAIL)) {
throw InvalidEmailAddressException::withValue($value);
}
$this->value = strtolower($value);
}
public function value(): string
{
return $this->value;
}
public function equals(EmailAddress $other): bool
{
return $this->value === $other->value;
}
}
L'invariante è protetta direttamente nel costruttore, garantendo che ogni istanza esistente sia per costruzione valida. Questo elimina la necessità di controlli ripetuti nel codice client e centralizza la logica di validazione nel value object stesso.
Considerazioni finali
La gestione delle eccezioni nel Domain-Driven Design non è una questione meramente tecnica, ma riflette profondamente la qualità del modello di dominio. Eccezioni ben modellate raccontano la storia del business, distinguono con chiarezza le diverse tipologie di errore e mantengono separati i livelli architetturali. La scelta tra eccezioni tradizionali e pattern come Result dipende dal contesto, dalla cultura del linguaggio e dalle esigenze di performance, ma in ogni caso il principio fondamentale rimane invariato: gli errori di dominio sono dominio, e come tali vanno trattati con la stessa cura riservata alle entità e ai value object.
Investire tempo nella progettazione di una gerarchia di eccezioni espressiva, nella loro corretta collocazione architetturale e nella loro traduzione ai confini dell'applicazione produce un codice più robusto, più leggibile e più facile da evolvere nel tempo. È un investimento che ripaga rapidamente, soprattutto nei progetti destinati a vivere a lungo.