🔬

Server Side Kotlin with Micronaut

2025/04/04に公開

はじめに

バックエンドでKotlinを採用する場合、フレームワークの選定に迷いますよね。micronaut (+ jdbi3) が最もバランスが良さそうだという結論にいたったので、その構成を実装を混じえながら紹介します。

使用したrepositoryはこちら。

https://github.com/uga-rosa/micronaut-demo/tree/develop

選定理由

Kotlin向けのWebフレームワークとして有名なものとして、SpringとKtorがありますが

  • Springは遅い。
  • ktorのような薄いフレームワークはDIなど面倒な部分が多い。

そのため、高性能で成熟したフレームワーク(=Java製)かつ、公式にKotlinサポートがあるものを求めた結果、micronautに辿り着きました。

また、データベース操作のモジュールmicronaut-dataは素のSQLを書くのには向いていません。
単純なCrudRepositoryなどはinterfaceだけで簡単に生成できるので便利ですが、SpringのJdbcClientのように複雑なクエリを直接記述したい場合はjdbi3を併用するのがいいでしょう。
今回はjdbi3は使わず、micronaut-data-jdbcのCrudRepositoryだけで実装します。

導入

では実装例を添えて見てみましょう。簡単のため、ドメインは単純なCRUDで済むTODOアプリにしますが、認証などはきちんと作ります。CAベースで設計しますが、DI用のアノテーションをimportする必要があります。

現時点の最新版である、Micronaut 4.8.0を使用しました。

私が慣れているので、言語はKotlin、ビルドシステムはGradleを採用します。IntelliJ IDEA Ultimateを持っているならIDEA上でも作成できますが、今回はmicronaut cliを使用しましょう。sdkmanで入れます。

https://docs.micronaut.io/4.8.0/guide/#buildCLI

# まだsdkmanをインストールしていない場合
# curl -s "https://get.sdkman.io" | bash 
# source "~/.sdkman/bin/sdkman-init.sh"
sdk update && sdk install micronaut

フラグで有効にする機能を選択します。jdbcをhikariCPで使うことにしました。databaseにはpostgersを選びました。

mn create-app micronaut-demo --lang kotlin --build gradle --test kotest --jdk 21 --features data-jdbc,jdbc-hikari,postgres,flyway,security-jwt

これでカレントディレクトリ以下に micronaut-demo/ というプロジェクトが作成されました。Application.ktだけの空っぽのプロジェクトです。

実装例

では実装を進めていきましょう。

CAっぽく3層(adapter/application/domain)でやります。

Domain

まずはドメイン定義からです。

TODOのタスクを表現する Task とそれの所有者 User があればいいでしょう。IDがnullableになってしまうのが嫌なので、新規作成用のモデルは分けます。

domain/
  entity/
    Task.kt
    User.kt
  repository/
    TaskRepository.kt
package micronaut.demo.domain.entity

import java.time.Instant

data class Task(
    val id: Long,
    val userId: Long,
    val name: String,
    val description: String,
    val isCompleted: Boolean,
    val createdAt: Instant,
    val updatedAt: Instant,
)

data class NewTask(
    val userId: Long,
    val name: String,
    val description: String,
)
package micronaut.demo.domain.entity

data class User(
    val id: Long,
    val username: String,
    val passwordHash: String,
)

data class NewUser(
    val username: String,
    val passwordHash: String,
)
package micronaut.demo.domain.repository

import micronaut.demo.domain.entity.NewTask
import micronaut.demo.domain.entity.Task

interface TaskRepository {
    fun save(task: NewTask): Task
    fun findById(id: Long): Task?
    fun findByUserId(userId: Long): List<Task>
    fun update(task: Task): Task
    fun deleteById(id: Long): Boolean
}

Application (Usecase)

基本操作のusecaseを作成します。簡単のために今回は例外でエラー処理しますが、arrow-coreのEitherなどを使って処理した方がいいと思います。

