发布于 2026-01-05 8 阅读
0

在 DDD、Clean 和六边形架构之间找到合适的平衡 DEV 的全球展示挑战赛,由 Mux 呈现:展示你的项目!

在领域驱动设计、Clean架构和六边形架构之间找到合适的平衡点

由 Mux 主办的 DEV 全球展示挑战赛:展示你的项目!

选择合适的软件架构是一项挑战,尤其是在平衡理论、网络建议与实际应用之间时。在本文中,我将分享我的经验以及对我行之有效的架构选择。

虽然标题可能让人觉得我是来教你如何构建应用程序的,但这并非我的目的。相反,我将重点分享我个人在构建应用程序时的经验、选择以及背后的原因。这并不意味着你应该完全照搬我的做法,但由于很多朋友都问过我这个问题,所以我想尝试解释一下我们在TimeMates(PS:一个我和朋友们一起开发的个人项目)中使用的架构。

重要声明:虽然我会尝试解释 CA、DDD 和 HA,但这并不意味着您必须在项目中全部使用它们。即使您不打算将 CA 和 DDD 与 HA 结合使用,也建议您阅读其中关于 CA 和 DDD 的部分。

智能术语

你可能听说过一些术语,比如“整洁架构”、 “领域驱动设计”( DDD),甚至“六边形架构”。你也可能读过很多相关的文章。但就我个人而言,我发现大多数文章都存在一些问题——理论信息过多,而实践信息过少。它们或许会给出一些看似完美无缺的小型示例,但这些示例对我来说却毫无用处,也从未提供过有效的解决方案,反而增加了大量的样板代码。

它们中的一些几乎相同,或者在很大程度上相互包含,并且在大多数情况下不会相互冲突,但许多人止步于具体方法,没有意识到这并非世界末日。

除了我最初构建应用的方式之外,我们还会尝试从我借鉴的不同方法中学习最有价值的信息。然后,我们会重点讨论我的想法和实现方式。让我们从大多数人在开发 Android 应用时都会做的第一件事开始:

清洁建筑

清晰架构听起来很简单——就是把系统分成不同的层,每层只负责特定的任务,而且这些任务只能在特定的层完成(我知道这太具体了)。谷歌推荐以下结构:

  • 推介会
  • 域名(谷歌认为可选)
  • 数据

表示负责用户界面,理想情况下,它的唯一作用是连接用户(与用户界面交互的人)和领域模型领域层处理业务逻辑,而数据层处理诸如数据库读写等底层操作。

听起来很简单,对吧?然而,这种结构中隐藏着一个大问题:根据谷歌推荐的应用架构,为什么领域层是可选的?那么业务逻辑应该放在哪里呢?

这种想法源于谷歌的立场,即在某些情况下可以跳过领域层。在更简单的应用程序中,你可能会发现一些例子,其中业务逻辑被放在了ViewModel(表示层的一部分)中。那么,这种方法有什么问题呢?

问题在于MVVM/MVI/MVP模式以及表示层的角色。表示层应该只负责与平台细节集成以及 UI 相关任务。因此,无论采用 MVVM 还是其他任何模式,保持表示层不包含业务逻辑至关重要。它唯一应该包含的逻辑是与平台相关的需求。

为什么?在整洁架构中,每一层都有其特定的职责,以确保关注点分离和代码的可维护性。表示层负责通过用户界面与用户交互,并管理平台相关的操作,例如渲染视图或处理输入。它不应该包含业务逻辑,因为业务逻辑属于领域层,核心规则和决策都集中在领域层。

其理念是将平台相关的考量从表示层分离出来,这样就可以在不影响业务规则和其他代码的情况下更改或调整用户界面或平台。例如,如果您想将应用从 Android 迁移到 iOS,您只需重新设计用户界面,同时保留领域逻辑,这在 Kotlin 的上下文中尤其有利。😋

但回到谷歌的叙述——大多数误解都源于不理解什么是业务逻辑、它应该位于哪里以及某些示例的性质。

那么,为了解决其他问题,让我们更深入地讨论领域层,特别是领域驱动设计(DDD):

领域驱动设计

领域驱动设计(DDD)的核心在于构建应用程序以反映核心业务领域。简单来说,就是——应该编写什么代码,以及如何编写?

您肯定已经了解存储库或用例,有些人可能认为用例是其中的一部分。但最重要的部分并非用例或存储库,而是业务实体,您的领域逻辑就围绕这些业务实体运行。

