Eigene Exception-Hierarchien für fachliche und technische Fehler
AI generated
<?php
8.4
PHP · Exception-Hierarchien · Domain-Driven Design · Clean Architecture
Eigene Exception-Hierarchien für fachliche und technische Fehler
Basisklassen, Marker-Interfaces und Schichtenarchitektur in PHP 8.4

Wer in wachsenden PHP-Projekten weiterhin mit generischen Exceptions oder wahllos vielen Einzelklassen arbeitet, verliert schnell den Überblick zwischen fachlichen und technischen Fehlern. Dieser Artikel zeigt, wie eigene Exception-Hierarchien mit abstrakten Basisklassen, Marker-Interfaces und typisierten Kontextdaten entstehen, die über Domain-, Application- und Infrastructure-Schichten hinweg tragen.

14 Min. Lesezeit Exception-Hierarchien · Marker-Interfaces · Schichtenarchitektur PHP 8.4

1. Warum eine flache Exception-Struktur bei wachsenden Projekten scheitert

In den ersten Wochen eines Projekts reicht fast jede Fehlerbehandlung aus: ein paar SPL-Klassen, vielleicht eine einzige eigene Exception-Klasse für alles, was schiefgehen kann, und ein zentrales catch (Throwable $e) am Rand der Anwendung. Das funktioniert, solange die Zahl der Fehlerfälle klein und die Reaktion darauf immer gleich ist. Sobald ein Projekt wächst, wächst aber auch die Zahl der Stellen, an denen unterschiedlich auf Fehler reagiert werden muss, und genau hier beginnt eine flache Struktur zu scheitern.

Das typische Symptom ist ein Aufrufer, der per catch (Exception $e) fängt und dann anhand von $e->getMessage() oder str_contains() entscheidet, was eigentlich passiert ist. Ein Nutzer soll eine verständliche Meldung sehen, ein Entwickler braucht einen Stacktrace, das Monitoring braucht eine eindeutige Kategorie für Alerting, und alle drei Bedürfnisse hängen an derselben, fragilen Zeichenkette. Ohne eine durchdachte eigene Exception-Struktur wird jede Änderung an einer Fehlermeldung zu einem stillen Bruch an einer völlig anderen Stelle im Code.

Eigene Exception-Hierarchien lösen dieses Problem nicht durch mehr Klassen an sich, sondern durch eine bewusste Struktur: Klassen, die etwas über die Art des Fehlers aussagen, statt nur über seinen Text. Die folgenden Abschnitte bauen genau diese Struktur Schritt für Schritt auf, von der Trennung fachlicher und technischer Fehler bis zur Schichtenarchitektur im großen System.

2. Fachliche vs. technische Fehler: die zentrale Trennlinie für Exception-Hierarchien

Die wichtigste Entscheidung beim Entwurf eigener Exception-Hierarchien fällt, bevor die erste Klasse geschrieben wird: Ist ein Fehler fachlich oder technisch? Ein fachlicher Fehler ist Teil des normalen Geschäftsablaufs, ein Lagerbestand reicht nicht aus, ein Rabattcode ist abgelaufen, eine Kundennummer existiert nicht. Solche Fehler sind erwartbar, oft dem Endnutzer zumutbar zu erklären und in aller Regel kein Zeichen für einen Bug.

Ein technischer Fehler dagegen liegt außerhalb der fachlichen Logik: eine Datenbankverbindung bricht ab, ein externer Zahlungsdienst antwortet nicht, eine Konfigurationsdatei fehlt. Diese Fehler sind fast immer unerwartet, für Endnutzer nicht sinnvoll erklärbar und für den Betrieb alarmierungswürdig. Wenn Exception-Hierarchien diese Trennlinie nicht abbilden, landen fachliche und technische Fehler im selben catch-Block und im selben Log-Level, mit der Folge, dass entweder harmlose fachliche Fälle das Monitoring fluten oder echte Ausfälle in der Masse untergehen.

Diese Unterscheidung ist der rote Faden durch die gesamte restliche Architektur: Sie bestimmt, welchen HTTP-Statuscode eine API zurückgibt, welches Log-Level verwendet wird, ob ein Retry sinnvoll ist und ob überhaupt ein Mensch benachrichtigt werden muss. Jede weitere Design-Entscheidung in diesem Artikel, von der Basisklasse bis zum globalen Error-Handling, baut auf genau dieser fachlich/technisch-Trennung auf.

