「ドメインモデル、ぶっちゃらいらなくね?」本当に必要な2つの判断基準

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 を「防壁」として機能させる。

設計の原則をすべての場所に盲目的に適用するのではなく、

「そのコスト(マッピングの手間)を払うだけのメリット(変更への耐性やロジックの隔離)があるか」

を見極めることが、実務でメンテしやすいコードベースを維持する最大のコツです。


「PHP2Shell」のサービス終了ドミノと、Android SDKバージョンの厳格化は同じ構造の悩みではないか?

古いPHPバージョンを維持できずに、そのままサービスを終了せざるを得ないWebサービスやサーバーのニュースを耳にする機会が増えています。古いPHPにおける「PHP2Shell」などの脆弱性リスクやサポート終了に伴う対応の難しさは、IT業界の大きな頭痛の種です。

ふと思ったのですが、これってスマートフォンのAndroidアプリにおける「Android SDK(APIレベル)」のバージョン強制問題とまったく同じ構造ではないでしょうか?

今回は、この「レガシー基盤のジレンマ」について、共通点と背景を整理してみたいと思います。

 

🤔 なぜ「同じ」と言えるのか? 共通する3つの構造

表面上は「サーバー側の言語(PHP)」と「モバイルのOS環境(Android)」で領域が違いますが、本質的な課題は驚くほど似ています。

土台のライフサイクルと、容赦ない切り捨て

PHPの現実: 古いPHPバージョン(7系や初期の8系など)はすでに公式サポートが終了しています。重大な脆弱性が見つかってもパッチが当たらないため、安全性を保つには最新版へ上げるしかありません。しかし、古いWebアプリや自作のプラグインがそのバージョンに対応していません。

Androidの現実: Google Playストアのポリシーにより、アプリがターゲットとすべき「ターゲットAPIレベル(SDKのバージョン)」の最低要件が毎年引き上げられます。古いSDKのまま放置されたアプリは、新しいAndroid OSのセキュリティ基準を満たせず、ストアから削除されたりアップデートができなくなります。

「動いているのに、強制的に動かなくなる」ジレンマ

どちらのケースも、「プログラム自体は今のままで、エラーも出さずに元気に動いている」という状態であることが多くあります。
しかし、言語側やプラットフォーム側が「セキュリティ」や「エコ全体の健全化」を理由にアップデートを強制するため、現役で動いていたはずのシステムが突如として「NG」判定を受けることになります。

膨大な「移行・修正コスト」の壁

古いコードを最新のPHPや新しいAndroid SDKに追従させるには、非推奨(デプリケーティッド)になった関数の書き換えや、ライブラリの全面的な見直しが必要です。これには膨大な開発工数とコストがかかるため、リソースのない個人開発や中小サービスでは、「直すくらいならサービスを終了しよう」という苦渋の決断に至ってしまいます。

 

🤔 決定的な違いは「誰が強制しているか」

構造は似ていますが、外圧の性質には少し違いがあります。

Android SDK: Googleという「絶対的なプラットフォームホルダー」が、規約をもってトップダウンで強制してくる。

PHP2Shell / サーバー: プラットフォームからの強制というよりは、ネット上の「セキュリティの脅威(ボットによる攻撃や脆弱性)」というボトムアップ(環境起因)の現実が、サービスを安全に継続させることを不可能にさせる。

結果として「続けられなくなる」という結末は同じでも、そのプレッシャーの出所が異なります。

 

🤔 まとめ:ITエコシステムの高齢化という宿命

PHPのバージョン問題も、Android SDKの追従問題も、根っこにあるのは「ITエコシステムの高齢化と、それに伴う保守コストの肥大化」です。

ソフトウェアは一度作ったら終わりではなく、その周りを取り巻く環境(OSや言語、セキュリティ基準)の高速な進化に合わせて絶えずメンテナンスし続けなければなりません。

「動いているからそのままにしておきたい」という開発者側の願いと、「安全のために進化を強制する」というプラットフォームのルールの板挟み。このジレンマにどう向き合うかが、現代のシステム開発・運営における大きな課題だと言えそうです。