application/
  usecase/
    CreateTaskUsecase.kt
    ListTaskUsecase.kt
    UpdateTaskUsecase.kt
    DeleteTaskUsecase.kt
package micronaut.demo.application.usecase

import jakarta.inject.Singleton
import micronaut.demo.domain.entity.NewTask
import micronaut.demo.domain.entity.Task
import micronaut.demo.domain.repository.TaskRepository

interface CreateTaskUsecase {
    fun execute(userId: Long, title: String, description: String): Task
}

@Singleton
class CreateTaskUsecaseImpl(
    private val taskRepository: TaskRepository,
) : CreateTaskUsecase {
    override fun execute(userId: Long, title: String, description: String): Task {
        val newTask = NewTask(userId, title, description)
        return taskRepository.save(newTask)
    }
}
package micronaut.demo.application.usecase

import jakarta.inject.Singleton
import micronaut.demo.domain.entity.Task
import micronaut.demo.domain.repository.TaskRepository

interface ListTaskUsecase {
    fun execute(userId: Long): List<Task>
}

@Singleton
class ListTaskUsecaseImpl(
    private val taskRepository: TaskRepository,
) : ListTaskUsecase {
    override fun execute(userId: Long): List<Task> {
        return taskRepository.findByUserId(userId)
    }
}
package micronaut.demo.application.usecase

import jakarta.inject.Singleton
import micronaut.demo.domain.entity.Task
import micronaut.demo.domain.repository.TaskRepository

interface UpdateTaskUsecase {
    fun execute(
        userId: Long,
        id: Long,
        title: String?,
        description: String?,
        isCompleted: Boolean?,
    ): Task
}

@Singleton
class UpdateTaskUsecaseImpl(
    private val taskRepository: TaskRepository,
) : UpdateTaskUsecase {
    override fun execute(
        userId: Long,
        id: Long,
        title: String?,
        description: String?,
        isCompleted: Boolean?,
    ): Task {
        val task = taskRepository.findById(id)
            ?: throw IllegalArgumentException("Task with id $id not found")
        if (task.userId != userId) {
            throw IllegalArgumentException("Task with id $id does not belong to user $userId")
        }
        val updatedTask = task.copy(
            title = title ?: task.title,
            description = description ?: task.description,
            isCompleted = isCompleted ?: task.isCompleted,
        )
        return taskRepository.update(updatedTask)
    }
}

package micronaut.demo.application.usecase

import jakarta.inject.Singleton
import micronaut.demo.domain.entity.Task
import micronaut.demo.domain.repository.TaskRepository

interface UpdateTaskUsecase {
    fun execute(userId: Long, id: Long, title: String?, description: String?): Task
}

@Singleton
class UpdateTaskUsecaseImpl(
    private val taskRepository: TaskRepository,
) : UpdateTaskUsecase {
    override fun execute(userId: Long, id: Long, title: String?, description: String?): Task {
        val task = taskRepository.findById(id)
            ?: throw IllegalArgumentException("Task with id $id not found")
        if (task.userId != userId) {
            throw IllegalArgumentException("Task with id $id does not belong to user $userId")
        }
        val updatedTask = task.copy(
            title = title ?: task.title,
            description = description ?: task.description,
        )
        return taskRepository.update(updatedTask)
    }
}

User関連のUsecaseは認証の章でまとめてやります。

Adapter (Controller)

Annotation baseで書けます。Springっぽいですね。
先に認証周りを作らないとuserIdが取れないので、一旦TODOにしておきます。

また、本来はIDをそのまま外部に公開するべきではありません。プロダクションでは必ず暗号化してください。今回は実装の簡潔さを優先しています。

adapter/
  controller/
    request/
      CreateTaskRequest.kt
      UpdateTaskRequest.kt
    response/
      TaskResponse.kt
    TaskController.kt
package micronaut.demo.adapter.controller