3. Eine abstrakte Basisklasse für die eigene Exception-Hierarchie entwerfen

Der erste Baustein eigener Exception-Hierarchien ist eine abstrakte Basisklasse pro fachlichem Kontext (Bounded Context), nicht eine einzige globale Basisklasse für die gesamte Anwendung. Eine Basisklasse pro Kontext, etwa OrderDomainException für alles rund um Bestellungen, gibt dem Aufrufer einen einzigen sinnvollen catch-Punkt für diesen Kontext, ohne alle Exceptions der Anwendung in einen Topf zu werfen.

Wichtiger als die Vererbung selbst ist, was die Basisklasse erzwingt. Eine abstrakte Methode wie errorCode() zwingt jede konkrete Exception, einen stabilen, maschinenlesbaren Bezeichner zu deklarieren, der unabhängig vom veränderlichen Nachrichtentext ist. Ein privates, aber über einen Getter zugängliches Kontext-Array im Konstruktor gibt jeder Exception der Hierarchie von Anfang an einen Platz für strukturierte Zusatzdaten, statt dass jede Klasse ihre eigene Lösung dafür erfindet.


<?php

declare(strict_types=1);

namespace App\Domain\Exception;

/**
 * Abstract base class for all exceptions raised by the order domain.
 * Every custom exception in this bounded context extends this class,
 * which gives us a single catch point and a place for shared behavior.
 */
abstract class OrderDomainException extends \RuntimeException
{
    /**
     * @param string $message Human readable message (not shown to end users)
     * @param array<string, mixed> $context Structured data for logging and debugging
     * @param \Throwable|null $previous Original exception, if this wraps a lower layer error
     */
    public function __construct(
        string $message,
        private readonly array $context = [],
        ?\Throwable $previous = null,
    ) {
        parent::__construct($message, 0, $previous);
    }

    /**
     * Returns structured context data attached to this exception.
     *
     * @return array<string, mixed>
     */
    public function getContext(): array
    {
        return $this->context;
    }

    /**
     * Every concrete exception must declare a stable, machine readable
     * error code that frontends and logs can rely on, independent of message text.
     */
    abstract public function errorCode(): string;
}

Diese Basisklasse ist bewusst schlank gehalten: Sie erweitert RuntimeException, weil die meisten fachlichen und technischen Laufzeitfehler in diese Kategorie fallen, und sie reicht die $previous-Exception unverändert an die Elternklasse durch. Jede konkrete Exception der Hierarchie muss sich nur noch um zwei Dinge kümmern: eine sprechende Nachricht und einen stabilen errorCode, der Rest, inklusive Kontext-Handling, ist bereits gelöst.

4. Marker-Interfaces: Exceptions kategorisieren ohne Vererbungszwang

Vererbung allein reicht nicht aus, um Exceptions nach mehreren, sich überschneidenden Kriterien zu kategorisieren. Eine Exception kann gleichzeitig fachlich und wiederholbar sein, oder technisch und wiederholbar, eine einzelne Klassenhierarchie mit einfacher Vererbung kann solche Kombinationen nicht sauber abbilden. Genau hier kommen Marker-Interfaces ins Spiel: leere oder nahezu leere Interfaces wie BusinessException, TechnicalException und RetryableException, die eine Exception zusätzlich zu ihrer Klassenhierarchie kategorisieren.

Der entscheidende Vorteil von Marker-Interfaces gegenüber tieferer Vererbung: Eine konkrete Exception-Klasse kann implements BusinessException oder implements TechnicalException, RetryableException deklarieren, ohne dass die zugrunde liegende Klassenhierarchie verändert werden muss. Aufrufer fangen dann nicht mehr eine konkrete Klasse, sondern eine Fähigkeit, catch (RetryableException $e) funktioniert unabhängig davon, ob die konkrete Exception aus der Bestell-, Zahlungs- oder Versanddomäne stammt.


<?php

declare(strict_types=1);

namespace App\Domain\Exception;

