Modelling the domain layer using composable use cases

5 minute read

Note: This article has been featured in both Android Weekly (#581) and Kotlin Weeekly (#367). Thanks to both! 🙏

Some time ago we had a problem inside the Memrise Android codebase with our domain layer. Repositories and Use Cases were heavily used but there weren’t clear guidelines on what they were supposed to do and not do. It was common to find them being used in combination, which in reality meant we didn’t have Repositories at all but Use Cases with direct access to our Data Sources.

With this in mind, we started looking for a brighter future and thinking about what was right and what wasn’t:

  1. Repositories with other Repositories as dependencies ❌ Repositories should only depend on their Data Sources (i.e memory, persistence or api). Whenever we have a Repository doing more than this, we should treat it as a red flag and as a symptom that what we want is a Use Case.

  2. Repositories with Use Cases as dependencies ❌ Also bad. If our Repository has a Use Case as a dependency… Well, what we’ve got is a Use Case that is acting both as a Use Case and as a Repository! But we want our classes to have a single responsibility.
  3. Use Cases with Repositories as dependencies ✅ Yes! This is the classic showcase of a Use Case. Calls a Repository to fetch the data, not caring how the data was fetched or where it’s coming from (api? persistence? it doesn’t matter). Then, it implements the domain/business logic before passing it back to the presentation layer, where it will be transformed into some sort of ViewState to be consumed by views.
  4. Use Cases with Use Cases as dependencies ❓ This one had us thinking since we had some questions, such as:
  • Will this lead to circular dependencies?
  • Will this work well with modularisation or will we end up with a massive core module?
  • What about when a particular screen needs to tweak the logic, will that detail leak to other callers?

  • As Pablo Costa pointed out (thanks 🙏) it violates the Dependency Rule

The real answer for Use Cases within Use Cases, or in other words, Composable Use Cases is that…THEY’RE GREAT! ✅✅✅

Let’s see some examples to see why they’re great and get a grasp of them.

Use Cases

Before we start seeing some code, it’s worth describing some basic rules we normally follow when creating Use Cases:

  • Ideally, they’ve got a single function (i.e invoke()) that receives a parameter and returns something.
  • No threading details on them, these live in the Presentation layer (they’ll normally be run asynchronously).
  • Chaining using Rx or any other framework of your liking.
  • Dependencies are injected using Dependency Injection.
  • They don’t hold state.

NOTE: all code below is inspired in real implementations but simplified

Low-level Use Cases

Low-level Use Cases won’t do much. They are a single function that takes a parameter, calls a Repository, and returns some data. For example:

class GetEnrolledCourseUseCase @Inject constructor(
    private val coursesRepository: CoursesRepository,
) : (String) -> Single<Course> {

    override fun invoke(courseId: String): Single<EnrolledCourse> {
        return coursesRepository.getEnrolledCourse(courseId)
                .onErrorResumeNext {
                    Single.error(CourseNotAvailable(courseId))
                }
        }
    }
}

data class CourseNotAvailable(val courseId: String) : Throwable("Course not found: $courseId")

Not much going on here: a single dependency, the Repository. It calls the getEnrolledCourse method, if found it will be returned, otherwise, it throws an error. Can’t get more low-level than this!

Simple Composable Use Cases

Now, let’s go one step further. We’ve got a screen that shows if an enrolled course is available for offline mode. We can do this without writing much code by composing a few low-level Use Cases, including the one we presented above. It would be something like this

class IsCourseAvailableOfflineUseCase @Inject constructor(
    private val getEnrollCourseUseCase: GetEnrollCourseUseCase,
    private val isCourseDownloadedUseCase: IsCourseDownloadedUseCase,
    private val isUserProUseCase: IsUserProUseCase,
) : (String) -> Completable {

    override fun invoke(courseId: String): Completable {
        return when {
            !isUserProUseCase() -> Completable.error(UserIsNotProError())
            else -> {
                getEnrollCourseUseCase(courseId).flatMapCompletable { enrolledCourse ->
                    isCourseDownloadedUseCase(courseId).flatMapCompletable { downloaded ->
                        if (downloaded) {
                            Completable.complete()
                        } else {
                            Completable.error(CourseNotDownloadedError(courseId = courseId, courseName = enrolledCourse.name))
                        }
                    }
                }
            }
        }
    }
}

