
Android アプリの設計(Clean Architecture、MVVM/MVI、Jetpack Composeなど)を学びはじめると、必ずと言っていいほどこう言われます。
「Entity や DTO をそのままUI層に露出させるな。必ずドメインモデルへマッピングしろ。」
しかし、実務でシンプルな CRUD 画面や小さなアプリを作っていると、こう感じたことはないでしょうか?
・「これ、単に Entity を Domain に、Domain を UiState にコピーしてるだけの『作業ゲー』になってないか?」
・「わざわざすべての画面でレイヤーを分けてマッピングを書く意味あるのか?」
結論から言えば、すべての画面にドメインモデルや厳格なレイヤー分割は不要です。
CRUD 中心のシンプルな画面や、単にデータを保存して表示するだけのアプリであれば、Entity や DTO をそのまま UiState まで直通させたほうが明らかにコード量が減り、開発スピードが上がります。
では、ドメインモデルがどうしても必要になる瞬間(=存在意義)は一体いつなのか?
突き詰めると、理由は以下の2つのケースに集約されます。
それぞれのケースについて、「使わない場合」と「使う場合」のコードを比較してみましょう。
🧑🏻💻 複数のデータソースを混ぜて独自のルールを適用したいとき
ドメインモデルを使わない場合
ViewModel 内で直接データソースを結合し、判定ロジックも直書きしている状態です。ViewModel が肥大化し、テストが書きづらくなります。
@HiltViewModel
class UserViewModel @Inject constructor(
private val userDao: UserDao,
private val pointApi: PointApi
) : ViewModel() {
val uiState: StateFlow<UserUiState> = combine(
userDao.observeUser("123"),
pointApi.observePoints("123")
) { entity, response ->
val isVip = response.points >= 1000 && entity.planType == "PREMIUM"
val discountRate = if (isVip) 0.20 else 0.0
UserUiState.Success(entity.name, isVip, discountRate)
}.stateIn(scope = viewModelScope, started = SharingStarted.WhileSubscribed(5000), initialValue = UserUiState.Loading)
}
ドメインモデルを使う場合
ドメインモデル UserProfile に判定ロジックをカプセル化し、Repository で結合します。ViewModel は極めてシンプルになります。
data class UserProfile(
val id: String,
val name: String,
val planType: String,
val totalPoints: Int
) {
val isVip: Boolean get() = totalPoints >= 1000 && planType == "PREMIUM"
val discountRate: Double get() = if (isVip) 0.20 else 0.0
}
class UserRepositoryImpl @Inject constructor(
private val userDao: UserDao,
private val pointApi: PointApi
) : UserRepository {
override fun observeUserProfile(userId: String): Flow<UserProfile> {
return combine(userDao.observeUser(userId), pointApi.observePoints(userId)) { entity, response ->
UserProfile(entity.id, entity.name, entity.planType, response.points)
}
}
}
@HiltViewModel
class UserViewModel @Inject constructor(
observeUserProfileUseCase: ObserveUserProfileUseCase
) : ViewModel() {
val uiState: StateFlow<UserUiState> = observeUserProfileUseCase("123")
.map { profile -> UserUiState.Success(profile.name, profile.isVip, profile.discountRate) }
.stateIn(scope = viewModelScope, started = SharingStarted.WhileSubscribed(5000), initialValue = UserUiState.Loading)
}
🧑🏻💻 API や DB のスキーマ変更による影響を防ぎたいとき
ドメインモデルを使わない場合
API の DTO がそのまま ViewModel まで直通しているため、バックエンドの変更がそのままアプリ全体の修正地獄に繋がります。
@Serializable
data class UserDtoV1(
@SerialName("user_id") val userId: String,
@SerialName("first_name") val firstName: String,
@SerialName("last_name") val lastName: String
)
@HiltViewModel
class UserViewModel @Inject constructor(
private val userApi: UserApi
) : ViewModel() {
suspend fun loadUser(userId: String) {
val dto: UserDtoV1 = userApi.getUser(userId)
val displayName = "${dto.firstName} ${dto.lastName}"
}
}
ドメインモデルを使う場合
Repository の内部の Mapper で変動しやすい DTO を受け止め、不変のドメインモデルに変換します。上流の ViewModel や UI は完全に無傷で守られます。
data class User(
val id: String,
val fullName: String
)
class UserRepositoryImpl @Inject constructor(
private val userApi: UserApi
) : UserRepository {
override suspend fun getUser(userId: String): User {
val dto = userApi.getUser(userId)
return User(
id = dto.userId,
fullName = "${dto.firstName} ${dto.lastName}"
)
}
}
@HiltViewModel
class UserViewModel @Inject constructor(
private val userRepository: UserRepository
) : ViewModel() {
suspend fun loadUser(userId: String) {
val user: User = userRepository.getUser(userId)
}
}
🧑🏻💻 まとめ
・ CRUD だけのシンプルな画面やアプリ:無駄なマッピングやドメインモデルは作らず、Entity/DTOをそのまま使ってスピードを優先する。
・ 複雑なビジネスロジックや複数データの結合がある場合:ドメインモデルを「計算・ロジックの避難所」として使う。
・ 外部スキーマ変更の耐性を高めたい場合:Repository の Mapper を「防壁」として機能させる。
設計の原則をすべての場所に盲目的に適用するのではなく、
「そのコスト(マッピングの手間)を払うだけのメリット(変更への耐性やロジックの隔離)があるか」
を見極めることが、実務でメンテしやすいコードベースを維持する最大のコツです。