import io.micronaut.http.MediaType
import io.micronaut.http.annotation.Body
import io.micronaut.http.annotation.Consumes
import io.micronaut.http.annotation.Controller
import io.micronaut.http.annotation.Delete
import io.micronaut.http.annotation.Get
import io.micronaut.http.annotation.Patch
import io.micronaut.http.annotation.PathVariable
import io.micronaut.http.annotation.Post
import io.micronaut.http.annotation.Produces
import micronaut.demo.application.usecase.CreateTaskUsecase
import micronaut.demo.application.usecase.DeleteTaskUsecase
import micronaut.demo.application.usecase.ListTaskUsecase
import micronaut.demo.application.usecase.UpdateTaskUsecase
import micronaut.demo.interfaces.controller.request.CreateTaskRequest
import micronaut.demo.interfaces.controller.request.UpdateTaskRequest
import micronaut.demo.interfaces.controller.response.TaskResponse

@Controller("/tasks")
@Consumes(MediaType.APPLICATION_JSON)
@Produces(MediaType.APPLICATION_JSON)
class TaskController(
    private val createTaskUsecase: CreateTaskUsecase,
    private val listTaskUsecase: ListTaskUsecase,
    private val updateTaskUsecase: UpdateTaskUsecase,
    private val deleteTaskUsecase: DeleteTaskUsecase,
) {
    @Get
    fun listTasks(): List<TaskResponse> {
        TODO()
    }

    @Post
    fun createTask(
        @Body body: CreateTaskRequest,
    ): TaskResponse {
        TODO()
    }

    @Patch("/{id}")
    @Consumes(MediaType.APPLICATION_JSON_MERGE_PATCH)
    fun updateTask(
        @PathVariable id: Long,
        @Body body: UpdateTaskRequest,
    ): TaskResponse {
        TODO()
    }

    @Delete("/{id}")
    fun deleteTask(
        @PathVariable id: Long,
    ) {
        TODO()
    }
}
package micronaut.demo.adapter.controller.request

import io.micronaut.serde.annotation.Serdeable

@Serdeable
data class CreateTaskRequest(
    val title: String,
    val description: String,
)
package micronaut.demo.adapter.controller.request

import io.micronaut.serde.annotation.Serdeable

@Serdeable
data class UpdateTaskRequest(
    val title: String?,
    val description: String?,
    val isCompleted: Boolean?,
)
package micronaut.demo.adapter.controller.response

import io.micronaut.serde.annotation.Serdeable

@Serdeable
data class TaskResponse(
    val id: Long,
    val userId: Long,
    val title: String,
    val description: String,
    val isCompleted: Boolean,
    val createdAt: String,
    val updatedAt: String,
)

fun Task.toResponse(): TaskResponse =
    TaskResponse(
        id = id,
        userId = userId,
        title = title,
        description = description,
        isCompleted = isCompleted,
        createdAt = createdAt.toString(),
        updatedAt = updatedAt.toString(),
    )

Adapter (Persistence)

まずはdocker-composeでpostgresを起動しましょう。

簡単のために色々直書きしていますが、実際の開発では環境変数などでセキュアに扱ってください。

services:
  micronaut-demo-db:
    image: postgres:17.4
    container_name: postgres_17
    ports:
      - "5432:5432"
    environment:
      POSTGRES_USER: postgres
      POSTGRES_PASSWORD: postgres
      POSTGRES_DB: demo
    volumes:
      - pgdata:/var/lib/postgresql/data

volumes:
  pgdata:
docker compose up -d

初期化のSQLも書いておきましょう。flywayの命名規則に従います。

  • src/main/resources/db/migration/V1.0.0__init.sql
CREATE TABLE users
(
    id            BIGSERIAL PRIMARY KEY,
    username      TEXT NOT NULL UNIQUE,
    password_hash TEXT NOT NULL
);

CREATE TABLE tasks
(
    id           BIGSERIAL PRIMARY KEY,
    user_id      BIGINT REFERENCES users (id) ON DELETE CASCADE,
    title        TEXT    NOT NULL,
    description  TEXT    NOT NULL,
    is_completed BOOLEAN NOT NULL,
    created_at   TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
    updated_at   TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);

