Blog Infos
Author
Published
Topics
, , , ,
Published
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: BuyTicketUseCaseLogOutUserUseCaseGetRydersUseCase.

Dependencies

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

class BuyTicketUseCase(
private val ticketsRepository: TicketsRepository,
) { /* ... */ }

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:

class BuyTicketUseCase(
private val ticketsRepository: TicketsRepository,
private val getFareByIdUseCase: GetFareByIdUseCase,
) { /* ... */ }

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

class GetRydersUseCase(
private val ticketsRepository: TicketsRepository,
private val faresRepository: FaresRepository,
) { /* ... */ }
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.

class BuyTicketUseCase(
private val ticketsRepository: TicketsRepository,
private val getFareByIdUseCase: GetFareByIdUseCase,
) {
suspend operator fun invoke(ryderId: String, totalCount: Int) {
}
}

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:

class BuyTicketUseCase(
private val ticketsRepository: TicketsRepository,
private val getFareByIdUseCase: GetFareByIdUseCase,
) {
suspend operator fun invoke(ryderId: String, totalCount: Int) {
val fare = getFareByIdUseCase(ryderId)
ticketsRepository.buyTicket(ryderId = ryderId, fare = fare, totalCount = totalCount)
}
}

Job Offers

Job Offers

There are currently no vacancies.

OUR VIDEO RECOMMENDATION

Jobs

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.

data class Ryder(
val id: String,
val fares: List<Fare>,
val subtext: String?,
)
view raw Ryder.kt hosted with ❤ by GitHub
  • 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: RyderFare.

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.

interface TicketsRepository {
suspend fun buyTicket(ryderId: String, fare: Fare, totalCount: Int)
}
interface RydersRepository {
suspend fun getRyders(): List<Ryder>
}
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: TicketsRepositoryFaresRepository.

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.

Stay tuned for the next App Architecture topic to cover. Meantime you can read how to map data between layers.

https://proandroiddev.com/app-architecture-how-to-map-data-between-layers-df0179c52f04?source=post_page—–b9f6aa839e33——————————–

This article is previously published on proandroiddev.com

YOU MAY BE INTERESTED IN

YOU MAY BE INTERESTED IN

blog
It’s one of the common UX across apps to provide swipe to dismiss so…
READ MORE
blog
Hi, today I come to you with a quick tip on how to update…
READ MORE
blog
Automation is a key point of Software Testing once it make possible to reproduce…
READ MORE
blog
Drag and Drop reordering in Recyclerview can be achieved with ItemTouchHelper (checkout implementation reference).…
READ MORE

Leave a Reply

Your email address will not be published. Required fields are marked *

Fill out this field
Fill out this field
Please enter a valid email address.

Menu