GraphQL-APIs mit AppSync und DynamoDB
AWS AppSync ist ein managed GraphQL-Layer: Schema hochladen, Resolver an Data Sources hängen – und der GraphQL-Server verschwindet aus der eigenen Betriebsverantwortung. Ich zeige, wie DynamoDB-Resolver 2021 über VTL-Mapping-Templates funktionieren.
In meinen Schulungen kommt der Moment, in dem jemand fragt: „Wir haben unser GraphQL-Schema, wir haben unsere Clients – aber wer betreibt eigentlich den GraphQL-Server?" Das ist eine gute Frage, und sie zeigt, dass die meisten GraphQL-Einführungen an genau dieser Stelle aufhören. Man lernt, wie man Typen definiert, wie man Queries schreibt, wie ein Client mit Apollo oder urql die Daten abholt. Was danach passiert – der Server, der die Resolver ausführt, die Datenbank, die Skalierung, die Real-time-Verbindungen – bleibt oft ein weißer Fleck.
Genau dieser weiße Fleck ist das Thema dieses Beitrags. Ich habe an anderer Stelle über Schema-Design und die Client-Seite geschrieben; hier geht es um das AWS-Backend. Konkret um AWS AppSync in Kombination mit DynamoDB, und zwar so, wie es Mitte 2021 tatsächlich funktioniert – mit VTL-Mapping-Templates, die für viele beim ersten Kontakt gewöhnungsbedürftig sind.
Was AppSync eigentlich ist
AppSync ist ein vollständig managed GraphQL-Layer. Der entscheidende Punkt: Man betreibt keinen eigenen GraphQL-Server. Es gibt keinen Node-Prozess mit Apollo Server, keinen Container, kein Hosting, keine Skalierungslogik, um die man sich kümmern müsste. Stattdessen lädt man ein Schema in der GraphQL Schema Definition Language (SDL) hoch und hängt an jedes Feld einen Resolver, der auf eine Data Source zeigt. AWS übernimmt den Rest.
Diese Verschiebung ist der eigentliche Wert. In einem klassischen Setup ist der GraphQL-Server das Herzstück, das man selbst schreibt, deployt und betreibt. Bei AppSync liegt diese Verantwortung bei AWS, und man beschreibt nur noch die Abbildung zwischen Schema-Feld und Datenquelle. Wer sich schon mit den Grundlagen und Grenzen von Serverless beschäftigt hat, erkennt das Muster sofort: managed statt selbstbetrieben, Konfiguration statt Infrastruktur.
Ein Resolver verbindet dabei immer genau ein Schema-Feld mit einer Data Source. Und davon gibt es 2021 eine überschaubare, aber gut abgedeckte Auswahl:
- DynamoDB-Tabellen als Key-Value- und Dokument-Store
- AWS Lambda für beliebige Custom-Logik
- HTTP-Endpoints, um bestehende REST-APIs anzubinden
- Amazon Elasticsearch Service für Volltextsuche
- relationale Datenbanken über RDS bzw. Aurora Serverless sowie „None"-Local-Resolver ohne externe Quelle
Für diesen Beitrag konzentriere ich mich auf DynamoDB, weil das die häufigste Kombination in Serverless-Projekten ist und weil sich daran die VTL-Mechanik am besten zeigen lässt.
Der Request-Fluss
Bevor wir Code sehen, hilft ein Blick auf den Weg, den eine Anfrage nimmt. Ein Client schickt eine GraphQL-Query oder -Mutation an AppSync. AppSync prüft sie gegen das Schema und ruft für das angefragte Feld den passenden Resolver auf. Der Resolver hat zwei Teile: ein Request-Template, das die GraphQL-Anfrage in einen DynamoDB-Call übersetzt, und ein Response-Template, das das DynamoDB-Ergebnis zurück in GraphQL-Form bringt.
flowchart LR Client[GraphQL Client] -->|Query / Mutation| AppSync[AppSync<br/>Schema + VTL Resolver] AppSync -->|Request Template| DDB[(DynamoDB Table)] DDB -->|Response Template| AppSync AppSync -->|JSON Result| Client AppSync -.->|other data sources| Lambda[AWS Lambda] AppSync -.->|other data sources| HTTP[HTTP Endpoint]
Diese beiden Templates sind das Herzstück und zugleich der Teil, an dem sich 2021 die Geister scheiden. Sie sind nämlich nicht in JavaScript geschrieben, sondern in der Apache Velocity Template Language, kurz VTL.
VTL: die Sprache dazwischen
VTL ist eine Template-Sprache, keine vollwertige Programmiersprache. Sie kann Werte einsetzen, einfache Bedingungen und Schleifen ausdrücken, aber sie ist bewusst schmal gehalten. Für die Übersetzung zwischen GraphQL-Argumenten und DynamoDB-Aufrufen reicht das in den meisten Fällen aus, und AWS liefert eine Reihe von Hilfsfunktionen mit, die die typischen Aufgaben abnehmen.
Nehmen wir ein kleines CMS als Beispiel. Wir wollen Blogposts anlegen, einzeln abrufen und bei neuen Posts in Echtzeit benachrichtigt werden. Das Schema dazu sieht so aus:
type Post {
id: ID!
title: String!
body: String!
}
type Query {
getPost(id: ID!): Post
}
type Mutation {
createPost(title: String!, body: String!): Post
}
type Subscription {
onCreatePost: Post @aws_subscribe(mutations: ["createPost"])
}
Das ist reines SDL, wie man es aus jeder GraphQL-Einführung kennt. Der einzige AppSync-spezifische Zusatz ist die Direktive @aws_subscribe auf dem Subscription-Feld – dazu später mehr. Zunächst zu den Resolvern für createPost und getPost.
Ein PutItem-Resolver
Wenn ein Client createPost aufruft, soll ein neues Item in der DynamoDB-Tabelle landen. Das Request-Template beschreibt genau diesen PutItem-Aufruf:
{
"version": "2017-02-28",
"operation": "PutItem",
"key": {
"id": $util.dynamodb.toDynamoDBJson($util.autoId())
},
"attributeValues": $util.dynamodb.toMapValues($ctx.args)
}
Hier passiert einiges auf engem Raum. Der Versionsstring "2017-02-28" legt das Format des Templates fest – das ist 2021 der Standardwert, den man ohne besonderen Grund nicht ändert. Das operation-Feld sagt DynamoDB, was zu tun ist. Der Key wird über $util.autoId() erzeugt, eine Hilfsfunktion, die eine UUID generiert, damit jeder Post einen eindeutigen Primärschlüssel bekommt.
Der wichtigste Punkt versteckt sich in $util.dynamodb.toDynamoDBJson(...) und $util.dynamodb.toMapValues(...). DynamoDB erwartet Werte nicht als nackte Strings oder Zahlen, sondern in einem typisierten Attribut-Format – ein String wird zu {"S": "..."}, eine Zahl zu {"N": "..."}. Diese Hilfsfunktionen übernehmen die Umwandlung automatisch. Wer sie vergisst und die Rohwerte direkt einsetzt, produziert ein falsches Item-Format – das ist mit Abstand der häufigste Anfängerfehler, den ich in Workshops sehe.
Das Response-Template ist dagegen unspektakulär. Es gibt das geschriebene Item einfach als JSON zurück:
$util.toJson($ctx.result)
$ctx – auch als $context verfügbar – ist das Objekt, über das man an alles Relevante kommt: $ctx.args für die Argumente der GraphQL-Operation, $ctx.identity für Informationen zum aufrufenden Nutzer, $ctx.result für das Ergebnis der Data Source. Das Response-Template nutzt $ctx.result, um das eben geschriebene Item an den Client zurückzuspielen.
Neben dem reinen Ergebnis liefert AppSync im Response-Template auch $ctx.error, falls die Data Source einen Fehler zurückgibt – etwa eine abgelehnte Bedingung bei einem PutItem. Wer das ignoriert, gibt im Fehlerfall stillschweigend null zurück, statt dem Client eine aussagekräftige Meldung zu geben. Mit $util.error(...) oder $util.appendError(...) lässt sich der Fehler kontrolliert an die GraphQL-Antwort durchreichen, sodass er im errors-Block der Response landet. Für die beiden schlichten Resolver hier ist das noch verzichtbar, aber sobald echte Bedingungen ins Spiel kommen, gehört diese Prüfung ins Template.
Ein GetItem-Resolver
Für getPost ist die Sache noch schlichter. Das Request-Template beschreibt einen GetItem-Aufruf mit dem id-Argument als Key:
{
"version": "2017-02-28",
"operation": "GetItem",
"key": {
"id": $util.dynamodb.toDynamoDBJson($ctx.args.id)
}
}
Auch hier wandert der Wert aus $ctx.args.id durch toDynamoDBJson, damit er im richtigen Format bei DynamoDB ankommt. Das Response-Template ist identisch zum vorigen:
$util.toJson($ctx.result)
Wenn man diese beiden Resolver nebeneinanderlegt, wird das Prinzip greifbar: Ein Resolver ist immer ein Paar aus Request- und Response-Template, das ein Schema-Feld mit einer konkreten DynamoDB-Operation verbindet. Mehr Magie steckt nicht dahinter.
Die verfügbaren Operationen decken das übliche DynamoDB-Repertoire ab. Neben GetItem und PutItem stehen UpdateItem und DeleteItem bereit, dazu Query und Scan für Mehrfachtreffer sowie die Batch- und Transaktionsvarianten wie BatchGetItem oder TransactWriteItems. Für den Einstieg reichen die vier Einzeloperationen, und man wächst mit dem Datenmodell in die komplexeren Varianten hinein. Das Schöne daran: Das Muster bleibt immer gleich. Man ändert das operation-Feld, passt Key und Werte an – der Aufbau des Templates verändert sich kaum. Diese Gleichförmigkeit ist es, die VTL trotz seiner Sperrigkeit im Alltag handhabbar macht.
Subscriptions ohne eigenen Aufwand
Der Teil, der viele Teilnehmer am meisten überrascht, sind die Real-time-Subscriptions. In einem selbstgebauten GraphQL-Server ist das der aufwendigste Teil überhaupt: WebSocket-Verbindungen verwalten, ein Pub/Sub-System betreiben, den Zustand über mehrere Serverinstanzen synchron halten. Bei AppSync ist davon nichts zu tun.
Erinnern wir uns an die Direktive aus dem Schema:
type Subscription {
onCreatePost: Post @aws_subscribe(mutations: ["createPost"])
}
Diese eine Zeile genügt. @aws_subscribe(mutations: ["createPost"]) bindet das Subscription-Feld onCreatePost an die Mutation createPost. Immer wenn diese Mutation erfolgreich durchläuft, schiebt AppSync das Ergebnis über eine WebSocket-Verbindung an alle Clients, die onCreatePost abonniert haben. Kein Polling, kein eigener Server, keine Verbindungsverwaltung.
sequenceDiagram participant A as Client A participant AS as AppSync participant D as DynamoDB participant B as Client B (subscribed) A->>AS: mutation createPost AS->>D: PutItem D-->>AS: written item AS-->>A: created Post AS-->>B: onCreatePost (WebSocket push)
Die Verbindung läuft dabei über WebSockets, die AppSync von Haus aus bereitstellt. Der Client muss sich nur einmal auf onCreatePost subscriben und bekommt danach jeden neuen Post automatisch zugestellt. Für ein Live-Feed, ein Chat-Feature oder ein Dashboard ist das eine enorme Vereinfachung, weil der gesamte Real-time-Teil aus der eigenen Betriebsverantwortung verschwindet.
Ein Detail, das man dabei nicht übersehen darf: Ohne die @aws_subscribe-Direktive passiert schlicht nichts. Ich habe mehr als einmal erlebt, dass jemand eine Subscription definiert, den Client verbindet und sich dann wundert, warum keine Events ankommen. Die Antwort war jedes Mal dieselbe – die Direktive fehlte oder verwies auf die falsche Mutation. Das Subscription-Feld muss explizit an die auslösende Mutation gekoppelt sein.
Wo VTL an seine Grenzen kommt
So elegant der Ansatz ist, so klar sind seine Ränder. VTL ist bewusst keine Programmiersprache, und das merkt man, sobald die Logik komplexer wird. Verschachtelte Bedingungen, mehrstufige Transformationen, das Zusammenführen mehrerer Quellen – all das lässt sich in VTL zwar irgendwie ausdrücken, aber die Templates werden schnell unleserlich und schwer zu warten.
Meine Faustregel aus der Praxis: Sobald ein Template mehr tut, als Argumente umzusortieren und in einen DynamoDB-Call zu gießen, gehört die Logik in einen Lambda-Resolver. Lambda ist eine der Data Sources, und man kann ein Schema-Feld genauso gut an eine Funktion hängen wie an eine Tabelle. Dort schreibt man dann normalen Code – JavaScript, Python, was auch immer – statt sich mit VTL-Schleifen abzumühen. Die VTL-Templates bleiben so für das reserviert, wofür sie gut sind: schlanke, direkte Abbildungen auf DynamoDB.
Der zweite Rand betrifft nicht AppSync, sondern DynamoDB selbst. Die Frage, ob ein Zugriff eine Query oder ein Scan wird, entscheidet sich am Tabellendesign. Eine Query nutzt Partition- und Sort-Key oder einen Global Secondary Index und ist schnell und günstig. Ein Scan liest im Zweifel die ganze Tabelle und wird mit wachsendem Datenbestand teuer und langsam. Diese Zugriffsmuster muss man vor dem ersten Schreiben durchdenken – DynamoDB verzeiht ein schlecht geplantes Key-Design nur ungern nachträglich. Das schöne AppSync-Frontend ändert nichts daran, dass darunter eine DynamoDB-Tabelle liegt, die ihre eigenen Regeln hat.
Die Abgrenzung, die den Unterschied macht
Ich komme zum Anfang zurück. Die verbreiteten GraphQL-Inhalte behandeln das Schema-Design und die Client-Seite – wie man Typen modelliert, wie man Queries formuliert, wie ein Client die Daten konsumiert. Das ist wichtig und bleibt wichtig, egal welches Backend darunter liegt.
AppSync mit DynamoDB ist die andere Hälfte: das managed Backend. Der GraphQL-Server, den man in klassischen Setups selbst schreibt und betreibt, wird hier zu einer Konfiguration aus Schema, Resolvern und Data Sources. Man tauscht die volle Kontrolle eines eigenen Servers gegen den Wegfall des Betriebs – und für einen großen Teil der Projekte ist das ein sehr guter Tausch. Man behält die vertraute GraphQL-Oberfläche für die Clients und muss sich weder um Skalierung noch um WebSocket-Infrastruktur kümmern.
Die VTL-Templates sind der Preis dafür, dass man 2021 diese Abbildung ohne eigenen Code deklariert. Sie sind gewöhnungsbedürftig, aber überschaubar, sobald man das Muster aus Request- und Response-Template verinnerlicht hat. Und für alles, was VTL nicht sauber ausdrücken kann, steht mit Lambda ein Ausweg bereit, der sich nahtlos ins selbe Modell fügt.
Wer AppSync in einen größeren Kontext einordnen will, findet in meinem AWS-Serverless-Lernpfad den Rahmen, in den sich dieser Baustein einfügt. AppSync ist dort kein Solitär, sondern ein Puzzleteil neben Lambda, DynamoDB und den übrigen managed Diensten – und gerade im Zusammenspiel entfaltet es seinen Wert.
Weiterführende Quellen
- Repository mit den Schema- und Resolver-Beispielen aus meinen Serverless-Workshops
- DynamoDB-Resolver-Referenz (AWS)
- Tutorial: Simple Post Application mit DynamoDB-Resolvern (AWS)
- Resolver-Mapping-Template-Overview (AWS)
- $util-Utility-Referenz (AWS)
- AppSync FAQs zu Data Sources und Subscriptions (AWS)
Kommentare