0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

【Kotlin】kotlinx.serialization × Retrofit で型安全な API 層を構築する

Posted at

1. なぜ kotlinx.serialization なのか

  • KMP 対応:JVM/Android/JS/Nativeで同じモデルを扱える
  • ゼロリフレクション:Moshi/Gsonより軽量/高速になりやすい
  • ポリモーフィズム@SerialName等の言語統合的サポート

2. 依存関係(Gradle)

app/build.gradle.kts

plugins {
  kotlin("android")
  id("com.android.application")
  kotlin("plugin.serialization") // ← 重要
}

android {
  namespace = "com.example.app"
  compileSdk = 35

  defaultConfig {
    minSdk = 24
    targetSdk = 35
  }

  buildTypes {
    release {
      isMinifyEnabled = true
      proguardFiles(
        getDefaultProguardFile("proguard-android-optimize.txt"),
        "proguard-rules.pro"
      )
    }
  }
}

dependencies {
  // Retrofit
  implementation("com.squareup.retrofit2:retrofit:2.11.0")

  // kotlinx.serialization (JSON)
  implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.7.3")

  // Retrofit × kotlinx.serialization コンバータ
  // Jake Wharton のコンバータ(実績大)
  implementation("com.jakewharton.retrofit:retrofit2-kotlinx-serialization-converter:1.0.0")

  // OkHttp(ログ/タイムアウト等)
  implementation("com.squareup.okhttp3:okhttp:4.12.0")
  implementation("com.squareup.okhttp3:logging-interceptor:4.12.0")
}

バージョンはプロジェクトにあわせて更新してください。kotlinx-serialization-json と Kotlin のメジャー互換に注意。


3. Retrofit × kotlinx.serialization の最小セットアップ

import kotlinx.serialization.json.Json
import okhttp3.OkHttpClient
import okhttp3.logging.HttpLoggingInterceptor
import retrofit2.Retrofit
import com.jakewharton.retrofit2.converter.kotlinx.serialization.asConverterFactory
import kotlinx.serialization.ExperimentalSerializationApi
import okhttp3.MediaType.Companion.toMediaType

@OptIn(ExperimentalSerializationApi::class)
fun provideRetrofit(baseUrl: String): Retrofit {
    val json = Json {
        ignoreUnknownKeys = true      // 余分なフィールド無視(後方互換◎)
        explicitNulls = false         // null を省略したい場合に便利
        isLenient = true              // 非厳格 JSON 許容(APIによる)
        coerceInputValues = true      // 型違いを許容できるケースで便利
        encodeDefaults = false
    }

    val client = OkHttpClient.Builder()
        .addInterceptor(HttpLoggingInterceptor().apply {
            level = HttpLoggingInterceptor.Level.BODY
        })
        .build()

    val contentType = "application/json".toMediaType()

    return Retrofit.Builder()
        .baseUrl(baseUrl)
        .addConverterFactory(json.asConverterFactory(contentType))
        .client(client)
        .build()
}

4. API インターフェースと DTO

import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import retrofit2.http.GET
import retrofit2.http.Path
import retrofit2.http.Query

// --- DTO(APIワイヤ形式) ---
@Serializable
data class UserDto(
    val id: String,
    @SerialName("display_name") val displayName: String,
    val age: Int? = null,
    val email: String? = null
)

@Serializable
data class PagedUsersDto(
    val items: List<UserDto>,
    val nextCursor: String? = null
)

// --- Retrofit API ---
interface UserApi {
    @GET("users/{id}")
    suspend fun getUser(@Path("id") id: String): UserDto

    @GET("users")
    suspend fun listUsers(@Query("cursor") cursor: String? = null): PagedUsersDto
}

5. Domain モデルと Mapper(層の分離)

// Domain(UIやUseCaseが使う純粋モデル)
data class User(
    val id: String,
    val name: String,
    val age: Int?,        // 業務上null許容の意味があるならそのまま
    val hasEmail: Boolean // DTOと少し異なる表現
)

// Mapper(Data層内部)
fun UserDto.toDomain(): User = User(
    id = id,
    name = displayName,
    age = age,
    hasEmail = !email.isNullOrBlank()
)

Domain は外部形式に依存しない@Serializable は DTO のみに付与するのが基本。


6. エラーハンドリング(成功/失敗の型安全化)

6.1 統一レスポンス型(Result 風)

sealed interface ApiResult<out T> {
    data class Success<T>(val value: T) : ApiResult<T>
    sealed interface Failure : ApiResult<Nothing> {
        data class Http(val code: Int, val body: String?) : Failure
        data class Network(val throwable: Throwable) : Failure
        data class Unknown(val throwable: Throwable) : Failure
    }
}

6.2 安全呼び出しヘルパ

import retrofit2.HttpException
import java.io.IOException

suspend inline fun <T> safeCall(crossinline block: suspend () -> T): ApiResult<T> =
    try {
        ApiResult.Success(block())
    } catch (e: HttpException) {
        ApiResult.Failure.Http(e.code(), e.response()?.errorBody()?.string())
    } catch (e: IOException) { // ネットワーク切断やタイムアウト
        ApiResult.Failure.Network(e)
    } catch (e: Throwable) {
        ApiResult.Failure.Unknown(e)
    }

