beio Logobeio

【Android 架構系列 04】核心防禦:Domain 與 Data Layer 設計——防止 AI 污染業務邏輯

發布於

01. 前言:建立「同心圓」防線

這是系列文的第四篇。在上一篇,我們利用 Hilt 解決了物件的「組裝」問題。現在,我們的架構已經有了骨架(SOLID)和關節(DI)。接下來,我們要開始填充最重要的「大腦」與「血液」。

這篇將深入探討 Clean Architecture 的核心戰略:如何利用 Domain LayerData Layer 的分層設計,防止 AI 把外部的「髒資料」直接運送到你的核心業務邏輯中。

如果把你的 App 比喻為一座城堡:

  • UI (Presentation Layer) 是城門和城牆,負責接待使用者。
  • Data (Data Layer) 是補給線,負責從外部(API/DB)運送物資。
  • Domain (Domain Layer) 則是城堡最深處的國王大廳,這裡制定了所有的規則與決策。

在 AI 輔助開發的場景下,最危險的事情就是讓 AI 直接把補給線上的「生肉」(未經處理的 API 資料)直接扔進國王大廳,甚至扔到城門口(UI)。

如果沒有明確的邊界,AI 非常喜歡寫出 if (apiResponse.article.source?.name != null) 這種防禦性程式碼散落在 UI 各處。這篇將教你如何設計 DomainData 層,確保 AI 只能在受控的範圍內工作。

02. Domain Layer:AI 的「絕對安全區」

這是整個架構中最神聖的一層。

鐵律: 這裡只有純 Kotlin 程式碼,嚴禁出現 import android.* (除了少數如 @Parcelize 以外)。

為什麼要對 AI 施加這種限制?因為 AI 非常容易產生「依賴幻覺」。如果你允許它引用 Android SDK,它可能就會在業務邏輯裡偷偷塞入 Context 來讀取字串資源,或者用 Toast 顯示錯誤,這會導致單元測試變得寸步難行。

A. 實體 (Entities):定義你的世界

不要使用後端回傳的 JSON 物件 (DTO) 作為你的核心資料結構。後端的欄位會變、命名會爛(例如 is_vip_flag_01),你的 App 不應該被這些髒東西汙染。

Prompt 策略:

"請在 Domain 層定義一個 Article data class。這應該是 App 內部使用的純淨物件,不包含任何 Gson/Moshi 註解,也不要繼承任何框架類別。"

// domain/model/Article.kt
// 這是我們 App 自己定義的「新聞」,不管後端 API 怎麼改,這個物件盡量不動
data class Article(
    val id: String,
    val title: String,
    val imageUrl: String?, // 允許為 null,UI 層再決定怎麼顯示
    val publishedAt: Date, // 這裡用強型別 Date,不要用 String
    val sourceName: String
)

B. UseCase:原子化的業務邏輯

這是 AI 協作的神器。我們將每個業務規則拆解成極小的 UseCase 類別。

傳統寫法 (Bad): 在 ViewModel 裡寫 if (article.sourceName == "Google News" && !article.isRead)

AI 友善寫法 (Good): 獨立為 FilterImportantNewsUseCase

為什麼這對 AI 很重要?

當你要求 AI:「修改過濾邏輯,現在來源包含 'BBC' 的也算重要新聞」時:

  • 沒有 UseCase: 你要把整包 ViewModel 丟給它,它可能會不小心改壞 UI 狀態流。
  • 有 UseCase: 你只丟給它這個不到 20 行的檔案。上下文極度乾淨,AI 犯錯機率趨近於零。

實戰 Prompt:

"請實作 GetNewsUseCase。邏輯:從 Repository 取得新聞後,過濾掉沒有標題的文章,並依照時間排序。"

// domain/usecase/GetNewsUseCase.kt
class GetNewsUseCase @Inject constructor(
    private val repository: NewsRepository
) {
    // operator fun invoke 讓 UseCase 可以像函式一樣被呼叫
    suspend operator fun invoke(): Result<List<Article>> {
        return repository.getNews().map { articles ->
            // AI 在這裡可以專注寫邏輯,完全不用管 UI 怎麼顯示
            articles
                .filter { it.title.isNotBlank() }
                .sortedByDescending { it.publishedAt }
        }
    }
}

03. Data Layer:強制 AI 進行「資料清洗」

Data Layer 是負責髒活的地方(打 API、讀資料庫)。這裡充滿了 null、不規範的命名和未知的異常。

筆者最強調的 AI 防禦機制是:Mapper (轉換器)

