DEV Community

Cover image for Digging Deep to Find the Right Balance Between DDD, Clean and Hexagonal Architectures
Vadym Yaroshchuk
Vadym Yaroshchuk

Posted on

Digging Deep to Find the Right Balance Between DDD, Clean and Hexagonal Architectures

Choosing the right software architecture is challenging, especially when balancing theory and recommendations from internet with practical implementation. In this article, I will share my journey and the architectural choices that have worked for me.

Although the title might suggest I’m here to tell you exactly how to structure your application, that’s not my goal. Instead, I’ll focus on my personal experiences, choices, and the reasoning behind the approaches I’ve taken when building my apps. This doesn't mean you should structure things the same way, but since many of my friends have asked me about it, I thought I’d try to explain the architecture we use in TimeMates (P.S: my personal project that I make with my mates).

Smart terminology

You're probably already aware of certain terms like Clean Architecture, DDD (domain-driven design), or maybe even about Hexagonal Architecture. Maybe you've read a lot articles about it all. But as for me, I saw a few problems in most of them – too much theoretical information and a little practical information. They might give you small and non-real examples where everything works perfectly, but it never worked for me and never gave me good answers, only increased boilerplate.

Some of them are about almost the same or includes each other for most part and does not conflict with each other in most cases, but a lot of people stop at the specific approach not thinking that it's not the end of the world.

We'll try to learn the most valuable information from different approaches I take inspiration from, being apart from how I particularly build my apps at first. Then we'll come to, particularly my thoughts and implementation. Let's start from the same place most people do while developing Android apps:

Clean Architecture

