Domain层构建:实体类、仓库接口、用例的纯Kotlin模块设计

好,咱们今天来聊聊Domain层。说实话,很多初学者甚至一些有经验的开发者,都会把Domain层当成一个可有可无的中间层。我刚开始接触Clean Architecture时也这么想过——"多一层不就多一层麻烦吗?"直到我在一个中型项目里被业务逻辑和Android框架代码搅得焦头烂额,才真正体会到Domain层的价值。

Domain层说白了,就是你的业务核心。它不依赖任何框架,不关心你是用Retrofit还是OkHttp,也不在乎你是用Room还是Realm。它只关心一件事:你的业务规则是什么。

为什么Domain层必须是纯Kotlin模块?

你想想看,如果你的业务逻辑里混入了Android的Context,或者某个第三方库的注解,那这个模块还能随便测试吗?还能轻易替换实现吗?

纯Kotlin模块意味着:

  • 没有Android依赖
  • 没有第三方框架绑定
  • 可以独立编译和测试
  • 便于团队协作,不关心UI层

我在项目中遇到过最典型的反面案例:有人把网络请求的Response直接传到了Domain层,结果后来换了接口协议,整个Domain层都得跟着改。嗯,这种痛,经历过一次就记住了。

实体类:你的业务数据模型

实体类(Entity)是Domain层的核心数据模型。它和Data层的DTO(数据传输对象)不同,DTO是为了网络或数据库序列化服务的,而实体类只表达业务概念。

核心原则:实体类应该只包含业务相关的属性和行为方法,不要有任何框架注解。

来看一个例子:

// Domain层的实体类
data class User(
    val id: String,
    val name: String,
    val email: String,
    val role: UserRole
)

enum class UserRole {
    ADMIN, EDITOR, VIEWER
}

这个User类干净吧?没有@Entity,没有@SerializedName,没有任何框架的痕迹。它就是纯粹的业务概念。

我的习惯:实体类尽量用data class,因为数据类天然实现了equals()、hashCode()、toString(),在比较和调试时特别方便。

仓库接口:定义数据契约

仓库接口(Repository Interface)是Domain层和Data层之间的桥梁。它只声明"能做什么",不关心"怎么做"。

我曾经见过有人把仓库接口定义得特别细,每个方法对应一个API接口。这样做的问题在于:一旦业务变化,接口就得跟着改,而且调用方会变得很零散。

我建议按业务聚合来定义接口:

interface UserRepository {
    suspend fun getUser(id: String): Result<User>
    suspend fun updateUser(user: User): Result<User>
    suspend fun deleteUser(id: String): Result<Unit>
}

注意这里用了Result类型。为什么?因为Domain层不应该抛出异常,而是用Result来表达成功或失败。这样调用方可以清晰地处理各种情况。

避坑指南:我曾经在仓库接口里直接返回了Flow<User>,结果导致Data层必须一直保持数据库连接。后来改成了suspend函数配合Result,测试和调试都轻松多了。

用例:业务逻辑的原子单元

用例(Use Case)是Domain层的执行单元。每个用例只做一件事,并且只做一件事。这就是所谓的"单一职责原则"在业务层的体现。

来看一个典型的用例实现:

class GetUserUseCase(
    private val userRepository: UserRepository
) {
    suspend operator fun invoke(id: String): Result<User> {
        return userRepository.getUser(id)
    }
}

你可能会问:"就这么简单?直接调仓库不就行了?"

嗯,这里有个关键点:用例不仅仅是转发调用。它还可以包含业务校验、数据转换、多个仓库的组合调用等逻辑。

比如一个更复杂的用例:

class UpdateUserProfileUseCase(
    private val userRepository: UserRepository,
    private val authRepository: AuthRepository
) {
    suspend operator fun invoke(user: User): Result<User> {
        // 业务校验
        if (user.name.isBlank()) {
            return Result.failure(IllegalArgumentException("用户名不能为空"))
        }
        
        // 权限检查
        val currentUser = authRepository.getCurrentUser()
        if (currentUser?.role != UserRole.ADMIN) {
            return Result.failure(SecurityException("没有权限"))
        }
        
        return userRepository.updateUser(user)
    }
}

看到了吗?用例里可以组合多个仓库,可以做业务校验,可以做权限检查。这些逻辑放在用例里,既不会污染仓库,也不会泄露到ViewModel里。

依赖注入:让Domain层保持纯净

Domain层不依赖任何DI框架。那怎么把仓库实现注入到用例里呢?

答案是通过构造器注入。用例的构造函数接收仓库接口,具体的实现由上层(App层或DI模块)提供。

我习惯用Koin或Hilt在App层完成依赖注入,但Domain层只保留接口和用例类,不引入任何DI注解。

模块结构一览

下面这张图展示了Domain层的整体结构:

Domain层模块结构 实体类 (Entity) • 纯数据模型 • 无框架注解 • 业务行为方法 • 使用 data class 仓库接口 • 定义数据契约 • 按业务聚合 • 返回 Result 类型 • 不关心实现 用例 (Use Case) • 单一职责 • 业务校验 • 组合多个仓库 • 构造器注入 使用 注入 关键设计原则 1. 不依赖任何 Android 框架或第三方库 2. 所有依赖都是接口,具体实现在 Data 层 3. 用例是业务逻辑的最小执行单元 4. 使用 Result 类型处理成功/失败,不抛异常

测试:Domain层的最大优势

纯Kotlin模块最大的好处就是测试极其方便。不需要Android环境,不需要Mockito的复杂配置,直接用JUnit就能跑。

来看一个用例的单元测试:

class GetUserUseCaseTest {
    
    private val userRepository = mock<UserRepository>()
    private val useCase = GetUserUseCase(userRepository)
    
    @Test
    fun `should return user when repository succeeds`() = runTest {
        // 准备
        val user = User("1", "张三", "zhangsan@test.com", UserRole.VIEWER)
        whenever(userRepository.getUser("1")).thenReturn(Result.success(user))
        
        // 执行
        val result = useCase("1")
        
        // 验证
        assertTrue(result.isSuccess)
        assertEquals(user, result.getOrNull())
    }
}

你看,测试代码和普通Kotlin代码没什么两样。没有Android的Context,没有Activity的依赖,干净利落。

总结一下

Domain层的设计其实就三个核心要素:实体类、仓库接口、用例。实体类定义业务数据,仓库接口定义数据契约,用例封装业务逻辑。三者各司其职,互不干扰。

我个人习惯在项目初期就把Domain层搭好,哪怕Data层还没开始写。因为业务逻辑一旦确定,Domain层的结构就基本稳定了。后续换网络库、换数据库,Domain层都不需要动。

说白了,Domain层就是你的业务护城河。护城河修得越干净,后面的开发就越省心。


公众号:蓝海资料掘金营,微信deep3321