micronaut-flywayを入れているので、アプリを起動すれば自動でマイグレーションが走ります。開発中の変更は、docker compose down -vでデータを丸ごと消してしまうのが楽でしょう。

ただし、今のままだと起動できないと思います。detasourcesを定義してください。私の好みでHOCON(application.conf)に書き直しています。

datasources {
  default {
    db-type: postgres
    dialect: POSTGRES
    url: "jdbc:postgresql://localhost:5432/demo"
    username: postgres
    password: postgres
    driver-class-name: org.postgresql.Driver
  }
}
flyway {
  datasources.default.enabled: true
}
micronaut {
  application.name: micronaut-demo
  security {
    authentication: bearer
    token.jwt.signatures.secret.generator.secret: ${?JWT_GENERATOR_SIGNATURE_SECRET}
  }
}

ではrepositoryの実装に入ります。

単純なCRUD操作なのでmicronaut-data-jdbcのCrudRepositoryで楽します。ただし、table構造をdomain layerに公開しないよう変換処理を挟みます。

レコードに対応するクラスはEntityと命名することが多いかもしれませんが、DDDのDomain Entityと混ざってややこしいのでTableと名付けます。

adapter/
  persistence/
    crud/
      TaskCrudRepository.kt
    table/
      TaskTable.kt
    TaskRepositoryImpl.kt
package micronaut.demo.adapter.persistence.table

import io.micronaut.data.annotation.GeneratedValue
import io.micronaut.data.annotation.Id
import io.micronaut.data.annotation.MappedEntity
import io.micronaut.data.annotation.MappedProperty
import micronaut.demo.domain.entity.NewTask
import micronaut.demo.domain.entity.Task
import java.time.Instant

@MappedEntity("tasks")
data class TaskTable(
    @Id
    @GeneratedValue
    val id: Long?,

    @MappedProperty("user_id")
    val userId: Long,

    @MappedProperty("title")
    val title: String,

    @MappedProperty("description")
    val description: String,

    @MappedProperty("is_completed")
    val isCompleted: Boolean,

    @MappedProperty("created_at")
    val createdAt: Instant,

    @MappedProperty("updated_at")
    val updatedAt: Instant,
)

fun NewTask.toTable(): TaskTable =
    TaskTable(
        id = null,
        userId = userId,
        title = title,
        description = description,
        isCompleted = false,
        createdAt = Instant.now(),
        updatedAt = Instant.now(),
    )

fun Task.toTable(): TaskTable =
    TaskTable(
        id = id,
        userId = userId,
        title = title,
        description = description,
        isCompleted = isCompleted,
        createdAt = createdAt,
        updatedAt = updatedAt,
    )

fun TaskTable.toTask(): Task =
    Task(
        id = id ?: throw IllegalStateException("Task Id is null. Maybe not saved yet."),
        userId = userId,
        title = title,
        description = description,
        isCompleted = isCompleted,
        createdAt = createdAt,
        updatedAt = updatedAt,
    )
package micronaut.demo.adapter.persistence.crud

import io.micronaut.data.jdbc.annotation.JdbcRepository
import io.micronaut.data.model.query.builder.sql.Dialect
import io.micronaut.data.repository.CrudRepository
import micronaut.demo.adapter.gateway.table.TaskTable

@JdbcRepository(dialect = Dialect.POSTGRES)
interface TaskCrudRepository : CrudRepository<TaskTable, Long> {
    fun findByUserId(userId: Long): List<TaskTable>
}
package micronaut.demo.adapter.persistence

import jakarta.inject.Singleton
import micronaut.demo.adapter.gateway.crud.TaskCrudRepository
import micronaut.demo.adapter.gateway.table.toTable
import micronaut.demo.adapter.gateway.table.toTask
import micronaut.demo.domain.entity.NewTask
import micronaut.demo.domain.entity.Task
import micronaut.demo.domain.repository.TaskRepository
import kotlin.jvm.optionals.getOrNull