Clean architecture sounds pretty simple – you have specific layers that do only a specific job that should be done only at the specific layer (too much specific I know). Google recommends the following structure::

  • presentation
  • domain (optional in Google's opinion)
  • data

The presentation layer is responsible for your UI, and ideally, its only role is to communicate between the user (who interacts with the UI) and the domain model. The domain layer handles business logic, while the data layer deals with low-level operations like reading and writing to a database.

Sounds simple, right? But within this structure lies a big question. According to Google's recommended architecture for apps, why domain layer is optional? Where is the business logic supposed to go then?

This idea comes from Google's stance that the domain layer can be skipped in certain cases. In simpler applications, you might find examples where the business logic is placed in the ViewModel (part of the presentation layer). So, what’s the problem with this approach?

The issue lies in the MVVM/MVI/MVP patterns and the role of the presentation layer. The presentation layer should only handle the integration with platform details and UI-related tasks. In this context, it’s crucial to keep the presentation layer — whether it's using MVVM or any other pattern — free of business logic. The only logic it should contain is related to platform-specific requirements.

Why? In Clean Architecture, each layer has a specific responsibility to ensure separation of concerns and maintainable code. The presentation layer's job is to interact with the user through the UI and manage platform-related operations like rendering views or handling input. It’s not meant to contain business logic because that belongs to the domain layer, where the core rules and decision-making are centralized.

The idea is that by isolating platform concerns in the presentation layer, you can easily swap or modify the UI or platform without affecting the business rules and other code overall. For example, if you needed to switch from Android to a iOS app, you would only have to rewrite the UI part, keeping the domain logic intact, especially useful in context of Kotlin. 😋

But getting back to the Google's narratives – most of the misunderstanding comes from not understanding what business logic is, where it should be located, and the nature of certain examples.

So, to address other issues, let's talk more about the domain layer, specifically about DDD:

Domain-driven Design

Domain-driven design (DDD) revolves around structuring the application to reflect the core business domain. But simply – what code should be written and in what way?

You surely already know about Repositories or UseCases and some of you might think that UseCases are part of it. But the most important part is not UseCases or Repositories, but business entities around which your domain logic lives.

Business entities in the DDD realm are fundamental objects that are a mirror of business problem you're solving. It's not a regular DTO or POJO as usually it's done in most newbie projects – instead, business entities in DDD encapsulate both data and behavior. They are designed to represent real-world concepts and processes, and they carry the rules and logic that govern those concepts. Unlike simple DTOs (Data Transfer Objects) or POJOs (Plain Old Java Objects), which typically just hold data, entities in DDD are responsible for enforcing business rules, ensuring consistency, and managing their state. But what does it mean in simple words?

Don't use raw types like String, Int, Long and so on (the only exception that can be – Boolean). The data that models business object (entity within domain layer) can't afford to exist in invalid form (that, for example, may throw unexpected exceptions or provide meaningless information) in ideal maintaining model of such an approach.

And that's where one of important things from DDD comes – Value Objects and Aggregators.

Business object visualisation

Value objects – are building blocks of any of your business entities, the purpose of which is to provide descriptive information about the type, way, and constraints of information that models your business entity. They don't have identity meaning that they don't act solo – they're only a part of a business entity. And they shouldn't be mutable.

For example, if you have some kind of money flow in your business model, you'll have two value objects: Amount and Currency, instead regular Strings in your entity.

From it comes that value objects have their own type and value constraints that should be checked. The best practice for it to check it at the creation time.

Let's have an example for value object to understand it more, here's the EmailAddress value object with validation (this is an example from 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

So, basically, we have specific business constraints for the size and the form of email address that user/server should provide.

If we already validated the data within value object, why then do we need aggregators?

Aggregators – are some kind of watchers that check whether business entity has valid for its own state value objects that model it or not. In addition, it might validate business entities between each other if needed.

Any function that modify or create an object within domain entity is called Aggregate.

They do any kind of work regarding data changing or mutation if it has any special logic.

The difference between validation in Value objects is that data that models business entity can be valid, but not right or consistent for the specific business entity.

It mostly optional as you not always need them and most of the job does value object validation.

But here's an example of it:

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

Apart of Aggregates and Value objects, sometimes you might see domain layer's service classes. They're used for the logic that usually cannot be usually put on the aggregates, but still kind of business one. For example:

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

We won't discuss the usefulness or effectiveness of domain-level services or aggregators at this point. Just keep it in mind until we come to the point where we'll combine this approaches into a one piece.

But it's pretty much everything – the implementation may vary from project to project, the only thing I use as rule for anything – immutability whenever it's possible.

Problems

Incorrect mental model

As for mistakes I see the most, it's that developers don't understand that Domain layer is not just about a physical division, but about correct mental model. Let me explain:

Mental model is a conceptual representation of the work or structure of various parts of the system that interact with each other (simply, how the code is perceived by those who use it). It differs from a physical model in that a physical model involves physical interaction - for example, calling a specific function or implementing a module, i.e. anything that is done by hand.

A common issue in software design is allowing the domain layer to be aware of data storage or sourcing, which violates the separation of concerns principle. The domain's focus should remain on business logic, independent of data sources. However, you might encounter examples like LocalUsersRepository or RemoteUsersRepository, and corresponding use cases such as GetCachedUserUseCase or GetRemoteUserUseCase. While this may solve a specific problem, it violates the domain's mental model, which should remain agnostic to the source of the data.

The same applies to the DAOs in context of frameworks like androidx.room. They're not only violating the rule of saying about data source, but in addition violates the rule of independence of any frameworks.

Anemic Domain Entities

The Anemic Domain Model is a common anti-pattern in Domain-Driven Design (DDD) where the domain objects — entities and value objects — are reduced to passive data containers that lack behavior and only contain getters and setters (if applies) for their properties. This model is considered "anemic" because it fails to encapsulate the business logic that is supposed to live within the domain itself. Instead, this logic is often pushed into separate service classes, which leads to several problems in the overall design.

To understand this problem more, what particularly is bad about Anemic Domain Entities? Let's review:

  • Possible complexity while understanding what domain entity capable of: When logic is spread across controllers or UseCases, it's harder to track the responsibilities of the entity, slowing down understanding and debugging (also, take into account that apart of IDE it's hard to lookup the business logic that you put on some kind of controllers or UseCases, it makes code review much harder).
  • Encapsulation is broken: Entities hold only data without behavior, pushing the business logic into services, making the structure harder to maintain. It means that you should align logic across the UseCases/Controllers/etc. and make sure that business logic is actually changed to the right one.
  • Harder to test: When behavior is scattered, testing individual features gets harder because logic isn’t grouped inside the entity itself.
  • Repetition of logic: Business rules often get repeated across services/usecases, leading to unnecessary repetition and higher maintenance costs.

The example of a bad business entity:

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

These are simply containers with data about TimeMates states. The question is: how can we transform this anemic domain entity into a rich one?

In this case, for states, I had a different controller that was handling all transitions and events:

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

Besides the a good look of it, it actually violates the DDD principles, the entity should be like this:

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

Note: Sometimes some logic is put onto UseCases and it might be less obvious than my particular case.

By looking at such entity you understand faster what does it do, how it reacts to the domain events and other things that may happen.

But, as for business objects, sometimes you may feel that it does not have any behaviour that you may add/move to it. Here's my example of such object:

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

Interesting note: It's an actual code from my user's domain with such problem.

The potential problem is that User and Patch are just a container without any business logic. First of all, I use Patch only at the UseCases, which means that it should be put where it's needed. Use this rule for everything – declaration without usage on the layer which defines it, means that you do something wrong.

As for User, there's no need to create any aggregate functions – Kotlin's auto-generated copy method is more than enough as value-objects are already validated and there's no custom logic for that for the entire entity.

To learn more about this problem, you may reference, for example, to this article.

I'd like to add that you should try to avoid anemic domain entities, but at the same time do not enforce yourself to – if there is nothing to aggregate, do not add aggregates. Don't invent a behaviour if there's nothing to be added – KISS still applies.

Ignoring Ubiquitous Language

Ubiquitous Language, a key concept in DDD, is often ignored. The domain model and code should use the same language as the business stakeholders to reduce misunderstandings. Failing to align the code with the language of the domain experts results in a disconnect between the business logic and the actual implementation.

In simple terms, names should be easy to understand even for non-programmers. This is especially helpful for large projects involving multiple teams with varying knowledge, skills, and responsibilities.

That's something small to follow, but very important. I'd add that the same concepts shouldn't have different names across different domains – it's confusing even within one team.


Now, let's move on to the another approach I'm using in my projects – Hexagonal Architecture:

Hexagonal Architecture

Hexagonal Architecture, also known as Ports and Adapters, takes a different angle on structuring applications compared to traditional approaches. It's all about isolating the core domain logic from external systems — so that the core business logic isn't dependent on frameworks, databases, or any other infrastructure concerns. This approach promotes testability and maintainability, and it aligns well with DDD in that the focus remains on the business logic.

There're two types of Ports – Inbound and Outbound.

  1. Inbound ports are about defining the operations that the outside world can perform on the core domain.
  2. Outbound ports are about defining the services that the domain needs from the outside world.

The difference between DDD and Hexagonal architecture in isolation strategy is conceptually the same, but second one brings it to the next level – Hexagonal Architecture defines how exactly you should communicate with your domain model.

So, for example, if you need to access external service or feature in order to do something in your domain, you do the following:

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

UseCases for the most part are considered as Inbound ports, because typically they represent operations or interactions initiated by the external world. But sometimes the naming might be different and the implementation as well.

In my projects, I prefer not to introduce another terminology and usually I just create an interface of Repository that I need from outside:

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

Basically, I consolidate everything into a single repository to avoid unnecessary class creation, providing a clearer abstraction for most people familiar with the concept of a repository.

It may not always be the case that you need to call a repository from another feature or system. Sometimes, you may want to call a different business logic that handles what you need (what can be much better), known as UseCases. In this scenario, it's common to have a distinct interface from the first example.

Here's visualisation:

Visualisation

Note: By the way, the another terminology for 'feature' is the 'bounded context' from the DDD. They are pretty much mean the same.

And the example of defining and using ports that follows the schema above:

// FEATURE «A»

// Outbound port to get 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

Implementation of ports is done through Adapters – they're basically just linkage that implements your interface to work with an external system. The naming of such layer may vary – from simple data or integration to straightforward adapters. They are pretty interchangeable and up to specific project naming conventions. This layer usually implements other domains and uses other Ports to achieve what it needs.

Here's example of GetUserPort implementation:

// 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

So, coupling is the thing only at the data/adapters level. The advantage of it is that your domain logic remains unchanged no matter what happens to the external system. That's another reason why the domain's ports actually shouldn't really comply with everything that the external system wants – it's the Adapter's responsibility to deal with it. By that, I mean that, for example, the function signature might be different from the one that is used in the external system as long as it makes it possible to play around, of course.

Another thing is that it's important to consider how to handle domain types. Features are rarely completely isolated from other types of features. For instance, if we have a business object called User and a value object UserId, we often need to reuse the user's ID to store information related to the user. This creates a need to find a way to reuse this type in different parts of the system.

In an ideal Hexagonal architecture, different domains should exist independently. This means that each domain should have its specific definitions of the types they use. In simpler terms, it requires you to redeclare these types every time you need them.

It creates a lot of duplication, boilerplate while converting each type between separate domains, problems with validation (especially if requirements change over time, you might overlook something), and just a great pain in any developer's life.

The advice is that you shouldn't follow all of those rules as long as you don't see the benefit. Look for a happy medium while dealing with it, how I dealt with that we will discuss in the following part.

My implementation

Finishing the explanation of approaches I use, I'd like to proceed to my actual implementation and how I dealt with reducing unnecessary boilerplate and abstractions.

Let's start by defining key ideas of each approach we discussed:

  • Clean Architecture: divide your code in different layers by their responsibility (domain, data, presentation)
  • Domain-driven Design: Domain should contain only a business logic, all types should be consistent and valid among all of its lifecycle.
  • Hexagonal Architecture: Strict rules about accessing Domain and from Domain.

They are perfectly match each other for the most part, what makes it a key to write a good code.

The structure of TimeMates features (different domains) is following:

  • domain
  • data (Implements everything that is related to storage or network managing, includes submodules with DataSources)
    • database (integration with SQLDelight, auto-generated DataSources)
    • network (actually, I don't have it in TimeMates, because it's replaced with TimeMates SDK, but if it was not, I would add it)
  • dependencies (Integration layer with Koin)
  • presentation (UI with Compose and MVI)

I like this structure so far, but you might want to distinct the UI from ViewModels to be able to use different UI frameworks per platform, I am not planning to, so I leave as it is. But if I will posses such challenge in future, it's not hard for me as I am not dependent on the Compose in the ViewModels.

The main problem I experienced is boilerplate that I had while implementing Hexagonal Architecture – I copied and pasted types that made me wondering 'Do I actually need it much'? So, I came up with the following rules:

  1. I have common core types that are reused among different systems, it's kind of combined domain of most needed types.
  2. The type can be only common if it's used among most of the domains, has problem with duplication of the validation and is not complex structure (sometimes there's exceptions, but usually there're not a lot) at all.

What do I mean under 'complex structure'? Usually, your domain that demands another domain's type, don't actually need everything that's described in the given type. For example, you might want to share 'User' type, among with its value objects, but for the most part, other domains do not need all from User type and might want, for example, only name and the id. I am trying to avoid such situations and even if something is already in the core domain types, I would rather create distinct type with information that my certain domain need. But as for validation, I share almost all value objects.

You may extend this idea for bigger projects by creating not just common core types, but types for specific areas where some group of your subdomains (bounded contexts) works.

To sum up, I am reusing value objects that have the same validation rules in one common module; I am trying not to make my common core types module too big with everything. There should be always a happy medium.

In addition, in my projects I don't have 'Inbound Ports' as term in my projects. I replace them fully with UseCases:

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

Note: it's an example from the TimeMates Backend

It does not violate Hexagonal Architecture or DDD what makes it a good way to define how external world access your domain. It has the same meaning and behaviour as an inbound port.

As for outbound ports, I make the same I provided earlier in examples.

Conclusion

In my projects, I prefer to keep things practical. While theory and abstraction are useful, they can overcomplicate simple things. That’s why I combine the strengths of Clean Architecture, DDD, and Hexagonal Architecture without being overly strict about following them to the letter. Use critical thinking to determine what you actually need and why it benefits your project, rather than blindly following recommendations.

Top comments (0)