Delegating Complexity to other Use Cases

Use Cases can get very complicated very quickly. When that happens, it’s important to apply a bit of Single-Responsibility-Principle and delegate to other Use Cases. At Memrise we’ve got a component called the Single Continue Button (SCB from now on) that it’s a great example of this. The SCB is a button that can appear on multiple screens that will launch a new session when clicked. Depending on the screen where the user is at (dashboard, level, course, end of session, etc) and the user’s progress, the session type will be different.

Instead of having all this logic in a massive Use Case or several Use Cases as entry points, we can have the main entry point Use Case that checks the provided payload and delegates to the right Use Case:

class ScbUseCase @Inject constructor(
    private val scbEosUseCase: ScbEosUseCase,
    private val scbLevelUseCase: ScbLevelUseCase,
    private val scbCourseUseCase: ScbCourseUseCase,
    private val scbLandingUseCase: ScbLandingUseCase
) : (ScbScreenPayload) -> Single<ScbFetchResult> {

     override fun invoke(payload: ScbScreenPayload): Single<ScbFetchResult> {
        return when (payload) {
            is Landing -> scbLandingUseCase(payload)
            is Tooltip -> scbLandingUseCase(payload)
            is Course -> scbCourseUseCase(payload)
            is Level -> scbLevelUseCase(payload)
            is EndOfSession -> scbEosUseCase(payload)
        }
    }
}

sealed class ScbScreenPayload(
    open val courseId: String
) {
    data class Landing(...) : ScbScreenPayload(courseId)
    data class Tooltip(...) : ScbScreenPayload(courseId)
    data class Course(...) : ScbScreenPayload(courseId)
    data class EndOfSession(...) : ScbScreenPayload(courseId)
		...
}

Chaining set of actions Use Cases

Somehow similar to the previous example, we can have a Use Case that orchestrates the chaining/order of different actions. This will not only help to visualize logic easily, but it’ll make testing easier, as it moves away from implementation details. Fetching the Learnables for a particular session is a good example of it:

class SessionLearnablesUseCase @Inject constructor(
    private val isOnlineOrDownloadedCourseUseCase: IsOnlineOrDownloadedCourseUseCase,
    private val getEnrollCourseUseCase: GetEnrollCourseUseCase,
    private val getLevelsUseCase: GetLevelsUseCase,
    private val getSessionLearnablesUseCase: GetSessionLearnablesUseCase,
    private val getProgressUseCase: GetProgressUseCase,
) : (SessionsPayload) -> Single<List<LearnableWithProgress>> {
    override fun invoke(payload: SessionsPayload): Single<List<LearnableWithProgress>> {
        return isOnlineOrDownloadedCourseUseCase(payload.courseId).andThen(
            getOrEnrollCourseUseCase(payload.courseId).flatMap { course ->
                getLevelsUseCase(payload).flatMap { levels ->
                    getProgressUseCase(levels, course.id).flatMap { progress ->
                        getSessionLearnablesUseCase(progress).map { learnables ->
                            learnables.toLearnablesWithProgress(progress)
                        }
                    }
                }
            }
        )
    }

    private fun getLevels(payload: SessionsPayload): Single<List<Level>> {
        return when (payload) {
            is SessionsCourseIdPayload -> getCourseLevelsUseCase(payload.courseId)
            is SessionsLevelIdPayload -> getCourseLevel(payload.courseId)
        }
    }
}

Summing up

Composable Use Cases are a great way to effectively model the domain logic of our software, abstracting away our Repositories and data layers. On top of this, they’re a very scalable solution as the more Use Cases we write, the more we can re-use and quickly ship new features to our users!

Hope you enjoyed the article. For any questions, my Linkedin and Twitter accounts are the best place. Thanks for your time!

NOTE: This article was first published in the Memrise Engineering Blog. Check the blog out, there’s great content!

You might find interesting…