Modelling with Kotlin - Discriminated Unions
Ever since Kotlin became a first class citizen of the Android framework, I have been enjoying the reduced ceremony of Android development. Coming from a functional language like F#, I see some niceties that Kotlin has, like pattern matching, sum types, functions as values, etc., even though it is not as functional compared to functional languages like F# or Haskell, we will take what we get.
I worked on a recent project that uses IBM Watson, as a chatbot, to trigger and complete individual business processes: where a user of the application converses with a chat bot, then the bot interpertes the user's messages to discern the user's intentions within the app, which is then translated to a business process for the user to complete.
For instance, the user says something like "I will like to open an account", the bot responds with a user intent like "open_account", then the app starts an open account business process.
The Problem
It was a bit convoluted reasoning how to unify this feature's functionality in a homogeneous model. And the functional person within me started complaining about how, if it was F# this will just be a matter of sum and product types, and then it hit me! light-bulb, Kotlin's when expressions can match on class hierarchies (sum types), then I started looking at how I could model this feature using class heirarchies, and here is my solution:
First of all, the feature had to be understood, before it could be generalized into a model. Here is the flow:
- The user discusses with the chat bot until the bot can figure out what the user wants to do.
- The bot initiates a command within the app, to start a business process or provide options for which child process the user actually wants.
- The user follows the instructions on completing triggered business process.
e.g.
- user: "I want to open an account"
- bot: "I understand you want to open an account, please choose your desired account"
- bot: triggers open_account business process, to show options of savings account or checking account
- app: shows user the proposed options for opening account
- user: selects savings account or "savings account"
- bot: continues open_account business process with savings_account as selected child process.
- app: initiates process for opening savings account
- user: follows instructions till completion of process.
- app: shows completion message to user
This is just a basic idea of how it would work. After spending some time reasoning about it, I was able to come with three basic entities required to make this feature work.
The idea is, each action had sub actions or processes that are also interactions on their own, this means that a business process could start (e.g. open_account) but the process could have child processes as branches (e.g. open savings_account or checking_account), then each child process has a set of interactions that lead the user to his goal.
I eventually modelled it such that, the top-level ones are called Workflows (root-level user actions) and each Workflow contains Processes (second-level user actions) and each Process contains Steps (user interactions to complete a specific Process).
- Workflow: this serves as the parent process for each of the business processes (e.g. open_account)
- Process: this serves as the a child process to a particular Workflow (e.g. savings_account)
- Step: this serves as a particular step in a Process (e.g. fill_personal_information)
Here is a video of what it looks like
The Code
Here is the major part of the code that is concerned with the modelling of this feature.
- ChatBotActivity: First you have the ChatBotActivity which serves as the parent UI
- ChatViewModel: Second you have the ChatViewModel which provides a Workflow as a LiveData to the ChatBotActivity.
- Workflow: Third you have a class Workflow which serves as the base class to all Workflows, providing abstract methods to be implemented.
- Workflow Implementations: Fourth you have Concrete implementations of the Workflow base class (e.g. OpenAccountWorkflow or PayBillsWorkflow).
- ChatAdapter: Last you have the ChatAdapter responsible for displaying both the server and client messages in a recycler view.
ChatBotActivity
This is the activity that is responsible for communicating with the chatbot on the server. The interesting part is what goes on in the btnSend click listener, and the startChatBot method.
Take a look at what the code looks like first, then I will explain what is going on afterwards.
class ChatBotActivity : AppCompatActivity() {
// ... activity's fields & members
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
binding = DataBindingUtil.setContentView(this, R.layout.activity_chat_bot)
// ... setup other ui elements and bindings
// submit user response
binding.btnSend.setOnClickListener {
// add user's input to chat
val userMessage = binding.userMessage.text.toString()
adapter.addItem(ClientMessage(message = userMessage))
// reset the chat field
binding.userMessage.setText("")
// create and send message to chat bot
val message = UserAPIMessage(userMessage, context)
service.sendMessage(message, this::handleServerResponse, this::handleError)
}
// start the chat bot
startChatBot()
}
private fun startChatBot() {
viewModel = ViewModelProviders.of(this).get(ChatBotViewModel::class.java)
// observe any new workflow and start it
viewModel.getWorkflow().observe(this, Observer { workflow ->
// ... other bindings to updates from the current workflow
// start the new workflow
workflow?.start(this)
})
// get service and start conversation
service = ChatBotService()
service.startConversation(Object(), this::handleServerResponse, this::handleError)
}
private fun handleServerResponse(response: ServerAPIMessage) {
// add server message
val serverMessage = response.outputText.reduce { acc, s -> "$acc. \n $s."}
adapter.addItem(ServerMessage(message = serverMessage))
// respond to user's intent
when (response.topIntent) {
"" -> {
} // do nothing
else -> {
// check if the workflow has a process
val process: IntentEntity? = when(response.entities.size) {
0 -> null
else -> response.entities[0]
}
viewModel.startWorkflow(response.topIntent, process?.value)
}
}
context = response.context
}
private fun handleError(message: String) {
Log.e("ChatBotActivity", message)
}
}
In the btnSend click listener, I first get the string value of what the user typed, and send that to the bot for processing also attaching my callbacks for onsuccess and onfailure responses.
The startChatBot method first gets and observes the workflow liveData of the activity's ViewModel, reacts to changes on the liveData by calling the start method of the new workflow, to kick start its processes.
The handleSuccess method is a success callback for the server's response, it adds the server's message to the view, and checks if a selected workflow and process were returned by the server, then hands them off to the view model to process.
What the view model does, comes up next.
ChatViewModel
To understand what the chat viewModel does, you have to remember that the server communicates the user's intent through coded strings, so all that has to happen, is make use of the coded string to translate the user's intentions to a workflow.
Here is what the code looks like, I will explain after:
class ChatBotViewModel: ViewModel() {
private val workflow = MutableLiveData<Workflow>()
fun getWorkflow(): LiveData<Workflow> = workflow
fun startWorkflow(tag: String, process: String? = null) {
when(tag) {
TAG_WORKFLOW_PAY_BILLS -> { workflow.postValue(PayBillsWorkflow(process)) }
TAG_WORKFLOW_OPEN_ACCOUNT -> { workflow.postValue(OpenAccountWorkflow(process)) }
else -> {} // do nothing, user will be notified of wrong input by chat bot.
}
}
companion object {
// open account workflow and processes
const val TAG_WORKFLOW_OPEN_ACCOUNT = "create_account"
const val TAG_PROCESS_OPEN_SAVINGS = "savings"
const val TAG_PROCESS_OPEN_CURRENT = "current"
// pay bills workflow and processes
const val TAG_WORKFLOW_PAY_BILLS = "pay_bills"
const val TAG_PROCESS_PAY_POWER_BILL = "Power"
const val TAG_PROCESS_PAY_WATER_BILL = "Water"
const val TAG_PROCESS_PAY_CABLE_BILL = "cable"
}
}
Below the instance methods of the ViewModel, in its companion object, are the static coded strings that help translate the user's intended actions into the app specific workflows.
The first instance method getWorkflow returns a LiveData that can be observed by an observer, in my case, the ChatBotActivity.
The second instance method startWorkflow is the one responsible for translating the server's coded string response, into an app specific modelled workflow by instantiating a workflow with a selected process.
What each workflow represents comes up next.
Workflows
Workflow is meant to be the heart of the modelled solution, so I abstracted that into base class that contains neccessities for a child class to be called a workflow, and here is what it looks like.
abstract class Workflow(process: String?) {
protected var completed = false
fun isComplete() = completed
abstract fun nextStep(context: Context)
abstract fun start(context: Context)
}
The first thing to note is the constructor which takes a process parameter. The purpose of this parameter is to determine what child process should be initiated when the workflow starts, that is if the provided process is not null.
The two abstract methods take in context because, some workflows require inflating or manipulating the UI, so I pass in the Activity context when the methods are invoked, giving the workflow access to ui context.
The start method is responsible for starting the current step for a particular process in a workflow, and the nextStep method is responsible for changing and starting the next step in a workflow.
Workflow Implementation
The code below shows the pay_bills implementation of the workflow, remember that a workflow requires two other things a Process and Steps. So here, I first define the Steps, and each Process, that is needed to complete the PayBills Workflow.
////////////////////////////////////
/////// Pay Bills /////
////////////////////////////////////
abstract class BillType {
class WaterBill: BillType()
class PowerBill: BillType()
class CableBill: BillType()
}
abstract class PayBillSteps {
class ShowBillOptions: PayBillSteps()
class ShowBillForm(val type: BillType): PayBillSteps()
class ShowCompletionMessage: PayBillSteps()
}
class PayBillsWorkflow(process: String?) : Workflow(process) {
private var currentStep: PayBillSteps
private lateinit var billType: BillType
init {
when (process) {
ChatBotViewModel.TAG_PROCESS_PAY_WATER_BILL -> {
billType = BillType.WaterBill()
currentStep = PayBillSteps.ShowBillForm(billType)
}
ChatBotViewModel.TAG_PROCESS_PAY_CABLE_BILL -> {
billType = BillType.CableBill()
currentStep = PayBillSteps.ShowBillForm(billType)
}
ChatBotViewModel.TAG_PROCESS_PAY_POWER_BILL -> {
billType = BillType.PowerBill()
currentStep = PayBillSteps.ShowBillForm(billType)
}
else -> {
currentStep = PayBillSteps.ShowBillOptions()
}
}
}
override fun nextStep(context: Context) {
currentStep = when (currentStep) {
is PayBillSteps.ShowBillOptions -> PayBillSteps.ShowBillForm(billType)
is PayBillSteps.ShowBillForm -> PayBillSteps.ShowCompletionMessage()
else -> throw IllegalStateException("currentState is invalid")
}
// start step
start(context)
}
override fun start(context: Context) {
when (currentStep) {
is PayBillSteps.ShowBillOptions -> this.showBillOptions(context)
is PayBillSteps.ShowBillForm -> this.showBillForm(context, billType)
is PayBillSteps.ShowCompletionMessage -> this.showCompletionMessage()
else -> throw IllegalStateException("currentState is invalid")
}
}
//... member methods and implementations for 'showBillOptions, showBillForm, showCompletionMessage.
private fun showBillOptions(context: Context) {
// show possible options for paying bills
// ... other implemented stuff
// move to next steop once done
this.nextStep(context)
}
private fun showBillForm(context: Context, billType: BillType) {
// show possible bill form for selected bill type
// ... other implemented stuff
// move to next step once done
this.nextStep(context)
}
private fun showCompletionMessage(context: Context) {
// ... show completion message to user
// set completed flag to true
this.completed = true
// move to next step once done
this.nextStep(context)
}
}
This primarily, is where the Discriminated Union or Sum Types come into play in my proposed solution. The abstract classes serve as the base type and the inner subclasses serve as concrete types for each abstract class. For instance WaterBill & PowerBill are both BillTypes.
The code above defines a simple workflow for paying utility bills, it essentially defines the types of bills that can be paid and the steps for completing a bill payment.
Here we have three types of Bills, each of which determines the process that was initiated by the server and is used to know what UI components to render to the user, and then we have three steps which take the user from start to end of the initiated payment processs.
The start method is first called to kickoff this workflow, which is done by the ChatBotActivity in the startChatBot method.
What the start method does is, it uses Pattern Matching to look at the current step and call the appropriate member function to start that step in particular. From then on, at end of each member function, like showBillForm for instance, the nextStep method would be invoked to signify completion of that step and trigger a move to the next step.
The nextStep method is essentially responsible for moving to the next step, it also uses Pattern Matching to look at what the current step is and then it just moves the current step to the next step, then calls the start method, to begin that next step.
And that is pretty much all the magic, the beauty of this approach is the clearity and readability of the code. You do not have to know Kotlin to understand what i am trying to acheive here, because majority of the code is self implied and reads like english.
ChatAdapter
This last part is just an extra, it just shows how the messages are displayed in the recycler view, I use this chatAdapter for that.
The chatAdapter is responsible for displaying two types of messages, one from the server and one from the client, and the enum helps define the integer value for the type of message, for view inflation purposes.
The classes ChatMessage & ServerMessage, help distinguish which message was created, and the interface ChatMessageModel, helps keep the adapter's message list a homogenuous one, in that both server and client message models implement it, and the adapter just requires a list of models implementing the interface.
enum class MessageType(private val type: Int) {
ServerMessage(0),
ClientMessage(1)
fun getType(): Int = type
}
interface ChatMessageModel {
fun getType(): Int
}
// client's message model
class ClientMessage(val message: String): ChatMessageModel {
override fun getType() = MessageType.ClientMessage.getType()
}
// server's message model
class ServerMessage(val message: String): ChatMessageModel {
override fun getType() = MessageType.ServerMessage.getType()
}
class ChatBotAdapter: RecyclerView.Adapter<ChatViewHolder>() {
private val list = mutableListOf<ChatMessageModel>()
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ChatViewHolder {
// load the right views
return when(viewType) {
MessageType.ClientMessage.getType() -> {
// create client message layout
val layout = LayoutInflater
.from(parent.context)
.inflate(R.layout.list_item_message_client, parent, false)
ClientChatViewHolder(layout)
}
MessageType.ServerMessage.getType() -> {
// create server message layout
val layout = LayoutInflater
.from(parent.context)
.inflate(R.layout.list_item_message_server, parent, false)
ServerChatViewHolder(layout)
}
else -> throw IllegalArgumentException("viewType is not amongst defined view types")
}
}
override fun getItemCount() = list.size
override fun onBindViewHolder(holder: ChatViewHolder, position: Int) {
val item = list[position]
holder.bindView(item)
}
override fun getItemViewType(position: Int) = list[position].getType()
fun addItem(model: ChatMessageModel) {
val position = list.size
list.add(model)
notifyItemInserted(position)
}
fun remove(model: ChatMessageModel) {
val index = list.indexOf(model)
list.removeAt(index)
notifyItemRemoved(index)
}
}
// base view holder for adapter's messages
abstract class ChatViewHolder(view: View): RecyclerView.ViewHolder(view) {
abstract fun bindView(model: ChatMessageModel)
}
// view holder for server messages
class ServerChatViewHolder(view: View): ChatViewHolder(view) {
private val text: TextView = view.findViewById(R.id.messageBody)
override fun bindView(model: ChatMessageModel) {
text.text = (model as ServerMessage).message
}
}
// view holder for client's messages
class ClientChatViewHolder(view: View): ChatViewHolder(view) {
private val text: TextView = view.findViewById(R.id.messageBody)
override fun bindView(model: ChatMessageModel) {
text.text = (model as ClientMessage).message
}
}
Conclusion
The feature of pattern matching combined with Discriminated Unions, is very powerful in kotlin in three majour ways:
-
It enables you define your solution into a unified model that not only shows you different branches or states for your app (like enums) but it can also capture data for each specific state (unlike enums) like the BillStep ShowBillForm above, which takes in a BillType as a constructor parameter.
-
It reads like English, which I feel is what most code blocks should look like, as simple as reading English sentences: "when billType is PowerBill -> showBillForm(PowerBill)".
-
Exhaustive matching, another awesome feature that forces you, the developer, to cater for all possible situations for a pattern. That is, if you do not match for all possible patterns of BillTypes above, the compiler will complain. This is more powerful when you app requirements change and you add a new BillType e.g. MortgageBill, and then forget to match on it in the workflow, the compiler will show error and project wouldn't build.
In all, I love expressing my ideas and solutions in simple forms, and that is truely what excites me as a developer. So experiencing one of my beloved features of Functional Programming in Android was an amazing journey that I had to share. I hope you find it interesting as I did.