Application Programming Interfaces (APIs) are the contracts that your backend makes with everything that talks to it: web clients, mobile apps, and any other services. Get the design right at the beginning, and the system will be easy to extend and reason about. However, if you get it wrong early, you’ll always feel the pain every time requirements change.
Most backend developers start with Representational State Transfer (REST), and for a good reason. It maps naturally onto HTTP, fits neatly into Spring Boot and similar frameworks, and is well understood across several teams. But as applications grow and frontend requirements become more complex, REST can start to show friction: multiple endpoints to assemble a single view and endpoints that return too much data or not enough. GraphQL emerged as a response to the problems. It lets clients describe the shape of the data they need and return it in a single request. Since its emergence, adoption has grown steadily, especially among teams building data-heavy frontends or managing multiple client types.
I was curious to see if GraphQL actually delivers on resolving those REST challenges. So, I built the same backend in Kotlin twice: once with REST and again with GraphQL. If you follow this tutorial, I’ll show you how to do the same. You’ll see where REST works cleanly, where it starts to struggle as requirements evolve, and how GraphQL addresses those gaps. By the end, you’ll have a clear sense of the trade-offs and enough context to make the call for your own projects.
Building and Evolving an API
To follow along with this guide, you need to have the following:
- An IDE, such as IntelliJ IDEA
- JDK 17 or later installed on your local machine
- A web browser
The Problem
Imagine you’re building the backend for an e-commerce dashboard. The frontend needs to display a combined view: the user’s profile, their recent orders, and the products in those orders. This looks simple enough on the surface, but the data lives across three related resources: users, orders, and products.
This is where API decisions start to matter. Do you expose clean, resource-focused endpoints and let the frontend make multiple requests? Do you build a custom endpoint that bundles everything together? Or do you adopt a query-based approach that lets the client ask for exactly what it needs?
Let’s answer these questions by building this backend in Kotlin and SpringBoot, first using REST, then evolving it to GraphQL as the frontend requirements grow. Both implementations will use the same underlying data and service layer, so the comparison stays focused on the API design.
Initial Application Setup
To keep this guide focused on API design, I have configured the service layer, domain entities, data store, and shared response types in a starter template. Clone it to your local machine with the following command:
git clone --single-branch -b starter-template https://github.com/kimanikevin254/jetbrains-rest-graphql
To understand the starter template, let’s look at its structure:
src/main/kotlin/com/example/ecommerce/api
├── model/
│ ├── User.kt
│ ├── Order.kt
│ ├── OrderItem.kt
│ └── Product.kt
├── repository/
│ ├── UserRepository.kt
│ ├── OrderRepository.kt
│ ├── OrderItemRepository.kt
│ └── ProductRepository.kt
├── service/
│ ├── UserService.kt
│ ├── OrderService.kt
│ └── ProductService.kt
├── config/
│ └── DataInitializer.kt
├── dto/
│ ├── UserResponse.kt
│ ├── OrderResponse.kt
│ ├── OrderItemResponse.kt
│ └── ProductResponse.kt
├── mapper/
│ └── Mappers.kt
├── rest/
│ └── (you will implement this)
├── graphql/
│ └── (you will implement this)
└── EcommerceApplication.kt
The four domain entities (User, Order, OrderItem, and Product) map to the H2 database tables via JPA. Order has a ManyToOne relationship with User, and a OneToMany relationship with OrderItem. Each OrderItem links an order to a specific Product with a quantity, making it the join entity between the two.
The three service files (UserService, OrderService, and ProductService) sit between the API layer and the repositories. They’re where the core data access logic lives, and both the REST and GraphQL implementations will call them directly without modifying them.
The dto package contains response data classes that define the shape of data returned by the REST API. The mapper package contains extension functions that map each entity to its corresponding response type. For example, Order.toResponse() maps an Order and its nested items to an OrderResponse. The controllers you’ll implement shortly will use these directly.
On startup, DataInitializer checks whether the database is already populated and seeds it with two users, four products, and a few orders with items if it isn’t. You can inspect the seeded data at any point by opening the H2 console with the JDBC URL “jdbc:h2:mem:ecommerce-db”.
Run the application to verify everything is set up correctly:
./gradlew bootRun
You should see the application start without any errors. With the foundation in place, you can now start working on the API layer.
Implement the API with REST
REST maps naturally onto HTTP, which means that each method gets its own endpoint and clients interact with it using standard HTTP methods. For our e-commerce backend, this means separate endpoints for users and orders.
Start by creating a new package named rest to hold your REST API implementation. Next, create a new file named UserController.kt in the package and add the following code:
package com.example.ecommerce_api.rest
import com.example.ecommerce_api.mapper.toResponse
import com.example.ecommerce_api.dto.UserResponse
import com.example.ecommerce_api.dto.OrderResponse
import com.example.ecommerce_api.service.OrderService
import com.example.ecommerce_api.service.UserService
import org.springframework.web.bind.annotation.*
@RestController
@RequestMapping("/api/users")
class UserController(
private val userService: UserService,
private val orderService: OrderService
) {
@GetMapping("/{id}")
fun getUser(@PathVariable id: Long): UserResponse =
userService.getUserById(id).toResponse()
@GetMapping("/{id}/orders")
fun getUserOrders(@PathVariable id: Long): List<OrderResponse> =
orderService.getOrdersByUserId(id).map { it.toResponse() }
}
This controller defines two endpoints. GET /api/users/{id} returns a single user by ID, and GET /api/users/{id}/orders returns all orders belonging to that user. Both delegate to the service layer and use the mapper extension functions to convert entities to response types before returning. This keeps the persistence model decoupled from the API response shape.
In the same rest package, create a new file named OrderController.kt and add the following:
package com.example.ecommerce_api.rest
import com.example.ecommerce_api.dto.OrderItemResponse
import com.example.ecommerce_api.dto.OrderResponse
import com.example.ecommerce_api.mapper.toResponse
import com.example.ecommerce_api.service.OrderService
import org.springframework.web.bind.annotation.*
@RestController
@RequestMapping("/api/orders")
class OrderController(private val orderService: OrderService) {
@GetMapping("/{id}")
fun getOrder(@PathVariable id: Long): OrderResponse =
orderService.getOrderById(id).toResponse()
@GetMapping("/{id}/items")
fun getOrderItems(@PathVariable id: Long): List<OrderItemResponse> =
orderService.getOrderById(id).items.map { it.toResponse() }
}
This controller defines two endpoints. GET /api/orders/{id} returns a single order by ID. GET /api/orders/{id}/items returns the full item details for that order, including product information for each item.
Note that GET /api/users/{id}/orders returns a flat list of orders without nested items. This is a deliberate design choice for this guide, but it’s also a common pattern in production REST APIs where list endpoints are kept lean to avoid loading expensive nested data for every record in the list.
Run the application and test the endpoints:
# Fetch a user
curl http://localhost:8080/api/users/1
# Fetch a user's orders
curl http://localhost:8080/api/users/1/orders
# Fetch items for a specific order
curl http://localhost:8080/api/orders/1/items
Your responses should be similar to the following (prettified and truncated for readability):
// GET /api/users/1
{
"id": 1,
"name": "Alice",
"email": "alice@example.com"
}
// GET /api/users/1/orders
[
{
"id": 1,
"userId": 1,
"createdAt": "2026-04-19T15:17:23.002759"
},
...
]
// GET /api/orders/1/items
[
{
"id": 1,
"product": {
"id": 1,
"name": "Laptop",
"price": 999.99
},
"quantity": 1
},
...
]
Each endpoint has a clear responsibility, the request/response model is straightforward, and any HTTP client can consume it without special tooling. For a backend with stable, well-defined resource boundaries, this is a solid design.
The cracks start to show when the frontend’s data requirements grow more complex, which is exactly what happens next.
Assembling Complex Views with REST
The dashboard now has a new requirement: a single view that displays a user’s profile alongside their orders and product details for each order item. With the current REST API implementation, assembling this view requires the frontend to make multiple sequential requests:
# Step 1: Fetch the user profile
curl http://localhost:8080/api/users/1
# Step 2: Fetch their orders
curl http://localhost:8080/api/users/1/orders
# Step 3: Fetch items for each order
curl http://localhost:8080/api/orders/1/items
curl http://localhost:8080/api/orders/2/items
Notice that step 3 scales with the number of orders. If Alice has ten orders, the frontend would need to make twelve requests just to render one dashboard view. You could work around this by building a custom aggregation endpoint like GET /api/users/{id}/dashboard that bundles everything into a single response, or by adding query parameters like ?includeItems=true to control the response shape. But both approaches have a cost: the first means building and maintaining a new endpoint every time the frontend has a new data requirement, and the second turns your endpoints into a makeshift query language. Both approaches scale poorly and grow harder to reason about as your data requirements change.
This pattern exposes three problems that compound as the application grows:
- Multiple roundtrips: Each request depends on the result of the previous one. The frontend cannot fetch order items until it knows the order IDs, and it cannot know the order IDs until it fetches the orders. On a slow connection, this sequential fetching is a real performance concern.
- Over-fetching:
GET /api/users/1returns the full user object every time, even if the dashboard only needs the user’s name. Every field the frontend doesn’t need is wasted bandwidth. - Increasing API surface area. As new dashboard views emerge with different data requirements, the options are to build more custom endpoints, add query parameters to control response shape, or ask the frontend to make even more requests. None of these options scales cleanly.
These are not problems with REST itself. They’re natural consequences of applying a resource-oriented model to a data-heavy frontend with evolving requirements. GraphQL was designed specifically to address this gap.
Introducing GraphQL
GraphQL is a query language for APIs that lets clients request exactly the data they need in a single request. Instead of the server deciding the response shape, the client describes what it wants, and the server returns precisely that.
To see what that means in practice, here’s how the dashboard view we’ve been building toward would look as a single GraphQL query:
query {
user(id: 1) {
name
email
orders {
id
createdAt
items {
quantity
product {
name
price
}
}
}
}
}
This single query replaces the multiple sequential HTTP requests you made with REST. The client gets back exactly the fields they requested for, nested the way it needs them, in exactly one round-trip.
To implement this in the Spring Boot application, we’ll use Spring for GraphQL, which is the official Spring integration for GraphQL Java. It handles all the heavy lifting: parsing the schema, routing queries to the right resolvers, and exposing the GraphQL endpoint at /graphql. It’s already included as a dependency in the starter template, so there’s nothing extra to install. If you’re adding it to an existing project, refer to the setup instructions from the official docs.
Implementing the GraphQL API requires you to configure two things: a schema that defines the types and relationships, and resolvers that fetch the data for each type. Start with the schema by creating a new file named src/main/resources/graphql/schema.graphqls and adding the following:
type User {
id: ID!
name: String!
email: String!
orders: [Order!]!
}
type Order {
id: ID!
createdAt: String!
items: [OrderItem!]!
}
type OrderItem {
id: ID!
quantity: Int!
product: Product!
}
type Product {
id: ID!
name: String!
price: Float!
}
type Query {
user(id: ID!): User
}
The schema defines the shape of your data and the relationships between types. User has a list of Orders, each Order has a list of OrderItems, and each OrderItem references a Product. The Query type defines the entry points: the queries a client can actually execute. The ! suffix means the field is non-nullable.
With the schema ready, you can now define the resolvers. Create a new package named graphql and a file named UserResolver.kt in the package. Add the following to the newly created file:
package com.example.ecommerce_api.graphql
import com.example.ecommerce_api.model.Order
import com.example.ecommerce_api.model.User
import com.example.ecommerce_api.service.OrderService
import com.example.ecommerce_api.service.UserService
import org.springframework.graphql.data.method.annotation.Argument
import org.springframework.graphql.data.method.annotation.QueryMapping
import org.springframework.graphql.data.method.annotation.SchemaMapping
import org.springframework.stereotype.Controller
@Controller
class UserResolver(
private val userService: UserService,
private val orderService: OrderService
) {
@QueryMapping
fun user(@Argument id: Long): User = userService.getUserById(id)
@SchemaMapping(typeName = "User", field = "orders")
fun orders(user: User): List<Order> = orderService.getOrdersByUserId(user.id)
}
@QueryMapping marks user() as the handler for the user query defined in the schema. @SchemaMapping tells Spring for GraphQL how to resolve the orders field on the User type. When a client includes orders in their query, Spring calls this method with the parent User object and returns the result. If the client doesn’t ask for orders, this method never runs.
In the same graphql package, create a new file named OrderResolver.kt and add the following:
package com.example.ecommerce_api.graphql
import com.example.ecommerce_api.model.Order
import com.example.ecommerce_api.model.OrderItem
import com.example.ecommerce_api.service.OrderService
import org.springframework.graphql.data.method.annotation.SchemaMapping
import org.springframework.stereotype.Controller
@Controller
class OrderResolver(private val orderService: OrderService) {
@SchemaMapping(typeName = "Order", field = "items")
fun items(order: Order): List<OrderItem> =
orderService.getOrderById(order.id).items
}
This resolver handles the items field on the Order type. Spring for GraphQL calls it only when the client explicitly requests items in their query.
Before you can test the API, make sure to enable the GraphiQL playground (a browser-based tool for testing GraphQL queries) by adding the following to application.properties:
spring.graphql.graphiql.enabled=true
Now that everything is ready, run the application and navigate to “http://localhost:8080/graphiql” on your browser. In the playground, replace the commented instructions with the query below and execute it:
query {
user(id: 1) {
name
email
orders {
id
createdAt
items {
quantity
product {
name
price
}
}
}
}
}
Your response should be similar to the following:
{
"data": {
"user": {
"name": "Alice",
"email": "alice@example.com",
"orders": [
{
"id": "1",
"createdAt": "2026-04-20T08:54:07.301632",
"items": [
{
"quantity": 1,
"product": {
"name": "Laptop",
"price": 999.99
}
},
... // Truncated
]
},
... // Truncated
]
}
}
}
Now try removing any field from the query and run it again. You’ll see that it won’t be included in the response. That’s the core value of GraphQL: the client is in control of the response shape, not the server.
The Role of Strong Typing in API Design
One of GraphQL’s defining characteristics is its type system. Every field in a GraphQL schema has an explicit type, every relationship between types is declared upfront, and non-nullable fields are marked with !. This means that the schema is not just a documentation, but also a contract between the client and the server that GraphQL enforces at runtime.
Kotlin’s type system maps onto this quite naturally. For example, consider the OrderItem entity from the application:
@Entity
@Table(name = "order_items")
data class OrderItem(
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
val id: Long = 0,
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "order_id", nullable = false)
val order: Order,
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "product_id", nullable = false)
val product: Product,
@Column(nullable = false)
val quantity: Int
)
And it’s corresponding GraphQL type in the schema:
type OrderItem {
id: ID!
quantity: Int!
product: Product!
}
Both express the same constraints: product and quantity are required, never null. In Kotlin, that guarantee is enforced at compile time. In GraphQL, it’s enforced at the schema level. When a resolver returns an OrderItem, Spring for GraphQL maps the Kotlin object directly onto the schema type, and any nullability mismatch between the two surfaces immediately as an error rather than silently producing unexpected API behavior.
This alignment has a practical benefit beyond correctness. When a new developer reads the schema, they get an accurate picture of the data model. When a Kotlin model changes, say a field becomes nullable, the type mismatch between the entity and the schema makes the inconsistency explicit. The two representations help to keep each other honest.
REST doesn’t have an equivalent mechanism by default. Response shapes are defined implicitly by what the serializer produces from your entities, and there’s no built-in way to enforce that the response always matches a declared contract. Tools like OpenAPI can add this, but it requires a separate layer of tooling and discipline to keep in sync.
REST vs GraphQL: Practical Differences
The two approaches differ across several practical dimensions. REST organizes APIs around resources with fixed response shapes, which makes it immediately familiar and easy to consume with standard HTTP tools. GraphQL exposes a single endpoint where the client controls the response shape through queries, which eliminates over-fetching and reduces roundtrips but introduces a learning curve around schemas, resolvers, and query language. On performance, REST’s multiple roundtrips are real, but GraphQL has its own failure mode: the N+1 problem, where resolvers trigger redundant database queries if not carefully implemented. Both are well supported in the Spring ecosystem, and both require deliberate optimization as traffic grows.
Neither approach is universally better as they both address different needs. REST is the right fit for simple CRUD services, small teams, and public APIs with stable contracts where simplicity and predictability matter most. GraphQL earns its complexity when dealing with relational data across multiple resources, multiple frontend clients with different data requirements, data-heavy dashboards, or rapidly evolving UI requirements where fixed response shapes create constant friction.
As the system grows, both approaches have natural next steps. REST benefits from API gateway integration for centralized authentication, rate limiting, and routing. GraphQL scales through schema federation, which lets multiple teams own separate parts of the graph and compose them into a unified API. Caching strategies, security concerns like field-level authorization and query depth limiting for GraphQL or versioning for REST, and observability through structured logging and distributed tracing all become important considerations regardless of which approach you choose.
API design stops being a tactical decision as systems scale. The choice you make early shapes how the system evolves, how teams work with it, and how much friction accumulates over time.
Conclusion
REST and GraphQL are two different answers to the same question: how should a backend expose data to the clients that depend on it? REST’s resource-oriented model is simple, predictable, and well understood across teams, and it’s the right default for most early-stage systems. GraphQL’s query-driven model trades that simplicity for flexibility, and that trade-off starts to pay off when client data requirements are complex, varied, or frequently changing.
Having built the same backend twice, you’ve now seen where that difference actually shows up in code. If you want to keep pulling on that thread, clone the demo application, extend the schema, add new queries, and break things intentionally. The gap between knowing trade-offs and feeling them closes quickly once you’ve built with both.
Top comments (0)