7. Repository 実装(Data 層)

interface UserRepository {
    suspend fun getUser(id: String): ApiResult<User>
    suspend fun listUsers(cursor: String? = null): ApiResult<Pair<List<User>, String?>>
}

class UserRepositoryImpl(
    private val api: UserApi
) : UserRepository {

    override suspend fun getUser(id: String): ApiResult<User> =
        safeCall {
            api.getUser(id).toDomain()
        }

    override suspend fun listUsers(cursor: String?): ApiResult<Pair<List<User>, String?>> =
        safeCall {
            val page = api.listUsers(cursor)
            page.items.map { it.toDomain() } to page.nextCursor
        }
}

8. Presentation からの利用(例:ViewModel)

class UserViewModel(
    private val repo: UserRepository
) : ViewModel() {

    private val _state = MutableStateFlow<UiState>(UiState.Loading)
    val state: StateFlow<UiState> = _state

    sealed interface UiState {
        object Loading : UiState
        data class Data(val user: User) : UiState
        data class Error(val message: String) : UiState
    }

    fun load(id: String) = viewModelScope.launch {
        when (val r = repo.getUser(id)) {
            is ApiResult.Success -> _state.value = UiState.Data(r.value)
            is ApiResult.Failure.Http -> _state.value = UiState.Error("HTTP ${r.code}")
            is ApiResult.Failure.Network -> _state.value = UiState.Error("ネットワークエラー")
            is ApiResult.Failure.Unknown -> _state.value = UiState.Error("不明なエラー")
        }
    }
}

9. Polymorphic/Discriminator(高度なシリアライズ)

APIが「type」によってペイロード形を切り替える場合:

import kotlinx.serialization.*
import kotlinx.serialization.json.*

@Serializable
sealed class Item {
    abstract val id: String
}

@Serializable
@SerialName("article")
data class Article(override val id: String, val title: String): Item()

@Serializable
@SerialName("video")
data class Video(override val id: String, val url: String): Item()

val json = Json {
    ignoreUnknownKeys = true
    classDiscriminator = "type" // ← "type" フィールドで分岐
}

10. テスト戦略(Unit / Integration)

  • Mapper の単体テスト:DTO → Domain の境界を保証
  • API の統合テスト:MockWebServer + 実コンバータで JSON ラウンドトリップ
test("UserDto toDomain") {
    val dto = UserDto("1", "Anna", 25, "a@example.com")
    val domain = dto.toDomain()
    assert(domain.name == "Anna" && domain.hasEmail)
}

11. ProGuard(R8)設定のヒント

kotlinx.serialization は基本的にリフレクション不要ですが、万一の最適化でフィールドが消えるのを防ぐ場合:

# kotlinx.serialization (通常は不要だが保険で)
-keepattributes *Annotation*
-keep class kotlinx.serialization.** { *; }
-keep class **$$serializer { *; }
-keepclassmembers class ** {
    @kotlinx.serialization.Serializable *;
}

12. よくある落とし穴(と対策)

  • 未知フィールドで失敗ignoreUnknownKeys = true を有効化
  • null地獄explicitNulls=falseencodeDefaults=false の調整、Domainでは意味のある null のみにする
  • エラー本文の解析errorBody()?.string() を JSON として別 DTO にパース → エラー画面に詳細表示
  • Date/Time@Contextualkotlinx-datetimeInstant/LocalDateTime 用カスタムシリアライザを実装

13. 差し替え可能なコンバータ設計(拡張性)

同一 API でも、将来的に ProtoBuf / CBOR に切替えたい場合:

interface ConverterFactoryProvider {
    fun provide(): retrofit2.Converter.Factory
}

class KotlinxJsonConverterProvider(
    private val json: Json
) : ConverterFactoryProvider {
    override fun provide() = json.asConverterFactory("application/json".toMediaType())
}

// Retrofit Builder では DI で差し替え
fun retrofit(baseUrl: String, provider: ConverterFactoryProvider) =
    Retrofit.Builder()
        .baseUrl(baseUrl)
        .addConverterFactory(provider.provide())
        .build()

14. Clean Architecture での配置

domain/
  model/User.kt
  repository/UserRepository.kt
data/
  dto/UserDto.kt
  mapper/UserMapper.kt
  network/UserApi.kt
  repository/UserRepositoryImpl.kt
  net/RetrofitModule.kt
app/ (presentation)
  viewmodel/UserViewModel.kt

まとめ

  • kotlinx.serialization軽量・KMP対応・型安全。Retrofit とは JakeWharton のコンバータで連携
  • DTO ↔ Domain を明確分離し、Mapperで境界を管理
  • ApiResult で成功/失敗を型に閉じ込め、ViewModel で UI 状態へ変換
  • ignoreUnknownKeys などの Json 設定で 互換性と堅牢性 を確保
  • 将来のフォーマット変更に備え ConverterFactory を DI で差し替え可能に設計

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?