Blog Infos
Author
Published
Topics
Published
Topics
Unleashing the Power of Functional Thinking and Modularity in Usecase Design

Photo by Alex Kondratiev on Unsplash

 

Now, let’s delve deeper into the process of making our use cases more fluent and enjoyable. In our previous article, we discussed why we’re taking this approach, so we’ll skip rehashing that and focus on the practical steps.

Before we begin, let’s clarify our main goal and the criteria for achieving a truly fluent and enjoyable use case. To illustrate, let’s revisit the desired end result.

OrderUseCase.kt

fun OrderRepository.placeOrder() {
    whenUserLoggedIn {
        whenCartNotEmpty {
            withSufficientFunds {
                updateProductStock()
                clearCart()
            }
        }
    }
}

Based on this desired outcome, there are specific criteria we need to meet:

  1. Function-First Development: Start by creating functions before building classes in the use case. Treat functions as key elements in Kotlin programming.
  2. Pure Functions: Strive to create pure functions, meaning they don’t get affected by side effects. This ensures reliability and predictability in how functions behave.
  3. Utilize High-Order Functions: Leverage high-order functions to enhance the capabilities of your functions and enable more flexible and dynamic behavior.
  4. Function Composition: Combine functions to craft new functions. This helps build more complex behavior from simpler building blocks.

In essence, these criteria revolve around Functional Programming. To embrace this approach, we need to shift our mindset, focusing on creating use cases centered on functions before introducing other concepts like classes.

Exploring the Process of Recreating a Use Case

Before we delve into the exploration, let’s provide some additional context about what an OrderRepository is. In this context, an OrderRepository is like a blueprint or a set of rules. It’s not a complete piece of code itself, but rather a guide for creating the code.

interface OrderRepository {
    fun isLoggedIn(): Boolean
    
    fun getCart(): List<Order>
    
    fun hasEnoughFunds(price: Double): Boolean
    
    fun updateProductStock(order: Order)
    
    fun clearCart()

    fun getTotalPrice(): Double

    fun getConfirmedOrderDetails(): ConfirmedOrderDetails()
}

We will now start on giving simple example to illustrate the concept and understand how it applies to real-world situations. Let’s start to shift our focus towards how I organized and reconstructed the use case within the framework of Fluent and Fun Clean Architecture.

After delving into functional programming, I began to investigate ways to enhance my use case. I initiated the code below, which shares similarities with my second article on Fluent and Fun Clean Architecture.

fun placeOrderUseCase(orderRepository: OrderRepository) {
    if (orderRepository.isLoggedIn()) {
        val cart = orderRepository.getCart()
        if (cart.isNotEmpty()) {
            if (orderRepository.hasEnoughFunds(getTotalPrice())) {
                orderRepository.updateProductStock()
                orderRepository.clearCart()
            } else {
                throw InsufficientFundsException(
                    "Not enough funds in the wallet."
                )
            }
        } else {
            throw EmptyCartException("The cart is empty.")
        }
    } else {
        throw NotLoggedInException("User is not logged in.")
    }
}

This already meets our criteria, but the code contains three layers of nested if-else conditions. While the code is clear, it could be more readable.

To enhance simplicity, we can wrap these conditional statements. How can we achieve this?

Take advantage of High-order function and Lamda Expressions

Let’s gradually transition the code. We’ll begin with isLoggedIn. To encapsulate the condition, we’ll leverage high-order functions.

fun placeOrderUseCase(orderRepository: OrderRepository) {
    executeWhenUserLoggedIn(orderRepository) {
        val cart = orderRepository.getCart()
        if (cart.isNotEmpty()) {
            if (orderRepository.hasEnoughFunds(getTotalPrice())) {
                orderRepository.updateProductStock()
                orderRepository.clearCart()
            } else {
                throw InsufficientFundsException(
                    "Not enough funds in the wallet."
                )
            }
        } else {
            throw EmptyCartException("The cart is empty.")
        }
    }
}

private fun executeWhenUserLoggedIn(
    orderRepository: OrderRepository,
    executeFunction: () -> Unit
) {
    if (orderRepository.isLoggedIn()) executeFunction()
    else throw NotLoggedInException("User is not logged in.")
}

