Skip to content

🚨 Domain Exception Handling Guide

Comprehensive guide to designing and implementing domain exceptions for XOOPS 2026 modules.

Well-designed exceptions communicate what went wrong, where it happened, and often how to fix it. This guide covers exception hierarchies, best practices, and integration patterns.


Exception Philosophy

flowchart TB
    subgraph Philosophy["Exception Design Philosophy"]
        SPEC[Specific over Generic]
        MSG[Meaningful Messages]
        CTX[Rich Context]
        REC[Recoverable Info]
    end

    subgraph AntiPatterns["Anti-Patterns"]
        GEN[Generic Exception]
        EMPTY[Empty Message]
        CATCH[Catch-All]
        SILENT[Silent Failure]
    end

    Philosophy -->|Prefer| GOOD[Good Exception Design]
    AntiPatterns -->|Avoid| BAD[Poor Exception Design]

Key Principles: 1. Be Specific: Use domain-specific exceptions, not generic ones 2. Be Meaningful: Messages should explain what happened 3. Be Contextual: Include relevant data for debugging 4. Be Recoverable: Indicate if the error is fixable


Exception Hierarchy

classDiagram
    class DomainException {
        <<abstract>>
        +getContext(): array
        +toArray(): array
    }

    class EntityException {
        <<abstract>>
        +getEntityType(): string
        +getEntityId(): ?string
    }

    class ValidationException {
        <<abstract>>
        +getViolations(): array
    }

    class InvalidArticleId
    class InvalidAuthorId
    class InvalidCategoryId
    class InvalidCommentId
    class InvalidTagId
    class ArticleNotFound
    class ArticleAlreadyPublished
    class InvalidStatusTransition
    class InvalidArticleTitle
    class InvalidArticleContent

    DomainException <|-- EntityException
    DomainException <|-- ValidationException

    EntityException <|-- InvalidArticleId
    EntityException <|-- InvalidAuthorId
    EntityException <|-- InvalidCategoryId
    EntityException <|-- InvalidCommentId
    EntityException <|-- InvalidTagId
    EntityException <|-- ArticleNotFound
    EntityException <|-- ArticleAlreadyPublished
    EntityException <|-- InvalidStatusTransition

    ValidationException <|-- InvalidArticleTitle
    ValidationException <|-- InvalidArticleContent

Base Exception Classes

DomainException

The root of all domain exceptions:

<?php

declare(strict_types=1);

namespace Xoops\GoldStandard\Domain\Exception;

/**
 * Base exception for all domain errors
 *
 * Extends RuntimeException because domain errors typically occur
 * during runtime when business rules are violated.
 */
abstract class DomainException extends \RuntimeException implements \JsonSerializable
{
    /**
     * Additional context data for debugging
     */
    protected array $context = [];

    /**
     * Create exception with message and context
     */
    public function __construct(
        string $message,
        array $context = [],
        int $code = 0,
        ?\Throwable $previous = null,
    ) {
        parent::__construct($message, $code, $previous);
        $this->context = $context;
    }

    /**
     * Get additional context data
     */
    public function getContext(): array
    {
        return $this->context;
    }

    /**
     * Get a specific context value
     */
    public function getContextValue(string $key, mixed $default = null): mixed
    {
        return $this->context[$key] ?? $default;
    }

    /**
     * Get error code identifier (for API responses)
     */
    public function getErrorCode(): string
    {
        // Convert class name to error code
        // InvalidArticleId -> INVALID_ARTICLE_ID
        $className = (new \ReflectionClass($this))->getShortName();
        return strtoupper(preg_replace('/([a-z])([A-Z])/', '$1_$2', $className));
    }

    /**
     * Convert to array (for logging/API responses)
     */
    public function toArray(): array
    {
        return [
            'error' => $this->getErrorCode(),
            'message' => $this->getMessage(),
            'context' => $this->context,
        ];
    }

    /**
     * JSON serialization
     */
    public function jsonSerialize(): array
    {
        return $this->toArray();
    }

    /**
     * Create a user-friendly message (for UI display)
     */
    public function getUserMessage(): string
    {
        return $this->getMessage();
    }
}

EntityException

For entity-related errors:

<?php

declare(strict_types=1);

namespace Xoops\GoldStandard\Domain\Exception;

/**
 * Base exception for entity-related errors
 */
abstract class EntityException extends DomainException
{
    protected ?string $entityId = null;

