API AbstraktionWarum sind API-Abstraktionen sinnvoll. Und wann sind sie unabdingbar.
- API
- Angular
Nicht jede API ist ein Paradebeispiel für gutes Design. Häufig stößt man auf Datenstrukturen, deren direkte Integration im Client auf Dauer zu erheblichen Problemen führen kann. In solchen Fällen ist eine Abstraktionsschicht unerlässlich, wird jedoch oft vernachlässigt. Dieser Artikel zeigt, wie du dein Projekt vor unnötiger Kopplung und deren Konsequenzen schützen kannst.
Ich lehne mich mal weit aus dem Fenster und behaupte, dass Abstraktionen im Backend recht häufig anzutreffen sind, während im Frontend oft roh das „gegessen“ wird, was über die Leitung kommt. Ich möchte deshalb das Thema aus der Perspektive eines Frontend-Entwicklers beleuchten und zwei Ansätze vorstellen, wie solche Abstraktionen umgesetzt werden können.
Als Framework wird Angular verwendet. Die vorgestellten Konzepte lassen sich jedoch problemlos auf andere Frameworks übertragen.
Wann ist eine Abstraktion sinnvoll?
Die Antwort lautet nicht "immer". Wenn die API vollständig in eigener Hand liegt und für die eigene Anwendung entworfen wurde, ist es oft vergebene Liebesmüh. Eine Abstraktion, die keinen echten Mehrwert bietet, führt nur zu unnötiger Komplexität.
Anders sieht es aus, wenn die Schnittstelle von mehreren Clients genutzt wird oder Anforderungen mehrerer Stakeholder umsetzen muss. Besonders kritisch wird es, wenn man mit einer API arbeiten muss, die wie das Werk eines Praktikanten nach einer durchzechten Nacht mit etlichen Energy-Drinks aussieht. In solchen Fällen ist eine Abstraktion nicht nur ratsam – es wäre fahrlässig, sie nicht einzuführen.
Eine "Worst Case" API
Nehmen wir eine besonders fragwürdige API als Beispiel. Hier ein Endpunkt, der Benutzerdaten bereitstellt.
GET /api/get_user?id=42
{"id": 42,"status": "SUCCESS","err": null,"UsrData": {"usrId": 42,"active": "j","bod": "10.12.2000","img": null,"Credentials": {"usrName": "max123","e_mail": "max.mustermann@test.com"},"Details": {"first": "Max","last": "Mustermann","addr": ["Musterstr.", "1", "12345", "Musterstadt"]},"auth": 1,"Roles": {"role1": "ADMIN","role2": "USER","role3": null},"last_login": "1703020800000"}}
Wahrscheinlich ist es offensichtlich, was an dieser Schnittstelle "besonders" ist. Dennoch gehe ich kurz auf die aus meiner Sicht gravierendsten Probleme ein.
Endpunkt
Es fängt bereits bei der Definition des Endpunkts an.
- Die Bezeichnung
get_user
in der URL ist überflüssig. RESTful API‘s sollten ressourcenorientiert sein und keine Operationen in der URL enthalten: Die HTTP-Methode GET gibt bereits die Operation an. - Die Verwendung eines Query-Parameters zur Identifikation einer Ressource ist unüblich. Für gewöhnlich werden Query-Parameter für Filter, Suchkriterien oder optionale Parameter verwendet. Die User-ID sollte stattdessen als Pfad-Parameter übergeben werden.
- Auch wenn es nicht immer notwendig ist, sollte eine Versionierung in Betracht gezogen werden. Wenn eine Versionierung vorgesehen ist, sollte die Version in die URL aufgenommen werden.
Den Best-Practices nach würde der Endpunkt wie folgt lauten:
GET /api/v1/users/42
Response
Betrachtet man die Response, so könnten einem die Tränen in die Augen steigen. Was stimmt hier also nicht?
- Überflüssiger Status – HTTP-Status reicht völlig aus.
- Redundante IDs –
id
undusrId
tragen dieselbe Information. - Mit
null
belegte Felder – darüber lässt sich wahrscheinlich streiten. Ich plädiere jedoch stark dafür, solche Felder aus der Response herauszufiltern. - Listen als Einzelfelder –
role1
,role2
undrole3
gehören in ein Array. - Unnötige Verschachtelung – Beispiel:
UsrData
oderCredentials
. - Inkonsistente Schreibweisen – Mal camelCase, mal snake_case.
- Zeitformat-Wirrwarr – Timestamp als Zahl statt eines ISO-Strings.
- Booleans als Strings oder Zahlen –
active
: "j" statttrue
. - Adress-Array – Ein Array für Adressbestandteile ist weniger lesbar als ein Objekt mit sprechenden Feldnamen.
Allein diese Punkte sind mehr als Grund genug, um eine Abstraktion einzuführen.
Ansatz 1: Mapper
Um eine solche Datenstruktur in eine brauchbare Form zu überführen und damit eine Abstraktion zu schaffen, kann der Einsatz eines Mappers eine mögliche Lösung sein.
Das Mapping kann dabei durch eine Funktion oder einen Service erfolgen. Einen Service würde ich vorziehen, weil hier die Möglichkeit besteht Abhängigkeiten zu injizieren. Außerdem profitiert man bei Services in den Unit-Tests, da sich diese einfacher mocken lassen als Funktionen.
Doch eins nach dem anderen. Zunächst sollte man eine Zielstruktur für die eigenen Ansprüche entwerfen, in die die API-Response transformiert werden soll. Diese könnte in TypeScript z. B. so aussehen:
type UserRole = 'ADMIN' | 'USER' | 'GUEST';interface Address {street: string;housenumber: string;postcode: string;city: string;}interface User {id: number;username: string;firstName: string;lastName: string;email: string;dateOfBirth: Date;userImageURL?: string;address?: Address;lastLogin: Date;roles: UserRole[];isActive: boolean;isEmailConfirmed: boolean;}
Damit ist auch schon die schwierigste Aufgabe erledigt. Als Nächstes müsse nur noch die Response in die neue Datenstruktur transformiert werden. Hier am Beispiel eines „injectable“ Mapping-Services.
import { inject, Injectable } from '@angular/core';import { Address, User, UserRole } from '../models/user';import { DateService } from './date.service';@Injectable({providedIn: 'root',})export class UserMapper {private dateService = inject(DateService);toModel(response: UserResponse): User {return {id: response.id,username: response.UsrData.Credentials.usrName,firstName: response.UsrData.Details.first,lastName: response.UsrData.Details.last,email: response.UsrData.Credentials.e_mail,dateOfBirth: this.dateService.parseDe(response.UsrData.bod),userImageURL: response.UsrData.img ?? undefined,address: this.mapAddress(response.UsrData.Details.addr),lastLogin: this.dateService.fromTime(+response.UsrData.last_login),roles: this.mapRoles(response.UsrData.Roles),isActive: response.UsrData.active === 'j',isEmailConfirmed: response.UsrData.auth === 1,};}private mapRoles(roles: Roles): UserRole[] {return Object.values(roles);}private mapAddress(addr: string[]): Address | undefined {const [street, housenumber, postcode, city] = addr;return {street,housenumber,postcode,city,};}}
Die Transformation sollte an einem zentralen Ort und so früh wie möglich erfolgen, idealerweise unmittelbar nach dem Datenabruf. In Angular könnte dies beispielsweise in einem Service stattfinden, der den HttpClient
verwendet, um Anfragen an das Backend zu senden.
@Injectable({providedIn: 'root',})export class UserApiService {private http = inject(HttpClient);private mapper = inject(UserMapper);public getUser(userId: string): Observable<User> {const params = { id: userId };return this.http.get<UserResponse>('/api/get_user', params).pipe(map((res) => this.mapper.toModel(res)));}}
Ansatz 2: Decorator
Alternativ bietet sich eine „Decorator“-Klasse an, die die Datenstruktur der Response kapselt und über Getter eine saubere Schnittstelle definiert.
import { fromTime, parseDe } from '../utils/date.utils';import { Address, UserRole } from './user';export class User {constructor(private data: UserResponse) {}get id(): number {return this.data.id;}get username(): string {return this.data.UsrData.Credentials.usrName;}get firstName(): string {return this.data.UsrData.Details.first;}get lastName(): string {return this.data.UsrData.Details.last;}get email(): string {return this.data.UsrData.Credentials.e_mail;}get dateOfBirth(): Date {return parseDe(this.data.UsrData.bod);}get userImageURL(): string | undefined {return this.data.UsrData.img ?? undefined;}get address(): Address | undefined {const [street, housenumber, postcode, city] =this.data.UsrData.Details.addr;return {street,housenumber,postcode,city,};}get lastLogin(): Date {return fromTime(+this.data.UsrData.last_login);}get roles(): UserRole[] {return Object.values(this.data.UsrData.Roles);}get isActive(): boolean {return this.data.UsrData.active === 'j';}get isEmailConfirmed(): boolean {return this.data.UsrData.auth === 1;}}
Ein Decorator verbirgt nicht nur die interne Datenstruktur und reduziert so die Komplexität für den Nutzer. Er lässt sich auch leicht um zusätzliche Funktionalität erweitern, indem parametrisierte Methoden implementiert werden. Ein Nachteil dieses Ansatzes ist jedoch, dass die Möglichkeit entfällt, Services oder Tokens zu injizieren. Dies ist ein wichtiger Punkt, der bei der Wahl des richtigen Ansatzes berücksichtigt werden sollte.
Fazit
Am Ende hängt die Entscheidung, welcher Ansatz der bessere ist, wie so häufig vom Kontext ab. Es wäre sogar möglich, beide Ansätze gleichzeitig zu verwenden: Zuerst die Schnittstellenantwort in die benötigte Form transformieren und anschließend mit einem Decorator um zusätzliche Funktionalität erweitern. Wenn keine weitere Funktionalität benötigt wird, sollte man sich die zusätzliche Schicht sparen, um die Komplexität gering zu halten.
Wichtig sei vielmehr, dass eine Abstraktionsschicht überhaupt eingeführt wird.
Letztendlich ist dies nicht nur eine technische Entscheidung, sondern auch eine strategische. Der nächste Entwickler, der an dem Projekt arbeitet, wird euch danken, sollte die Schnittstelle eine Änderung erfahren. Er wird diese Änderung sehr wahrscheinlich in der Abstraktionsschicht umsetzen können, ohne an zig Stellen im Code Refactorings durchführen zu müssen.