We have a database. We have JSON serialization. Now it is time to build a real REST API.

In this tutorial, you will build full CRUD (Create, Read, Update, Delete) operations for notes and users. You will learn the repository pattern, proper HTTP status codes, validation, and how to organize a production-ready API.

The Repository Pattern

In the previous tutorial, we put database queries directly in route handlers. This works for small projects but becomes messy as your API grows.

The repository pattern separates database logic from route logic:

Route Handler → Repository → Database
   (HTTP)        (Logic)      (SQL)

Each layer has one job:

  • Route Handler — Reads HTTP requests, validates input, sends responses
  • Repository — Runs database queries, returns domain objects
  • Database — Stores and retrieves data

Creating a Repository

class NoteRepository {

    private fun resultRowToNote(row: ResultRow) = NoteResponse(
        id = row[Notes.id],
        title = row[Notes.title],
        content = row[Notes.content]
    )

    suspend fun findAll(page: Int = 1, size: Int = 10): List<NoteResponse> = dbQuery {
        Notes.selectAll()
            .orderBy(Notes.id to SortOrder.DESC)
            .limit(size)
            .offset(((page - 1) * size).toLong())
            .map(::resultRowToNote)
    }

    suspend fun findById(id: Int): NoteResponse? = dbQuery {
        Notes.selectAll().where { Notes.id eq id }
            .map(::resultRowToNote)
            .singleOrNull()
    }

    suspend fun create(request: CreateNoteRequest): NoteResponse = dbQuery {
        val result = Notes.insert {
            it[title] = request.title
            it[content] = request.content
        }
        resultRowToNote(result.resultedValues!!.first())
    }

    suspend fun update(id: Int, request: UpdateNoteRequest): NoteResponse? = dbQuery {
        val updated = Notes.update({ Notes.id eq id }) {
            request.title?.let { value -> it[title] = value }
            request.content?.let { value -> it[content] = value }
        }
        if (updated == 0) null
        else Notes.selectAll().where { Notes.id eq id }
            .map(::resultRowToNote)
            .single()
    }

    suspend fun delete(id: Int): Boolean = dbQuery {
        Notes.deleteWhere { Notes.id eq id } > 0
    }
}

Every method is a suspend function that uses dbQuery to run on the IO dispatcher. The repository handles all database logic and returns simple data classes.

Adding Users to the Database

Let’s add a users table:

object Users : Table("users") {
    val id = integer("id").autoIncrement()
    val name = varchar("name", 100)
    val email = varchar("email", 255).uniqueIndex()

    override val primaryKey = PrimaryKey(id)
}

The .uniqueIndex() on email prevents duplicate emails at the database level.

User Repository

class UserRepository {

    private fun resultRowToUser(row: ResultRow) = UserResponse(
        id = row[Users.id],
        name = row[Users.name],
        email = row[Users.email]
    )

    suspend fun findAll(name: String? = null): List<UserResponse> = dbQuery {
        val query = if (name != null) {
            Users.selectAll().where {
                Users.name.lowerCase() like "%${name.lowercase()}%"
            }
        } else {
            Users.selectAll()
        }
        query.orderBy(Users.id to SortOrder.ASC).map(::resultRowToUser)
    }

    suspend fun findById(id: Int): UserResponse? = dbQuery {
        Users.selectAll().where { Users.id eq id }
            .map(::resultRowToUser)
            .singleOrNull()
    }

    suspend fun findByEmail(email: String): UserResponse? = dbQuery {
        Users.selectAll().where { Users.email eq email }
            .map(::resultRowToUser)
            .singleOrNull()
    }

    suspend fun create(request: CreateUserRequest): UserResponse = dbQuery {
        val result = Users.insert {
            it[Users.name] = request.name
            it[Users.email] = request.email
        }
        resultRowToUser(result.resultedValues!!.first())
    }

    suspend fun delete(id: Int): Boolean = dbQuery {
        Users.deleteWhere { Users.id eq id } > 0
    }
}

Injecting Repositories into Routes

Pass repositories to route functions as parameters:

// plugins/Routing.kt
fun Application.configureRouting() {
    val noteRepository = NoteRepository()
    val userRepository = UserRepository()

    routing {
        get("/") { call.respondText("Hello, Ktor!") }
        get("/health") { call.respondText("OK") }

        route("/api") {
            userRoutes(userRepository)
            noteRoutes(noteRepository)
        }
    }
}

Routes accept the repository:

fun Route.noteRoutes(repository: NoteRepository) {
    route("/notes") {
        get {
            val notes = repository.findAll()
            call.respond(notes)
        }
        // ...
    }
}