The executeWhenUserLoggedIn function takes two parameters: orderRepository and executeFunction. The executeFunction parameter is a higher-order function that accepts a lambda expression with no input parameters and no return value (represented as () -> Unit).

Here’s how the code works:

  1. The placeOrderUseCase function is called with the orderRepository parameter.
  2. Inside placeOrderUseCase, the executeWhenUserLoggedIn function is invoked with the orderRepository parameter and a lambda expression as the executeFunction.
  3. The executeWhenUserLoggedIn function checks if the user is logged in using the orderRepository.isLoggedIn() function. If the user is logged in, it executes the provided executeFunction.
  4. In this case, the executeFunction is the lambda expression defined in the placeOrderUseCase function. This lambda expression contains the logic to place an order if the user is logged in and the cart is not empty. If any conditions are not met, appropriate exceptions are thrown.

In summary, the code uses a higher-order function (executeWhenUserLoggedIn) and a trailing lambda expression to ensure that the order placement logic is executed only when the user is logged in. This approach helps make the code more modular and readable by separating the authentication logic from the specific use case logic.

That’s the initial condition. Now, let’s move on to the next one, which deals with the cart.
fun placeOrderUseCase(orderRepository: OrderRepository) {
    executeWhenUserLoggedIn(orderRepository) {
        executeWhenCartNotEmpty(orderRepository) {
            if (orderRepository.hasEnoughFunds(getTotalPrice())) {
                orderRepository.updateProductStock()
                orderRepository.clearCart()
            } else {
                throw InsufficientFundsException(
                    "Not enough funds in the wallet."
                )
            }
        }
    }
}

private fun executeWhenCartNotEmpty(
    orderRepository: OrderRepository,
    executeFunction: () -> Unit
) {
    val cart = orderRepository.getCart()
    if (cart.isNotEmpty()) executeFunction()
    else throw EmptyCartException("The cart is empty.")
}

The purpose of executeWhenCartNotEmpty is to encapsulate the common logic of checking if the cart is empty before performing an action. By using this function, we can ensure that the action is only executed when the cart is not empty, and we avoid duplicating the same cart-empty check in different parts of the code.

In the context of the placeOrderUseCase function, we’re using the executeWhenCartNotEmpty function to ensure that the order placement logic is only executed when the cart is not empty. This approach promotes code reusability, readability, and modularity, as each step of the process is encapsulated in its own function, making the code easier to understand and maintain.

OUR VIDEO RECOMMENDATION

, ,

Migrating to Jetpack Compose – an interop love story

Most of you are familiar with Jetpack Compose and its benefits. If you’re able to start anew and create a Compose-only app, you’re on the right track. But this talk might not be for you…
Watch Video

Migrating to Jetpack Compose - an interop love story

Simona Milanovic
Android DevRel Engineer for Jetpack Compose
Google

Migrating to Jetpack Compose - an interop love story

Simona Milanovic
Android DevRel Engin ...
Google

Migrating to Jetpack Compose - an interop love story

Simona Milanovic
Android DevRel Engineer f ...
Google

Jobs

No results found.

Following that, we can now move forward to the final condition, which verifies whether the user has sufficient funds to complete the order.
fun placeOrderUseCase(orderRepository: OrderRepository) {
    executeWhenUserLoggedIn(orderRepository) {
        executeWhenCartNotEmpty(orderRepository) {
            executeWithSufficientFunds(orderRepository) {
                orderRepository.updateProductStock()
                orderRepository.clearCart()
            }
        }
    }
}

private fun executeWithSufficientFunds(
    orderRepository: OrderRepository,
    executeFunction: () -> Unit
) {
    val totalPrice = getTotalPrice()
    if (orderRepository.hasEnoughFunds(totalPrice))
        executeFunction()
    else throw InsufficientFundsException(
        "Not enough funds in the wallet."
    )
}

The code is taking the shape we’re aiming for in a use case. However, readability remains a concern as we repeatedly use the OrderRepository.

To address this, we introduce a new concept: Extension Functions. These functions let us call a function on an object without needing to explicitly mention the receiver or use the ‘this’ keyword. This will further enhance the clarity of our code.