@Singleton
class TaskRepositoryImpl(
    private val taskCrudRepository: TaskCrudRepository,
) : TaskRepository {
    override fun save(task: NewTask): Task {
        val table = task.toTable()
        return taskCrudRepository.save(table)
            .toTask()
    }

    override fun findById(id: Long): Task? {
        return taskCrudRepository.findById(id).getOrNull()
            ?.toTask()
    }

    override fun findByUserId(userId: Long): List<Task> {
        return taskCrudRepository.findByUserId(userId)
            .map { it.toTask() }
    }

    override fun update(task: Task): Task {
        val table = task.toTable()
        return taskCrudRepository.update(table)
            .toTask()
    }

    override fun deleteById(id: Long): Boolean {
        if (!taskCrudRepository.existsById(id)) {
            return false
        }
        taskCrudRepository.deleteById(id)
        return true
    }
}

認証(jwt)

今回はmicronaut-security-jwtを使います。

https://guides.micronaut.io/latest/micronaut-security-jwt-gradle-kotlin.html

/login エンドポイントは自動で作成されるので、登録処理と認証結果の利用方法を追加すればいいです。
/auth/register では認証を除外するのを忘れずに。

adapter/
  controller/
    request/
      RegisterRequest.kt
    AuthController.kt
package micronaut.demo.adapter.controller

import io.micronaut.http.annotation.Body
import io.micronaut.http.annotation.Controller
import io.micronaut.http.annotation.Post
import micronaut.demo.adapter.controller.request.RegisterRequest
import micronaut.demo.application.usecase.UserRegisterUsecase

@Controller("/auth")
class AuthController(
    private val userRegisterUsecase: UserRegisterUsecase,
) {
    @Post("/register")
    @Secured(SecurityRule.IS_ANONYMOUS)
    fun register(
        @Body body: RegisterRequest,
    ) {
        userRegisterUsecase.execute(body.name, body.password)
    }
}
package micronaut.demo.adapter.controller.request

import io.micronaut.serde.annotation.Serdeable

@Serdeable
data class RegisterRequest(
    val username: String,
    val password: String,
)

ここまで簡単のために色々と雑に書いてきましたが、パスワードのハッシュ化は流石にやります。Springからモジュールだけお借りしましょう。

    implementation("org.springframework.security:spring-security-crypto:6.4.4")
    implementation("commons-logging:commons-logging:1.3.5") // 依存
application/
  usecase/
    UserRegisterUsecase.kt
package micronaut.demo.application.usecase

import jakarta.inject.Singleton
import micronaut.demo.domain.entity.NewUser
import micronaut.demo.domain.repository.UserRepository
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder

interface UserRegisterUsecase {
    fun execute(username: String, password: String)
}

@Singleton
class UserRegisterUsecaseImpl(
    private val userRepository: UserRepository,
) : UserRegisterUsecase {
    private val encoder = BCryptPasswordEncoder()

    override fun execute(username: String, password: String) {
        if (userRepository.existsByUsername(username)) {
            throw IllegalArgumentException("User with name $name already exists")
        }
        val hash = encoder.encode(password)
        val user = NewUser(name, hash)
        userRepository.save(user)
    }
}

Repository周りもまとめて作ります。

domain/
  repository/
    UserRepository.kt
adapter/
  persistence/
    crud/
      UserCrudRepository.kt
    table/
      UserTable.kt
    UserRepositoryImpl.kt
package micronaut.demo.domain.repository

import micronaut.demo.domain.entity.NewUser
import micronaut.demo.domain.entity.User

interface UserRepository {
    fun save(user: NewUser): User
    fun findByUsername(username: String): User?
    fun existsByUsername(username: String): Boolean
}
package micronaut.demo.adapter.persistence

import jakarta.inject.Singleton
import micronaut.demo.adapter.persistence.crud.UserCrudRepository
import micronaut.demo.adapter.persistence.table.toTable
import micronaut.demo.adapter.persistence.table.toUser
import micronaut.demo.domain.entity.NewUser
import micronaut.demo.domain.entity.User
import micronaut.demo.domain.repository.UserRepository