/**
 * Marker interface for all business/domain errors: expected, user-facing,
 * and part of normal application flow (e.g. validation, business rule violations).
 */
interface BusinessException extends \Throwable
{
}

/**
 * Marker interface for technical/infrastructure errors: unexpected,
 * not meant to be shown to end users, always worth alerting on.
 */
interface TechnicalException extends \Throwable
{
}

/**
 * Marker interface for errors where a retry might succeed
 * (e.g. transient network or lock issues).
 */
interface RetryableException extends \Throwable
{
}

/**
 * Raised when a referenced product no longer exists. Business error:
 * expected, user-facing, no bug, no alert needed.
 */
final class ProductNotFoundException extends OrderDomainException implements BusinessException
{
    public function errorCode(): string
    {
        return 'ORDER_PRODUCT_NOT_FOUND';
    }
}

/**
 * Raised when the payment gateway times out. Technical error, but
 * retrying the same request is likely to succeed.
 */
final class PaymentGatewayTimeoutException extends OrderDomainException implements TechnicalException, RetryableException
{
    public function errorCode(): string
    {
        return 'ORDER_PAYMENT_GATEWAY_TIMEOUT';
    }
}

// Catching by capability, independent of class hierarchy position
function handle(\Throwable $e, LoggerInterface $logger): void
{
    match (true) {
        $e instanceof RetryableException => retryLater($e),
        $e instanceof BusinessException => $logger->info($e->getMessage()),
        $e instanceof TechnicalException => $logger->error($e->getMessage()),
        default => throw $e,
    };
}

In der Praxis bedeutet das: Ein globaler Error-Handler kann mit einem einzigen match (true) über instanceof-Prüfungen gegen Marker-Interfaces entscheiden, wie mit einer Exception umzugehen ist, ganz ohne die konkrete Klasse zu kennen. Neue konkrete Exceptions, die künftig hinzukommen, müssen nur die passenden Interfaces implementieren, der bestehende Error-Handling-Code ändert sich nicht.

5. Strukturierte Kontextdaten mit Constructor Property Promotion

Ein generisches Kontext-Array, wie es die Basisklasse anbietet, ist ein guter Anfang, aber am Ende nur ein weiteres assoziatives Array mit den bekannten Schwächen: keine Typprüfung, keine Autovervollständigung in der IDE, kein Fehler beim Zugriff auf einen falsch geschriebenen Schlüssel. Für Exceptions, die regelmäßig ausgewertet werden, etwa um eine Nutzermeldung zu bauen oder eine Retry-Entscheidung zu treffen, lohnt sich stattdessen ein typisiertes Kontextobjekt direkt im Konstruktor der konkreten Exception.

Constructor Property Promotion macht das ohne zusätzlichen Boilerplate möglich: Die Kontextdaten werden als readonly-Properties direkt im Konstruktor deklariert und gleichzeitig für die Nachricht sowie für das generische Kontext-Array der Basisklasse verwendet. So bekommt jeder Aufrufer typsichere Getter wie getSku() oder getRequestedQuantity(), während Logging-Code weiterhin auf das generische getContext() zurückgreifen kann, wenn er keine konkrete Klasse kennt.


<?php

declare(strict_types=1);

namespace App\Domain\Exception;

/**
 * Raised when a requested product variant is out of stock.
 * Carries strongly typed context instead of a generic array,
 * so callers and logging code get IDE autocompletion and type safety.
 */
final class InsufficientStockException extends OrderDomainException implements BusinessException
{
    public function __construct(
        private readonly string $sku,
        private readonly int $requestedQuantity,
        private readonly int $availableQuantity,
        ?\Throwable $previous = null,
    ) {
        parent::__construct(
            message: sprintf(
                'Cannot fulfill order for SKU "%s": requested %d, only %d available.',
                $sku,
                $requestedQuantity,
                $availableQuantity,
            ),
            context: [
                'sku' => $sku,
                'requested_quantity' => $requestedQuantity,
                'available_quantity' => $availableQuantity,
            ],
            previous: $previous,
        );
    }

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

    public function getRequestedQuantity(): int
    {
        return $this->requestedQuantity;
    }

    public function getAvailableQuantity(): int
    {
        return $this->availableQuantity;
    }