在领域驱动设计(DDD)中,业务实体是反映你所解决的业务问题的关键对象。它们并非像许多入门项目中常用的普通数据对象(DTO)或普通对象对象(PO​​JO)。相反,在DDD中,业务实体封装了数据和行为。它们旨在表示现实世界的概念和流程,并体现了支配这些概念的规则和逻辑。那么,从中可以得出什么简单的建议呢?

不要使用原始类型,例如 String、Int、Long 等(唯一例外是 Boolean)。在这种方法的理想维护模型中,用于建模业务对象(领域层中的实体)的数据不能以无效形式存在(例如,可能抛出意外异常或提供无意义的信息)。

而这正是 DDD 中一个重要概念——值对象和聚合器——发挥作用的地方。

业务对象可视化

值对象是构成任何业务实体的基本单元,其目的是提供关于业务实体信息类型、方式和约束的描述性信息。它们没有身份标识,这意味着它们不能独立运行,而只是业务实体的一部分。这也意味着它们不应该是可变的。它们回答诸如“这是什么类型的数据?”、“数据以什么形式存在?”等问题。

例如,如果你的商业模型中有某种资金流,那么你的实体中将有两个值对象:AmountCurrency,而不是普通的字符串。

由此可见,值对象有其自身的类型和值约束,需要进行检查。最佳实践是在创建值对象时执行这些检查。

为了更好地理解值对象,我们来看一个例子。以下是一个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.size !in LENGTH_RANGE -> Result.failure(...)
                !value.matches(EMAIL_PATTERN) -> Result.failure(...)
                else -> EmailAddress(value)
            }
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

因此,我们对用户/服务器应提供的电子邮件地址的大小和格式有具体的业务限制。

如果我们已经验证了值对象中的数据,那么为什么还需要聚合器呢?

聚合器是一种观察器,它检查业务实体是否拥有与其自身状态值对象相符的有效状态值对象。此外,如有必要,它还可以验证业务实体之间的相互关系。

任何修改或创建域实体内对象的函数都称为聚合

他们会处理任何与数据更改或变异相关的特殊逻辑工作。

值对象验证与数据验证的区别在于,对业务实体进行建模的数据可能是有效的,但对于特定的业务实体而言,这些数据可能不正确或不一致。

它大多是可选的,因为你并非总是需要它们,而且大多数工作确实重视对象验证。

但这里有一个例子:

class User private constructor(
    val id: UserId,
    val email: EmailAddress,
    val isAdmin: Boolean,
) {
    companion object {
        // aggregate
        fun create(
            id: UserId, 
            email: EmailAddress, 
            isAdmin: Boolean
        ): Result<User> {
            if (isAdmin && !email.string.contains("@business_email.com"))
                return Result.failure(
                    IllegalStateException(
                        "Admins should always have business email"
                    )
                )

            return User(id, email, isAdmin)
        }
    }

    // part of the aggregator (it's an aggregate)
    fun promoteToAdmin(newEmail: EmailAddress? = null): User {
        val email = newEmail ?: this.email

        if (!email.string.contains("@business_email.com"))
            return Result.failure(
                    IllegalStateException(
                        "Admins should always have business email"
                    )
                )

        return User(
            id = id,
            email = email,
            isAdmin = true,
        )
    }

    // ...
}
Enter fullscreen mode Exit fullscreen mode

除了聚合对象和值对象之外,有时您还会看到领域层的服务类。它们用于处理通常无法放入聚合对象中,但仍然属于业务逻辑的逻辑。例如:

class ShippingService {
    fun calculatePrice(
        order: Order, 
        shippingAddress: ShippingAddress, 
        shippingOption: ShippingOption,
    ): Price {
        return if (order.product.country == shippingAddress.country)
            order.price
        else order.price + someFee
    }
}
Enter fullscreen mode Exit fullscreen mode

我们目前暂不讨论领域级服务或聚合器的实用性或有效性。请记住这一点,直到我们将这些方法整合为一个整体。

但几乎所有方面都是如此——具体实现方式可能因项目而异,而我唯一遵循的原则是尽可能保持不可变性。

问题

错误的心智模型

至于我最常看到的错误——开发人员不明白领域层不仅仅是物理上的划分,更重要的是正确的思维模型。让我解释一下:

