Spring BootでのKotlinバックエンド
1. 概要
KotlinはSpringが公式にサポートしている言語です。Kotlinを使用すれば、ボイラープレートコードを削減でき、コンパイル時のnull safetyが得られ、DTOのためのdata classが利用できます。このガイドでは、KotlinとSpring Boot 3.xを使用して注文管理(Order)のREST APIを構築する方法を説明します。JPAエンティティ、type-safe設定、およびテストが含まれます。この記事を読み終わると、Kotlinバックエンドプロジェクトを自分で初期化でき、JavaからKotlinに移行する際の最も一般的なエラーを回避できるようになります。
2. Mavenプロジェクトのセットアップ
2.1. 依存関係
一般的なstarterの他に、Kotlinプロジェクトにはさらに3つが必要です。kotlin-stdlibは標準ライブラリです。kotlin-reflectはSpringがKotlinメタデータを読み取るために使用します。jackson-module-kotlinはJacksonがdata classとパラメータのデフォルト値を理解するのに役立ちます。
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>3.5.0</version>
</parent>
<properties>
<java.version>21</java.version>
</properties>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-validation</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-jpa</artifactId>
</dependency>
<dependency>
<groupId>com.h2database</groupId>
<artifactId>h2</artifactId>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>com.fasterxml.jackson.module</groupId>
<artifactId>jackson-module-kotlin</artifactId>
</dependency>
<dependency>
<groupId>org.jetbrains.kotlin</groupId>
<artifactId>kotlin-reflect</artifactId>
</dependency>
<dependency>
<groupId>org.jetbrains.kotlin</groupId>
<artifactId>kotlin-stdlib</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
Kotlinアーティファクトのバージョンを記述する必要はありません。Spring Bootの親がすでにkotlin.versionを管理しています。親もまたjava.versionに基づいてkotlin.compiler.jvmTargetを自動的に設定するため、生成されたバイトコードはJava 21を対象とします。
2.2. kotlin-maven-pluginの設定
これが最も重要な部分です。Kotlinでは、classおよびmethodはデフォルトでfinalです。Springは@Configuration、@Transactional、@Asyncなどのプロキシ(CGLIB)を作成する必要があるため、これらのクラスはopenでなければなりません。Hibernateはノーアーグコンストラクタを必要とします。2つのコンパイラプラグインallopenとnoargは両方の問題を解決します:
<build>
<sourceDirectory>${project.basedir}/src/main/kotlin</sourceDirectory>
<testSourceDirectory>${project.basedir}/src/test/kotlin</testSourceDirectory>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
</plugin>
<plugin>
<groupId>org.jetbrains.kotlin</groupId>
<artifactId>kotlin-maven-plugin</artifactId>
<configuration>
<args>
<arg>-Xjsr305=strict</arg>
</args>
<compilerPlugins>
<plugin>spring</plugin>
<plugin>jpa</plugin>
<plugin>all-open</plugin>
</compilerPlugins>
<pluginOptions>
<option>all-open:annotation=jakarta.persistence.Entity</option>
<option>all-open:annotation=jakarta.persistence.MappedSuperclass</option>
<option>all-open:annotation=jakarta.persistence.Embeddable</option>
</pluginOptions>
</configuration>
<dependencies>
<dependency>
<groupId>org.jetbrains.kotlin</groupId>
<artifactId>kotlin-maven-allopen</artifactId>
<version>${kotlin.version}</version>
</dependency>
<dependency>
<groupId>org.jetbrains.kotlin</groupId>
<artifactId>kotlin-maven-noarg</artifactId>
<version>${kotlin.version}</version>
</dependency>
</dependencies>
</plugin>
</plugins>
</build>
各部分の意味:
spring:@Component、@Configuration、@Transactional、@Async、@Cacheableなどのアノテーションを持つクラスと、@Service、@RestControllerなどの派生アノテーションを自動的にopenにします。jpa:@Entity、@Embeddable、@MappedSuperclassを持つクラスのノーアーグコンストラクタを生成します。all-open(3つのオプション付き):Hibernateがlazy loadingプロキシを作成できるようにエンティティも開きます。springプラグインはこの部分を処理しません。-Xjsr305=strict:KotlinはSpringのnullabilityアノテーション(@Nullable、@NonNullApi)を実際のKotlin型として読み取ります。Spring APIが@Nullableを返す場合、それはT?になり、nullを処理する必要があります。
3. アプリケーション起動クラス
package dev.nonla.shop
import org.springframework.boot.autoconfigure.SpringBootApplication
import org.springframework.boot.context.properties.ConfigurationPropertiesScan
import org.springframework.boot.runApplication
@SpringBootApplication
@ConfigurationPropertiesScan
class ShopApplication
fun main(args: Array<String>) {
runApplication<ShopApplication>(*args)
}
runApplication<ShopApplication>はreified genericを持つextension functionです。これはSpringApplication.run(ShopApplication::class.java, *args)と同等です。main関数はトップレベルにあり、companion objectは必要ありません。@ConfigurationPropertiesScanはセクション7で使用されます。
4. JPAエンティティとリポジトリ
package dev.nonla.shop.order
import jakarta.persistence.*
import java.math.BigDecimal
enum class OrderStatus { NEW, PAID, CANCELLED }
@Entity
@Table(name = "orders")
class Order(
var customerName: String,
var amount: BigDecimal,
@Enumerated(EnumType.STRING)
var status: OrderStatus = OrderStatus.NEW,
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
var id: Long? = null,
)
注意すべき点がいくつかあります:
- すべてのフィールドはprimaryコンストラクタで直接宣言されます。getter/setterを書く必要はありません。
idはLong?型でデフォルトはnullです。エンティティが保存された後にのみidを持つためです。- Hibernateが必要とするノーアーグコンストラクタは、プラグイン
jpaによって生成されます。Kotlinコードには見えません。 - これは通常の
classであり、data classではありません。理由はセクション9にあります。
Repositoryは以下のようにシンプルに書きます:
package dev.nonla.shop.order
import org.springframework.data.jpa.repository.JpaRepository
interface OrderRepository : JpaRepository<Order, Long> {
fun findByStatus(status: OrderStatus): List<Order>
}
5. data classとnull safetyを使ったDTO
DTOはdata classを使用するのに最適な場所です。Kotlinは自動的にequals、hashCode、toString、copyを生成します。
package dev.nonla.shop.order
import jakarta.validation.constraints.NotBlank
import jakarta.validation.constraints.Positive
import java.math.BigDecimal
data class CreateOrderRequest(
@field:NotBlank
val customerName: String,
@field:Positive
val amount: BigDecimal,
)
data class OrderResponse(
val id: Long,
val customerName: String,
val amount: BigDecimal,
val status: OrderStatus,
)
fun Order.toResponse() = OrderResponse(
id = requireNotNull(id) { "Order has not been saved yet" },
customerName = customerName,
amount = amount,
status = status,
)
@NotBlankではなく@field:NotBlankと書く必要があります。use-site target field:がない場合、Kotlinはアノテーションをコンストラクタパラメータに付与します。Bean Validationはアノテーションを見られず、検証は静かに実行されません。
Extension function Order.toResponse()はエンティティがDTOに依存しないようにしますが、メソッドのように呼び出すことができます:order.toResponse()。requireNotNullはidがnullの場合IllegalArgumentExceptionをスローします。その後、コンパイラはidをLongと見なし、Long?ではなくなります。
6. ServiceとController
6.1. Service
package dev.nonla.shop.order
import org.springframework.data.repository.findByIdOrNull
import org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Transactional
@Service
@Transactional(readOnly = true)
class OrderService(private val orderRepository: OrderRepository) {
@Transactional
fun create(request: CreateOrderRequest): OrderResponse {
val order = Order(
customerName = request.customerName.trim(),
amount = request.amount,
)
return orderRepository.save(order).toResponse()
}
fun findById(id: Long): OrderResponse? =
orderRepository.findByIdOrNull(id)?.toResponse()
}
BeanはprimaryコンストラクタInjectされます。1つのコンストラクタのみを持つクラスの場合、@Autowiredは不要です。フィールドはprivate valであるため、再割り当てできません。
findByIdOrNullはKotlin専用のSpring Data extensionです。Optional<Order>の代わりにOrder?を返します。?.演算子と組み合わせると、findById関数が1行でまとめられます。戻り値の型OrderResponse?は、呼び出し元に結果が存在しない可能性があることも示しています。
springプラグインのおかげで、OrderServiceは自動的にopenになります。Springはopen classを書かなくても@Transactionalのプロキシを作成できます。
6.2. Controller
package dev.nonla.shop.order
import jakarta.validation.Valid
import org.springframework.http.HttpStatus
import org.springframework.http.ResponseEntity
import org.springframework.web.bind.annotation.*
@RestController
@RequestMapping("/api/orders")
class OrderController(private val orderService: OrderService) {
@PostMapping
@ResponseStatus(HttpStatus.CREATED)
fun create(@Valid @RequestBody request: CreateOrderRequest): OrderResponse =
orderService.create(request)
@GetMapping("/{id}")
fun get(@PathVariable id: Long): ResponseEntity<OrderResponse> =
orderService.findById(id)
?.let { ResponseEntity.ok(it) }
?: ResponseEntity.notFound().build()
}
Elvis演算子?:はif (x != null) … else …の代わりになります:注文がある場合は200を返し、ない場合は404を返します。curlで試してみてください:
curl -s -X POST localhost:8080/api/orders \
-H "Content-Type: application/json" \
-d '{"customerName":"Nguyễn Văn An","amount":250000}'
{"id":1,"customerName":"Nguyễn Văn An","amount":250000,"status":"NEW"}
リクエストが完全にcustomerNameを欠いている場合、Jackson(jackson-module-kotlin付き)はデシリアライズ時に直ちにエラーを報告します。non-nullパラメータに値がないためです。Springはvalidationステップの前に400 Bad Requestを返します。"customerName": " "を送信した場合、リクエストはデシリアライズステップを通過しますが、@field:NotBlankによってブロックされ、やはり400を受け取ります。
7. @ConfigurationPropertiesを使ったtype-safe設定
payment.provider=vnpay
payment.timeout=3s
package dev.nonla.shop.payment
import org.springframework.boot.context.properties.ConfigurationProperties
import java.time.Duration
@ConfigurationProperties(prefix = "payment")
data class PaymentProperties(
val provider: String,
val timeout: Duration = Duration.ofSeconds(5),
val retryCount: Int = 2,
)
Spring Boot 3以降、1つのコンストラクタのみを持つクラスはコンストラクタを通じてバインドされます。@ConstructorBindingは不要です。propertiesファイルにない属性(ここではretryCount)はKotlinのデフォルト値を受け取ります。valであるため、設定オブジェクトは起動後は不変です。
通常のbeanのようにserviceにInjectします:
@Service
class PaymentService(private val props: PaymentProperties) {
fun describe() = "Thanh toán qua ${props.provider}, timeout ${props.timeout.seconds}s"
}
Thanh toán qua vnpay, timeout 3s
payment.provider(デフォルト値なし)がない場合、アプリケーションは起動しません。これはあなたが望む動作です:設定エラーはdeploy時に即座に公開され、runtime時に待つ必要はありません。
8. テストを書く
spring-boot-starter-testにはJUnit 5とAssertJが既に含まれています。Kotlinでは、バックティック内に説明文を置いてテストに名前を付けます:
package dev.nonla.shop.order
import org.assertj.core.api.Assertions.assertThat
import org.junit.jupiter.api.Test
import org.springframework.beans.factory.annotation.Autowired
import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest
import org.springframework.context.annotation.Import
import java.math.BigDecimal
@DataJpaTest
@Import(OrderService::class)
class OrderServiceTest(@Autowired private val orderService: OrderService) {
@Test
fun `新しい注文を作成し、ステータスはNEW、名前は両側の空白を削除した状態`() {
val response = orderService.create(
CreateOrderRequest(customerName = " Trần Thị Bình ", amount = BigDecimal("99000"))
)
assertThat(response.id).isPositive()
assertThat(response.customerName).isEqualTo("Trần Thị Bình")
assertThat(response.status).isEqualTo(OrderStatus.NEW)
}
@Test
fun `注文が見つからない場合はnullを返す`() {
assertThat(orderService.findById(9_999)).isNull()
}
}
@DataJpaTestは小さなcontextをセットアップし、JPAとH2 in-memoryのみを含みます。@ImportはそのcontextにOrderServiceを追加します。テストクラスもコンストラクタinjectを使用します:テストではJUnit 5が、Springから値を取得するためにパラメータの@Autowiredが必要です。各テストはトランザクション内で実行され、終了時にロールバックされるため、データが他のテストに漏れません。
OrderServiceTest > 新しい注文を作成し、ステータスはNEW、名前は両側の空白を削除した状態() PASSED
OrderServiceTest > 注文が見つからない場合はnullを返す() PASSED
9. よくあるエラー
エンティティにdata classを使用する。 equals/hashCodeはすべてのフィールド(nullの可能性があるidおよびlazy関係を含む)に基づいて自動的に生成されます。結果として、エンティティが保存された後に「ハッシュが変わる」ため、HashSetを破壊します。toString()はlazy loadingをトリガーするか、双方向関係で無限再帰を引き起こす可能性があります。
注意:
data classはDTOと設定にのみ使用してください。エンティティの場合は通常のclassを使用してください。必要に応じて、idに基づいてequals/hashCodeを自分で書きます。
springまたはall-openプラグインを忘れる。 プラグインがない場合、Springは@Configuration class may not be finalのようなエラーを報告します。さらに悪いことに、finalメソッドの@Transactionalは警告なく無視されます。エンティティの場合、all-openがないとHibernateはプロキシを作成できず、LAZY関係は黙ってEAGERになります。
validation annotationのfield:を忘れる。 Validationは実行されず、エラーも報告されません。このエラーをキャッチするには、常に無効なデータの場合をテストします。
Injectにlateinit varを使用する。 これは機能しますが、不変性を失い、UninitializedPropertyAccessExceptionが発生しやすくなります。private valを使ったコンストラクタinjectを優先してください。
-Xjsr305=strictを忘れる。 このフラグがない場合、Spring APIからの戻り値の型はplatform type(T!)です。コンパイラがnullチェックを強制せず、JavaのようにNullPointerExceptionが発生する可能性があります。
Kotlinに切り替えるべきでない場合は? チームがKotlinに慣れておらず、プロジェクトが急いでいる場合、学習コスト(coroutine、extension、scope function)はメリットより大きい可能性があります。Kotlinコードはコンパイルも少し遅くなります。さらに、LombokやMapStructなど、reflectionに大きく依存する一部のライブラリはKotlinとの連携が悪い場合があります。この場合、Kotlinを段階的に導入できます:既存のJavaコード部分を保ちながら、テストまたは新しいモジュールをKotlinで書きます。
10. まとめ
Kotlinを使用してSpring Boot 3バックエンドを構築しました。これには以下が含まれます:
spring、jpa、all-open、-Xjsr305=strictを使ったkotlin-maven-pluginの設定;- JPAエンティティは通常の
classを使用、DTOは@field:を使ったdata classを使用; - null safety、
findByIdOrNull、およびElvis演算子を活用するserviceおよびcontroller; data classの@ConfigurationPropertiesを使った不変設定;- JUnit 5とAssertJを使ったテスト。バックティック内の説明文で命名。
あなたが持っているほとんどのSpringの知識はそのまま使用できます。Kotlinに切り替える際に覚えておくべき3つの違いがあります:クラスはデフォルトでfinal、annotationはuse-site targetが必要、そしてnullable型は明示的に宣言する必要があります。これら3つのポイントをしっかり理解することで、JavaからKotlinへの移行時に最も難しいエラーのほとんどを回避できます。