    public function errorCode(): string
    {
        return 'ORDER_INSUFFICIENT_STOCK';
    }
}

// Usage: type-safe access to context, no array key lookups
try {
    $orderService->placeOrder($cart);
} catch (InsufficientStockException $e) {
    $logger->warning($e->getMessage(), [
        'sku' => $e->getSku(),
        'missing' => $e->getRequestedQuantity() - $e->getAvailableQuantity(),
    ]);
}

Ein wichtiger Nebeneffekt: Weil die Kontextdaten explizite, typisierte Properties sind, fällt es sofort auf, wenn versehentlich sensible Daten wie Zahlungsdaten oder Passwörter in eine Exception wandern, der Typ macht sichtbar, was tatsächlich transportiert wird. Bei einem unstrukturierten Array bleibt so ein Fehler dagegen oft unbemerkt, bis er in einem Log-Aggregator auftaucht.

6. Schichtenarchitektur: Domain-, Application- und Infrastructure-Exceptions abgrenzen

In größeren Anwendungen mit einer Trennung zwischen Domain-, Application- und Infrastructure-Schicht sollten auch Exception-Hierarchien dieser Schichtung folgen. Die Infrastructure-Schicht kennt Datenbanktreiber, HTTP-Clients und Dateisysteme und wirft entsprechend spezifische Exceptions wie DatabaseConnectionException. Die Domain-Schicht kennt nur fachliche Konzepte wie Bestellungen und Lagerbestände und darf von diesen technischen Details nichts wissen, ihre Exceptions wie OrderRepositoryUnavailableException drücken aus, was aus fachlicher Sicht schiefgegangen ist, nicht wie.

Die Application-Schicht übernimmt dabei die Rolle des Übersetzers zwischen beiden Welten: Sie orchestriert Use Cases, ruft Infrastruktur-Code auf und fängt dessen technische Exceptions ab, um sie in domänenspezifische Exceptions zu übersetzen. Würde die Domain-Schicht stattdessen direkt PDOException oder eine Guzzle-spezifische Exception fangen, wäre sie an eine konkrete Infrastruktur-Implementierung gekoppelt, ein Wechsel des Datenbanktreibers würde dann Änderungen tief im fachlichen Code erzwingen.


<?php

declare(strict_types=1);

namespace App\Infrastructure\Exception;

// Infrastructure layer: knows about the database, HTTP client, filesystem
final class DatabaseConnectionException extends \RuntimeException
{
}

namespace App\Domain\Exception;

// Domain layer: knows nothing about infrastructure, only business rules
// (constructor with context/errorCode omitted here for brevity, see section 3)
abstract class OrderDomainException extends \RuntimeException
{
}

final class OrderRepositoryUnavailableException extends OrderDomainException
{
}

namespace App\Application\Order;

// Application layer: orchestrates use cases, translates between layers
use App\Domain\Exception\OrderRepositoryUnavailableException;
use App\Infrastructure\Exception\DatabaseConnectionException;
use App\Infrastructure\Repository\OrderRepository;

final readonly class PlaceOrderHandler
{
    public function __construct(
        private OrderRepository $orderRepository,
    ) {
    }

    public function handle(PlaceOrderCommand $command): void
    {
        try {
            $this->orderRepository->save($command->toOrder());
        } catch (DatabaseConnectionException $e) {
            // Translate the infrastructure detail into a domain-level exception;
            // callers above this point must not need to know about PDO or SQL.
            throw new OrderRepositoryUnavailableException(
                'Order could not be persisted due to a repository failure.',
                previous: $e,
            );
        }
    }
}

Diese Schichtentrennung zahlt sich vor allem beim Testen aus: Domain- und Application-Tests müssen keine echten Infrastruktur-Exceptions simulieren, sondern arbeiten ausschließlich mit den eigenen, klar benannten Exceptions der jeweiligen Schicht. Das macht Tests robuster gegenüber Änderungen an der eingesetzten Infrastruktur und hält die Fehlerklassen jeder Schicht in sich konsistent.

7. Namenskonventionen und Granularität: wann eine neue Exception-Klasse sinnvoll ist