    public function __construct(
        string $message,
        ?string $entityId = null,
        array $context = [],
        int $code = 0,
        ?\Throwable $previous = null,
    ) {
        $this->entityId = $entityId;

        if ($entityId !== null) {
            $context['entity_id'] = $entityId;
        }

        parent::__construct($message, $context, $code, $previous);
    }

    /**
     * Get the entity type (e.g., "article", "author")
     */
    abstract public function getEntityType(): string;

    /**
     * Get the entity ID if available
     */
    public function getEntityId(): ?string
    {
        return $this->entityId;
    }

    public function toArray(): array
    {
        return array_merge(parent::toArray(), [
            'entity_type' => $this->getEntityType(),
            'entity_id' => $this->entityId,
        ]);
    }
}

ValidationException

For validation errors:

<?php

declare(strict_types=1);

namespace Xoops\GoldStandard\Domain\Exception;

/**
 * Base exception for validation errors
 */
abstract class ValidationException extends DomainException
{
    /** @var array<string, string[]> Field => error messages */
    protected array $violations = [];

    public function __construct(
        string $message,
        array $violations = [],
        array $context = [],
        int $code = 0,
        ?\Throwable $previous = null,
    ) {
        $this->violations = $violations;
        $context['violations'] = $violations;

        parent::__construct($message, $context, $code, $previous);
    }

    /**
     * Get all validation violations
     *
     * @return array<string, string[]>
     */
    public function getViolations(): array
    {
        return $this->violations;
    }

    /**
     * Get violations for a specific field
     */
    public function getFieldErrors(string $field): array
    {
        return $this->violations[$field] ?? [];
    }

    /**
     * Check if a field has errors
     */
    public function hasFieldErrors(string $field): bool
    {
        return isset($this->violations[$field]) && !empty($this->violations[$field]);
    }

    public function toArray(): array
    {
        return array_merge(parent::toArray(), [
            'violations' => $this->violations,
        ]);
    }
}

ID Exception Classes

InvalidArticleId

<?php

declare(strict_types=1);

namespace Xoops\GoldStandard\Domain\Exception;

/**
 * Thrown when an invalid article ID is provided
 */
final class InvalidArticleId extends EntityException
{
    /**
     * Create from invalid ID string
     */
    public static function fromString(string $id): self
    {
        return new self(
            message: "Invalid article ID format: '{$id}'. Expected a valid ULID (26 characters).",
            entityId: $id,
            context: [
                'provided_id' => $id,
                'expected_format' => 'ULID (26 characters, Crockford Base32)',
                'example' => '01HV8X5Z0KDMVR8SDPY62J9ACP',
            ],
        );
    }

    /**
     * Create for empty ID
     */
    public static function empty(): self
    {
        return new self(
            message: 'Article ID cannot be empty.',
            context: ['reason' => 'empty_value'],
        );
    }

    public function getEntityType(): string
    {
        return 'article';
    }

    public function getUserMessage(): string
    {
        return 'The provided article ID is invalid.';
    }
}

InvalidAuthorId

<?php

declare(strict_types=1);

namespace Xoops\GoldStandard\Domain\Exception;

/**
 * Thrown when an invalid author ID is provided
 */
final class InvalidAuthorId extends EntityException
{
    public static function fromString(string $id): self
    {
        return new self(
            message: "Invalid author ID format: '{$id}'. Expected a valid ULID.",
            entityId: $id,
            context: [
                'provided_id' => $id,
                'expected_format' => 'ULID',
            ],
        );
    }

    public function getEntityType(): string
    {
        return 'author';
    }
}

InvalidCategoryId

<?php

declare(strict_types=1);

namespace Xoops\GoldStandard\Domain\Exception;

/**
 * Thrown when an invalid category ID is provided
 */
final class InvalidCategoryId extends EntityException
{
    public static function fromString(string $id): self
    {
        return new self(
            message: "Invalid category ID format: '{$id}'. Expected a valid ULID.",
            entityId: $id,
        );
    }

    public function getEntityType(): string
    {
        return 'category';
    }
}

InvalidCommentId

<?php

declare(strict_types=1);

namespace Xoops\GoldStandard\Domain\Exception;

/**
 * Thrown when an invalid comment ID is provided
 */
final class InvalidCommentId extends EntityException
{
    public static function fromString(string $id): self
    {
        return new self(
            message: "Invalid comment ID format: '{$id}'. Expected a valid ULID.",
            entityId: $id,
        );
    }

    public function getEntityType(): string
    {
        return 'comment';
    }
}

