URL, Header, Sunset und Backward Compatibility
Ungeplante API-Versionierung endet mit /v1, /v2, /v2-new, /v3-beta und einem Team, das nicht mehr weiß, welche Version welche Clients noch nutzen. Klare Versionierungsstrategien, Sunset-Header und Backward-Compatibility-Muster verhindern, dass Versionen zum Wartungsproblem werden.
Inhaltsverzeichnis
- 1. Warum API-Versionierung ein strategisches Problem ist
- 2. URL-Versionierung: /v1, /v2 – Vor- und Nachteile
- 3. Header-Versionierung: Accept und API-Version Header
- 4. Symfony-Routing für mehrere API-Versionen
- 5. Backward Compatibility: Was Breaking Change bedeutet
- 6. Sunset-Header und Deprecation-Kommunikation
- 7. Mehrere API-Versionen in OpenAPI dokumentieren
- 8. Versionierungsstrategien im Vergleich
- 9. Zusammenfassung
- 10. FAQ
1. Warum API-Versionierung ein strategisches Problem ist
Die meisten Teams beginnen ohne explizite Versionierungsstrategie. Wenn der erste Breaking Change kommt – ein umbenanntes Feld, eine geänderte Response-Struktur, eine entfernte Eigenschaft – entsteht die erste Ad-hoc-Entscheidung: Prefix /v2 an alle Routen hängen und weitermachen. Sechs Monate später existieren /v1 und /v2 parallel, die Business-Logik ist zwischen beiden Versionen dupliziert, und niemand weiß, welche Clients noch auf /v1 zugreifen.
Das eigentliche Problem ist nicht die Versionierung selbst, sondern das Fehlen einer klaren Deprecation-Strategie und eines definierten Lebenszyklusmodells. Wann wird eine Version als deprecated markiert? Wie lange wird sie noch unterstützt? Wer informiert die API-Consumer? Ohne Antworten auf diese Fragen wächst die Anzahl parallel betriebener Versionen mit jedem Breaking Change – bis der Wartungsaufwand die Feature-Entwicklung übersteigt.
2. URL-Versionierung: /v1, /v2 – Vor- und Nachteile
URL-Versionierung ist die am häufigsten eingesetzte Strategie: Der Versionspräfix steht im Pfad, z.B. /api/v1/orders und /api/v2/orders. Der größte Vorteil ist Sichtbarkeit: Die Version ist in jedem Request-Log, jedem Monitoring-Dashboard und jeder Browser-Adresszeile sofort erkennbar. Entwickler können beide Versionen gleichzeitig in Browser-Tabs offen haben, Logs nach Version filtern und Cache-Systeme einfach pro Version konfigurieren.
Die Nachteile sind ebenso real: Technisch gesehen verstößt URL-Versionierung gegen das REST-Prinzip, dass eine URI eine eindeutige Ressource identifiziert – /v1/orders/42 und /v2/orders/42 sind dieselbe Order, nur in unterschiedlichen Darstellungen. In der Praxis ist das ein akademisches Argument, das kaum zählt. Schwerwiegender ist, dass URL-Versionierung Clients zwingt, beim Versions-Upgrade alle Basis-URLs in ihrer Konfiguration zu ändern – das ist ein echter Migrationsaufwand, besonders für mobile Apps mit langem Release-Zyklus.
<?php
// src/Controller/Api/V1/OrderController.php
declare(strict_types=1);
namespace App\Controller\Api\V1;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\Routing\Attribute\Route;
/**
* Order API controller – version 1 (deprecated, sunset 2026-12-31).
*/
#[Route('/api/v1/orders', name: 'api_v1_orders_')]
final class OrderController extends AbstractController
{
#[Route('/{id}', name: 'show', methods: ['GET'])]
public function show(int $id): JsonResponse
{
// V1 response format: flat structure with customer_name
$response = $this->json([
'id' => $id,
'customer_name' => 'Max Mustermann', // deprecated field
'total' => 149.99,
'status' => 'shipped',
]);
// Sunset and Deprecation headers inform clients
$response->headers->set('Sunset', 'Sat, 31 Dec 2026 23:59:59 GMT');
$response->headers->set('Deprecation', 'true');
$response->headers->set('Link', '</api/v2/orders/' . $id . '>; rel="successor-version"');
return $response;
}
}
// src/Controller/Api/V2/OrderController.php
declare(strict_types=1);
namespace App\Controller\Api\V2;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\Routing\Attribute\Route;
/**
* Order API controller – version 2 (current stable).
*/
#[Route('/api/v2/orders', name: 'api_v2_orders_')]
final class OrderController extends AbstractController
{
#[Route('/{id}', name: 'show', methods: ['GET'])]
public function show(int $id): JsonResponse
{
// V2 response format: nested customer object
return $this->json([
'id' => $id,
'customer' => [
'name' => 'Max Mustermann',
'email' => 'max@example.com',
],
'total' => 149.99,
'status' => 'shipped',
'items' => [],
]);
}
}
3. Header-Versionierung: Accept und API-Version Header
Header-basierte Versionierung hält die URL sauber: GET /api/orders/42 bleibt dieselbe URL, die Version wird über einen Header kommuniziert. Zwei verbreitete Varianten: Der Accept-Header mit Media-Type-Versionierung (Accept: application/vnd.mironsoft.v2+json) folgt streng dem HTTP-Standard und nutzt Content Negotiation. Der pragmatischere Custom-Header Api-Version: 2 ist einfacher zu debuggen und zu loggen, ist aber nicht Teil des HTTP-Standards.
Der wichtigste Nachteil von Header-Versionierung: Sie ist für Entwickler weniger sichtbar. In Browser-Adresszeilen, API-Logs ohne Header-Ausgabe und Monitoring-Dashboards sieht man die Version nicht sofort. Das Debuggen von Problemen, bei denen Client A Version 1 und Client B Version 2 aufruft, wird aufwändiger. Für interne APIs zwischen Teams desselben Unternehmens ist Header-Versionierung oft die bessere Wahl. Für public APIs mit vielen unterschiedlichen Clients überwiegen die Vorteile der URL-Versionierung.
4. Symfony-Routing für mehrere API-Versionen
In Symfony gibt es mehrere Ansätze, mehrere API-Versionen zu routen. Der sauberste für URL-Versionierung: separate Controller-Namespaces (App\Controller\Api\V1, App\Controller\Api\V2) mit gemeinsam genutzten Services und DTOs, die sich nur in ihrer Serialisierung unterscheiden. Die Business-Logik liegt in Services, nicht in Controllern – Versionsunterschiede betreffen fast immer nur das Request/Response-Format, nicht die Domänenlogik.
Für Header-Versionierung eignet sich ein Request-Attribute-Listener, der die angeforderte Version aus dem Header liest und als Request-Attribut setzt. Das Routing kann dann über condition-Ausdrücke auf den Header reagieren, oder ein Event-Listener leitet den Request intern an den richtigen Handler weiter.
# config/routes/api_v1.yaml
api_v1:
resource: '../../src/Controller/Api/V1/'
type: attribute
prefix: /api/v1
defaults:
_api_version: '1'
# Add deprecation response middleware via event listener
# config/routes/api_v2.yaml
api_v2:
resource: '../../src/Controller/Api/V2/'
type: attribute
prefix: /api/v2
defaults:
_api_version: '2'
# config/routes/api_v3.yaml — content negotiation approach
api_v3_orders:
path: /api/orders/{id}
controller: App\Controller\Api\V3\OrderController::show
methods: [GET]
condition: "request.headers.get('Api-Version') === '3'"
# Alternative: route versioning via Accept header media type
# Accept: application/vnd.mironsoft.v2+json
# Requires custom RequestMatcher or Kernel listener
5. Backward Compatibility: Was Breaking Change bedeutet
Ein Breaking Change in einer REST API ist jede Änderung, die bestehende Clients ohne Code-Anpassung kaputt macht. Klassische Breaking Changes: Pflichtfeld aus Response entfernen, Feldname ändern (customer_name → customer.name), Datentyp ändern (String → Integer), HTTP-Status ändern (200 → 201), URL-Struktur ändern. Alles andere sind Non-Breaking Changes: optionale Felder hinzufügen, neue optionale Request-Parameter, neue Endpunkte, neue HTTP-Methoden auf bestehenden Routen.
Die wichtigste Regel: Hinzufügen ist fast immer sicher, Entfernen und Umbenennen ist immer ein Breaking Change. Clients müssen zusätzliche Felder ignorieren können (Robustness Principle). Das bedeutet: Keine stricte Schema-Validierung auf Client-Seite, und API-Schemas müssen additionalProperties: true erlauben. Wer diese Regel konsequent befolgt, kann viele geplante Breaking Changes als Non-Breaking Changes implementieren, indem das alte Feld weiter mitgeliefert wird, während das neue Feld hinzukommt.
// Non-Breaking Change: Neues Feld hinzufügen
// V1 Response (weiterhin gültig):
{
"id": 42,
"customer_name": "Max Mustermann",
"total": 149.99
}
// V1 Response nach Non-Breaking Change (altes Feld bleibt):
{
"id": 42,
"customer_name": "Max Mustermann",
"customer": {
"name": "Max Mustermann",
"email": "max@example.com"
},
"total": 149.99
}
// Breaking Change in V2 (altes Feld entfernt):
{
"id": 42,
"customer": {
"name": "Max Mustermann",
"email": "max@example.com"
},
"total": 149.99
}
// Strategie: In V1 beide Felder liefern, Sunset-Datum kommunizieren,
// erst in V2 das alte Feld entfernen. So haben Clients Zeit zur Migration.
6. Sunset-Header und Deprecation-Kommunikation
Der Sunset-Header (RFC 8594) ist das Werkzeug, um API-Konsumenten über bevorstehende Abschaltungen zu informieren. Er enthält ein HTTP-Datum, nach dem der Endpunkt oder die Version nicht mehr verfügbar sein wird. Zusammen mit dem Deprecation-Header (RFC 9745) und einem Link-Header mit rel="successor-version" haben Clients alle Informationen, die sie für eine Migration brauchen – maschinenlesbar, ohne auf Dokumentation angewiesen zu sein.
Das Sunset-Datum sollte realistische Migrationszeit einkalkulieren: mindestens 6 Monate für interne APIs, 12 Monate oder mehr für Public APIs mit unbekanntem Client-Ökosystem. Ein Event-Listener, der den Sunset-Header automatisch für alle Anfragen an versaltete Routen setzt, verhindert, dass einzelne Endpunkte vergessen werden. Monitoring-Tools wie Prometheus können auf den Sunset-Header reagieren und Alerts erzeugen, bevor das Abschaltdatum erreicht wird.
<?php
// src/EventListener/ApiDeprecationListener.php
declare(strict_types=1);
namespace App\EventListener;
use Symfony\Component\HttpKernel\Event\ResponseEvent;
/**
* Automatically adds Sunset and Deprecation headers to responses
* for deprecated API versions, based on route attribute _api_version.
*/
final class ApiDeprecationListener
{
/** @var array<string, string> Map: API version => Sunset date (RFC 7231 format) */
private const SUNSET_DATES = [
'1' => 'Sat, 31 Dec 2026 23:59:59 GMT',
];
public function onKernelResponse(ResponseEvent $event): void
{
if (!$event->isMainRequest()) {
return;
}
$request = $event->getRequest();
$version = $request->attributes->get('_api_version');
$response = $event->getResponse();
if (!isset(self::SUNSET_DATES[$version])) {
return;
}
$sunsetDate = self::SUNSET_DATES[$version];
$response->headers->set('Sunset', $sunsetDate);
$response->headers->set('Deprecation', 'true');
// Link to migration guide
$response->headers->set(
'Link',
sprintf(
'<https://mironsoft.de/api/migration/v%s-to-v%d>; rel="deprecation"',
$version,
(int) $version + 1
)
);
}
}
7. Mehrere API-Versionen in OpenAPI dokumentieren
Die verbreitetste Praxis ist eine eigene OpenAPI-Datei pro Version: openapi-v1.yaml, openapi-v2.yaml. Das ist wartbar und vermeidet, dass sich Versions-Unterschiede in einer einzigen großen Spezifikation verstecken. Gemeinsame Schemas (z.B. ProblemDetails, Pagination) werden in einer separaten Datei verwaltet und in beiden Versionen per $ref referenziert.
Swagger UI und Redoc unterstützen Versions-Switcher: Entweder über separate URLs (/api-docs/v1, /api-docs/v2) oder über einen Dropdown im UI. In der OpenAPI-Datei der veralteten Version gehört ein x-deprecated: true-Extensionsfeld und ein Hinweis in der info.description mit Sunset-Datum und Link zur Nachfolgerversion.
8. Versionierungsstrategien im Vergleich
Keine Versionierungsstrategie ist universell richtig. Die Wahl hängt vom Typ der API (intern / public), dem Client-Ökosystem und den Anforderungen an die Abwärtskompatibilität ab.
| Strategie | Sichtbarkeit | Cache-freundlich | REST-konform | Empfehlung |
|---|---|---|---|---|
| URL-Prefix (/v1, /v2) | Sehr hoch | Ja | Diskutiert | Public APIs |
| Custom Header (Api-Version) | Mittel | Nur mit Vary | Pragmatisch | Interne APIs |
| Accept-Header (vnd. Media Types) | Niedrig | Mit Vary | Hoch | REST-Puristen |
| Query-Parameter (?version=2) | Hoch | Ja | Niedrig | Vermeiden |
| Keine Versionierung (Evergreen API) | – | Ja | Ja | Nur mit strikter Backward Compat. |
Mironsoft
REST API Design, Symfony Backend-Entwicklung und OpenAPI-Dokumentation
API-Versionierung strategisch aufsetzen?
Wir analysieren eure bestehende API-Struktur, definieren eine klare Versionierungsstrategie und implementieren Sunset-Header, Deprecation-Monitoring und Migration-Guides für geordnete Versionsübergänge.
Strategie-Review
Analyse bestehender Versionsstrukturen und Empfehlung der passenden Strategie
Implementierung
Symfony-Routing, Deprecation-Listener und automatische Sunset-Header
OpenAPI-Docs
Separate OpenAPI-Dateien pro Version mit Migration-Guides und Deprecation-Hinweisen
9. Zusammenfassung
REST API Versionierung ohne Chaos erfordert drei Entscheidungen vor dem ersten Breaking Change: Welche Strategie (URL vs. Header), welches Lebenszyklusmodell (wie lange werden Versionen unterstützt), und wie werden Clients über Deprecation informiert. URL-Versionierung ist für die meisten Public APIs die pragmatisch beste Wahl – sie ist sichtbar, debuggbar und Cache-freundlich. Header-Versionierung eignet sich für interne APIs, wo alle Clients kontrolliert sind.
Sunset-Header kommunizieren Abschalttermine maschinenlesbar. Backward-Compatibility-Analyse vor jeder API-Änderung verhindert unnötige Breaking Changes. Und eine klare Trennung: Business-Logik in Services, Versions-Unterschiede nur in Controllers und DTOs. So bleibt das Codebase wartbar, auch wenn V1 und V2 parallel betrieben werden.
API-Versionierung ohne Chaos — Das Wichtigste auf einen Blick
Strategie früh festlegen
URL-Versionierung für Public APIs (/v1, /v2). Header-Versionierung für interne APIs. Query-Parameter immer vermeiden.
Breaking vs. Non-Breaking
Hinzufügen ist safe, Entfernen und Umbenennen ist breaking. Alte Felder parallel mitliefern, Sunset-Datum kommunizieren, dann in V2 entfernen.
Sunset-Header (RFC 8594)
Maschinenlesbares Abschaltdatum. Zusammen mit Deprecation-Header und Link: rel="successor-version" für automatisierte Client-Alerts.
OpenAPI pro Version
Separate openapi-v1.yaml, openapi-v2.yaml. Gemeinsame Schemas als $ref. Deprecated-Version mit x-deprecated: true markieren.