關鍵元件:Mapper (DTO -> Domain)

AI 在寫程式碼時常有一個壞習慣:為了省事,直接把 API 回傳的 Response 物件一路傳到 UI 層。這就是「技術債」的開始。

我們必須在 Data Layer 設立「海關」,強制 AI 進行資料清洗。

實戰場景: NewsAPI 回傳的 author 可能是 null,publishedAt 是 ISO 字串,需要轉成 Date 物件。

Prompt 策略:

"請實作一個 Extension Function,將 ArticleDto 轉換為 Domain 層的 Article 物件。 規則:

  1. 如果 title 是 null,過濾掉或給預設值。
  2. 將 String 時間格式解析為 Date 物件。
  3. 確保回傳的 Domain 物件欄位符合定義。"

AI 生成的代碼:

// data/mapper/NewsMapper.kt

// DTO: 後端給的髒資料
data class ArticleDto(
    @SerializedName("title") val title: String?,
    @SerializedName("urlToImage") val imageUrl: String?,
    @SerializedName("publishedAt") val dateStr: String?,
    @SerializedName("source") val source: SourceDto?
)

// Mapper: AI 的消毒工作
fun ArticleDto.toDomain(): Article {
    return Article(
        // AI 自動幫你處理了 null safety
        id = UUID.randomUUID().toString(), // 補齊缺少的 ID
        title = this.title ?: "無標題", 
        imageUrl = this.imageUrl,
        // 處理日期解析邏輯,這裡如果不寫 Mapper,就會散落在 UI 層
        publishedAt = DateUtils.parseIso8601(this.dateStr) ?: Date(),
        sourceName = this.source?.name ?: "未知來源"
    )
}

筆者點評: 透過 Mapper,我們把所有「處理髒資料」的醜陋程式碼都鎖死在 Data Layer。 當後端 API 爆炸時(例如日期格式改了),你只需要叫 AI:「去修 NewsMapper」,而不需要驚動國王大廳 (Domain) 或城牆 (UI)。

Repository 實作:串接一切

最後,Repository 的實作就是把 API 和 Mapper 串起來的地方。

Prompt:

"請實作 NewsRepository 介面。

  1. 從 NewsApi 取得 DTO。
  2. 使用 toDomain() 轉換。
  3. 使用 runCatching 處理所有網路異常,回傳 Result 型別。"
// data/repository/NewsRepositoryImpl.kt
class NewsRepositoryImpl @Inject constructor(
    private val api: NewsApi
) : NewsRepository {

    override suspend fun getNews(): Result<List<Article>> {
        return runCatching {
            val response = api.getTopHeadlines("tw", apiKey)
            // 關鍵:在這裡進行轉換,如果不成功會直接拋出 Exception 被 runCatching 捕獲
            response.articles.map { it.toDomain() }
        }
    }
}

04. 總結:架構即是「隔離」

在這一篇,我們完成了 App 的核心建設:

  1. Domain Layer: 我們建立了純淨的業務邏輯區,讓 AI 透過 UseCase 精準執行指令,避免依賴幻覺。
  2. Data Layer: 我們建立了嚴格的海關 Mapper,強迫 AI 在資料進入核心前完成清洗。

現在,我們的核心邏輯已經固若金湯。無論 API 怎麼變,Domain 層的 Article 和 UseCase 都不受影響。

但現實世界沒那麼簡單。App 不只要從 API 拿資料,還要有 離線瀏覽 功能。當我們同時有資料庫和 API 兩個來源時,AI 最容易寫出「資料不同步」的 Bug。

在下一篇,我們將進入深水區,探討 Offline-First (離線優先) 架構,看看如何讓 AI 寫出「單一真相來源 (Single Source of Truth)」的代碼。

下集預告:【Part 5】離線優先 (Offline-First):當 AI 遇上快取——如何設計 Single Source of Truth 避免資料打架


Ken Huang

關於作者

Ken Huang

熱衷於嘗試各類新技術的軟體開發者,現階段主力為 Android / iOS 雙平台開發,同時持續深耕前端與後端技術,以成為全端工程師與軟體架構師為目標。

最廣為人知的代表作為 BePTT。開發哲學是「以做身體健康為前提」,致力於在工作與生活平衡的基礎上,打造出擁有絕佳使用體驗的軟體服務。

這裡是用於紀錄與分享開發經驗的空間,希望能透過我的實戰筆記,幫助開發者解決疑難雜症並提升效率。

Android APP DevelopmentiOS APP DevelopmentBePTT CreatorFull Stack Learner