心智模型是对系统中各个相互交互的部分的工作或结构的概念性表征(简单来说,就是代码使用者如何理解代码)。它与物理模型的区别在于,物理模型涉及物理交互——例如,调用特定函数或实现模块,也就是任何需要手动完成的操作。

软件设计中一个常见的问题是允许领域层感知数据存储或数据源,这违反了关注点分离原则。领域层的关注点应该始终放在业务逻辑上,而与数据源无关。然而,你可能会遇到类似 `<data-store>`LocalUsersRepository或 ` <data-source>` 这样的示例RemoteUsersRepository,以及相应的用例,例如 `<data-store>`GetCachedUserUseCase或 ` <data-source>`。GetRemoteUserUseCase虽然这可能解决了某个特定问题,但它违背了领域层的思维模型,该模型应该保持与数据源无关。

同样的情况也适用于类似框架中的 DAO androidx.room。它们不仅违反了声明数据源的规则,而且还违反了独立于任何框架的规则。

即使在实现并非直接在领域模型中的情况下,你的存储库/用例也应该远离数据源,尽管这样做似乎没问题。

贫血领域实体

贫血领域模型是领域驱动设计 (DDD) 中常见的反模式。在这种模型中,领域对象(实体和值对象)被简化为被动的数据容器,缺乏行为,仅包含属性的 getter 和 setter 方法(如果适用)。之所以称之为“贫血”,是因为它未能封装本应存在于领域内部的业务逻辑。相反,这些逻辑通常被推入单独的服务类中,从而导致整体设计出现诸多问题。

为了更深入地理解这个问题,贫血领域实体究竟有哪些弊端?让我们回顾一下:

  • 理解领域实体的功能时可能存在的复杂性:当逻辑分散在控制器或用例中时,就很难跟踪实体的职责,从而减慢理解和调试的速度(此外,还要考虑到,除了 IDE 之外,很难查找放在某种控制器或用例中的业务逻辑,这使得代码审查变得更加困难)。
  • 封装性被破坏:实体只包含数据而没有行为,业务逻辑被推到了服务中,这使得结构更难维护。这意味着您应该在用例/控制器等中统一逻辑,并确保业务逻辑确实被更改为正确的逻辑。
  • 测试难度更大:当行为分散时,测试单个功能会变得更加困难,因为逻辑没有被组织在实体本身内部。
  • 逻辑重复:业务规则经常在不同的服务/用例中重复出现,导致不必要的重复和更高的维护成本。

一个糟糕的商业实体的例子:

sealed interface TimerState : State<TimerEvent> {
    override val alive: Duration
    override val publishTime: UnixTime

    data class Paused(
        override val publishTime: UnixTime,
        override val alive: Duration = 15.minutes,
    ) : TimerState {
        override val key: State.Key<*> get() = Key

        companion object Key : State.Key<Paused>
    }

    data class ConfirmationWaiting(
        override val publishTime: UnixTime,
        override val alive: Duration,
    ) : TimerState {
        override val key: State.Key<*> get() = Key

        companion object Key : State.Key<ConfirmationWaiting>
    }

    data class Inactive(
        override val publishTime: UnixTime,
    ) : TimerState {
        override val alive: Duration = Duration.INFINITE

        override val key: State.Key<*> get() = Key

        companion object Key : State.Key<Inactive>
    }

    data class Running(
        override val publishTime: UnixTime,
        override val alive: Duration,
    ) : TimerState {
        override val key: State.Key<*> get() = Key

        companion object Key : State.Key<Running>
    }

    data class Rest(
        override val publishTime: UnixTime,
        override val alive: Duration,
    ) : TimerState {
        override val key: State.Key<*> get() = Key

        companion object Key : State.Key<Rest>
    }
}
Enter fullscreen mode Exit fullscreen mode

这些只是包含 TimeMates 状态数据的容器。问题是:我们如何将这个贫乏的领域实体转化为一个丰富的领域实体?

在这种情况下,对于状态,我使用了一个不同的控制器来处理所有的转换和事件:

