Upgrade to Pro — share decks privately, control downloads, hide ads and more …

Spring time - From REST to GraphQL

Sponsored · SiteGround - Reliable hosting with speed, security, and support you can count on.

Spring time - From REST to GraphQL

Many teams rely on REST API’s as the backbone of their applications. Over time, however, REST can become a burden: endpoints multiply, versioning becomes messy, and clients receive too much (or too little) data. Maybe it’s time to wake up and stop REST-ing, because Spring GraphQL has arrived.
In this session, Peter and Frederieke explore the transition from REST to GraphQL using the latest Spring GraphQL features, combined with an Angular micro-frontend to show the client experience. No buzzwords, no silver bullets, just a practical look at how GraphQL can simplify your data flows and increase development velocity.
Expect a live demo, real code, and honest trade-offs: GraphQL isn’t magic, but when used well, it can make your APIs feel lighter, cleaner, and most importantly: fun!

Avatar for Frederieke Scheper

Frederieke Scheper

April 22, 2026

More Decks by Frederieke Scheper

Other Decks in Technology

Transcript

  1. From REST to GraphQL A Practical Journey with Spring GraphQL

    Frederieke Scheper · Peter Eijgermans Spring I/O · Barcelona · April 2026
  2. About us Peter Eijgermans CodeSmith / FrontEnd Architect Sopra Steria

    & Dutch Railways Frederieke Scheper CodeSmith / Java Architect Sopra Steria & Dutch National Police
  3. Watch This IT'S LIVE — RIGHT NOW 🎧 localhost:4200 IT'S

    POWERED - BY GRAPHQL query/mutation: graphql subscription: graphql Angular + Apollo spring-graphql Schema Resolver Service / Repo / DB PE Act 1 — Why GraphQL? · 1 / 8
  4. Spring Time: From REST to GraphQL TODAY'S JOURNEY — IN

    FOUR ACTS 1. Why — where REST stopped scaling for us, and what GraphQL offers instead 2. What — domain model, GraphQL schema, and how Spring wires them together 3. How — Angular micro-frontends consuming the API live 4. When — trade-offs, decision criteria, and what comes next in production PE Act 1 — Why GraphQL? · 2 / 8
  5. REST Still Works — Until It Doesn't WHERE REST EARNS

    ITS PLACE Simple CRUD, stable domain, one or two known consumers → keep REST Mature tooling: OpenAPI, Swagger, Spring MVC — years of investment HTTP caching: ETags, Cache-Control, CDN — GraphQL gives you none of this out of the box Every team already knows it WHERE IT BROKE DOWN — FOR US DJ screen needs session + tracks + songs + artists → one fat endpoint, or multiple round-trips DJ Console needs to stay live → polling — no push, no WebSocket in REST PE Act 1 — Why GraphQL? · 3 / 8
  6. The REST Tax One endpoint. One fixed fat shape. Every

    client gets everything — whether they need it or not. 🌐 GET /api/sessions/current // live response — click button above to load PE Act 1 — Why GraphQL? · 4 / 8
  7. What if the client could ask what it needs? Not

    a URL. Not a fixed document. A query — authored by the client. The client declares exactly what it needs. The server answers precisely that. Same endpoint. Same schema. Different shape every time. PE Act 1 — Why GraphQL? · 5 / 8
  8. Same field, two questions DJ Console — CurrentMixSession query CurrentMixSession

    { currentMixSession { id status tracks { # all tracks (full setlist) id # track id (selection / audio playback) energyLevel song { title audioFile # local mp3 path artist { name } } } } } Versus — AudienceSession (crowd screen) query AudienceSession { currentMixSession { id status tracks(last: 1) { # only the last track song { title artist { name } } energyLevel } # no track id, no audioFile } } PE Act 1 — Why GraphQL? · 6 / 8
  9. Angular + Apollo in one call const sessionId$ = this.apollo

    .query<{ currentMixSession: AudienceSession }>({ query: AUDIENCE_SESSION, fetchPolicy: 'no-cache', }) PE Act 1 — Why GraphQL? · 7 / 8
  10. The Schema IS the Contract The schema is defined in

    SDL before any implementation. The Query is the public API surface — explicit, typed, versioned by design. type Query { # Convenience: the active/most-recent session (domain decides what "current" means) currentMixSession: MixSession! # Explicitly load a session by ID (UUID as ID scalar) mixSession(id: ID!): MixSession! # (... etc ...) } enum MixSessionStatus { WARM_UP PEAK COOL_DOWN } type MixSession { id: ID! status: MixSessionStatus! tracks(last: Int): [SessionTrack!]! # argument on a field, not a new endpoint } # (... etc ...) PE Act 1 — Why GraphQL? · 8 / 8
  11. The Party & The DJ THE SCENE A crowded venue

    — people are here to dance The DJ plays songs to get everyone to the dancefloor The crowd reacts: cheers, silence, audience requests THE DJ Has — a curated track library with energy levels Does — runs a session, selects tracks Should — react to the crowd, keep the energy up I want Music DJ ! FS Act 2 — The Party · 1 / 4
  12. The Music Library playable version of performed by derived from

    1 1 1 1 1..* 1..* 1..* 0..* «AggregateRoot» MusicLibrary List<Artist> artists List<Song> songs List<Track> tracks Track TrackId id EnergyLevel energyLevel List<CuePoint> cuePoints Song SongId id String title String audioFile Artist ArtistId id String name CuePoint String label Duration elapsedTime «enum» EnergyLevel LOW MEDIUM HIGH «runtime side» SessionTrack MusicLibrary is the static catalog — SessionTrack is derived from it at runtime FS Act 2 — The Party · 2 / 4
  13. The Session Model tracks crowdEvents dj getStatus() trackLibrary 1 1

    1 0..* 0..* 0..* «AggregateRoot» MixSession MixSessionId id DiscJockey dj getStatus() : Status applyEvent(CrowdEvent) «enum derived» MixSession.Status WARM_UP PEAK COOL_DOWN «session-local copy» SessionTrack TrackId sourceTrackId Song song EnergyLevel energyLevel DiscJockey String name List<SessionTrack> trackLibrary findNextNewTrack(EnergyLevel, List) findTrackByTitle(String) «sealed interface» CrowdEvent CrowdCheered CrowdEnergyDropped DancefloorEmptied DancefloorFilledUp RequestFromAudienceReceived MixSession is the aggregate root — it owns tracks, events, and the DJ FS Act 2 — The Party · 3 / 4
  14. The SDL The domain model maps directly to the schema

    — types you just saw, now as a contract. type MixSession { id: ID! status: MixSessionStatus! # WARM_UP · PEAK · COOL_DOWN tracks(last: Int): [SessionTrack!]! } type SessionTrack { song: Song! energyLevel: Int! } type Song { title: String! audioFile: String # nullable — crowd screen never asks for it artist: Artist! } type Artist { name: String! } The client declares the shape — the engine resolves it query AudienceSession { currentMixSession { # one request status tracks(last: 1) { # only the latest track song { title artist { name } } energyLevel } # no id, no audioFile — client decides } } FS Act 2 — The Party · 4 / 4
  15. Act 3 — Mechanics query/mutation: graphql subscription: graphql Angular +

    Apollo spring-graphql Schema Resolver Service / Repo / DB FS Act 3 — Mechanics
  16. Spring MVC vs Spring GraphQL — Wiring One new entry

    point. Routing, resolution, and shape follow from it. Spring MVC Spring GraphQL Entry point @RestController + @GetMapping @Controller + @QueryMapping Routing URL + HTTP method Schema field name → method name Field resolution Controller returns full object @SchemaMapping — per field, lazily Client shape Server-fixed Selection set — ask only what you need Server shape New endpoint or query param Schema argument — tracks(last: Int) FS Act 3 — Mechanics · 1 / 10
  17. The Request Chain 🌐 Query & mutation over HTTP POST

    ( /graphql ) Service layer @Controller ExecutionGraphQlService HTTP /graphql Client Service layer @Controller ExecutionGraphQlService HTTP /graphql Client Query / Mutation — HTTP POST POST /graphql { query } execute(WebGraphQlRequest) @QueryMapping / @MutationMapping apply domain event updated aggregate @SchemaMapping (nested fields) resolved value { data: … } FS Act 3 — Mechanics · 2 / 10
  18. Query Mapping A schema Query field maps to a method

    — name matches by convention. 📋 currentMixSession 📋 mixSession(id) 📋 voteTally(id) @Controller public class DiscJockeyConsoleGraphQLController { @QueryMapping public MixSession currentMixSession() { return mixSessionService.getCurrentSession(); } @QueryMapping public MixSession mixSession(@Argument UUID id) { return mixSessionService.getSessionById(id); } @QueryMapping public VoteTally voteTally(@Argument UUID id) { mixSessionService.getSessionById(id); // validates session exists return crowdVoteService.getTally(id); } } 🔍 DEMO with GraphiQL FS Act 3 — Mechanics · 3 / 10
  19. Mutation Mapping Same annotation pattern — return type follows the

    schema, not a convention. 📋 crowdCheered 📋 requestFromAudience 📋 castCrowdVote // Returns MixSession — domain event applied @MutationMapping public MixSession crowdCheered(@Argument UUID id) { return mixSessionService.applyCrowdCheered(id); } // ... crowdEnergyDropped, dancefloorEmptied, dancefloorFilledUp follow the same pattern // Returns MixSession — audience request resolved via domain @MutationMapping public MixSession requestFromAudience(@Argument UUID id, @Argument String trackName) { return mixSessionService.applyRequestFromAudience(id, trackName); } // Returns VoteTally — different schema type, same mapping pattern @MutationMapping public VoteTally castCrowdVote(@Argument UUID id, @Argument int slot) { return crowdVoteService.castVote(id, slot); // publish happens inside service } 🔍 DEMO with GraphiQL FS Act 3 — Mechanics · 4 / 10
  20. Nested Field Resolvers Each field in the graph can have

    its own resolver — resolved only when asked. @SchemaMapping public List<SessionTrack> tracks( MixSession mixSession, @Argument Integer last) { List<SessionTrack> all = mixSession.tracks(); if (last == null || last >= all.size()) return all; return all.subList(all.size() - last, all.size()); } @SchemaMapping public Song song(SessionTrack track) { return track.song(); } @SchemaMapping public Artist artist(Song song) { return song.artist(); } FS Act 3 — Mechanics · 5 / 10
  21. Error Handling Exceptions map to typed GraphQL errors — domain

    codes travel to the client. @Component public class GraphQLExceptionResolver extends DataFetcherExceptionResolverAdapter { @Override protected GraphQLError resolveToSingleError( Throwable ex, DataFetchingEnvironment env) { var errorType = ErrorType.BAD_REQUEST; var extensions = new LinkedHashMap<String, Object>(); if (ex instanceof DJConsoleException consoleEx) { extensions.put("errorCode", consoleEx.getErrorCode()); if (consoleEx instanceof MixSessionNotFoundException nf) { extensions.put("sessionId", nf.getSessionId()); errorType = ErrorType.NOT_FOUND; } } return GraphqlErrorBuilder.newError(env) .message(ex.getMessage()) .errorType(errorType) .extensions(extensions) .build(); } } FS Act 3 — Mechanics · 6 / 10
  22. Testing the Slice Two test types, one discipline: @GraphQlTest verifies

    wiring; ArchUnit verifies the architecture. // GraphQL slice test — schema, controller, resolvers, exception handlers @GraphQlTest(DiscJockeyConsoleGraphQLController.class) class DiscJockeyConsoleGraphQLControllerTests { @Autowired GraphQlTester graphQlTester; @MockitoBean MixSessionService mixSessionService; @Test void shouldGetCurrentMixSession() { given(mixSessionService.getCurrentSession()).willReturn(sampleSession()); graphQlTester.documentName("currentMixSession").execute() .path("currentMixSession.status").entity(String.class).isEqualTo("WARM_UP"); } } // Architecture rule — machine-checked hexagonal invariants @Test void serviceMustNotDependOnControllerOrInfrastructureLayers() { noClasses().that().resideInAPackage("..service..") .should().dependOnClassesThat() .resideInAnyPackage("..controller..", "..graphql..", "..infrastructure..") .check(productionClasses); // → enforces output-port pattern } FS Act 3 — Mechanics · 7 / 10
  23. The Push Chain 📡 publish(session) → MixSessionUpdatePublisher → Flux filtered

    per session → WebSocket client Client MixSessionUpdatePublisher (implements MixSessionUpdatePort) MixSessionServiceImpl Client MixSessionUpdatePublisher (implements MixSessionUpdatePort) MixSessionServiceImpl Mutation side-effect → WebSocket push sink.tryEmitNext(session) Flux filtered by session id Same MixSession shape as the query response publish(session) subscription event via WebSocket FS Act 3 — Mechanics · 8 / 10
  24. Real-Time Server Push Mutation fires → domain state changes →

    all subscribers receive the update. No polling. 📡 Controller — subscribe to MixSession updates @SubscriptionMapping public Flux<MixSession> mixSessionUpdated(@Argument UUID id) { return Flux.concat( Mono.fromCallable(() -> mixSessionService.getSessionById(id)), // snapshot mixSessionUpdatePublisher.streamForSession(id)); // live stream } ⚙️ Service — mutation produces the same MixSession ① 📡 Subscribe: mixSessionUpdated ② 🔥 Trigger: crowdCheered @Transactional public MixSession applyCrowdCheered(UUID id) { return applyAndPublish(id, new CrowdCheered(LocalDateTime.now())); } private MixSession applyAndPublish(UUID id, CrowdEvent event) { var session = getSessionById(id); var updated = session.applyEvent(event); var saved = repository.save(updated); mixSessionUpdatePublisher.publish(saved); // → Sinks → mixSessionUpdated Flux ticks return saved; } 🔍 DEMO with GraphiQL FS Act 3 — Mechanics · 9 / 10
  25. Recap — What We Just Wired THE SPRING GRAPHQL BACKEND

    STACK — END TO END 📐 The contract Schema-first — SDL in src/main/resources/graphql/ is the contract; Java follows @QueryMapping / @MutationMapping — method name binds to schema field; zero routing config ⚙️ The internals @SchemaMapping — nested field resolvers load child data only when the client asks for it DataFetcherExceptionResolverAdapter — typed errors surfaced in errors[] , HTTP stays 200 @GraphQlTest + ArchUnit — slice test covers schema + wiring 📡 The push layer @SubscriptionMapping + Flux — Flux.concat(snapshot, liveStream) over WebSocket Service calls MixSessionUpdatePort.publish() — the service never imports a GraphQL class FS Act 3 — Mechanics · 10 / 10
  26. Act 4 — Frontend BFF query/mutation: graphql subscription: graphql Angular

    + Apollo spring-graphql Schema Resolver Service / Repo / DB PE Act 4 — Frontend BFF
  27. Microfrontends Microfrontends Microfrontends One shell · independently deployable slices ·

    stitched together at runtime Host shell lazy lazy orchestrates same schema same schema Routing · layout · context Remote A Remote B GraphQL API shared contract PE Act 4 — Frontend BFF · 1 / 3
  28. Microfrontends in this app Microfrontends in this app Microfrontends in

    this app Native Federation · dj-console-ui host · remotes on :4201 & :4202 · one GraphQL backend dj-console-ui · :4200 lazy lazy orchestrates same schema same schema federation.manifest.json loadRemoteModule(...) mfe-crowd-vote :4201 mfe-spotlight :4202 Spring GraphQL :8080 PE Act 4 — Frontend BFF · 2 / 3
  29. The BFF Strategy Persistence Spring Boot App Clients { status

    tracks { song { title audioFile } } } { mixSession(id:$id) { status } } crowdVoteTallyUpdated subscription GET /api/sessions/current Web App Mobile App Dashboard REST Client @Controller (GraphQL) @RestController GET /api/sessions/current MixSessionService CrowdVoteService (in-memory) MixSessionRepository MusicLibraryLookup PostgreSQL JSONB One GraphQL layer, three clients, three different query shapes — zero new endpoints PE Act 4 — Frontend BFF · 3 / 3
  30. Act 5 — Demo Payoff query/mutation: graphql subscription: graphql Angular

    + Apollo spring-graphql Schema Resolver Service / Repo / DB PE Act 5 — Demo Payoff
  31. The DJ Console — Critical User Journey Live stream (WebSocket

    · graphql-ws) Domain / DB Spring GraphQL Host UI (Angular) ① Query — one HTTP query loads session + setlist ② Subscription / Websocket — same fields as the query, no polling ③ Mutation + push — write on HTTP, updates also reach subscribers Query currentMixSession Load active MixSession Aggregate (tracks, energy, …) JSON: full deck state Subscribe mixSessionUpdated(id) First event = snapshot (current server state) Mutation (e.g. crowdCheered) Apply event → persist Updated MixSession Mutation response (HTTP) Publish same session object PE Act 5 — Demo Payoff · 1 / 5
  32. One Bad Track, One Big Comeback LIVE RECOVERY FLOW Request

    from audience Wrong song starts DJ clicks RECOVERY Live update: status flips to PEAK, comeback track starts I want Music DJ ! PE Act 5 — Demo Payoff · 2 / 5
  33. The Full Chain Controller → domain event → persist →

    publish → subscriber Flux ticks. GraphQL just carries the result. // 1. Controller — thin delivery: name matches schema field, zero logic @MutationMapping public MixSession requestFromAudience(@Argument UUID id, @Argument String trackName) { return mixSessionService.applyRequestFromAudience(id, trackName); } // 2. Service — "mistake" event applied, persisted, then pushed via output port public MixSession applyRequestFromAudience(UUID id, String trackName) { var updated = getSessionById(id) .applyEvent(new RequestFromAudienceReceived(trackName, LocalDateTime.now())); var saved = repository.save(updated); // persisted mixSessionUpdatePort.publish(saved); // output port call return saved; } // 2b. Service — recovery button, same chain, separate intent name public MixSession applyRecovery(UUID id) { var updated = getSessionById(id) .applyEvent(new RecoverMusic(LocalDateTime.now())); var saved = repository.save(updated); mixSessionUpdatePort.publish(saved); return saved; } // 3. Publisher (GraphQL layer implements the port) public void publish(MixSession session) { sink.tryEmitNext(session); // → every active subscription Flux receives the update } // sink = Sinks.many().multicast().onBackpressureBuffer() // subscribers filter by session id: sink.asFlux().filter(s -> s.id().value().equals(id)) PE Act 5 — Demo Payoff · 3 / 5
  34. Live: Query & Mutation GRAPHQL IN ACTION — TWO OPERATIONS

    🔍 Query — ask exactly what you need query CurrentMixSession { currentMixSession { id status tracks { song { title artist { name } } energyLevel } } } ✍️ Mutation — recovery in one command mutation ApplyRecovery($id: ID!) { applyRecovery(id: $id) { id status tracks(last: 1) { song { title audioFile } energyLevel } } } PE Act 5 — Demo Payoff · 4 / 5
  35. Live: Crowd Vote The audience picks the next track —

    via QR code, live, in the room. I want Music DJ ! # 1. Audience scans QR → vote cast mutation CastCrowdVote($id: ID!, $slot: Int!) { castCrowdVote(id: $id, slot: $slot) { totalVotes choices { label votes } } } # 2. Dashboard watches live tally subscription CrowdVoteLive($id: ID!) { crowdVoteTallyUpdated(id: $id) { totalVotes choices { label votes } } } # 3. DJ applies the winner → session updates, tally resets mutation ApplyWinner($id: ID!) { applyCrowdVoteWinner(id: $id) { tracks(last: 1) { song { title } } } } PE Act 5 — Demo Payoff · 5 / 5
  36. Act 6 — Wrapup query/mutation: graphql subscription: graphql Angular +

    Apollo spring-graphql Schema Resolver Service / Repo / DB FS Act 6 — Wrapup
  37. Should I Migrate? Question Lean REST Lean GraphQL How many

    consumers? One or two, known Many, different needs Response shape? Stable, server-owned Client-driven, varies HTTP caching needed? Yes — CDN, ETags Harder — single endpoint Schema governance? Not ready Team can own a contract Team experience? REST-native Willing to invest FS Act 6 — Wrapup · 1 / 5
  38. REST vs GraphQL Aspect REST GraphQL API shape Server-defined Client-defined

    Versioning /v1 , /v2 — explicit Additive by default; deprecate fields HTTP caching Native — CDN, ETags No URL-based caching Error model HTTP status + RFC 9457 ProblemDetail Errors in body, HTTP 200 by default Tooling maturity Excellent — OpenAPI, Swagger Good — GraphiQL, schema introspection Over/under- fetching Common pain point Eliminated by design FS Act 6 — Wrapup · 2 / 5
  39. Before You Ship "IT WORKS" VS "IT SURVIVES PRODUCTION" 🔒

    Subscription hardening Auth: validate token from connectionParams — HTTP headers are gone after handshake Reconnect: graphql-ws retries; Flux.concat re-delivers snapshot — no stale UI Observability: WebSocket frames need explicit Micrometer instrumentation 📄 Pagination discipline Add cursor-based pagination ( first / after ) where lists grow unbounded Keep connection shapes stable — breaking pagination is a breaking change Spring GraphQL ScrollSubrange + Window<T> supports keyset/offset pagination 🛡️ Security depth Field-level authorization: @PreAuthorize on @SchemaMapping methods Method-level security applies per resolver — not per HTTP endpoint Mask sensitive fields by returning null rather than throwing from a resolver FS Act 6 — Wrapup · 3 / 5
  40. Subscriptions in Production Win Watch out Solution No polling —

    server pushes on state change WebSocket is stateful — sticky sessions required Sticky LB or Redis/Kafka fan-out per node Flux.concat snapshot on connect Auth headers unavailable after handshake Pass token via graphql-ws connectionParams Same type across query/mutation/subscription Client must handle reconnect & re- subscribe graphql-ws retries automatically; snapshot re-delivered graphql-ws sub-protocol — broad client support In-memory Sinks — single-node only Kafka/Redis broker + per-node fan- out for multi-pod Reactive backpressure with Reactor Flux Traces don't span WebSocket frames Micrometer WebSocket metrics; structured logging per session FS Act 6 — Wrapup · 4 / 5
  41. Use the Right Tool REST isn't wrong. GraphQL isn't magic.

    If the answer is no — that's the problem GraphQL was built to solve. "Who are my consumers — and can they live with what I give them today?" 📖 docs.spring.io/spring-graphql/reference 💻 github.com/fbascheper/spring-io-2026-from-rest-to-graphql FS Act 6 — Wrapup · 5 / 5
  42. THANK YOU ! THANK YOU ! Peter Eijgermans CodeSmith /

    FrontEnd Architect Frederieke Scheper CodeSmith / Java Architect