Empowering UseCases with Extension Functions

Kotlin’s extension functions offer a way to expand the capabilities of existing classes. By employing extension functions, we can refine our order placement logic, resulting in a streamlined and well-organized arrangement. The following code demonstrates how extension functions can significantly improve the clarity and modularity of our use case implementation. This enhancement leads to a more polished and efficient codebase.

Now, let’s implement these principles in the private methods we’ve established. The code will adopt the following structure:

fun placeOrderUseCase(orderRepository: OrderRepository) {
    orderRepository.run {
        executeWhenUserLoggedIn {
            executeWhenCartNotEmpty {
                executeWithSufficientFunds {
                    updateProductStock()
                    clearCart()
                }
            }
        }
    }
}

private fun OrderRepository.executeWhenUserLoggedIn(
    executeFunction: () -> Unit
) {
    if (isLoggedIn()) executeFunction()
    else throw NotLoggedInException("User is not logged in.")
}

private fun OrderRepository.executeWhenCartNotEmpty(
    executeFunction: () -> Unit
) {
    val cart = getCart()
    if (cart.isNotEmpty()) executeFunction()
    else throw EmptyCartException("The cart is empty.")
}

private fun OrderRepository.executeWithSufficientFunds(
    executeFunction: () -> Unit
) {
    val totalPrice = getTotalPrice()
    if (hasEnoughFunds(totalPrice))
        executeFunction()
    else throw InsufficientFundsException(
        "Not enough funds in the wallet."
    )
}

The changes we made help us better understand what’s needed for placing an order. However, having OrderRepository.run inside the placeOrder might not fit perfectly. To improve this, we can also turn PlaceOrder into an extension function. To do this, let’s update the code like this:

OrderUseCase.kt

fun OrderRepository.placeOrderUseCase() {
    executeWhenUserLoggedIn {
        executeWhenCartNotEmpty {
            executeWithSufficientFunds {
                updateProductStock()
                clearCart()
            }
        }
    }
}

This now looked like the end result that we are expecting. We just need to refactor the function names to clean it up so that it is aligned in our end result. So overall the OrderUseCase file looks like this:

OrderUseCase.kt

// Main UseCase for placing an order.
fun OrderRepository.placeOrder() {
    whenUserLoggedIn {
        whenCartNotEmpty {
            withSufficientFunds {
                updateProductStock()
                clearCart()
            }
        }
    }
}

private fun OrderRepository.whenUserLoggedIn(executeFunction: () -> Unit) {
    if (isLoggedIn()) executeFunction()
    else throw NotLoggedInException("User is not logged in.")
}

private fun OrderRepository.whenCartNotEmpty(executeFunction: () -> Unit) {
    val cart = getCart()
    if (cart.isNotEmpty()) executeFunction()
    else throw EmptyCartException("The cart is empty.")
}

private fun OrderRepository.withSufficientFunds(
    executeFunction: () -> Unit
) {
    val totalPrice = getTotalPrice()
    if (hasEnoughFunds(totalPrice)) executeFunction()
    else throw InsufficientFundsException(
     "Not enough funds in the wallet."
    )
}

This now looks great. This is aligned with our criteria to achieve fluent and fun clean architecture. We have taken advantage of high-order functions, trailing lambda, function composition and extension function.

But you may wonder, isn’t it more straightforward and short to use the nested conditional statement instead of this?

Using straightforward nested conditionals is okay for simple tasks, but it can become messy as your program gets bigger. It may lead to repeating code, testing problems, and trouble handling errors consistently.

However, there’s a better way:

By using higher-order functions, you can already see what placeOrder is doing. It makes your code more organized and reusable. By breaking down the order process into smaller functions like whenUserLoggedInwhenCartNotEmpty, and withSufficientFunds, you gain benefits like:

  • Modularity: Each function handles a specific check, making your code easier to manage.
  • Reusability: You can use these functions in different parts of your app, reducing repetition.
  • Readability: Named functions give a clear overview of the order process.
  • Error Handling: Errors are managed centrally, leading to consistent error messages.
  • Testing: Testing becomes simpler and more effective.