InvalidTagId

<?php

declare(strict_types=1);

namespace Xoops\GoldStandard\Domain\Exception;

/**
 * Thrown when an invalid tag ID is provided
 */
final class InvalidTagId extends EntityException
{
    public static function fromString(string $id): self
    {
        return new self(
            message: "Invalid tag ID format: '{$id}'. Expected a valid ULID.",
            entityId: $id,
        );
    }

    public function getEntityType(): string
    {
        return 'tag';
    }
}

Entity State Exceptions

ArticleNotFound

<?php

declare(strict_types=1);

namespace Xoops\GoldStandard\Domain\Exception;

/**
 * Thrown when an article cannot be found
 */
final class ArticleNotFound extends EntityException
{
    public static function withId(string $id): self
    {
        return new self(
            message: "Article not found with ID: '{$id}'.",
            entityId: $id,
            code: 404,
        );
    }

    public static function withSlug(string $slug): self
    {
        return new self(
            message: "Article not found with slug: '{$slug}'.",
            context: ['slug' => $slug],
            code: 404,
        );
    }

    public function getEntityType(): string
    {
        return 'article';
    }

    public function getUserMessage(): string
    {
        return 'The requested article could not be found.';
    }
}

InvalidStatusTransition

<?php

declare(strict_types=1);

namespace Xoops\GoldStandard\Domain\Exception;

use Xoops\GoldStandard\Domain\Entity\ArticleStatus;

/**
 * Thrown when an invalid status transition is attempted
 */
final class InvalidStatusTransition extends EntityException
{
    public static function create(
        string $articleId,
        ArticleStatus $from,
        ArticleStatus $to,
    ): self {
        $allowedTransitions = match ($from) {
            ArticleStatus::Draft => 'published, archived',
            ArticleStatus::Published => 'archived',
            ArticleStatus::Archived => 'none (terminal state)',
        };

        return new self(
            message: "Cannot transition article from '{$from->value}' to '{$to->value}'.",
            entityId: $articleId,
            context: [
                'current_status' => $from->value,
                'requested_status' => $to->value,
                'allowed_transitions' => $allowedTransitions,
            ],
        );
    }

    public function getEntityType(): string
    {
        return 'article';
    }

    public function getUserMessage(): string
    {
        $from = $this->context['current_status'] ?? 'unknown';
        $to = $this->context['requested_status'] ?? 'unknown';

        return "This article cannot be changed from {$from} to {$to}.";
    }
}

ArticleAlreadyPublished

<?php

declare(strict_types=1);

namespace Xoops\GoldStandard\Domain\Exception;

/**
 * Thrown when trying to publish an already published article
 */
final class ArticleAlreadyPublished extends EntityException
{
    public static function withId(string $id, \DateTimeImmutable $publishedAt): self
    {
        return new self(
            message: "Article '{$id}' is already published.",
            entityId: $id,
            context: [
                'published_at' => $publishedAt->format(\DateTimeInterface::ATOM),
            ],
        );
    }

    public function getEntityType(): string
    {
        return 'article';
    }

    public function getUserMessage(): string
    {
        return 'This article has already been published.';
    }
}

Validation Exceptions

InvalidArticleTitle

<?php

declare(strict_types=1);

namespace Xoops\GoldStandard\Domain\Exception;

/**
 * Thrown when an article title is invalid
 */
final class InvalidArticleTitle extends ValidationException
{
    private const MIN_LENGTH = 3;
    private const MAX_LENGTH = 255;

    public static function empty(): self
    {
        return new self(
            message: 'Article title cannot be empty.',
            violations: ['title' => ['Title is required.']],
        );
    }

    public static function tooShort(string $title): self
    {
        $length = mb_strlen($title);
        return new self(
            message: "Article title is too short ({$length} characters). Minimum is " . self::MIN_LENGTH . ".",
            violations: ['title' => ["Title must be at least " . self::MIN_LENGTH . " characters."]],
            context: [
                'provided_length' => $length,
                'min_length' => self::MIN_LENGTH,
            ],
        );
    }

    public static function tooLong(string $title): self
    {
        $length = mb_strlen($title);
        return new self(
            message: "Article title is too long ({$length} characters). Maximum is " . self::MAX_LENGTH . ".",
            violations: ['title' => ["Title cannot exceed " . self::MAX_LENGTH . " characters."]],
            context: [
                'provided_length' => $length,
                'max_length' => self::MAX_LENGTH,
            ],
        );
    }

