App Architecture: Domain layer

Mkhytar Mkhoian
ProAndroidDev
Published in
5 min readMay 5, 2024

--

Photo by Rene Böhmer on Unsplash

This is a series of articles about how to architecture your app that it was inspired by Google Guide to App Architecture and my personal experience.

Today I aim to cover the Domain layer. It is a layer that sits between the UI and data layers. It should be the most stable layer in the app.

The domain layer is responsible for encapsulating business logic, that is reused by the Presentation layer (e.g. multiple ViewModels). This separation of concerns allows the domain layer to be used as a platform-independent module to reproduce business logic on different platforms (e.g. Android, iOS, Web) and be covered with unit testing.

A domain layer provides the following benefits:

  • It avoids code duplication.
  • Platform-independent
  • It improves the testability of the app.
  • It avoids large classes by allowing you to split responsibilities.
  • Contain business logic.

The Domain layer contains base components such as UseCases, Data Models, and Repository interfaces. Do not put classes aka Utils into this layer, consider putting that logic into separate use cases.

Let's shed light on each component of this layer.

UseCase

Usually, use case classes sit between ViewModels from the UI layer and repositories from the data layer. This means that use case classes depend on the repository or other UseCase classes, and they communicate with the UI layer the same way repositories do — using coroutines (for Kotlin).

Naming conventions

Before moving forward let’s look at naming conventions. The use cases are named after the single action they’re responsible for. The convention is as follows:

verb in present tense + noun/what (optional) + UseCase.

For example: BuyTicketUseCase, LogOutUserUseCase, GetRydersUseCase.

Dependencies

For example, in your app, you might have a use case class that buys a ticket:

The use case contains reusable logic and can also be used by other use cases. It’s normal to have such dependencies between use cases in the domain layer. For example, the use case defined in the example below can make use of the GetFareByIdUseCase:

The use case can also contain logic that involves multiple repositories to combine data into more complex models.

Single Responsibility

The main idea of the use case is to be a simple and lightweight piece of business logic and only have responsibility over a single functionality to follow the single responsibility principle. To achieve that in Kotlin, you should make use case class instances callable as functions by defining the invoke() function with the operator modifier and make it the only public one.

The invoke() method in class (e.g. BuyTicketUseCase) allows you to call instances of the class as if they were functions. The invoke() method is not restricted to any specific signature—it can take any number of parameters and return any type. You can also overload invoke() with different signatures in your class. You'd call the GetFareByIdUseCase from the example above as follows:

Concurrency

As you can notice the invoke() function is suspend. The use case does not always need the coroutine support, it can contain some code that can be executed inside the coroutine as a regular one. At first sign everything is good, but the problem starts when you need to add an async operation to the existing use case to follow new business logic requirements. Imagine you are using this use case across the code base and treating it as a use case without coroutine support and now you need to call suspend methods inside this use case. For that, you need to make the invoke() function suspend and it breaks the API, as a result, you end up with a bunch of errors. To avoid that situation and make our API more maintainable and extendable the invoke() method should be suspend by default.

Use cases from the domain layer and the layer itself must not know about CoroutineScope, in other words, the scope must be defined in the Presentation layer (e.g. ViewModel) or Data layer (e.g. Repository). I'll cover it in detail in the next articles about the Presentation and Data layer.

For example, ViewModel one can use Dispatchers.Default for executing UI-related changes, mapping data models, and executing use cases. Typically, complex computations happen in the data layer to encourage reusability or caching IO operation it should be executed on Dispatchers.IO. For example, a resource-intensive operation is better placed in the data layer than in the domain layer if the result needs to be cached and reused on multiple screens of the app.

Lifecycle and State

Use cases don’t have their own lifecycle. Instead, they should be part of the scope of the class that uses them. Also, use cases should be stateless (without mutual data), it should be a new instance of a use case class every time you pass it as a dependency.

The domain layer itself should be stateless. The UI state should be in the Presentation and the Application state in the Data layers.

Model

The domain layer also contains data models that describe real-life objects related to the business logic. For that, we can use data class.

  • The domain layer should expose and take as input only the domain model.
  • The domain model should not implement Parcelable.
  • The data exposed by this layer should be immutable.

Naming conventions

The model classes are named after the data type that they’re responsible for. The convention is as follows:

type of data.

For example: Ryder, Fare.

Repository

There has been a lot of debate about how to communicate between the Domain and Data layers, and where the right place for repositories should be. My advice is to follow the Dependency Inversion Principle (DIP). The domain layer should communicate with the data layer only via the interface, for example, repository or service. Those interfaces must be placed in the domain layer and implemented in the data layer.

Naming conventions

The repository classes are named after the data that they’re responsible for. The convention is as follows:

type of data + Repository.

For example: TicketsRepository, FaresRepository.

Packaging conventions

domain/
├─ model/
│ ├─ Ryder
│ ├─ Fare
├─ use_cases/
│ ├─ GetFareByIdUseCase
│ ├─ BuyTicketUseCase
│ ├─ GetRydersUseCase
├─ repository/
│ ├─ TicketsRepository // Interface
│ ├─ FaresRepository // Interface
│ ├─ RydersRepository // Interface

The layer access restriction

You should not allow direct access to the data layer from the UI layer, everything goes through the domain layer. An advantage of making this restriction is that it stops your UI from bypassing domain layer logic, for example, if you are performing analytics logging on each access request to the data layer.

Wrapping up

Today, I want to end by emphasizing on that the Domain layer should be the most stable in your app architecture. That means independent from other layers and platform-independent code which makes it safer to make changes without causing unexpected problems.

--

--