Viaduct: When Airbnb Said 'Hold My GraphQL' to the Entire Industry

You know how most companies build a GraphQL API, hit some scaling issues, and then write a blog post about it? Airbnb took a different approach: they built an entire data-oriented service mesh, ran it for five years at massive scale, and then open-sourced it. Meet Viaduct - GraphQL's ambitious cousin who went to architecture school.
What Even Is Viaduct?
Let's start with the basics. According to Airbnb's engineering blog, Viaduct is:
"A GraphQL-based system that provides a unified interface for accessing and interacting with any data source."
But that undersells it dramatically. Viaduct is really three things:
- A GraphQL execution engine (built on graphql-java)
- A serverless platform for hosting business logic
- A data-oriented service mesh that connects 130+ teams
Since its 2020 announcement, traffic through Viaduct has grown 8x, with teams contributing 1.5+ million lines of hosted code. This isn't a proof-of-concept. This is battle-tested infrastructure.
┌─────────────────────────────────────────────────────────────────────┐
│ VIADUCT AT A GLANCE │
├─────────────────────────────────────────────────────────────────────┤
│ │
│ • Built on graphql-java with Kotlin runtime │
│ • Runs on Spring Boot │
│ • 130+ teams contributing code │
│ • 1.5M+ lines of hosted business logic │
│ • 75% of requests are internal (service-to-service) │
│ • 8x traffic growth since 2020 │
│ │
│ Not just a GraphQL layer - a platform for hosting logic │
│ │
└─────────────────────────────────────────────────────────────────────┘
The Problem Viaduct Solves
Before Viaduct, Airbnb had the typical microservices problem:
┌─────────────────────────────────────────────────────────────────────┐
│ THE BEFORE TIMES │
├─────────────────────────────────────────────────────────────────────┤
│ │
│ Mobile App │
│ │ │
│ ├──▶ User Service ──▶ Database │
│ ├──▶ Booking Service ──▶ Database ──▶ User Service (again!) │
│ ├──▶ Search Service ──▶ Database ──▶ Listing Service │
│ ├──▶ Payment Service ──▶ ... │
│ └──▶ (47 more services) │
│ │
│ Problems: │
│ ❌ Clients need to know about every service │
│ ❌ Services call each other chaotically │
│ ❌ No unified data model │
│ ❌ Observability nightmare │
│ │
└─────────────────────────────────────────────────────────────────────┘
Viaduct's solution: put a single, integrated GraphQL schema in front of everything.
┌─────────────────────────────────────────────────────────────────────┐
│ THE VIADUCT WAY │
├─────────────────────────────────────────────────────────────────────┤
│ │
│ Mobile App │
│ │ │
│ └──▶ Viaduct (single GraphQL endpoint) │
│ │ │
│ ├──▶ User Tenant Module │
│ ├──▶ Booking Tenant Module │
│ ├──▶ Search Tenant Module │
│ └──▶ (all other modules) │
│ │ │
│ └──▶ Downstream services / databases │
│ │
│ Benefits: │
│ ✅ One schema, one endpoint │
│ ✅ Teams own their piece of the graph │
│ ✅ Built-in batching, caching, observability │
│ ✅ Logic composes via GraphQL fragments │
│ │
└─────────────────────────────────────────────────────────────────────┘
The Architecture: Three Layers
Viaduct Modern (their latest iteration) has a clean three-layer design:
Layer 1: GraphQL Execution Engine
The engine is dynamically typed - it works with GraphQL values as maps from field name to value. This is the raw graphql-java layer with Airbnb's optimizations.
Layer 2: Tenant API
The tenant API is statically typed. Viaduct generates Kotlin classes for every GraphQL type in the schema. This is what developers interact with.
Layer 3: Application Code
Your business logic. This is where the magic happens.
┌─────────────────────────────────────────────────────────────────────┐
│ VIADUCT LAYERS │
├─────────────────────────────────────────────────────────────────────┤
│ │
│ ┌───────────────────────────────────────────────────────────────┐ │
│ │ Layer 3: Application Code (Your Business Logic) │ │
│ │ • Node resolvers, Field resolvers │ │
│ │ • Hosted by teams in "tenant modules" │ │
│ └───────────────────────────────────────────────────────────────┘ │
│ │ │
│ ▼ │
│ ┌───────────────────────────────────────────────────────────────┐ │
│ │ Layer 2: Tenant API (Generated Kotlin Classes) │ │
│ │ • Strongly typed │ │
│ │ • Schema-first: write GraphQL, get Kotlin │ │
│ └───────────────────────────────────────────────────────────────┘ │
│ │ │
│ ▼ │
│ ┌───────────────────────────────────────────────────────────────┐ │
│ │ Layer 1: GraphQL Execution Engine (graphql-java) │ │
│ │ • Dynamically typed │ │
│ │ • Parsing, validation, execution │ │
│ └───────────────────────────────────────────────────────────────┘ │
│ │
│ This separation lets engine and API evolve independently │
│ │
└─────────────────────────────────────────────────────────────────────┘
The Tenant Module Model
Here's where Viaduct gets interesting. Instead of one giant GraphQL service, you have tenant modules:
A tenant module is a unit of schema together with the code that implements that schema, and crucially, is owned by a single team.
Each team owns their slice:
// User Tenant Module
// Owned by: Team Identity
@NodeResolver
class UserResolver {
suspend fun resolve(id: GlobalId): User? {
return userService.findById(id.localId)
}
}
@FieldResolver
class UserFieldsResolver {
suspend fun User.displayName(): String {
return "${this.firstName} ${this.lastName}"
}
suspend fun User.bookings(first: Int = 10): List<Booking> {
// This calls INTO the Booking tenant module
// via GraphQL fragment - not direct code dependency!
return viaduct.query("""
fragment on User {
bookings(first: $first) {
id
checkIn
checkOut
}
}
""", mapOf("first" to first))
}
}
The key insight: modules compose via GraphQL, not code. The User module doesn't import the Booking module. It queries it through GraphQL fragments.
Re-entrancy: The Secret Sauce
At the heart of Viaduct is re-entrancy:
Logic hosted on Viaduct composes with other logic hosted on Viaduct by issuing GraphQL fragments and queries.
This is unusual. Most GraphQL frameworks have resolvers call services directly. Viaduct has resolvers call... GraphQL.
// Traditional approach (what Spring for GraphQL does)
@SchemaMapping
fun bookings(user: User): List<Booking> {
// Direct code dependency
return bookingService.findByUserId(user.id)
}
// Viaduct approach
@FieldResolver
suspend fun User.bookings(): List<Booking> {
// GraphQL composition
return viaduct.query("""
query GetBookings($userId: ID!) {
bookingsForUser(userId: $userId) {
id
listing { name }
checkIn
}
}
""", mapOf("userId" to this.id))
}
Why does this matter?
- No code dependencies between modules - Teams stay decoupled
- Automatic batching - Viaduct batches these internal queries
- Consistent observability - All calls go through the graph
- Modularity at scale - 130+ teams, no monolith hazards
Batching and Caching: Built-In
Viaduct doesn't just support DataLoader - it makes batching fundamental:
┌─────────────────────────────────────────────────────────────────────┐
│ AUTOMATIC BATCHING │
├─────────────────────────────────────────────────────────────────────┤
│ │
│ Query: │
│ { │
│ user(id: "1") { bookings { listing { name } } } │
│ user(id: "2") { bookings { listing { name } } } │
│ user(id: "3") { bookings { listing { name } } } │
│ } │
│ │
│ Without Viaduct: │
│ • 3 user fetches │
│ • N booking fetches │
│ • M listing fetches │
│ • Total: 3 + N + M round trips │
│ │
│ With Viaduct: │
│ • 1 batched user fetch │
│ • 1 batched booking fetch │
│ • 1 batched listing fetch │
│ • Total: 3 round trips (regardless of N and M) │
│ │
└─────────────────────────────────────────────────────────────────────┘
Plus: intra-request caching, type-safe Global IDs, soft dependencies, and short-circuiting for reliability.
Viaduct vs. Spring for GraphQL
Now for the comparison everyone's waiting for. How does Viaduct differ from Spring for GraphQL?
| Aspect | Spring for GraphQL | Viaduct |
|---|---|---|
| Philosophy | Library for building GraphQL APIs | Platform for hosting business logic |
| Ownership | You own the full stack | Viaduct owns execution, you own modules |
| Schema | Per-application | One central schema, many contributors |
| Code generation | Optional (use records/classes) | Core feature, generates Kotlin |
| Composition | Direct code calls | GraphQL fragment composition |
| Batching | @BatchMapping, DataLoader | Built into the platform |
| Multi-tenancy | You implement it | First-class concept |
| Target | Any Spring team | Large orgs with many teams |
When to Choose Spring for GraphQL
✅ You're a single team building a GraphQL API ✅ You want full control over your stack ✅ Your schema is self-contained (one service, one schema) ✅ You prefer Java (Spring for GraphQL is Java-first) ✅ Simpler is better for your use case
Spring for GraphQL is excellent for:
@Controller
public class MovieController {
@QueryMapping
public Movie movie(@Argument Long id) {
return movieService.findById(id);
}
@SchemaMapping
public Director director(Movie movie) {
return directorService.findById(movie.getDirectorId());
}
@BatchMapping
public Map<Movie, List<Review>> reviews(List<Movie> movies) {
return reviewService.findByMovies(movies);
}
}
Clear, simple, powerful. You own everything.
When to Choose Viaduct
✅ You have many teams (10+) contributing to one graph ✅ You want enforced modularity between teams ✅ You need a serverless model (host logic, not services) ✅ You're Kotlin-first (Viaduct leverages coroutines heavily) ✅ Observability is critical (field-level attribution)
Viaduct excels when:
// Team A: Identity
@NodeResolver
class UserResolver {
suspend fun resolve(id: GlobalId): User? = userService.find(id)
}
// Team B: Bookings (no code dependency on Team A)
@FieldResolver
class BookingUserResolver {
suspend fun Booking.guest(): User {
return viaduct.resolve(this.guestId) // Goes through the graph
}
}
// Team C: Reviews (no code dependency on A or B)
@FieldResolver
class ReviewResolver {
suspend fun Listing.reviews(): List<Review> {
return reviewService.findByListingId(this.id)
}
}
130 teams. One graph. No merge conflicts. No monolith.
The Technical Differences
Coroutines vs. Threads
Viaduct uses Kotlin coroutines heavily:
// Viaduct: Coroutines are first-class
@FieldResolver
suspend fun User.bookings(): List<Booking> {
// Suspends, doesn't block threads
return bookingService.findByUser(this.id)
}
Spring for GraphQL supports reactive types but defaults to thread-per-request:
// Spring for GraphQL: Blocking by default
@SchemaMapping
public List<Booking> bookings(User user) {
// Blocks a thread
return bookingService.findByUser(user.getId());
}
// Or reactive
@SchemaMapping
public Flux<Booking> bookings(User user) {
return bookingService.findByUserReactive(user.getId());
}
Schema Ownership
Spring for GraphQL: Each application owns its schema.
App A: schema.graphqls (owns types A, B, C)
App B: schema.graphqls (owns types D, E, F)
Viaduct: One central schema, many contributors.
Central Schema:
├── User (owned by Identity team)
├── Booking (owned by Reservations team)
├── Listing (owned by Homes team)
├── Review (owned by Trust team)
└── ... (130+ teams contribute)
Build System Integration
Viaduct invests heavily in developer experience:
We've made investments including direct-to-bytecode code generation that bypasses lengthy compilation of generated code.
Spring for GraphQL relies on standard Java/Kotlin compilation. Fast, but not optimized for massive codebases.
Getting Started with Viaduct
Viaduct is open source on GitHub. Here's a taste:
// Define your schema
// src/main/resources/graphql/movie.graphqls
type Movie @key(fields: "id") {
id: ID!
title: String!
releaseYear: Int!
director: Director!
}
type Director @key(fields: "id") {
id: ID!
name: String!
}
type Query {
movie(id: ID!): Movie
movies: [Movie!]!
}
// Implement resolvers
@NodeResolver
class MovieResolver(private val movieService: MovieService) {
suspend fun resolve(id: GlobalId): Movie? {
return movieService.findById(id.localId)
}
}
@FieldResolver
class MovieFieldResolver(private val directorService: DirectorService) {
suspend fun Movie.director(): Director {
return directorService.findById(this.directorId)
?: throw NotFoundException("Director not found")
}
}
@QueryResolver
class MovieQueryResolver(private val movieService: MovieService) {
suspend fun movie(id: ID): Movie? = movieService.findById(id)
suspend fun movies(): List<Movie> = movieService.findAll()
}
The code generation creates typed Kotlin classes from your schema, and the engine handles batching, caching, and observability.
Should You Use Viaduct?
Here's my honest take:
Yes, if:
- You're at Airbnb scale (or heading there)
- You have 10+ teams that need to contribute to one graph
- You want platform-level features (batching, caching, observability) without building them
- You're comfortable with Kotlin
- You value enforced modularity over flexibility
No, if:
- You're a single team or small org
- You want to own your full stack
- You prefer Java over Kotlin
- Your GraphQL needs are straightforward
- You don't want to adopt a new platform
Maybe, if:
- You're growing fast and anticipate multi-team needs
- You're evaluating federation alternatives
- You want to learn from Airbnb's architecture decisions
The Bigger Picture
Viaduct represents a different philosophy than most GraphQL frameworks. It's not "here's a library, build your API." It's "here's a platform, host your logic."
Spring for GraphQL says: "You're building a GraphQL server." Viaduct says: "You're contributing to a graph."
Neither is wrong. They solve different problems.
If you're building a GraphQL API for your application, Spring for GraphQL (or Apollo Server, or Strawberry, or whatever) is probably right.
If you're building a unified data layer for a large organization with many teams, Viaduct is worth a serious look.
The fact that Airbnb open-sourced it after five years of production use - with 130+ teams and 1.5M lines of code - suggests they're confident it solves real problems.
At minimum, read their engineering blog posts. Even if you never use Viaduct, the architectural patterns are worth understanding.
This blog post was composed via GraphQL fragments from multiple tenant modules in my brain. Re-entrancy is a hell of a drug.