Skip to main content

Same level of abstraction ⚖️

Abstract objects balancing on the scales.

A powerful rule of thumb to make something easy to understand is that all of its parts should be at the same level of abstraction.

That sounds a bit abstract… what does it mean? Let’s look at an example.

Example: Email invoice for order #

This is a hypothetical function to process an order and send out an invoice via email. It is a contrived example, but I hope it will be enough to get my point across.

Let’s jump in!

fun processOrder(order: Order, smtpHost: String, smtpPort: Int) {
    if (order.items.isEmpty()) {
        throw IllegalArgumentException("Order cannot be empty")
    }
    val totalPrice = order.items.sumOf { it.price * it.quantity }    
    val invoice = Invoice(
        id = UUID.randomUUID().toString(),
        date = LocalDate.now(),
        customerEmail = order.email,
        items = order.items,
        totalPrice = totalPrice
    )
    val emailRegex = "^[A-Za-z0-9+_.-]+@[A-Za-z0-9.-]+$".toRegex()
	if (!emailRegex.matches(invoice.customerEmail)) {
		throw IllegalArgumentException("Customer email is invalid")
	}
    val emailSender = setupEmailSender(smtpHost, smtpPort)
    emailSender.sendEmail(
        to = invoice.customerEmail,
        subject = "Your Invoice",
        body = "Thank you for your order. " +
	        "Your total is ${invoice.totalPrice}. " +
	        "Your invoice ID is ${invoice.id}."
    )
}

When I read this code, I try to create a mental picture of what is going on. The function seems to create an invoice and send it by email. There is some input validation, and a price calculation, but there is also some technical detail on how an invoice id is generated. Then there is more validation, including some regular expression. As for sending the email, we also seem to deal with setting up some kind of sender, which needs host name and port. Then we compose the actual email and send it.

While this function is not too long or particularly complex, I’d say it still takes a little while to connect the dots and create that mental image. Why is that? One contributing factor is that this function contains code at several different levels of abstraction, and it is a bit hard to see the major parts.

Breaking it up into blocks #

If I found this code and needed to understand it, I would start by identifying the larger conceptual blocks. As a first step, I would use comments and a newline to clearly mark what I think are the two logical halves of the function.

fun processOrder(order: Order, smtpHost: String, smtpPort: Int) {
	// Create invoice for order
    if (order.items.isEmpty()) {
        throw IllegalArgumentException("Order cannot be empty")
    }
    val totalPrice = order.items.sumOf { it.price * it.quantity }    
    val invoice = Invoice(
        id = UUID.randomUUID().toString(),
        date = LocalDate.now(),
        customerEmail = order.email,
        items = order.items,
        totalPrice = totalPrice
    )

	// Send invoice by email
    val emailRegex = "^[A-Za-z0-9+_.-]+@[A-Za-z0-9.-]+$".toRegex()
	if (!emailRegex.matches(invoice.customerEmail)) {
		throw IllegalArgumentException("Customer email is invalid")
	}
    val emailSender = setupEmailSender(smtpHost, smtpPort)
    emailSender.sendEmail(
        to = invoice.customerEmail,
        subject = "Your Invoice",
        body = "Thank you for your order. " +
	        "Your total is ${invoice.totalPrice}. " +
	        "Your invoice ID is ${invoice.id}."
    )
}

I’m sure there are other ways to segment it, but this would be my first attempt. But I’m certainly not happy yet. The code in this function is a bit all over the place.

  • We have some high-level business logic such as that we can’t create an invoice for an empty order.
  • We have technical details on how an invoice id is created.
  • We have more technical detail on how to validate an email adress, including a regular expression.
  • We get down to infrastructure and set up an email sender, including low-level details such as host and port.
  • Back on a business logic level, we also describes how to compose that invoice email.

Extract low-level stuff #

My instinct would be to try to get rid of some of that low-level technical stuff. The email regular expression is very low hanging fruit. As a next step, I would extract it to a function isValidEmail. I would also extract the ID generation to a newInvoiceId function.

fun processOrder(order: Order, smtpHost: String, smtpPort: Int) {  
    // Create invoice for order  
    if (order.items.isEmpty()) {  
        throw IllegalArgumentException("Order cannot be empty")  
    }  
    val totalPrice = order.items.sumOf { it.price * it.quantity }  
    val invoice = Invoice(  
        id = newInvoiceId(),  
        date = LocalDate.now(),  
        customerEmail = order.email,
        items = order.items,  
        totalPrice = totalPrice  
    )  
  
    // Send invoice by email  
    if (!isValidEmail(invoice.customerEmail)) {  
        throw IllegalArgumentException("Customer email is invalid")  
    }    
    val emailSender = setupEmailSender(smtpHost, smtpPort)  
    emailSender.sendEmail(  
        to = invoice.customerEmail,  
        subject = "Your Invoice",  
        body = "Thank you for your order. " +
	        "Your total is ${invoice.totalPrice}. " +
	        "Your invoice ID is ${invoice.id}."
    )  
}  
  