    public function getUserMessage(): string
    {
        $errors = $this->getFieldErrors('title');
        return $errors[0] ?? 'Invalid article title.';
    }
}

InvalidArticleContent

<?php

declare(strict_types=1);

namespace Xoops\GoldStandard\Domain\Exception;

/**
 * Thrown when article content is invalid
 */
final class InvalidArticleContent extends ValidationException
{
    private const MIN_LENGTH = 50;
    private const MAX_LENGTH = 100000;

    public static function tooShort(int $length): self
    {
        return new self(
            message: "Article content is too short ({$length} characters). Minimum is " . self::MIN_LENGTH . ".",
            violations: ['content' => ["Content must be at least " . self::MIN_LENGTH . " characters."]],
            context: [
                'provided_length' => $length,
                'min_length' => self::MIN_LENGTH,
            ],
        );
    }

    public static function tooLong(int $length): self
    {
        return new self(
            message: "Article content is too long ({$length} characters). Maximum is " . self::MAX_LENGTH . ".",
            violations: ['content' => ["Content cannot exceed " . self::MAX_LENGTH . " characters."]],
            context: [
                'provided_length' => $length,
                'max_length' => self::MAX_LENGTH,
            ],
        );
    }

    public static function invalidFormat(string $reason): self
    {
        return new self(
            message: "Article content format is invalid: {$reason}",
            violations: ['content' => [$reason]],
        );
    }
}

Using Exceptions in Value Objects

Integration with the EntityId trait:

<?php

declare(strict_types=1);

namespace Xoops\GoldStandard\Domain\ValueObject;

use Xmf\EntityId;
use Xoops\GoldStandard\Domain\Exception\InvalidArticleId;

final readonly class ArticleId implements \Stringable, \JsonSerializable
{
    use EntityId;

    protected static function exceptionClass(): string
    {
        return InvalidArticleId::class;
    }
}

// Usage:
try {
    $id = ArticleId::fromString('invalid-id');
} catch (InvalidArticleId $e) {
    // Handle specific exception
    echo $e->getUserMessage();
    echo $e->getErrorCode(); // "INVALID_ARTICLE_ID"

    // Log with context
    $logger->error($e->getMessage(), $e->getContext());
}

Exception Handling in Controllers

REST API Controller

<?php

declare(strict_types=1);

namespace Xoops\GoldStandard\Infrastructure\Http;

use Xoops\GoldStandard\Domain\Exception\DomainException;
use Xoops\GoldStandard\Domain\Exception\EntityException;
use Xoops\GoldStandard\Domain\Exception\ValidationException;
use Psr\Http\Message\ResponseInterface;

final class ArticleController
{
    public function show(string $id): ResponseInterface
    {
        try {
            $articleId = ArticleId::fromString($id);
            $article = $this->articleRepository->findOrFail($articleId);

            return $this->json($article->toArray());

        } catch (EntityException $e) {
            return $this->errorResponse($e, $e->getCode() ?: 400);
        }
    }

    public function store(array $data): ResponseInterface
    {
        try {
            $article = $this->createArticleUseCase->execute($data);

            return $this->json($article->toArray(), 201);

        } catch (ValidationException $e) {
            return $this->validationErrorResponse($e);

        } catch (DomainException $e) {
            return $this->errorResponse($e, 400);
        }
    }

    private function errorResponse(DomainException $e, int $status): ResponseInterface
    {
        return $this->json([
            'error' => [
                'code' => $e->getErrorCode(),
                'message' => $e->getUserMessage(),
            ],
        ], $status);
    }

    private function validationErrorResponse(ValidationException $e): ResponseInterface
    {
        return $this->json([
            'error' => [
                'code' => 'VALIDATION_ERROR',
                'message' => $e->getUserMessage(),
                'details' => $e->getViolations(),
            ],
        ], 422);
    }
}

Global Exception Handler

<?php

declare(strict_types=1);

namespace Xoops\GoldStandard\Infrastructure\Http;

use Xoops\GoldStandard\Domain\Exception\DomainException;
use Xoops\GoldStandard\Domain\Exception\EntityException;
use Xoops\GoldStandard\Domain\Exception\ValidationException;
use Psr\Log\LoggerInterface;

final class ExceptionHandler
{
    public function __construct(
        private readonly LoggerInterface $logger,
        private readonly bool $debug = false,
    ) {}

