Blog

GraphQL-APIs mit AppSync und DynamoDB

AWS AppSync verschiebt den GraphQL-Server aus der eigenen Betriebsverantwortung in einen managed Dienst. Ich zeige am DynamoDB-Backend, wann diese Build-vs-Buy-Entscheidung trägt und wann managed zur Sackgasse wird, inklusive der VTL-Mapping-Templates, die 2021 der Preis dafür sind.

≈ 11 Min. Lesezeit

Diesen Beitrag anhören (16 Min.)

MP3 herunterladen

Wer ein GraphQL-Schema veröffentlicht, hat damit einen Serverbetrieb übernommen – auch wenn im Projektplan nur „Schema und Clients“ steht. Die Resolver müssen irgendwo laufen, die Verbindungen irgendwo enden, die Last irgendwo abgefangen werden. Diese Rechnung kommt nicht am Tag des Deployments, sie kommt jeden Monat danach.

Die verbreiteten GraphQL-Einführungen enden vor dieser Rechnung. Typen definieren, Queries schreiben, Daten mit Apollo oder urql abholen: alles gut erklärt. Der Server mit den Resolvern, die Datenbank darunter, die Skalierung, die Real-time-Verbindungen – darüber schweigen sie. Wegdefinieren lässt sich diese Schicht trotzdem nicht. Jemand baut sie, deployt sie und hält sie im Betrieb.

Bleibt die Frage, wer dieses Jemand ist: das eigene Team oder ein managed Dienst. Ich mache die Antwort an einem konkreten Aufbau fest – AWS AppSync auf DynamoDB, so wie er Mitte 2021 tatsächlich aussieht, samt der VTL-Mapping-Templates, die beim ersten Kontakt gewöhnungsbedürftig sind. Der Code ist dabei nicht der Kern. Der Kern ist, was diese Entscheidung einbringt und was sie kostet.

Redaktionelle Einordnung (Juli 2026): AppSync unterstützt heute neben VTL die JavaScript-basierte Resolver-Runtime APPSYNC_JS. VTL bleibt gültig, ist aber nicht mehr die einzige direkte Abbildung auf DynamoDB. Managed nimmt dem Team den GraphQL-Serverbetrieb ab; Schema, Resolver, Berechtigungen, Datenmodell, Deployment und Beobachtbarkeit bleiben eigene Arbeit.

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 dieser Serverbetrieb bei AWS, während das Team die Abbildung zwischen Schema-Feld und Datenquelle sowie deren Infrastruktur beschreibt. Das ist der bekannte Serverless-Tausch: weniger eigener Betrieb gegen stärkere Bindung an den Dienst.

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, schreibt ein Item im falschen Format – der Fehler, über den beim Einstieg in AppSync fast alle einmal stolpern.

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, und 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 beim ersten Kontakt 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.

Die Grenze verläuft dort, wo ein Template mehr tut, als Argumente umzusortieren und in einen DynamoDB-Call zu gießen. Im Stand von 2021 gehörte komplexere Logik in einen Lambda-Resolver. Heute ist vorher zu prüfen, ob ein APPSYNC_JS-Resolver die Abbildung lesbarer ausdrückt; für freie Laufzeitlogik und externe Bibliotheken bleibt Lambda die weitergehende Grenze. VTL eignet sich weiterhin für 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, denn 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.

Managed oder selbstbetrieben: die eigentliche Entscheidung

Bis hierher ging es um Mechanik. Der Grund, warum ich AppSync in Projekten überhaupt aufrufe, liegt eine Ebene höher. Ein GraphQL-Server ist kein Feature, das man einmal schreibt und dann vergisst. Er ist eine dauerhafte Betriebsverpflichtung. Er muss laufen, skalieren, aktualisiert und überwacht werden, und bei Real-time-Verbindungen kommt der ganze Zustand über mehrere Instanzen hinweg dazu. Diese Kosten fallen nicht einmalig beim Bauen an, sondern jeden Monat im Betrieb.