In short, while simple nested conditionals work for small things, using higher-order functions offers a cleaner and more flexible solution. It keeps your code neat, encourages reuse, and ensures better quality, especially as your app grows.

Experiment Time

Photo by Talha Hassan on Unsplash

 

Let’s put our setup to the test by adding a new request. Imagine that now, along with placing the order, we also need to make sure the order is confirmed and then proceed with the transaction.

To make this happen, we’re going to break down the order process into three main steps: checkoutconfirm order, and execute the transaction. This helps us keep things organized and easy to manage.

Here’s how the code will look with these changes:

fun OrderRepository.doCheckout() {
    whenUserLoggedIn {
        whenCartNotEmpty { 
            println("Can proceed to next step") 
        }
    }
}

fun OrderRepository.confirmOrder() {
    whenUserLoggedIn {
        whenCartNotEmpty {
            getConfirmedOrderDetails()
        }
    }
}

fun OrderRepository.finishOrderTransaction() {
    whenUserLoggedIn {
        whenCartNotEmpty {
            withSufficientFunds {
                updateProductStock()
                clearCart()
            }
        }
    }
}

The way we’ve organized these functions helps us see the order process more clearly. You might notice we do similar checks, like if a user is logged in or if the cart is empty, in different steps. This may seem like repeating ourselves, but it’s actually quite helpful.

Here’s why it’s a good thing:

Easy to Understand: Breaking down the order process into different functions, such as doCheckoutconfirmOrder, and doOrderTransaction, makes it easier to see what’s happening. Even if you’re not familiar with the nitty-gritty details, you can follow the main steps.

Simple to Change: Each step has its own function, which makes it simpler to change or add new things without messing up the rest of the code. This makes sure we don’t accidentally cause problems somewhere else.

Works in Different Situations: This approach is like building with Lego blocks. If we need to add or change something, it’s like putting in a new Lego piece. The names of the functions also tell us what’s going on, which helps when we’re working together with others or coming back to the code later.

In a real situation, we might not need to do the same checks every time. For example, in confirmOrder and doOrderTransaction, we could skip some of the checks we did before. But the code we’ve got here is like a practice run to show how the pieces fit together.

In summary, the seemingly repeated patterns serve a purpose and offer significant benefits in terms of organization, clarity, and future maintainability of the code. While you might not always need all the checks in every step, the design’s flexibility accommodates real-world variations.

Wrapping Up: Navigating the Road Ahead

As we draw this exploration to a close, we’ve embarked on a meaningful journey toward enhancing our architectural approach. We’ve delved into the realm of fluent and fun clean architecture, unraveling the power of high-order functions, extension functions, and functional composition. This path has illuminated a clearer understanding of how to structure use cases and bring modularity to the forefront.

Our architectural journey has taken a significant turn thanks to valuable feedback. Initially, we placed extension functions within the repository, but we’ve learned the value of keeping domain logic in the domain layer itself. This change makes things clearer, separates responsibilities, and avoids confusion.

However, our journey continues with many paths ahead and unanswered questions. How do we put these refined use cases into practice? How do we handle dependencies gracefully? Also, we didn’t touch on using the Result construct in our earlier discussion. Don’t worry, we’ll address all of this in the upcoming chapters of our architectural story.

But our exploration doesn’t end here. Our next article will dive into testing methods to confirm our architectural choices. Plus, we’re excited to showcase a real project that brings together all the concepts we’ve explored. Remember, this series is an ongoing adventure, and your insights and feedback shape our shared knowledge.

Before we wrap up, a big thank you for your interest and feedback. Your thoughts are like gems on our path of learning and progress. As we move ahead, let’s remember:

“Any intelligent fool can make things bigger and more complex….. It takes a touch of genius — and a lot of courage to move in the opposite direction.” — Albert Einstein

Photo by Slidebean on Unsplash

This article was previously published on proandroiddev.com

YOU MAY BE INTERESTED IN

YOU MAY BE INTERESTED IN

blog
There are many people who have shared their own versions of use case implementations,…
READ MORE
blog
Use cases show us the intent of the software. They describe the behavior of…
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