PHP2Shell で騒ぐ前に知っておくべきこと:「PHPのバージョンを上げれば終わり」ができない現場の構造的欠陥

最近、「PHP2Shell」と呼ばれる深刻な脆弱性が話題になり、SNSやセキュリティ関連のニュースで大きく取り上げられています。

これに対して「何をそんなに大騒ぎしているのか? PHPのバージョンを安全な版にアップデートすれば終わる話ではないのか?」と感じた人も多いのではないでしょうか。

結論から言えば、その通りです。

技術的な解決策としては「修正されたバージョンに上げる」のが唯一かつ完全な正解です。しかし、それでもこれほどの大騒ぎになっているのは、「バージョンを上げることすらできないレガシーシステムが、世の中に大量に放置されているから」に他なりません。

この記事では、なぜ「バージョンアップ」という当たり前の対応ができないのか、そしてその裏にある現場の構造的な課題について整理します。

 

🤔 なぜ「PHPのバージョンアップ」で終わらないのか?

今回の脆弱性は、PHP 5.4から8.5という非常に広い範囲に影響を与えます。そして、ここが最大のネックなのですが、すでにサポートが終了(EOL)しているPHP 8.1以前のバージョンには、公式からのパッチが提供されません。

つまり、古いPHPを使っている環境は「修正版の8.2以降」へ一気にジャンプアップ(メジャー/マイナーバージョンアップ)する必要があります。

しかし、古いPHPで動いているシステムをそのまま最新のPHP 8.2/8.3に持っていくと、以下のような問題が確実に発生します。

・ 非推奨(Deprecated)機能・廃止関数の影響で画面が真っ白になる
・ 古いフレームワークやWordPressプラグインが動作しなくなる
・ データベースの接続ドライバや仕様変更に伴う挙動不審

結果として、「ただPHPのバイナリを差し替える」だけでは済まず、アプリケーションコードの大規模な改修と全機能の再テストが必要になります。

 

🤔 バージョンアップできないシステムは、ただの「延命」である

「コードを直せばいいじゃないか」と思うかもしれませんが、現場ではそれができません。

・ 仕様書がなく、当時の開発者もいない(誰も全体像を把握していない)
・「現状動いているから触るな」という経営陣・決裁者の圧力
・ 売上を生み出さない基盤改修に予算が出ない

このようにして、「修正費用が出せないから」「壊れるのが怖いから」とバージョンアップを先送りにしてきたシステムが、今回のPHP2Shellのような致命的な脆弱性に晒された瞬間、手も足も出なくなるのです。

WAF(Web Application Firewall)の設定調整や、一時的な設定変更で攻撃を防ごうとする現場もありますが、これはあくまで「時間稼ぎの応急処置」にすぎません。

バージョンアップに追従できないシステムは、ソフトウェアとしての寿命をすでに迎えており、「技術的負債の返済を先送りして延命しているだけ」というのが残酷な現実です。

 

🤔 開発者・エンジニアが取るべき姿勢

今回の騒動から私たちが学ぶべきなのは、「脆弱性が出たときにどう対処するか」だけではありません。「そもそもバージョンアップを前提としたアーキテクチャや保守体制をどう維持するか」という点です。

・ フレームワークや言語のマイナーアップデートを日々の運用に組み込む
・ EOL(サポート終了)を迎える前に、計画的なマイグレーション予算を確保する
・「動いているから触らない」ではなく、「触り続けられる状態(テストコードやCI/CD)を保つ」

これらができていないシステムは、どれだけ強固なセキュリティ製品を前段に置いたとしても、いつか必ず破綻します。

 

🤔 まとめ

「PHPのバージョンを上げたら終わりじゃないの?」という疑問に対する答えは、「理屈の上ではその通り。でも、上げられないシステムがあまりにも多すぎるから大騒ぎになっている」です。

セキュリティの脆弱性問題は、単なる技術の問題ではなく、これまでのシステム運用・投資判断のツケが可視化された結果とも言えます。