Build-vs-Buy heißt an dieser Stelle konkret: Selbst betreiben bedeutet einen Node-Prozess mit Apollo Server, ein Hosting, eine Skalierungsstrategie, ein Pub/Sub-System für Subscriptions und die Bereitschaft, das alles im Ernstfall nachts zu reparieren. Managed bedeutet, dass ich Schema, Resolver und Data Sources deklariere und den Rest an AWS abgebe. Ich zahle dann nicht mehr in Betriebsstunden, sondern in Nutzung und in einem Stück Kontrolle, das ich aus der Hand gebe.

Wann trägt managed? Immer dann, wenn der GraphQL-Server für das Produkt kein Alleinstellungsmerkmal ist, sondern eine Notwendigkeit. Ein CRUD-Backend mit Real-time-Feed ist selten der Teil, mit dem sich ein Team vom Wettbewerb abhebt. Genau solche Standardschichten kauft man besser ein, weil jede selbstbetriebene Stunde dort fehlt, wo das Team tatsächlich Wert schafft. Kleine Teams ohne eigene Plattformmannschaft, Projekte mit schwankender Last, Prototypen, die schnell live müssen: In all diesen Fällen ist der Wegfall des Betriebs mehr wert als die abgegebene Kontrolle.

Wann wird managed zur Sackgasse? Sobald die Abbildung, die der Dienst anbietet, nicht mehr zum eigenen Problem passt. Bei AppSync 2021 ist VTL diese Abbildung. Solange ich Argumente in DynamoDB-Calls gieße, ist das ein guter Tausch. Sobald ich verschachtelte Logik, mehrstufige Transformationen oder die Zusammenführung mehrerer Quellen in Templates presse, zahle ich den managed Komfort mit Wartbarkeit zurück. Der zweite Sackgassen-Marker ist der Lock-in. Schema und Clients bleiben portabel, die VTL-Resolver und das DynamoDB-Key-Design sind es nicht. Wer damit rechnet, den Anbieter später zu wechseln, sollte diese Grenzschicht bewusst dünn halten.

Damit lässt sich die Entscheidung auf wenige Kriterien eindampfen. Ist die Schicht Standard oder Differenzierung? Wie teuer ist der Betrieb im Verhältnis zur Teamgröße? Wie gut passt die angebotene Abbildung auf das eigene Problem, heute und in zwei Jahren? Und wie tief reicht der Lock-in, falls die Antwort einmal nicht mehr passt? Managed ist keine Glaubensfrage, sondern eine Rechnung, die man pro Schicht neu aufmacht. Bei AppSync fällt sie für einen großen Teil der Serverless-Projekte klar zugunsten von Buy aus, aber eben nicht für alle.

Was am Ende bleibt

Ich komme zum Anfang zurück, zur Frage nach dem Betrieb des Servers. Die verbreiteten GraphQL-Inhalte behandeln Schema-Design und Client-Seite. Das ist wichtig und bleibt wichtig, egal welches Backend darunter liegt. AppSync mit DynamoDB ist die andere Hälfte, das managed Backend, bei dem der selbstgeschriebene Server zu einer Konfiguration aus Schema, Resolvern und Data Sources zusammenschmilzt.

Die Wirkung dieser Entscheidung ist konkret: Das Team deployt weiterhin Schema, Resolver und Infrastruktur, betreibt aber keinen eigenen GraphQL-Prozess und keine eigene WebSocket-Schicht. Dadurch bleibt mehr Zeit für Datenmodell und fachliche Logik. Ob die Rechnung aufgeht, hängt daran, wie gut die Resolver-Runtimes und Datenquellen zum Problem passen.

Der Preis dafür sind die VTL-Templates und ein Stück Bindung an AWS. Beides ist überschaubar, solange man die Grenzen kennt: schlanke Abbildungen in VTL, komplexe Logik in Lambda, ein Key-Design, das die Zugriffsmuster vorwegnimmt. Wo diese Grenzen halten, ist der Tausch hervorragend. Wo sie reißen, gehört die Schicht selbst gebaut. Diese Linie sauber zu ziehen ist die eigentliche Architekturarbeit, nicht das Schreiben der Templates.

AppSync steht nicht für sich allein. Es greift mit Lambda, DynamoDB und den übrigen Managed Services ineinander, und gerade im Zusammenspiel entfaltet es seinen Wert.

Weiterführende Quellen

Wie fandest du diesen Beitrag?

Kommentare