This makes routes easy to test — you can pass a mock repository in tests.

HTTP Status Codes

Using the right status codes makes your API professional and predictable:

OperationSuccess CodeDescription
GET (list)200 OKReturns list of items
GET (single)200 OKReturns one item
POST (create)201 CreatedItem created successfully
PUT (update)200 OKReturns updated item
DELETE204 No ContentItem deleted, no body
Validation error400 Bad RequestInvalid input
Not found404 Not FoundItem does not exist
Duplicate409 ConflictResource already exists

Example: Create User with Conflict Detection

post {
    val request = call.receive<CreateUserRequest>()

    if (request.name.isBlank() || request.email.isBlank()) {
        call.respond(
            HttpStatusCode.BadRequest,
            ErrorResponse("Name and email required", 400)
        )
        return@post
    }

    // Check if email already exists
    val existing = repository.findByEmail(request.email)
    if (existing != null) {
        call.respond(
            HttpStatusCode.Conflict,
            ErrorResponse("Email already exists", 409)
        )
        return@post
    }

    val user = repository.create(request)
    call.respond(HttpStatusCode.Created, user)
}

Complete Note Routes

fun Route.noteRoutes(repository: NoteRepository) {
    route("/notes") {
        get {
            val page = call.queryParameters["page"]?.toIntOrNull() ?: 1
            val size = call.queryParameters["size"]?.toIntOrNull() ?: 10
            val notes = repository.findAll(page, size)
            call.respond(notes)
        }

        get("/{id}") {
            val id = call.parameters["id"]?.toIntOrNull()
            if (id == null) {
                call.respond(HttpStatusCode.BadRequest, ErrorResponse("Invalid ID", 400))
                return@get
            }
            val note = repository.findById(id)
            if (note == null) {
                call.respond(HttpStatusCode.NotFound, ErrorResponse("Note not found", 404))
                return@get
            }
            call.respond(note)
        }

        post {
            val request = call.receive<CreateNoteRequest>()
            if (request.title.isBlank()) {
                call.respond(HttpStatusCode.BadRequest, ErrorResponse("Title required", 400))
                return@post
            }
            val note = repository.create(request)
            call.respond(HttpStatusCode.Created, note)
        }

        put("/{id}") {
            val id = call.parameters["id"]?.toIntOrNull()
            if (id == null) {
                call.respond(HttpStatusCode.BadRequest, ErrorResponse("Invalid ID", 400))
                return@put
            }
            val request = call.receive<UpdateNoteRequest>()
            val note = repository.update(id, request)
            if (note == null) {
                call.respond(HttpStatusCode.NotFound, ErrorResponse("Note not found", 404))
                return@put
            }
            call.respond(note)
        }

        delete("/{id}") {
            val id = call.parameters["id"]?.toIntOrNull()
            if (id == null) {
                call.respond(HttpStatusCode.BadRequest, ErrorResponse("Invalid ID", 400))
                return@delete
            }
            if (repository.delete(id)) {
                call.respond(HttpStatusCode.NoContent)
            } else {
                call.respond(HttpStatusCode.NotFound, ErrorResponse("Note not found", 404))
            }
        }
    }
}

Testing with curl

Start the server and test each endpoint:

# Create a note
curl -X POST http://localhost:8080/api/notes \
  -H "Content-Type: application/json" \
  -d '{"title": "My Note", "content": "Hello World"}'

# List all notes
curl http://localhost:8080/api/notes

# Get a single note
curl http://localhost:8080/api/notes/1

# Update a note
curl -X PUT http://localhost:8080/api/notes/1 \
  -H "Content-Type: application/json" \
  -d '{"title": "Updated Title"}'

# Delete a note
curl -X DELETE http://localhost:8080/api/notes/1

# Create a user
curl -X POST http://localhost:8080/api/users \
  -H "Content-Type: application/json" \
  -d '{"name": "Alex", "email": "alex@example.com"}'

Automated Tests

@Test
fun `create and get user`() = testApplication {
    application { module() }
    val client = jsonClient()

    val created = client.post("/api/users") {
        contentType(ContentType.Application.Json)
        setBody(CreateUserRequest("Alex", "alex@example.com"))
    }.body<UserResponse>()
    assertEquals("Alex", created.name)

    val fetched = client.get("/api/users/${created.id}").body<UserResponse>()
    assertEquals("Alex", fetched.name)
}