「そもそも、アナウンス済みのさまざまな脆弱性を放置したままだったよな?」

自社で管理しているシステムや関わっているプロダクトがあるなら、今回の件を機に「いつでも言語バージョンを上げられる状態になっているか?」を改めて見直してみるのが良いかもしれません。

👉 PHP 8.5.12, 8.4.27, 8.3.36 and 8.2.35 fix two critical vulnerabilities that can allow unauthenticated remote code execution. All PHP users are strongly encouraged to update immediately.


スマホ用電子証明書を搭載していても要注意!「マイナ免許証」でスマホ単体での運転ができない理由

2025年3月から運用が開始された「マイナ免許証(マイナンバーカードと一体化した運転免許証)」ですが、ニュースなどで話題になるにつれて、このような誤解が増えています

「スマホにマイナンバーカードの機能を入れれば、財布やカードを持たずにスマホだけで運転できるのでは?」

結論からお伝えすると、現時点において、スマホ単体で運転免許証の代わりとすることはできません。 スマートフォンだけで運転すると「免許不携帯」の取締り対象になってしまいます。

ここでは、なぜスマホ単体での運転ができないのか、その理由と現在の正しいルールを分かりやすく解説します。

 

🤔 スマホにマイナンバーカードを搭載しても「免許不携帯」になる理由

マイナンバーカードの機能(スマホ用電子証明書)をAndroidスマートフォンなどに搭載する動きが進んでいますが、現行の道路交通法および運用ルールには以下のような壁があります。

警察官による携帯義務の規定
道路交通法では、自動車を運転する際は「運転免許証を携帯していなければならない」と定められています。

スマホ画面の提示だけでは法的な証明として認められない
警察の取り締まりや検問を受けた際、現在のところ警察側のシステムや現場の運用において、スマホ画面や電子証明書のみで運転免許情報をリアルタイムかつ法的に有効に確認するインフラが全国の現場で完全に網羅されているわけではありません。

実物の「携帯」が必須
したがって、スマホにマイナンバーカードの機能が入っていたとしても、現時点では「実物のマイナンバーカード」または「従来の緑・青・金色のプラスチック製運転免許証」のどちらかを物理的に財布やカードケースに入れて携帯している必要があります。

 

🤔 マイナ免許証の本当の仕組みとメリット

「それならマイナ免許証にする意味はあるの?」と思われるかもしれませんが、マイナ免許証には以下のような確実なメリットがあります。

住所変更などの手続がワンストップ化
警察署や運転免許センターに行かなくても、マイナンバーカードの住所変更手続を行えば、自動的に免許証の記載事項変更も連動して行えるようになります(※マイナポータル等を活用したスムーズな連携)。

更新時の講習のオンライン化(優良運転者など)
条件を満たした優良運転者などは、更新時の講習を自宅のパソコンやスマホからオンラインで受講できるようになり、免許センターでの拘束時間が短縮されます。

カード1枚への集約
財布の中に入れるカード類をマイナンバーカード1枚にまとめられるスッキリ感があります。

 

🤔 将来的にスマホだけで運転できるようになる?

政府やデジタル庁、警察庁では、デジタル社会の推進に向けて各種身分証のスマホ完全対応(デジタルID化)に向けた検討や実証実験を段階的に進めています。

将来的には、スマートフォン自体が完全に運転免許証の法的効力を持つ日が来る可能性は十分にありますが、現時点(2026年時点)においては、まだ過渡期です。

 

🤔 まとめ:ドライブの際は必ず「実物」の携帯を!

「マイナ免許証にしたから、スマホさえあれば手ぶらでドライブに出かけられる!」と油断してスマホだけで運転してしまうと、免許不携帯(反則金等の対象)として交通違反を切られてしまうおそれがあります。

マイナ免許証をお持ちの方も、従来の運転免許証をお使いの方も、車を運転する際は必ず実物のカード(マイナンバーカードまたは従来の免許証)を携帯するよう、十分にご注意ください。