class TimersStateMachine(
    timers: TimersRepository,
    sessions: TimerSessionRepository,
    storage: StateStorage<TimerId, TimerState, TimerEvent>,
    timeProvider: TimeProvider,
    coroutineScope: CoroutineScope,
) : StateMachine<TimerId, TimerEvent, TimerState> by stateMachineController({
    initial { TimerState.Inactive(timeProvider.provide()) }

    state(TimerState.Inactive, TimerState.Paused, TimerState.Rest) {
        onEvent { timerId, state, event ->
            // ...
        }

        onTimeout { timerId, state ->
            // ...
        }
    // ...
}
Enter fullscreen mode Exit fullscreen mode

除了外观漂亮之外,它还违反了领域设计(DDD)原则——领域对象不仅要表示数据,还要表示行为。实体应该像这样:

sealed interface TimerState : State<TimerEvent> {
    override val alive: Duration
    override val publishTime: UnixTime

    // Now business entity can react to events by itself;
    // This functions are 'aggregates' from DDD;
    fun onEvent(event: TimerEvent, settings: TimerSettings): TimerState
    fun onTimeout(
        settings: TimerSettings, 
        currentTime: UnixTime,
    ): TimerState

    data class Paused(
        override val publishTime: UnixTime,
        override val alive: Duration = 15.minutes,
    ) : TimerState {
        override val key: State.Key<*> get() = Key

        companion object Key : State.Key<Paused>

        override fun onEvent(
            event: TimerEvent, 
            settings: TimerSettings,
        ): TimerState {
            return when (event) {
                TimerEvent.Start -> if (settings.isConfirmationRequired) {
                    TimerState.ConfirmationWaiting(publishTime, 30.seconds)
                } else {
                    TimerState.Running(publishTime, settings.workTime)
                }

                else -> this
            }
        }

        override fun onTimeout(
            settings: TimerSettings, 
            currentTime: UnixTime,
        ): TimerState {
            return Inactive(currentTime)
        }
    }

    // ...
}
Enter fullscreen mode Exit fullscreen mode

注意:有时用例中会包含一些逻辑,但这些逻辑可能不像本例中那样显而易见。

通过观察这样的实体,你可以更快地了解它的功能、它如何对领域事件做出反应以及可能发生的其他事情。

但是,对于业务对象而言,有时您可能会觉得它没有任何可以添加/移动的行为。以下是我举的一个此类对象的例子:

data class User(
    val id: UserId,
    val name: UserName,
    val emailAddress: EmailAddress?,
    val description: UserDescription?,
    val avatar: Avatar?,
) {
    data class Patch(
        val name: UserName? = null,
        val description: UserDescription? = null,
        val avatar: Avatar?,
    )
}
Enter fullscreen mode Exit fullscreen mode

值得注意的是:这是我的用户域中存在此类问题的实际代码。

潜在的问题在于,` Userand`Patch是数据容器,不包含业务逻辑。首先,我Patch只在用例中使用 `and`,这意味着它应该放在需要的地方。这条规则适用于所有情况——声明了 `and` 却不在定义它的层中使用,这意味着你的做法有问题。

至于聚合函数User,无需创建聚合函数——Kotlin 自动生成的复制方法已经足够,因为值对象已经过验证,并且整个实体不需要自定义逻辑。

要了解有关此问题的更多信息,您可以参考这篇文章

我还要补充一点,你应该尽量避免创建贫血的领域实体,但同时也不要强迫自己这样做——如果没有需要聚合的内容,就不要添加聚合。如果没有需要添加的内容,就不要凭空捏造行为——KISS原则仍然适用。

忽略普遍存在的语言

通用语言是领域驱动设计(DDD)中的一个关键概念,但常常被忽略。领域模型和代码应该使用与业务利益相关者相同的语言,以减少误解。如果代码未能与领域专家的语言保持一致,就会导致业务逻辑与实际实现脱节。

简而言之,名称应该易于理解,即使对于非程序员来说也是如此。这对于涉及多个团队、且团队成员的知识、技能和职责各不相同的大型项目尤其重要。

这虽然是个小细节,但却非常重要。我还要补充一点,相同的概念在不同的领域不应该有不同的名称——即使在同一团队内部,这也会造成混淆。


现在,让我们来看看我在项目中使用的另一种方法——六边形建筑:

六边形建筑

六边形架构(也称为端口和适配器架构)与传统方法相比,在应用程序结构方面采用了不同的视角。它的核心在于将核心领域逻辑与外部系统隔离,从而使核心业务逻辑不依赖于框架、数据库或其他基础设施。这种方法提高了可测试性可维护性,并且与领域驱动设计(DDD)的理念高度契合,因为DDD同样关注业务逻辑。

端口分为两种类型——入站端口和出站端口。

  1. 入站端口是指定义外部世界可以对核心域执行的操作。
  2. 出站端口用于定义域需要从外部世界获取的服务。

领域驱动设计(DDD)和六边形架构在隔离策略上的区别在概念上是相同的,但后者更进一步。六边形架构定义了如何与领域模型进行通信。

例如,如果您需要访问外部服务或功能来执行您域中的某些操作,您可以执行以下操作:

interface GetCurrentUserPort {
    suspend fun execute(): Result<User>
}

class TransferMoneyUseCase(
    private val balanceRepository: BalanceRepository,
    private val getCurrentUser: GetCurrentUserPort
) {
    suspend fun execute(): ... {
        val currentUser = getCurrentUser.execute()
        val availableAmount = balanceRepository.getCurrentBalance(user.id)
        // ... transfer logic
    }

    // ...
}
Enter fullscreen mode Exit fullscreen mode

用例通常被视为入站端口,因为它们代表由外部世界发起的操作或交互。但是,命名和实现方式可能有所不同。

在我的项目中,我倾向于不引入新的术语,通常我只会创建一个我需要的外部存储库接口:

interface UserRepository {
    suspend fun getCurrentUser(): Result<User>
    // ... other methods
}
Enter fullscreen mode Exit fullscreen mode

我将所有内容整合到一个存储库中,以避免不必要的类创建,从而为大多数熟悉存储库概念的人提供更清晰的抽象。

并非总是需要从其他功能或系统中调用存储库。有时,您可能需要调用不同的业务逻辑来处理您的需求(这种逻辑可能更好),这被称为用例。在这种情况下,通常会使用与第一个示例不同的接口。

以下是可视化结果:

可视化

:顺便一提,“特性”的另一种说法是领域驱动设计(DDD)中的“有界上下文”。它们的意思基本相同。

以下是按照上述模式定义和使用端口的示例:

// FEATURE «A»

// Outbound port to get the user from another feature (bounded context)
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("Transferring ${request.amount} to ${user.name}")
            return true
        }
        println("Insufficient balance for ${user.name}")
        return false
    }
}
Enter fullscreen mode Exit fullscreen mode