@Test
fun `create user with duplicate email returns 409`() = testApplication {
    application { module() }
    val client = jsonClient()

    client.post("/api/users") {
        contentType(ContentType.Application.Json)
        setBody(CreateUserRequest("Alex", "alex@example.com"))
    }

    val response = client.post("/api/users") {
        contentType(ContentType.Application.Json)
        setBody(CreateUserRequest("Alex2", "alex@example.com"))
    }
    assertEquals(HttpStatusCode.Conflict, response.status)
}

@Test
fun `update note preserves unchanged fields`() = testApplication {
    application { module() }
    val client = jsonClient()

    val created = client.post("/api/notes") {
        contentType(ContentType.Application.Json)
        setBody(CreateNoteRequest("Original", "Original content"))
    }.body<NoteResponse>()

    val updated = client.put("/api/notes/${created.id}") {
        contentType(ContentType.Application.Json)
        setBody(UpdateNoteRequest(title = "Updated Title"))
    }.body<NoteResponse>()

    assertEquals("Updated Title", updated.title)
    assertEquals("Original content", updated.content)
}

Run all tests:

./gradlew test

Error Handling Patterns

There are several patterns for handling errors in a CRUD API. Here are the most common:

Pattern 1: Return Early

Check for errors at each step and return early:

get("/{id}") {
    val id = call.parameters["id"]?.toIntOrNull()
    if (id == null) {
        call.respond(HttpStatusCode.BadRequest, ErrorResponse("Invalid ID", 400))
        return@get
    }
    val note = repository.findById(id)
    if (note == null) {
        call.respond(HttpStatusCode.NotFound, ErrorResponse("Not found", 404))
        return@get
    }
    call.respond(note)
}

This is simple and explicit. You see every check in the code.

Pattern 2: Custom Exceptions

Throw custom exceptions and let StatusPages handle them:

class NotFoundException(message: String) : RuntimeException(message)
class BadRequestException(message: String) : RuntimeException(message)
class ConflictException(message: String) : RuntimeException(message)

// In StatusPages plugin
install(StatusPages) {
    exception<NotFoundException> { call, cause ->
        call.respond(HttpStatusCode.NotFound, ErrorResponse(cause.message ?: "Not found", 404))
    }
    exception<BadRequestException> { call, cause ->
        call.respond(HttpStatusCode.BadRequest, ErrorResponse(cause.message ?: "Bad request", 400))
    }
    exception<ConflictException> { call, cause ->
        call.respond(HttpStatusCode.Conflict, ErrorResponse(cause.message ?: "Conflict", 409))
    }
}

// In route handler
get("/{id}") {
    val id = call.parameters["id"]?.toIntOrNull()
        ?: throw BadRequestException("Invalid ID")
    val note = repository.findById(id)
        ?: throw NotFoundException("Note $id not found")
    call.respond(note)
}

This is cleaner for large APIs. The route handler focuses on the happy path.

Pattern 3: Result Type

Use Kotlin’s Result or a custom sealed class:

sealed class ApiResult<out T> {
    data class Success<T>(val data: T) : ApiResult<T>()
    data class Error(val message: String, val code: Int) : ApiResult<Nothing>()
}

This is useful for service layer logic where you want to return errors without throwing exceptions.

Pagination Response

For production APIs, include pagination metadata in the response:

@Serializable
data class PaginatedResponse<T>(
    val data: List<T>,
    val page: Int,
    val size: Int,
    val total: Long
)

Then use it in routes:

get {
    val page = call.queryParameters["page"]?.toIntOrNull() ?: 1
    val size = call.queryParameters["size"]?.toIntOrNull() ?: 10
    val notes = repository.findAll(page, size)
    val total = repository.count()
    call.respond(PaginatedResponse(notes, page, size, total))
}

The client gets:

{
    "data": [...],
    "page": 1,
    "size": 10,
    "total": 42
}

Now the client knows there are 42 total items and can calculate the number of pages (5 pages at 10 per page).

Project Structure So Far

src/main/kotlin/com/kemalcodes/
├── Application.kt
├── db/
│   ├── DatabaseFactory.kt
│   └── Tables.kt
├── models/
│   ├── Note.kt
│   └── User.kt
├── plugins/
│   ├── Database.kt
│   ├── Routing.kt
│   ├── Serialization.kt
│   └── StatusPages.kt
└── repository/
    ├── NoteRepository.kt
    └── UserRepository.kt

Clean separation. Each layer has its own directory. Easy to navigate, easy to test.

Source Code

You can find the source code for this tutorial on GitHub:

github.com/kemalcodes/ktor-tutorial — Branch: tutorial-07-crud

What’s Next?

In the next tutorial, we will add relationships between tables — connecting notes to users with foreign keys, JOIN queries, and advanced filtering.

Ktor Tutorial #8: Relationships and Advanced Queries