选择合适的软件架构是一项挑战,特别是在平衡理论和互联网上的建议与实际实现之间时。在这篇文章中,我将分享我的经历以及对我有用的架构选择。
虽然标题可能让你以为我会告诉你如何具体地构建你的应用程序,但这并不是我的目的。相反,我将重点讲述我个人的经验、选择以及在构建应用程序时所采取的方法背后的理由。这并不意味着你应该以同样的方式构建你的应用程序,但因为许多朋友都问过我这方面的问题,我想试着解释我们在TimeMates中使用的架构(附注:这是我与朋友们一起开发的个人项目)。
智能术语
你可能已经熟悉一些术语,比如 Clean Architecture(干净架构)、DDD(领域驱动设计)或者 Hexagonal Architecture(六边形架构)。也许你已经读了很多关于这些概念的文章。但是对我来说,我发现大多数文章存在一些问题——理论信息太多而实用信息太少。它们可能会给你一些小而不太实际的例子,在这些例子中一切都完美运行,但这些例子从未对我有用,也从未给我提供好的答案,反而增加了冗余代码。
其中一些内容几乎是相同的或大部分包含彼此,并且在大多数情况下并不冲突,但很多人在特定方法上止步不前,没有意识到这并不是世界末日。
我们将从不同方法中学习最有价值的信息,这些方法是我借鉴的,与我最初如何构建应用程序的具体方式无关。然后我们将进入我特别的想法和实现。让我们从大多数人开发Android应用程序时开始的地方开始:
清晰架构
干净架构听起来很简单——你有特定的层,每层只做特定的工作(我知道说得有点太具体了)。Google 推荐了以下结构:
- 展示层
- 领域层 (谷歌认为可选)
- 数据层
表示层 负责你的用户界面,理想情况下,它的唯一角色是作为用户(与UI交互)和 领域模型 之间的通信桥梁。领域层 处理业务逻辑,而 数据层 则负责低级别的操作,如读写数据库。
听起来很简单,对吧?但在这种结构中却有一个大问题。根据谷歌推荐的应用架构,为什么领域层是可选的?那么业务逻辑应该放在哪里呢?
这个想法来源于谷歌的一个观点,即在某些情况下可以跳过领域层。在更简单的应用中,你可能会看到业务逻辑被放置在ViewModel(属于表示层的一部分)中。那么,这种做法有什么问题呢?
问题在于 MVVM/MVI/MVP 模式以及 展示层 的角色。展示层应该只处理与平台细节的 集成 和与UI相关的任务。在这个背景下,保持展示层(无论是使用 MVVM 还是其他模式)不包含 业务逻辑 是至关重要的。它应该只包含与平台特定需求相关的逻辑。
为什么?在Clean Architecture中,每一层都有特定的责任,以确保关注点分离和代码的可维护性。表示层的职责是通过UI与用户交互,并管理平台相关的操作,如渲染视图或处理输入。它不应该包含业务逻辑,因为这些逻辑属于领域层,那里集中了核心规则和决策制定。
想法是通过将平台相关的问题隔离在表示层中,你可以轻松地交换或修改UI或平台,而不影响业务规则和其他代码。例如,如果你需要从Android应用切换到iOS应用,你只需要重写UI部分,而保持领域逻辑不变,特别是在使用Kotlin的情况下。 😋
但是回到谷歌的叙述中——大多数误解来自于不了解什么是业务逻辑,它应该位于哪里,以及某些示例的性质。
所以,为了处理其他问题,让我们更多地讨论领域层,特别是关于领域驱动设计(DDD):
域驱动设计
领域驱动设计(DDD)围绕着通过应用程序结构来反映核心业务领域。简单来说,就是应该编写什么样的代码以及如何编写?
你肯定已经了解了仓库(Repositories)或用例(UseCases),其中一些人可能认为用例是其中的一部分。但最重要的是,不是用例或仓库,而是围绕你的领域逻辑展开的业务实体。
在领域驱动设计(DDD)中,业务实体是解决业务问题的基本对象。它们不是普通的 DTO 或 POJO,通常在大多数新手项目中使用的方式——相反,DDD 中的 业务实体 包含了 数据 和 行为。它们被设计用来表示现实世界中的概念和流程,并承载管理这些概念的规则和逻辑。与简单的 DTO(数据传输对象) 或 POJO(普通老式 Java 对象) 不同,这些对象通常只包含数据,DDD 中的实体负责 强制执行业务规则,确保 一致性,并 管理它们的状态。那么,这在简单的话来说是什么意思呢?
不要使用原始类型,比如 String、Int、Long 等(唯一的例外是 Boolean)。在理想的状态下,表示业务对象(领域层中的实体)的数据不能以无效的形式存在(例如,可能会抛出意外的异常或提供无意义的信息)。
而这正是领域驱动设计(DDD)中一个重要概念的来源——值对象和聚合。
值对象 – 是任何业务实体的构建块,其目的是提供关于描述您业务实体的类型、方式和约束的信息。它们没有身份,也就是说它们不能单独存在——它们只是业务实体的一部分。并且它们不应该可变。
例如,如果您在业务模型中有某种资金流动,您将有两个值对象:
Amount
和Currency
,而不是实体中的常规String
。由此可以得出,值对象有自己的类型和值约束,这些约束应该被检查。最佳实践是在创建时进行检查。
让我们通过一个例子来更好地理解值对象,这里是一个带有验证的 EmailAddress
值对象(这是来自 TimeMates 的一个示例):
@JvmInline
public value class EmailAddress private constructor(public val string: String) {
public companion object {
public val LENGTH_RANGE: IntRange = 5..200
public val EMAIL_PATTERN: Regex = Regex(
buildString {
append("[a-zA-Z0-9\\+\\.\\_\\%\\-\\+]{1,256}")
append("\\@")
append("[a-zA-Z0-9][a-zA-Z0-9\\-]{0,64}")
append("(")
append("\\.")
append("[a-zA-Z0-9][a-zA-Z0-9\\-]{0,25}")
append(")+")
}
)
public fun create(value: String): Result<EmailAddress> {
return when {
value.length !in LENGTH_RANGE -> Result.failure(...)
!value.matches(EMAIL_PATTERN) -> Result.failure(...)
else -> Result.success(EmailAddress(value))
}
}
}
}
进入全屏模式 退出全屏模式
所以,基本上,我们对用户/服务器提供的电子邮件地址的大小和格式有一些特定的业务限制。
如果我们已经在值对象中验证了数据,为什么还需要聚合器呢?
聚合器 – 是一些监视器,检查业务实体是否具有有效且符合其自身状态的价值对象。此外,如果需要的话,它们还可以验证业务实体之间是否相互一致。
任何修改或创建领域实体对象的函数都称为 聚合。
它们执行任何与数据更改或变异相关的操作,如果存在任何特殊逻辑的话。
与值对象中的验证不同,模型业务实体的数据可能是有效的,但不一定适合特定的业务实体。
这通常是可选的,因为你并不总是需要它们,并且大多数工作是由值对象验证完成的。
但这里有一个例子:
class User private constructor(
val id: UserId,
val email: EmailAddress,
val isAdmin: Boolean,
) {
companion object {
// 聚合
fun create(
id: UserId,
email: EmailAddress,
isAdmin: Boolean
): Result<User> {
if (isAdmin && !email.string.contains("@business_email.com"))
return Result.failure(
IllegalStateException(
"管理员应该始终使用业务邮箱"
)
)
return User(id, email, isAdmin)
}
}
// 聚合的一部分
fun promoteToAdmin(newEmail: EmailAddress? = null): User {
val email = newEmail ?: this.email
if (!email.string.contains("@business_email.com"))
return Result.failure(
IllegalStateException(
"管理员应该始终使用业务邮箱"
)
)
return User(
id = id,
email = email,
isAdmin = true,
)
}
// ...
}
进入全屏模式 退出全屏模式
除了聚合和值对象之外,你有时还会看到领域层的服务类。这些服务类用于那些通常不能放在聚合上的业务逻辑。例如:
class ShippingService {
fun calculatePrice(
order: Order,
shippingAddress: ShippingAddress,
shippingOption: ShippingOption,
): Price {
return if (order.product.country == shippingAddress.country)
order.price
else order.price + someFee
}
}
进入全屏模式 退出全屏模式
我们目前不会讨论领域级别服务或聚合器的有用性或有效性。只需记住,在我们把这两种方法结合起来时再回来讨论这一点。
但基本上就是这样——实现可能因项目而异,我唯一遵循的规则就是尽可能使用不可变性。
问题
错误的认知模型
关于我最常看到的错误,就是开发人员没有意识到领域层不仅仅是物理上的划分,更重要的是正确的思维模型。让我解释一下:
心智模型 是对系统中各个相互作用的部分的工作或结构的概念性表示(简单来说,就是使用者如何看待代码)。它与物理模型的不同之处在于,物理模型涉及 物理 交互——例如,调用特定的函数或实现一个模块,即任何由手操作完成的事情。
在软件设计中,一个常见的问题是允许领域层意识到数据存储或来源,这违反了关注分离的原则。领域层的重点应该是业务逻辑,而不依赖于数据来源。然而,你可能会遇到类似 LocalUsersRepository
或 RemoteUsersRepository
的例子,以及相应的用例,如 GetCachedUserUseCase
或 GetRemoteUserUseCase
。虽然这可能解决了特定问题,但它违反了领域层应该对数据来源保持无感知的模型。
同样适用于像 androidx.room
这样的框架中的 DAO。它们不仅违反了描述数据源规则,而且还违反了不依赖任何框架的规则。
枯竭的领域实体
贫血领域模型 是领域驱动设计 (DDD) 中常见的反模式,其中领域对象——实体和值对象——被简化为仅包含数据的被动容器,缺乏行为,仅有属性的 getter 和 setter 方法(如果适用)。这种模型被称为“贫血”是因为它未能封装本应存在于领域本身的业务逻辑。相反,这些逻辑通常被推入单独的服务类中,这导致整体设计出现多个问题。
为了更深入地理解这个问题,让我们回顾一下,贫血域实体(Anemic Domain Entities)具体有哪些不好的地方。
- 理解领域实体功能的可能复杂性:当逻辑分散在控制器或UseCases中时,追踪实体的责任变得更加困难,这会减慢理解和调试的速度(此外,除了IDE之外,查找你放在某些控制器或UseCases上的业务逻辑也变得更加困难,这使得代码审查变得更加艰难)。
- 封装被破坏:实体只持有数据而不包含行为,将业务逻辑推入服务中,使得结构更难维护。这意味着你应该在UseCases/Controllers等之间对齐逻辑,并确保业务逻辑确实被正确地更改。
- 更难测试:当行为分散时,测试单个功能变得更加困难,因为逻辑没有被分组在实体本身内。
- 逻辑重复:业务规则经常在服务/UseCases之间重复,导致不必要的重复和更高的维护成本。
一个糟糕的业务实体示例:
sealed interface 定时器状态 : 状态<TimerEvent> {
override val 生存时间: 持续时间
override val 发布时间: UnixTime
data class 暂停(
override val 发布时间: UnixTime,
override val 生存时间: 持续时间 = 15.minutes,
) : 定时器状态 {
override val 键: 状态.键<*> get() = 键
companion object 键 : 状态.键<暂停>
}
data class 等待确认(
override val 发布时间: UnixTime,
override val 生存时间: 持续时间,
) : 定时器状态 {
override val 键: 状态.键<*> get() = 键
companion object 键 : 状态.键<等待确认>
}
data class 不活跃(
override val 发布时间: UnixTime,
) : 定时器状态 {
override val 生存时间: 持续时间 = 持续时间.INFINITE
override val 键: 状态.键<*> get() = 键
companion object 键 : 状态.键<不活跃>
}
data class 运行中(
override val 发布时间: UnixTime,
override val 生存时间: 持续时间,
) : 定时器状态 {
override val 键: 状态.键<*> get() = 键
companion object 键 : 状态.键<运行中>
}
data class 休息(
override val 发布时间: UnixTime,
override val 生存时间: 持续时间,
) : 定时器状态 {
override val 键: 状态.键<*> get() = 键
companion object 键 : 状态.键<休息>
}
}
进入全屏模式 退出全屏模式
这些只是包含有关TimeMates状态的数据的容器。问题在于:我们如何将这个贫瘠的领域实体转换为一个丰富的领域实体?
在这种情况下,对于状态,我有一个不同的控制器来处理所有过渡和事件:
class 计时器状态机(
计时器: 计时器仓库,
会话: 计时器会话仓库,
存储: 状态存储<TimerId, TimerState, TimerEvent>,
时间提供器: 时间提供器,
协程范围: 协程范围,
) : 状态机<TimerId, TimerEvent, TimerState> by 状态机控制器({
初始 { 计时器状态.未激活(时间提供器提供()) }
状态(计时器状态.未激活, 计时器状态.暂停, 计时器状态.休息) {
在事件 { 计时器ID, 状态, 事件 ->
// ...
}
在超时 { 计时器ID, 状态 ->
// ...
}
// ...
}
进入全屏模式 退出全屏模式
除了外观上的问题,实际上它还违反了领域驱动设计(DDD)的原则,实体应该这样定义:
sealed interface 定时器状态 : 状态<定时器事件> {
override val 生存时间: 持续时间
override val 发布时间: UnixTime
// 现在业务实体可以自行响应事件;
// 这些函数是领域驱动设计中的聚合;
fun onEvent(event: 定时器事件, settings: 定时器设置): 定时器状态
fun onTimeout(
settings: 定时器设置,
currentTime: UnixTime,
): 定时器状态
data class 暂停(
override val 发布时间: UnixTime,
override val 生存时间: 持续时间 = 15.分钟,
) : 定时器状态 {
override val key: 状态.Key<*> get() = Key
companion object Key : 状态.Key<暂停>
override fun onEvent(
event: 定时器事件,
settings: 定时器设置,
): 定时器状态 {
return when (event) {
定时器事件.开始 -> if (settings.是否需要确认) {
定时器状态.等待确认(publishTime, 30.秒)
} else {
定时器状态.运行(publishTime, settings.工作时间)
}
else -> this
}
}
override fun onTimeout(
settings: 定时器设置,
currentTime: UnixTime,
): 定时器状态 {
return 不活跃(currentTime)
}
}
// ...
}
进入全屏模式 退出全屏模式
注意:有时一些逻辑会被放到用例中,可能不像我的特定情况那样明显。
通过查看这样的实体,你可以更快地理解它的作用,它如何响应领域事件以及其他可能发生的事情。
但是,对于业务对象,有时你可能会觉得它没有任何可以添加或移动的行为。这里是我遇到的一个这样的对象的例子:
data class 用户(
val id: 用户ID,
val name: 用户名,
val emailAddress: 电子邮件地址?,
val description: 用户描述?,
val avatar: 头像?,
) {
data class 补丁(
val name: 用户名? = null,
val description: 用户描述? = null,
val avatar: 头像?,
)
}
进入全屏模式 退出全屏模式
有趣的小知识:这是我的用户领域中实际存在的代码,遇到了这样的问题。
潜在的问题是 User
和 Patch
只是一个容器,没有任何业务逻辑。首先,我只在 UseCases 中使用 Patch
,这意味着它应该放在需要的地方。对于所有内容都使用这条规则——在定义它的层中如果没有使用到声明,就意味着你做错了什么。
对于 User
,无需创建任何聚合函数——Kotlin 自动生成的 copy 方法已经足够,因为值对象已经过验证,整个实体也没有自定义逻辑。
要了解更多关于这个问题的内容,你可以参考这篇文章 文章。
我想补充一点,你应该尽量避免领域实体贫血,但同时也不要强迫自己去实现——如果没有什么可以聚合的,就不要添加聚合。如果没有什么行为可以添加,就不要发明行为——KISS 仍然适用。
忽略领域通用语言
领域通用语言(Ubiquitous Language)是领域驱动设计(DDD)中的一个关键概念,但却常常被忽视。领域模型和代码应该使用与业务干系人相同的语言,以减少误解。如果代码没有与领域专家的语言保持一致,会导致业务逻辑与实际实现之间的脱节。
简单来说,命名应该让非程序员也能容易理解。这在涉及多个团队、具有不同知识、技能和职责的大项目中尤其有帮助。
这是一件小事,但非常重要。我还会补充一点,同一个概念在不同的领域中不应该有不同的名称——即使是在一个团队内部也会造成混淆。
现在,让我们继续介绍我在项目中使用的另一种方法——六边形架构:
六边形架构
六边形架构,也称为端口和适配器,与传统方法相比,采用了一种不同的应用结构方式。它的重点是将核心领域逻辑与外部系统隔离,使得核心业务逻辑不依赖于框架、数据库或其他任何基础设施问题。这种方法促进了可测试性和可维护性,并且与领域驱动设计(DDD)相吻合,因为重点仍然放在业务逻辑上。
有两种类型的 端口 —— 内端口和外端口。
- 内端口 是关于定义外部世界可以对核心领域执行的操作。
- 外端口 是关于定义领域从外部世界需要的服务。
在隔离策略方面,领域驱动设计(DDD)和六边形架构(Hexagonal Architecture)的概念相同,但后者将其提升到了一个新的水平——六边形架构定义了你应该如何与领域模型进行通信。
所以,例如,如果你需要访问外部服务或功能来完成你在领域中的某项任务,你可以这样做:
interface 获取当前用户端口 {
suspend fun 执行(): Result<User>
}
class 转账用例(
private val balanceRepository: BalanceRepository,
private val 获取当前用户: 获取当前用户端口
) {
suspend fun 执行(): ... {
val 当前用户 = 获取当前用户.execute()
val 可用金额 = balanceRepository.getCurrentBalance(user.id)
// ... 转账逻辑
}
// ...
}
进入全屏模式 退出全屏模式
UseCases 大部分情况下被视为入站端口,因为通常它们代表外部世界发起的操作或交互。但有时命名可能会有所不同,实现方式也会不同。
在我的项目中,我倾向于不引入其他术语,通常我只是创建一个从外部需要的Repository接口:
interface 用户仓库 {
suspend fun 获取当前用户(): Result<User>
// ... 其他方法
}
进入全屏模式 退出全屏模式
基本上,我将所有内容整合到一个仓库中,以避免不必要的类创建,为大多数熟悉仓库概念的人提供更清晰的抽象。
可能并不总是需要从另一个功能或系统中调用仓库。有时,你可能希望调用不同的业务逻辑来处理你需要的操作(这可能会更好),这被称为UseCases。在这种情况下,通常会有一个与第一个示例不同的接口。
这里是一个可视化示例:
注意:顺便说一下,DDD 中 'feature' 的另一个术语是 'bounded context'。它们基本上意思相同。
并且以下是一个遵循上述 schema 定义和使用端口的例子:
// 功能 «A»
// 获取其他功能(边界上下文)中的用户(出端口)
interface GetUserPort {
fun getUserById(userId: UserId): User
}
class TransferMoneyUseCase(private val getUserPort: GetUserPort) : TransferService {
override suspend fun transfer(
val userId: UserId, val amount: USDAmount
): Boolean {
val user = getUserPort.getUserById(request.userId)
if (user.balance >= request.amount) {
println("正在向 ${user.name} 转账 ${request.amount}")
return true
}
println("${user.name} 的余额不足")
return false
}
}
进入全屏模式 退出全屏模式
端口的实现是通过适配器完成的——它们基本上就是实现你的接口以与外部系统交互的链接。这类层的命名可能有所不同——从简单的数据或集成到直接的适配器。它们可以互换,并且取决于特定项目的命名约定。这一层通常实现其他领域,并使用其他端口来实现其需求。
这里是 GetUserPort
实现的示例:
// UserService 是来自另一个功能模块 (B) 的 Service
// Adapters 通常位于单独的模块中,因为它们依赖于另一个领域,以避免直接耦合。
class GetUserAdapter(private val getUserUseCase: GetUserUseCase) : GetUserPort {
override fun getUserById(userId: String): User? {
return userService.findUserById(userId)
}
}
进入全屏模式 退出全屏模式
所以,耦合只存在于数据/适配器层面。其优势在于,无论外部系统发生什么变化,你的领域逻辑都不会受到影响。这也是为什么领域端口实际上不应该完全遵循外部系统的所有要求——这是适配器的责任来处理这些问题。也就是说,例如,只要能够满足需求,适配器中的函数签名可以与外部系统中使用的签名不同。
另一个重要的考虑因素是如何处理领域类型。功能很少是完全独立于其他类型功能的。例如,如果我们有一个名为 User
的业务对象和一个值对象 UserId
,我们通常需要重用用户的ID来存储与用户相关的信息。这需要找到一种方法,在系统的不同部分重用这种类型。
在一个理想的六边形架构中,不同的领域应该独立存在。这意味着每个领域都应该有自己的类型定义。简单来说,每次你需要使用这些类型时,都需要重新声明它们。
它在将每种类型在不同领域之间转换时会产生很多重复和样板代码,同时在验证方面也会遇到问题(特别是如果需求随时间变化,你可能会忽略某些细节),这给开发人员的生活带来了很大的麻烦。
建议是你不必遵循所有这些规则,除非你看到了好处。在处理这些问题时,寻找一个平衡点。我们将在接下来的部分中讨论我是如何处理这些问题的。
我的实现
完成了我对使用的各种方法的解释,接下来我想谈谈我的实际实现以及我是如何减少不必要的样板代码和抽象的。
让我们先定义我们讨论过的每种方法的关键概念:
- Clean Architecture : 按照职责将代码划分为不同的层次(领域、数据、表示)
- Domain-driven Design : 领域中应只包含业务逻辑,所有类型在整个生命周期中都应一致且有效。
- Hexagonal Architecture : 严格规定访问领域和从领域访问的规则。
它们大多数时候都能很好地匹配,这使得编写好的代码成为可能。
TimeMates 特性(不同领域)的结构如下:
- 领域
-
数据(实现与存储或网络管理相关的所有内容,包括包含 DataSources 的子模块)
-
数据库(与 SQLDelight 的集成,自动生成的 DataSources)
- 网络(实际上,在 TimeMates 中我没有这个模块,因为它被 TimeMates SDK 替代了,但如果有的话,我会添加它)
- 依赖项(与 Koin 的集成层)
- 展示层(使用 Compose 和 MVI 的 UI)
到目前为止,我喜欢这种结构,但你可能希望将UI与ViewModels区分开来,以便每个平台可以使用不同的UI框架。我没有这样的计划,所以保持现状。但如果将来遇到这样的挑战,对我来说并不难,因为我并不依赖于ViewModels中的Compose。
我遇到的主要问题是,在实现六边形架构时出现的冗余代码——我复制粘贴了一些类型,让我开始怀疑“我真的需要这么多吗”?因此,我制定了以下规则:
- 我有一些通用的核心类型,这些类型在不同的系统中会被重复使用,可以说是大多数系统中最常用类型的一个集合。
- 这种类型只有在被大多数领域使用、没有重复的验证问题且结构不复杂(有时会有例外,但通常很少)的情况下,才能被认为是通用类型。
什么是“复杂结构”?通常,你的领域需要另一个领域的类型,但实际上并不需要该类型中描述的所有内容。例如,你可能希望在“User”类型及其值对象之间共享,但大多数情况下,其他领域并不需要“User”类型中的所有内容,可能只需要名字和ID。我尽量避免这种情况,即使某些内容已经在核心领域类型中,我也会创建一个特定于我领域所需的类型。但在验证方面,我几乎共享所有值对象。
你可以通过不仅创建通用核心类型,还为特定领域创建类型来扩展这个想法,这些特定领域是你的子领域(有界上下文)中某些组工作的领域。
总之,我正在一个公共模块中重用具有相同验证规则的价值对象;我尽量不让我的公共核心类型模块变得太大。应该始终有一个合适的平衡。
此外,在我的项目中没有使用“Inbound Ports”这一术语,而是完全用UseCases来替代。
class 获取计时器用例(
private val timers: TimersRepository,
private val fsm: TimersStateMachine,
) {
suspend fun 执行(
auth: Authorized<TimersScope.Read>,
pageToken: PageToken?,
pageSize: PageSize,
): 结果 {
val 信息 = timers.getTimersInformation(
auth.userId, pageToken, pageSize,
)
val ids = 信息.map(TimersRepository.TimerInformation::id)
val 状态 = ids.map { id -> fsm.getCurrentState(id) }
return 结果.成功(
信息.mapIndexed { index, information ->
information.toTimer(
状态.value[index]
)
},
)
}
sealed interface 结果 {
data class 成功(
val 页: Page<Timer>,
) : 结果
}
}
进入全屏模式 退出全屏模式
注意:这是一个来自TimeMates Backend的示例
它并不违反六边形架构或领域驱动设计(DDD),这使得它成为定义外部世界如何访问你的领域的一种好方式。它与入站端口具有相同的意义和行为。
至于 outbound 端口,我使用了之前示例中提供的相同设置。
结论在我的项目中,我喜欢保持实际性。虽然理论和抽象很有用,但它们可能会使简单的事情变得过于复杂。这就是为什么我会结合使用Clean Architecture、DDD和Hexagonal Architecture的优点,而不是严格遵循它们。使用批判性思维来确定你真正需要什么以及这对你的项目有何好处,而不是盲目遵循建议。