@Singleton
class UserRepositoryImpl(
    private val userCrudRepository: UserCrudRepository,
) : UserRepository {
    override fun save(user: NewUser): User {
        val table = user.toTable()
        return userCrudRepository.save(table)
            .toUser()
    }

    override fun findByUsername(username: String): User? {
        return userCrudRepository.findByUsername(username)
            ?.toUser()
    }

    override fun existsByUsername(username: String): Boolean {
        return userCrudRepository.existsByUsername(username)
    }
}
package micronaut.demo.adapter.persistence.crud

import io.micronaut.data.jdbc.annotation.JdbcRepository
import io.micronaut.data.model.query.builder.sql.Dialect
import io.micronaut.data.repository.CrudRepository
import micronaut.demo.adapter.gateway.table.UserTable

@JdbcRepository(dialect = Dialect.POSTGRES)
interface UserCrudRepository : CrudRepository<UserTable, Long> {
    fun findByName(name: String): UserTable?
    fun existsByName(name: String): Boolean
}
package micronaut.demo.adapter.persistence.table

import io.micronaut.data.annotation.GeneratedValue
import io.micronaut.data.annotation.Id
import io.micronaut.data.annotation.MappedEntity
import io.micronaut.data.annotation.MappedProperty
import micronaut.demo.domain.entity.NewUser
import micronaut.demo.domain.entity.User

@MappedEntity("users")
data class UserTable(
    @Id
    @GeneratedValue
    val id: Long?,

    @MappedProperty("username")
    val username: String,

    @MappedProperty("password_hash")
    val passwordHash: String,
)

fun NewUser.toTable(): UserTable =
    UserTable(
        id = null,
        username = username,
        passwordHash = passwordHash,
    )

fun UserTable.toUser(): User =
    User(
        id = id ?: throw IllegalStateException("User Id is null. Maybe not saved yet."),
        username = username,
        passwordHash = passwordHash,
    )

あとは認証プロバイダを作ります。

adapter/
  security/
    AuthenticationProviderUserPassword.kt
package micronaut.demo.adapter.security

import io.micronaut.http.HttpRequest
import io.micronaut.security.authentication.AuthenticationFailed
import io.micronaut.security.authentication.AuthenticationRequest
import io.micronaut.security.authentication.AuthenticationResponse
import io.micronaut.security.authentication.provider.HttpRequestAuthenticationProvider
import jakarta.inject.Singleton
import micronaut.demo.domain.repository.UserRepository
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder

@Singleton
class AuthenticationProviderUserPassword<B>(
    private val userRepository: UserRepository,
) : HttpRequestAuthenticationProvider<B> {
    private val passwordEncoder = BCryptPasswordEncoder()

    override fun authenticate(
        requestContext: HttpRequest<B>?,
        authRequest: AuthenticationRequest<String, String>
    ): AuthenticationResponse {
        val username = authRequest.identity
        val password = authRequest.secret

        val user = userRepository.findByUsername(username)
        if (user != null && passwordEncoder.matches(password, user.passwordHash)) {
            return AuthenticationResponse.success(username, listOf("ROLE_USER"), mapOf("userId" to user.id))
        } else {
            return AuthenticationFailed()
        }
    }
}

これで Authentication.attributes から userId を取れるようになりました。

Adapter (Controller) 完成

では /tasks エンドポイントを実装しましょう。

adapter/
  controller/
    TaskController.kt
package micronaut.demo.adapter.controller