端口的实现是通过适配器完成的——它们本质上只是连接外部系统接口的桥梁。这一层的命名可能有所不同,从简单的数据集成到直接的适配器都有可能。它们之间可以互换使用,具体取决于项目的命名约定。这一层通常会实现其他域,并使用其他端口来实现其所需功能。

以下是一个GetUserPort实现示例:

// UserService is the Service from another feature (B)
// Adapters are usually in separate module because they're dependent on
// another domain, to avoid straight coupling.
class GetUserAdapter(private val getUserUseCase: GetUserUseCase) : GetUserPort {
    override fun getUserById(userId: String): User? {
        return userService.findUserById(userId)
    }
}
Enter fullscreen mode Exit fullscreen mode

因此,功能仅在数据/适配器层面耦合。这样做的好处是,无论外部系统发生什么,你的领域逻辑都保持不变。这也是为什么领域端口实际上不应该完全符合外部系统的所有要求——处理外部系统的要求是适配器的职责。我的意思是,例如,函数签名可以与外部系统使用的函数签名不同,当然,前提是这样做能够方便地进行各种尝试。

另一个需要考虑的问题是,如何处理领域类型。功能很少能完全与其他类型的功能隔离。例如,如果我们有一个名为“用户”的业务对象User和一个名为“值”的值对象UserId,我们通常需要重用用户的 ID 来存储与用户相关的信息。这就需要找到一种方法,在系统的不同部分重用这种类型。

在理想的六边形架构中,不同的域应该独立存在。这意味着每个域都应该有其自身使用的类型定义。简单来说,就是每次需要这些类型时都需要重新声明。

它造成了大量的重复工作,在不同的域之间转换每种类型时会产生大量的样板代码,验证方面也会出现问题(尤其是当需求随着时间推移而变化时,你可能会忽略某些东西),这对任何开发人员来说都是一个巨大的痛苦。

建议是,如果你看不到好处,就不要完全遵守这些规则。在处理这些问题时,要找到一个折中的办法,至于我是如何处理的,我们将在下一部分讨论。

我的实现

在解释完我所使用的方法之后,我想继续介绍我的实际实现以及我是如何减少不必要的样板代码和抽象的。