Java 脳のまま Jetpack Compose を書いてはいけない (2/2)

👉 Java 脳のまま Jetpack Compose を書いてはいけない (1/2) の続きです。

 

🧑🏻‍💻 画面遷移を if 文だけで管理してはいけない理由

Composition から外れると remember の状態はどうなるのか

小さな UI なら、条件分岐で表示を切り替える方法も有効です。


@Composable
fun App() {
    var showDetail by remember {
        mutableStateOf(false)
    }

    if (showDetail) {
        DetailScreen(
            onBack = { showDetail = false }
        )
    } else {
        HomeScreen(
            onOpenDetail = { showDetail = true }
        )
    }
}

しかし、HomeScreen が Composition から外れると、その内部の remember で保持していた状態は通常失われます。

例えば、ホーム画面でスクロールし、詳細画面へ移動して戻ると、ホーム画面を再構築したときにスクロール位置が初期化されることがあります。

この挙動自体がバグというわけではありません。単純な条件分岐は、複数画面の履歴や状態を管理する仕組みではないからです。

画面履歴、戻る操作、状態の保存・復元が必要になったら、Navigation の導入を検討すべきです。

navBackStack は単なる画面履歴ではない

Navigation 3 では、バックスタックをアプリ側が所有し、画面を識別するキーのリストとして扱います。NavDisplay はその状態を監視し、対応する画面を表示します。

👉 基本を理解して実装する  |  App architecture  |  Android Developers

概念的には、次のような流れです。


ユーザー操作
    |
    v
Back Stack の変更
    |
    v
NavDisplay が状態を反映
    |
    v
表示する画面が変わる

Navigation 3 の基本的な例を見てみましょう。


@Serializable
data object Home : NavKey

@Serializable
data class Detail(
    val itemId: String
) : NavKey

画面を表すキーを定義します。実際の画面コンテンツそのものではなく、画面を特定するための情報です。

次にバックスタックを作成します。


@Composable
fun AppNavigation() {
    val backStack = rememberNavBackStack(Home)

    NavDisplay(
        backStack = backStack,
        onBack = {
            backStack.removeLastOrNull()
        },
        entryProvider = entryProvider {
            entry<Home> {
                HomeScreen(
                    onOpenDetail = { id ->
                        backStack.add(Detail(id))
                    }
                )
            }

            entry<Detail> { key ->
                DetailScreen(itemId = key.itemId)
            }
        }
    )
}

この例では、ホーム画面から詳細画面に移動すると、バックスタックに Detail が追加されます。戻る操作では最後のエントリーが削除されます。

コードのポイントは、画面の表示を手動で切り替えていないことです。バックスタックというナビゲーション状態を変更し、その状態を Navigation が UI に反映します。

なお、この例は Navigation 3 の API を使用しています。必要な依存関係やシリアライザーの設定は、プロジェクトの採用バージョンに合わせて追加してください。

バックスタックだけで状態がすべて保持されるわけではない

ここは誤解しやすいポイントです。

バックスタックに画面のキーが残っていることと、画面内部の状態が自動的にすべて復元されることは同じではありません。

Navigation 3 で状態を適切に保持するには、必要に応じて保存可能なバックスタック、Saveable State、ViewModel のスコープなどを構成します。公式ドキュメントでも、rememberNavBackStack やエントリー用のデコレーターを利用した状態管理が説明されています。

👉 Save and manage navigation state  |  App architecture  |  Android Developers

例えば、画面エントリーに ViewModel のスコープを与える場合は、対応する Navigation 3 の ViewModel 統合を利用します。


NavDisplay(
    backStack = backStack,
    onBack = { backStack.removeLastOrNull() },
    entryProvider = entryProvider {
        entry<Home> {
            HomeScreen()
        }

        entry<Detail> { key ->
            DetailScreen(itemId = key.itemId)
        }
    },
    entryDecorators = listOf(
        rememberSaveableStateHolderNavEntryDecorator(),
        rememberViewModelStoreNavEntryDecorator()
    )
)