    public function handle(\Throwable $e): array
    {
        // Log all exceptions
        $this->logger->error($e->getMessage(), [
            'exception' => get_class($e),
            'file' => $e->getFile(),
            'line' => $e->getLine(),
            'context' => $e instanceof DomainException ? $e->getContext() : [],
        ]);

        // Handle domain exceptions
        if ($e instanceof ValidationException) {
            return [
                'status' => 422,
                'body' => [
                    'error' => [
                        'code' => 'VALIDATION_ERROR',
                        'message' => $e->getUserMessage(),
                        'details' => $e->getViolations(),
                    ],
                ],
            ];
        }

        if ($e instanceof EntityException) {
            $status = $e->getCode() ?: 400;
            return [
                'status' => $status,
                'body' => [
                    'error' => [
                        'code' => $e->getErrorCode(),
                        'message' => $e->getUserMessage(),
                    ],
                ],
            ];
        }

        if ($e instanceof DomainException) {
            return [
                'status' => 400,
                'body' => [
                    'error' => [
                        'code' => $e->getErrorCode(),
                        'message' => $e->getUserMessage(),
                    ],
                ],
            ];
        }

        // Generic server error (hide details in production)
        return [
            'status' => 500,
            'body' => [
                'error' => [
                    'code' => 'INTERNAL_ERROR',
                    'message' => $this->debug
                        ? $e->getMessage()
                        : 'An unexpected error occurred.',
                ],
            ],
        ];
    }
}

Best Practices

1. Use Named Constructors

// Good: Clear, descriptive factory methods
InvalidArticleId::fromString($id);
InvalidArticleId::empty();
ArticleNotFound::withId($id);
ArticleNotFound::withSlug($slug);

// Avoid: Generic constructors
throw new InvalidArticleId("Invalid ID: {$id}");

2. Provide Context

// Good: Rich context for debugging
throw new self(
    message: "Invalid article ID: {$id}",
    context: [
        'provided_id' => $id,
        'expected_format' => 'ULID',
        'example' => '01HV8X5Z0KDMVR8SDPY62J9ACP',
    ],
);

// Avoid: No context
throw new InvalidArgumentException("Invalid ID");

3. Separate User and Developer Messages

// getMessage() - for logs/developers
// getUserMessage() - for UI display

$e->getMessage();      // "Invalid article ID format: 'abc'. Expected ULID."
$e->getUserMessage();  // "The provided article ID is invalid."

4. Use Specific Exception Types

// Good: Catch specific exceptions
try {
    $article = $this->find($id);
} catch (ArticleNotFound $e) {
    return $this->notFound($e->getUserMessage());
} catch (InvalidArticleId $e) {
    return $this->badRequest($e->getUserMessage());
}

// Avoid: Catching generic exceptions
try {
    $article = $this->find($id);
} catch (\Exception $e) {
    // What kind of error? How to handle?
}

5. Don't Use Exceptions for Flow Control

// Good: Check first
if ($this->articleRepository->exists($slug)) {
    return $this->findBySlug($slug);
}
return $this->createWithSlug($slug);

// Avoid: Using exceptions for flow control
try {
    return $this->findBySlug($slug);
} catch (ArticleNotFound $e) {
    return $this->createWithSlug($slug);
}

Testing Exceptions

<?php

use PHPUnit\Framework\TestCase;
use Xoops\GoldStandard\Domain\Exception\InvalidArticleId;
use Xoops\GoldStandard\Domain\ValueObject\ArticleId;

final class ArticleIdTest extends TestCase
{
    public function test_throws_for_invalid_format(): void
    {
        $this->expectException(InvalidArticleId::class);
        $this->expectExceptionMessage('Invalid article ID format');

        ArticleId::fromString('invalid');
    }

    public function test_exception_contains_context(): void
    {
        try {
            ArticleId::fromString('abc123');
            $this->fail('Expected exception');
        } catch (InvalidArticleId $e) {
            $this->assertSame('article', $e->getEntityType());
            $this->assertSame('abc123', $e->getEntityId());
            $this->assertArrayHasKey('expected_format', $e->getContext());
            $this->assertSame('INVALID_ARTICLE_ID', $e->getErrorCode());
        }
    }

    public function test_user_message_is_safe(): void
    {
        $e = InvalidArticleId::fromString('<script>alert("xss")</script>');

        // User message should not contain raw input
        $this->assertStringNotContainsString('<script>', $e->getUserMessage());
    }
}


exceptions #domain #error-handling #best-practices #goldstandard