Der Name einer Exception-Klasse ist keine Kleinigkeit: InsufficientStockException sagt sofort, was passiert ist, während GeneralOrderException oder gar OrderProblemException nichtssagend bleibt und den Aufrufer zwingt, wieder in getMessage() zu suchen. Eine bewährte Konvention: der Name beschreibt die fachliche Situation, nicht die technische Implementierung, und endet konsequent auf Exception, damit im Code sofort erkennbar ist, womit man es zu tun hat.

Bei der Granularität hilft eine einfache Leitfrage: Muss der Aufrufer auf diesen Fehlerfall anders reagieren als auf einen bestehenden? Wenn ja, rechtfertigt das eine neue Klasse. Wenn der Unterschied nur im Wert einer Variable liegt, etwa welche SKU betroffen ist, reicht eine bestehende Klasse mit entsprechenden Konstruktor-Parametern völlig aus. Diese Regel verhindert zwei entgegengesetzte Fehler: eine einzige Gott-Exception für alles und eine unübersichtliche Explosion von fast identischen Klassen.

In der Praxis bewährt sich außerdem, Exception-Namen im gleichen Vokabular wie die Ubiquitous Language der Domäne zu halten. Wenn Fachexperten von einer "Stornierungssperre" sprechen, sollte die Exception CancellationBlockedException heißen und nicht etwa OrderStateException, so bleiben Code und Fachsprache über die gesamte Exception-Hierarchie hinweg deckungsgleich, was Reviews und das Onboarding neuer Entwickler erheblich erleichtert.

8. Exception-Übersetzung an Schichtgrenzen: technische in fachliche Fehler umwandeln

Exception-Übersetzung an Schichtgrenzen bedeutet: eine tiefliegende technische Exception wird abgefangen und durch eine höherwertige, fachlich sinnvolle Exception ersetzt, wobei die ursprüngliche Exception als $previous erhalten bleibt. Dieses Muster ist keine Ausnahme, sondern die Regel überall dort, wo Domain- oder Application-Code Infrastruktur aufruft, die Fehler werfen kann, die im aktuellen Kontext keine Bedeutung haben.

Der Gewinn liegt in der Entkopplung: Kein Aufrufer der Domain-Schicht muss jemals \PDOException oder eine Guzzle-spezifische Exception kennen oder importieren. Bricht das Team von PDO auf einen anderen Datenbank-Layer um, ändert sich nur die Übersetzung innerhalb des Repositorys, alle Domain- und Application-Exceptions bleiben unverändert, und mit ihnen jeder bestehende catch-Block.


<?php

declare(strict_types=1);

namespace App\Infrastructure\Repository;

use App\Domain\Exception\BusinessException;
use App\Domain\Exception\CustomerNotFoundException;
use App\Domain\Exception\OrderDomainException;
use App\Domain\Exception\OrderRepositoryUnavailableException;

final readonly class PdoOrderRepository
{
    public function __construct(private \PDO $connection)
    {
    }

    /**
     * Loads a customer's order history. Translates low-level PDO failures
     * into domain exceptions so callers never need to catch \PDOException.
     *
     * @return array<int, array<string, mixed>>
     */
    public function findOrdersByCustomerId(string $customerId): array
    {
        try {
            $statement = $this->connection->prepare(
                'SELECT * FROM orders WHERE customer_id = :customer_id',
            );
            $statement->execute(['customer_id' => $customerId]);
            $rows = $statement->fetchAll(\PDO::FETCH_ASSOC);
        } catch (\PDOException $e) {
            // Connection lost, deadlock, syntax error: technical details
            // the domain layer should never have to know about.
            throw new OrderRepositoryUnavailableException(
                sprintf('Could not load orders for customer "%s".', $customerId),
                previous: $e,
            );
        }

        if ($rows === []) {
            // CustomerNotFoundException is a BusinessException, defined
            // analogous to InsufficientStockException in an earlier example.
            throw new CustomerNotFoundException($customerId);
        }

        return $rows;
    }
}