これはエントリーごとの Saveable State と ViewModel の管理を組み込む例です。利用する依存関係と API のシグネチャは、採用バージョンの公式ドキュメントで確認してください。

Navigation 3 が重要なのは、単に戻るボタンを実装できるからではありません。画面履歴を明示的な State として表現し、画面の表示や状態管理をそのモデルと連携させられることにあります。

 

🧑🏻‍💻 State・Runtime・Navigation を一つの設計モデルで考える

ここまでの話は、別々のテクニックに見えるかもしれません。しかし、共通する考え方があります。


アプリの状態
    |
    +----------------------+
    |                      |
    v                      v
画面の UI State        Navigation State
    |                      |
    v                      v
Compose Runtime        Back Stack
    |                      |
    v                      v
Composition          表示する画面の決定
    |                      |
    +-----------+----------+
                |
                v
          ユーザーに見える UI

この図は責務の関係を概念的に示したものです。実際には Navigation の画面表示も Compose Runtime 上の Composition として実行されます。

設計では、次の 3 点を区別します。

・ State: UI に何を表示するかを決めるデータ。
・ Compose Runtime: State の読み取りを追跡し、Composition の更新を管理する仕組み。
・ Navigation: 画面履歴やエントリーを管理し、ナビゲーション状態を UI に反映する仕組み。

それぞれの責務を分けると、コードも自然に整理されます。

例えば、次のような実装は避けたいものです。


// Avoid: UI state and navigation are mixed together.
var currentScreen by mutableStateOf("home")
var detailTitle by mutableStateOf("")
var isLoading by mutableStateOf(false)

// Every action manually synchronizes several values.
fun openDetail(title: String) {
    currentScreen = "detail"
    detailTitle = title
    isLoading = false
}

複数の変数を手動で同期すると、更新漏れや矛盾が生まれやすくなります。

代わりに、画面のデータは UiState として導出し、画面遷移はバックスタックの変更として表現します。


// UI state is derived from data sources.
val uiState: StateFlow<DetailUiState> =
    combine(repository.detail, selectedItemId) {
            detail, selectedId ->
        DetailUiState(
            detail = detail,
            selectedItemId = selectedId
        )
    }.stateIn(
        viewModelScope,
        SharingStarted.WhileSubscribed(5_000),
        DetailUiState()
    )

// Navigation is handled separately.
backStack.add(Detail(itemId))

これは概念例であり、selectedItemId の定義や combine の型はアプリに合わせる必要があります。重要なのは、UI データと画面履歴を一つの巨大な可変オブジェクトに詰め込まないことです。

 

🧑🏻‍💻 まとめ: Compose 化とは、構文ではなく設計モデルを変えること

Compose を使えば、自動的に宣言型の設計になるわけではありません。従来の命令型 UI の考え方を持ち込めば、状態の手動同期や画面管理の複雑さは残ります。

移行時には、次の点を確認してみてください。

・ UI を View のプロパティのように直接変更していないか。
・ UI の状態を複数の場所で手動同期していないか。
・ State をどこで所有し、どこで読み取るかが明確か。
・ Composition の寿命と ViewModel の寿命を区別しているか。
・ 画面履歴と戻る操作を、単純な条件分岐だけに任せていないか。
・ 状態の保存・復元を、利用する仕組みに合わせて設計しているか。

Compose の本質は、UI のコードを Kotlin に書き換えることではありません。

State と UI の依存関係を宣言し、Runtime の仕組みに沿って Composition を更新し、Navigation の状態と画面のライフサイクルを適切に連携させることです。

Compose を本当に使いこなすには、Composable の書き方を覚えるだけでなく、Compose Runtime が前提としている設計モデルに合わせる必要があります。

 

🧑🏻‍💻 参考

👉 Lifecycle of composables  |  Jetpack Compose  |  Android Developers
👉 State and Jetpack Compose  |  Android Developers
👉 Understand and implement the basics  |  App architecture  |  Android Developers
👉 Save and manage navigation state  |  App architecture  |  Android Developers