我们先来明确一下我们讨论的每种方法的核心思想:

  • 整洁架构:根据职责(领域层、数据层、表示层)将代码划分为不同的层。
  • 领域驱动设计:领域应该只包含业务逻辑,所有类型在其整个生命周期中都应该保持一致和有效。
  • 六边形架构:对访问域和从域访问域有严格的规则。

它们在大多数情况下完美匹配,这是编写优秀代码的关键。

TimeMates 功能(不同领域)的结构如下:

  • 领域
  • 数据(实现与存储或网络管理相关的一切,包括带有数据源的子模块)
    • 数据库(与 SQLDelight 集成,自动生成数据源)
    • 网络(实际上,TimeMates 中没有这个功能,因为它已被TimeMates SDK取代,但如果还没有被取代,我会添加它)
  • 依赖项(与 Koin 的集成层)
  • 演示(包含 Compose 和 MVI 的 UI)

目前为止我挺喜欢这个架构的,不过你可能想把 UI 和 ViewModel 分开,这样就能在不同平台上使用不同的 UI 框架。我暂时没打算这么做,所以就先这样吧。但如果将来遇到类似的问题,对我来说也不难,因为我并不依赖 ViewModel 中的 Compose 组件。

我在实现六边形架构时遇到的主要问题是大量的样板代码——我复制粘贴了很多类型,这让我不禁怀疑“我真的需要这些类型吗?” 因此,我总结了以下规则:

  1. 我有一些在不同系统中重复使用的通用核心类型,它是一种最常用类型的组合域。
  2. 只有当类型在大多数领域中使用,存在验证重复问题,并且结构并不复杂(有时会有例外,但通常不多)时,该类型才能被称为通用类型。

我所说的“复杂结构”是什么意思呢?通常情况下,需要使用其他领域类型的领域,实际上并不需要给定类型中描述的所有内容。例如,你可能希望共享“用户”类型及其值对象,但大多数情况下,其他领域并不需要“用户”类型中的所有信息,例如,可能只需要姓名和 ID。我尽量避免这种情况,即使某些信息已经存在于核心领域类型中,我也更倾向于为特定领域创建包含所需信息的不同类型。至于验证,我几乎共享所有值对象。

你可以将此想法扩展到更大的项目中,不仅要创建通用的核心类型,还要创建适用于某些子域(限界上下文)的特定领域的类型。

总而言之,我在一个公共模块中重用了具有相同验证规则的值对象;我尽量避免让我的公共核心类型模块包含所有内容而变得过于庞大。应该始终找到一个合适的平衡点。

此外,在我的项目中,我没有使用“入站端口”这个术语。我完全用用例来代替它:

class GetTimersUseCase(
    private val timers: TimersRepository,
    private val fsm: TimersStateMachine,
) {
    suspend fun execute(
        auth: Authorized<TimersScope.Read>,
        pageToken: PageToken?,
        pageSize: PageSize,
    ): Result {
        val infos = timers.getTimersInformation(
            auth.userId, pageToken, pageSize,
        )

        val ids = infos.map(TimersRepository.TimerInformation::id)
        val states = ids.map { id -> fsm.getCurrentState(id) }

        return Result.Success(
            infos.mapIndexed { index, information ->
                information.toTimer(
                    states.value[index]
                )
            },
        )
    }

    sealed interface Result {
        data class Success(
            val page: Page<Timer>,
        ) : Result
    }
}
Enter fullscreen mode Exit fullscreen mode

注意:这是 TimeMates 后端的一个示例。

它不违反六边形架构或领域驱动设计(DDD),因此是定义外部世界如何访问你的域的好方法。它的含义和行为与入站端口相同。

至于出站端口,我设置的与之前示例中提供的相同。

结论

在我的项目中,我更倾向于注重实用性。理论和抽象固然有用,但它们有时会把简单的事情复杂化。因此,我会结合 Clean Architecture、DDD 和 Hexagonal Architecture 的优势,但不会过于拘泥于条条框框。与其盲目地遵循建议,不如运用批判性思维来确定你真正需要什么以及它对项目的好处。

奖金

如果你喜欢这篇文章,我建议你关注我的其他社交媒体账号,我在那里分享我的想法、文章和最新动态:

文章来源:https://dev.to/y9vad9/digging-deep-to-find-the-right-balance- Between-ddd-clean-and-hexagonal-architectures-4dnn