// Global handler at the system boundary (e.g. a PSR-15 middleware)
set_exception_handler(function (\Throwable $e) use ($logger): void {
    if ($e instanceof BusinessException) {
        $logger->info('Business exception surfaced at the boundary', [
            'code' => $e instanceof OrderDomainException ? $e->errorCode() : 'unknown',
        ]);
        http_response_code(422);
    } else {
        $logger->critical('Unhandled technical exception', ['exception' => $e]);
        http_response_code(500);
    }
});

Wichtig ist, die ursprüngliche Exception niemals zu verschlucken: Der $previous-Parameter erhält den vollständigen Stacktrace der technischen Ursache für Logging und Debugging, während die neue Exception-Nachricht bewusst auf fachlicher Ebene formuliert bleibt. So bekommen Entwickler beim Debuggen die volle technische Tiefe, während Nutzer oder API-Konsumenten nur die fachlich relevante, übersetzte Fehlermeldung sehen.

9. Zusammenspiel mit globalem Error-Handling und Logging an der Systemgrenze

Am Rand des Systems, sei es ein PSR-15-Middleware-Stack, ein Framework-Kernel oder ein einfacher set_exception_handler(), laufen letztlich alle Exception-Hierarchien der Anwendung zusammen. Genau hier zahlt sich die Vorarbeit aus den vorherigen Abschnitten aus: Statt Dutzende konkrete Klassen einzeln zu behandeln, prüft der globale Handler nur gegen die wenigen Marker-Interfaces und Basisklassen, um Log-Level, HTTP-Statuscode und Alerting-Verhalten zu bestimmen.

Ein durchdachtes globales Error-Handling nutzt dabei konsequent die strukturierten Kontextdaten aus Abschnitt 5: statt einer reinen Textmeldung landen SKU, Kundennummer oder Bestellnummer als strukturierte Felder im Log-Eintrag und lassen sich mit einer Korrelations-ID über mehrere Log-Zeilen hinweg zusammenführen. Die folgende Tabelle vergleicht die vier gängigsten Ansätze für eigene Exception-Hierarchien hinsichtlich Wartbarkeit, Granularität und Catch-Aufwand.

Ansatz Wartbarkeit Granularität Catch-Aufwand
Eine Exception für alles gering, verschleiert Ursachen keine minimal, aber nichtssagend
Pro Anwendungsfall eine Klasse explodiert bei Wachstum sehr hoch hoch, viele catch-Blöcke
Basisklasse + Marker-Interfaces hoch gezielt steuerbar gering, nach Interface fangen
Schichtenspezifische Hierarchien hoch in großen Systemen pro Schicht klar begrenzt mittel, Übersetzung an Grenzen nötig
Nur eingebaute SPL-Exceptions gering keine fachliche Trennung gering, aber ohne Kontext

Für produktive Systeme empfiehlt sich fast immer eine Kombination: eine Basisklasse pro Bounded Context, Marker-Interfaces für fachlich/technisch sowie Wiederholbarkeit, und an größeren Schichtgrenzen zusätzlich eine Übersetzung zwischen Infrastructure- und Domain-Exceptions. Diese Kombination hält die Zahl der Klassen überschaubar, ohne auf Granularität oder Typsicherheit zu verzichten, und macht die eigene Fehlerklassen-Architektur zu einem Werkzeug, das mit dem Projekt mitwächst, statt es zu bremsen.

10. Zusammenfassung

Eigene Exception-Hierarchien lohnen sich, sobald ein Projekt über eine Handvoll Fehlerfälle hinauswächst und unterschiedliche Konsumenten, Nutzer, Entwickler, Monitoring, unterschiedliche Informationen aus demselben Fehler brauchen. Die zentrale Trennlinie zwischen fachlichen und technischen Fehlern entscheidet über Log-Level, HTTP-Status und Alerting und sollte die erste Design-Entscheidung jeder eigenen Exception-Hierarchie sein, noch vor der ersten konkreten Klasse.

Eine abstrakte Basisklasse pro Bounded Context, Marker-Interfaces für cross-cutting Kategorien wie Retryable, typisierte Kontextdaten per Constructor Property Promotion und eine bewusste Übersetzung an Schichtgrenzen ergeben zusammen eine Struktur, die weder in einer einzigen Gott-Exception noch in hunderten Fast-Duplikaten endet. Wer diese Bausteine von Anfang an konsequent einsetzt, spart sich ein aufwendiges Refactoring der Fehlerbehandlung, sobald das Projekt seinen ersten Wachstumsschub erlebt.

