API Idempotency
Building an Idempotent REST API with Spring Boot and Kotlin
Spring Boot and Kotlin make a well-matched pair for building REST APIs. Spring Boot eliminates the boilerplate associated with traditional Spring configuration, while Kotlin's null safety, concise syntax, and idiomatic data classes result in less code with fewer runtime surprises.
This article focuses on idempotency—a property that is easy to overlook until it causes a production incident.
What Is Idempotency?#
An operation is idempotent if applying it multiple times produces the same result as applying it once. In a distributed system, this matters because clients cannot always know whether a request was received. Network failures, timeouts, and retries are normal. A non-idempotent endpoint invoked twice creates two records, charges a card twice, or sends a notification twice.
A Spotify-style API illustrates the point clearly: if adding a song to a playlist is not idempotent, a client that retries after a timeout ends up with the same song in the playlist multiple times.
Project Setup#
Initialize a Spring Boot project with Spring Web, Spring Data JPA, and an H2 database. The directory structure follows the standard Spring Boot layout:
src/main/kotlin/com.example.spotify
controller/
model/
repository/
service/
Domain Model#
package com.example.spotify.model
import javax.persistence.*
@Entity
data class Song(
@Id @GeneratedValue(strategy = GenerationType.IDENTITY)
val id: Long = 0,
val title: String,
val artist: String
)
@Entity
data class Playlist(
@Id @GeneratedValue(strategy = GenerationType.IDENTITY)
val id: Long = 0,
val name: String,
@ManyToMany
@JoinTable(
name = "playlist_song",
joinColumns = [JoinColumn(name = "playlist_id")],
inverseJoinColumns = [JoinColumn(name = "song_id")]
)
val songs: MutableList<Song> = mutableListOf()
)
Repository#
package com.example.spotify.repository
import com.example.spotify.model.Playlist
import org.springframework.data.jpa.repository.JpaRepository
import com.example.spotify.model.Song
interface PlaylistRepository : JpaRepository<Playlist, Long>
interface SongRepository : JpaRepository<Song, Long>
Service#
The idempotency logic lives here. Before adding a song, the service checks whether it is already in the playlist. If it is, the operation is a no-op. The result returned to the caller is identical regardless of how many times the request is made.
package com.example.spotify.service
import com.example.spotify.model.Playlist
import com.example.spotify.model.Song
import com.example.spotify.repository.PlaylistRepository
import com.example.spotify.repository.SongRepository
import org.springframework.stereotype.Service
import javax.transaction.Transactional
@Service
class PlaylistService(
private val playlistRepository: PlaylistRepository,
private val songRepository: SongRepository
) {
@Transactional
fun addSongToPlaylist(playlistId: Long, songId: Long): Playlist {
val playlist = playlistRepository.findById(playlistId)
.orElseThrow { IllegalArgumentException("Playlist not found") }
val song = songRepository.findById(songId)
.orElseThrow { IllegalArgumentException("Song not found") }
if (!playlist.songs.contains(song)) {
playlist.songs.add(song)
playlistRepository.save(playlist)
}
return playlist
}
}
Controller#
package com.example.spotify.controller
import com.example.spotify.model.Playlist
import com.example.spotify.service.PlaylistService
import org.springframework.http.ResponseEntity
import org.springframework.web.bind.annotation.*
@RestController
@RequestMapping("/api/playlists")
class PlaylistController(private val playlistService: PlaylistService) {
@PostMapping("/{playlistId}/songs/{songId}")
fun addSongToPlaylist(
@PathVariable playlistId: Long,
@PathVariable songId: Long
): ResponseEntity<Playlist> {
val updatedPlaylist = playlistService.addSongToPlaylist(playlistId, songId)
return ResponseEntity.ok(updatedPlaylist)
}
}
Configuration#
spring.datasource.url=jdbc:h2:mem:testdb
spring.datasource.driverClassName=org.h2.Driver
spring.datasource.username=sa
spring.datasource.password=password
spring.jpa.database-platform=org.hibernate.dialect.H2Dialect
spring.h2.console.enabled=true
The Idempotency Check#
The key is in the service:
if (!playlist.songs.contains(song)) {
playlist.songs.add(song)
playlistRepository.save(playlist)
}
This single check is what makes the endpoint idempotent. A song is added only if it is not already present. Subsequent identical requests return the same playlist state without modification.
Conclusion#
Idempotency is not a nice-to-have. In any system where clients retry requests—which is most systems—non-idempotent write operations are a reliability risk. The fix is often simple: check before you act. In Spring Boot with Kotlin, the implementation is concise and the intent is clear. Apply this pattern to every write endpoint that can be safely called more than once.