import io.micronaut.http.MediaType
import io.micronaut.http.annotation.Body
import io.micronaut.http.annotation.Consumes
import io.micronaut.http.annotation.Controller
import io.micronaut.http.annotation.Delete
import io.micronaut.http.annotation.Get
import io.micronaut.http.annotation.Patch
import io.micronaut.http.annotation.PathVariable
import io.micronaut.http.annotation.Post
import io.micronaut.http.annotation.Produces
import io.micronaut.security.annotation.Secured
import io.micronaut.security.authentication.Authentication
import io.micronaut.security.rules.SecurityRule
import micronaut.demo.adapter.controller.request.CreateTaskRequest
import micronaut.demo.adapter.controller.request.UpdateTaskRequest
import micronaut.demo.adapter.controller.response.TaskResponse
import micronaut.demo.adapter.controller.response.toResponse
import micronaut.demo.application.usecase.CreateTaskUsecase
import micronaut.demo.application.usecase.DeleteTaskUsecase
import micronaut.demo.application.usecase.ListTaskUsecase
import micronaut.demo.application.usecase.UpdateTaskUsecase

@Controller("/tasks")
@Consumes(MediaType.APPLICATION_JSON)
@Produces(MediaType.APPLICATION_JSON)
@Secured(SecurityRule.IS_AUTHENTICATED)
class TaskController(
    private val createTaskUsecase: CreateTaskUsecase,
    private val listTaskUsecase: ListTaskUsecase,
    private val updateTaskUsecase: UpdateTaskUsecase,
    private val deleteTaskUsecase: DeleteTaskUsecase,
) {
    @Post
    fun createTask(
        authentication: Authentication,
        @Body body: CreateTaskRequest,
    ): TaskResponse {
        val userId = extractUserId(authentication)
        val task = createTaskUsecase.execute(userId, body.title, body.description)
        return task.toResponse()
    }

    @Get
    fun listTasks(
        authentication: Authentication,
    ): List<TaskResponse> {
        val userId = extractUserId(authentication)
        return listTaskUsecase.execute(userId)
            .map { it.toResponse() }
    }

    @Patch("/{id}")
    @Consumes(MediaType.APPLICATION_JSON_MERGE_PATCH)
    fun updateTask(
        authentication: Authentication,
        @PathVariable id: Long,
        @Body body: UpdateTaskRequest,
    ): TaskResponse {
        val userId = extractUserId(authentication)
        val task = updateTaskUsecase.execute(userId, id, body.title, body.description, body.isCompleted)
        return task.toResponse()
    }

    @Delete("/{id}")
    fun deleteTask(
        authentication: Authentication,
        @PathVariable id: Long,
    ) {
        val userId = extractUserId(authentication)
        deleteTaskUsecase.execute(userId, id)
    }

    private fun extractUserId(authentication: Authentication): Long {
        return authentication.attributes["userId"] as? Long
            ?: throw IllegalArgumentException("User ID not found in authentication attributes")
    }
}

完成

これで一通りの実装ができました。
/auth/register でユーザーを登録して、 /login でトークンを取得し、 /tasks でCRUD操作ができるようになります。

テストは後日足すかもしれません。

まとめ

Kotlin + Micronaut の構成で、シンプルなTODOアプリを作成しました。
Micronaut は軽量で高性能なフレームワークであり、Kotlin からも簡単に使えます。ぜひお試してあれ。

おまけ

個人的にハマった点メモ

@QueryValueを10個以上付けられない

バグなのか仕様なのか微妙なラインなのですが、@QueryValueを10個以上付けるとぬるぽを吐きます。
@RequestBeanを使ってマッピングすれば回避できます。

ただし、@RequestBeanの対象はJavaのPOJOを期待するので、KSPの制約でconstructor引数にできません。
各プロパティはvarで定義する必要があります。

@Controller("/foo")
class FooController {
    @Get
    fun get(
        @RequestBean fooRequest: FooRequest,
    ): FooResponse {
        TODO()
    }
}

@Introspected
class FooRequest {
    @QueryValue("foo")
    var foo: String? = null
    @QueryValue("bar")
    var bar: String? = null
    @QueryValue("baz")
    var baz: String? = null
    // ...
}

Flywayのgradle pluginが動かない

Micronautは全く関係ないんですが、flywayのgradle plugin動いたことないです。
毎回自分でgradle taskを作ってます。

build.gradle.ktsで最新版動かせる書き方ご存じの方教えてください。。。

GitHubで編集を提案

Discussion