Eigene Exception-Hierarchien: Das Wichtigste auf einen Blick

Fachlich vs. Technisch

Die zentrale Trennlinie jeder Exception-Hierarchie: bestimmt Log-Level, HTTP-Status und ob ein Retry sinnvoll ist.

Basisklasse pro Kontext

Eine abstrakte Basisklasse je Bounded Context bündelt Kontext-Handling und erzwingt einen stabilen errorCode().

Marker-Interfaces

BusinessException, TechnicalException, RetryableException kategorisieren quer zur Klassenhierarchie, ganz ohne tiefe Vererbung.

Schichten & Übersetzung

Domain-Exceptions kennen keine Infrastruktur-Details; Application-Code übersetzt technische in fachliche Fehler an der Grenze.

11. FAQ: Eigene Exception-Hierarchien

1Was ist der Unterschied zu den SPL-Exceptions?
SPL-Exceptions sind generische, technische Kategorien ohne fachlichen Bezug. Eine eigene Exception-Hierarchie fügt fachliche Bedeutung, Kontextdaten und Marker-Interfaces hinzu, zugeschnitten auf die eigene Domäne.
2Wann lohnt sich eine eigene Hierarchie, wann ist es Overkill?
Sobald mehrere Aufrufer unterschiedlich reagieren müssen oder das Projekt wächst. Für ein kleines Skript mit einem Fehlerpfad ist eine eigene Hierarchie Overkill.
3Exception oder RuntimeException als Basis?
In der Regel RuntimeException, da fachliche und technische Fehler erst zur Laufzeit erkennbar sind. LogicException passt eher zu Programmierfehlern.
4Vorteil von Marker-Interfaces gegenüber Vererbung?
Erlauben mehrere, sich überschneidende Kategorisierungen ohne Änderung der Klassenhierarchie. Aufrufer fangen eine Fähigkeit statt einer konkreten Klasse.
5Wie viele Kontextdaten sollte eine Exception tragen?
So viele wie zur Diagnose nötig, aber niemals Passwörter, Tokens oder Zahlungsdaten. Typisierte Properties machen sichtbar, was transportiert wird.
6Wie übersetzt man technische in fachliche Fehler?
Die technische Exception wird als previous-Parameter übergeben. Der Stacktrace bleibt erhalten, die neue Nachricht ist fachlich formuliert.
7Braucht jede Schicht eine eigene Hierarchie?
In größeren Systemen ja. Jede Schicht wirft nur passende Exceptions, die Application-Schicht übersetzt zwischen den Hierarchien.
8Wie wirkt sich das auf globales Error-Handling aus?
Ein globaler Handler entscheidet anhand weniger Marker-Interfaces und Basisklassen über Log-Level und HTTP-Status, statt Dutzende Klassen einzeln zu behandeln.
9Ist eine Exception pro Anwendungsfall sinnvoll?
Nur wenn der Aufrufer wirklich anders reagieren muss. Unterscheidet sich nur ein Datenwert, reicht eine bestehende Klasse mit anderen Konstruktor-Argumenten.
10BusinessException und TechnicalException gleichzeitig?
In der Regel schließen sich fachlich und technisch gegenseitig aus. Cross-cutting Eigenschaften wie RetryableException lassen sich mit beiden kombinieren.

Mironsoft

PHP-Architektur, Domain-Driven Design und wartbare Fehlerbehandlung

Wächst euer PHP-Projekt aus der eigenen Fehlerbehandlung heraus?

Wir analysieren bestehende Codebasen, identifizieren fragile catch-Blöcke und entwerfen eigene Exceptions mit klaren Basisklassen, Marker-Interfaces und sauberer Schichtenarchitektur, die auch nach Jahren im Betrieb noch wartbar bleiben.

Architektur-Review

Analyse bestehender Fehlerklassen und Aufdecken fachlich/technischer Vermischung

Refactoring

Basisklassen, Marker-Interfaces und typisierte Kontextdaten schrittweise einführen

Schichtenarchitektur

Domain-, Application- und Infrastructure-Exceptions sauber abgrenzen und übersetzen