Clean Architecture — Functional Style Use Case Composition with RxJava/Kotlin
Imagine you’ve been assigned to a new project and this is the first meeting where the first thing the project manager says is
We want to give our users the ability to book a movie ticket from the app. We’ll validate the booking, update the database on success, send push notifications on an I/O thread and cache for better performance. Also, we should log and send analytics.
Weird right?! Business requirements never talk about how to get something done.
This is probably what your code looks like to someone else (or you after 6 months) when they’re reading it. The most important part is buried somewhere in a pile of functions and variables that had to be added for error handling, logging, threading, etc.
Purpose
Clean Architecture mentions a UseCase
as a specification of business rules. These rules are implementation independent. In other words they don’t specify usages/features of platform frameworks like Spring or Android, threading requirements, caching, analytics, etc. They purely define what steps are required to complete a business flow.
However, such business flows are complex and involve many steps. Some of these steps can be reusable. Moreover, the result of a step can determine if the next step needs to be executed, skipped or the entire flow needs to be aborted.
In this article, I share my techniques of handling such flows in a more streamlined manner through UseCase
composition.
UseCase as a Function
Loosely speaking, a UseCase
can be thought of as a function T -> R
that maps some T
to some R
. For example GetUserByIdUseCase
takes a userId
and returns a User
(or an Error
). Composing UseCase
s then becomes similar to composing functions. Complex UseCase
s are built from simpler ones by combining them in various ways.
It is worth noting that the input of this UseCase
function is a combination of the Request
parameter of the execute()
function and all the injected UseCase
s/Functions.
The UseCase Contract (Conventions)
In order to stay ‘functionally pure’, I use the following conventions in writing a UseCase.
These conventions also help in keeping aUseCase
as a specification and have no implementation details. (See the example below to understand these in more detail).
- A
UseCase
should be a concrete finalclass
. - A
UseCase
class name should be suffixed withUseCase
. - A
UseCase
should have a single public final method calledexecute()
. - The
execute()
method should take a single parameter calledRequest
. ARequest
is adata class
(for exampleGetUserRequest(val userId: String)
). All properties of aRequest
areval
(read only). - The
execute()
method should always return a deferrable computation (In this case, RxJava). - The
execute()
method should return aResult<T>
. AResult
is aSuccess
or anError
. I only useObservable<Result<T>>
orFlowable<Result<T>>
as return types.Maybe<T>
andSingle<T>
are handled by theResult
return type which can hold errors. - A
UseCase
should not specify any custom annotations. Such details can be handled at the implementation level. - The
UseCase
constructor should be injected only with otherUseCase
s (see points 9 and 10). This is composition wherein we build a complexUseCase
by composing otherUseCase
s. - If the number of
UseCase
s being injected grows, some of them can be extracted and combined into a newUseCase
. Such refactoring should ensure that eachUseCase
is still reusable and makes sense as an independent function. - If no
UseCase
exists that provides the required data, a newUseCase
is created that is injected with a singleRepository
or aService
interface. This typically happens for low levelUseCase
s which fetch/save data. (For exampleGetUserByIdUseCase(val userRepository: UserRepository)
. - The
execute()
method should not use RxJava operators that switch threads (subscribeOn()
,observeOn()
, etc.) or implicitly run on their ownScheduler
s (Observable.interval()
,Observable.timer()
, etc.). - The
execute()
method makes no assumptions about the number of items emitted by injectedUseCase
s. - A
UseCase
is preferred to be a single chain of RxJava operators. - Since the
execute()
method returns anObservable<Result<T>>
, aUseCase
makes no assumptions that any injectedUseCase
would specifically return aSuccess
or anError
. - A
UseCase
is immutable. - A
UseCase
is stateless.
UseCase Composition Example
Rather than describing the example, I’ll let you look at the code and try to figure out what’s happening here (after all, that’s how software development works anyways!)
If you guessed that this is a ticket booking feature with some validation and analytics, you’re awesome! :)
Key Takeaways
- The code through its variable, class and function names explains its purpose.
- Each of lines 9–13 can return an
Observable.error()
thereby aborting the flow and jumping to line 14. AUseCase
should never triggeronError()
on its client. All errors are converted toResult.OnError
by line 14. - A
UseCase
is preferred to be a single chain of RxJava operators as shown here. If you find yourself getting into complex RxJava chains, consider refactoring your code into separateUseCase
s and compose them to simplify your logic. - Helpful converters (listed below) like
Result.toDataObservable
andThrowable.toErrorResult
allow easy conversion betweenResult
,Throwable
and the actual data type viz.Ticket
. checkDoubleBooking
andisSeatBooked
can be extracted into a newUseCase
calledValidateTicketBookingUseCase
if required.- A new validation step can be easily added.
- Modifying the business logic of an existing step does not impact this
UseCase
. - Dependencies can be injected using a DI framework if required. Most DI frameworks work with
@Inject
. - This
UseCase
can be tested by mocking injectedUseCase
s or by using fakes.
Low level UseCase
Let’s look at a UseCase
that deals with I/O. This UseCase
is injected with an interface
.
Key Takeaways
- Uses a
Service
to perform I/O. TheUseCase
itself is unaware of it. - Caching, threading, etc. are not part of the
UseCase
logic. - Errors are defined at
UseCase
level and hence act as documentation. Service
s orRepositories
can useSingle<T>
orMaybe<T>
. It is preferred to convert these to anObservable<Result<T>>
for easy composition.
RxJava Composition Helpers
These functions greatly help in composing RxJava streams. Hope you find them useful.
So why compose?
- A function should do one thing. This helps in writing robust, testable code. A
UseCase
here acts like a function. - A business logic step is represented by a
UseCase
. Such steps can be modified without impacting the ‘business flow’. Steps can even be swapped for different ones or skipped conditionally. New steps can be added easily.UseCase
s are pluggable. - Since a
UseCase
is an independent unit of logic, almost no context is required to understand it. This greatly simplifies finding and debugging business logic errors. UseCase
composition is independent of software architecture and implementation details. This allows to choose and upgrade backend and frontend frameworks without impacting business logic.
That’s it for now. Thanks and hope you’ve enjoyed reading this. Do let me know what you think and happy coding :)