fun isValidEmail(email: String): Boolean {  
    val emailRegex = "^[A-Za-z0-9+_.-]+@[A-Za-z0-9.-]+$".toRegex()  
    return emailRegex.matches(email)  
}  
  
fun newInvoiceId(): String = UUID.randomUUID().toString()

Creating separate functions #

I’m happy with that change. But I’m still not happy with the overall structure. The comments I added are not an ideal solution. I had to add comments because the code was not expressive enough. My next step would be to try to extract the blocks of code as separate functions instead.

fun processOrder(order: Order, smtpHost: String, smtpPort: Int) {  
    val invoice = createInvoiceForOrder(order)  
    sendInvoiceByEmail(smtpHost, smtpPort, invoice)  
}  
  
fun createInvoiceForOrder(order: Order): Invoice {  
    if (order.items.isEmpty()) {  
        throw IllegalArgumentException("Order cannot be empty")  
    }  
    val totalPrice = order.items.sumOf { it.price * it.quantity }  
    return Invoice(  
        id = newInvoiceId(),  
        date = LocalDate.now(),  
        customerEmail = order.email,
        items = order.items,  
        totalPrice = totalPrice  
    )  
}  
  
fun sendInvoiceByEmail(
	smtpHost: String, 
	smtpPort: Int, 
	invoice: Invoice
) {  
    if (!isValidEmail(invoice.customerEmail)) {  
        throw IllegalArgumentException("Customer email is invalid")  
    }  
    val emailSender = setupEmailSender(smtpHost, smtpPort)  
    emailSender.sendEmail(  
        to = invoice.customerEmail,  
        subject = "Your Invoice",  
        body = "Thank you for your order. " +
	        "Your total is ${invoice.totalPrice}. " +
	        "Your invoice ID is ${invoice.id}."
    )  
}  
  
fun newInvoiceId(): String = UUID.randomUUID().toString()  
  
fun isValidEmail(email: String): Boolean {  
    val emailRegex = "^[A-Za-z0-9+_.-]+@[A-Za-z0-9.-]+$".toRegex()  
    return emailRegex.matches(email)  
}

This definitely makes me happier. The original processOrder gives a much clearer picture of the high-level behavior, and each of the new functions is more focused.

Improving the parameters #

But now that processOrder describes the processing on a very high level, I realize I don’t like the parameters for processOrder. In particular I don’t like that order is a high-level concept consisting of multiple fields, while smtpHost and smtpPort are very low-level primitives.

I also don’t really like that sendInvoiceByEmail deals with infrastructural stuff like hosts and ports. To me its job should be to compose and sends an invoice by email.

Fortunately, I can see a next step that would solve both these problems. I would move the construction of the email sender out of both sendInvoiceByEmail and processOrder and instead pass it as a parameter to processOrder.

fun processOrder(order: Order, emailSender: EmailSender) {  
    val invoice = createInvoiceForOrder(order)  
    sendInvoiceByEmail(emailSender, invoice)  
}  
  
fun createInvoiceForOrder(order: Order): Invoice {  
    if (order.items.isEmpty()) {  
        throw IllegalArgumentException("Order cannot be empty")  
    }  
    val totalPrice = order.items.sumOf { it.price * it.quantity }  
    return Invoice(  
        id = newInvoiceId(),  
        date = LocalDate.now(),  
        customerEmail = order.email,
        items = order.items,  
        totalPrice = totalPrice  
    )  
}  
  
fun sendInvoiceByEmail(emailSender: EmailSender, invoice: Invoice) {  
    if (!isValidEmail(invoice.customerEmail)) {  
        throw IllegalArgumentException("Customer email is invalid")  
    }  
    emailSender.sendEmail(  
        to = invoice.customerEmail,  
        subject = "Your Invoice",  
        body = "Thank you for your order. " +
	        "Your total is ${invoice.totalPrice}. " +
	        "Your invoice ID is ${invoice.id}."
    )  
}  

fun newInvoiceId(): String = UUID.randomUUID().toString()  
  
fun isValidEmail(email: String): Boolean {  
    val emailRegex = "^[A-Za-z0-9+_.-]+@[A-Za-z0-9.-]+$".toRegex()  
    return emailRegex.matches(email)
}

Well, that is definitely better. I think the updated code will be both easier to understand and maintain. As a bonus, the code becomes easier to unit test since we can provide a fake EmailSender under test. There is definitely still room for improvement, but we’ll stop for now.

The end result #

The guiding principle for these changes was getting all parts of a whole to be at the same level of abstraction. For the functions in our example, we could apply this principle both to the statements in the function body and to the function parameters.

I think the situation is now much better.

  • processOrder describes the high-level flow. Even a non-technical person should be able to understand it.
  • createInvoiceForOrder deals with business logic-level things that have to do with orders and invoices.
  • sendInvoiceByEmail knows what email to send, but relies on the EmailSender to do the low-level communication.
  • newInvoiceId and isValidEmail captures logic that is quite technical and likely to be reusable in other circumstances.

What do you think? Is the final example easier to understand, or did you prefer the original one? Perhaps you can think of an even better way to structure it?