Aggressively deal with the unexpected at the edge of your system

Every program, to be useful, needs to talk to the outside world. A program is like a black box that you can give inputs to and that spits out something in response.

The interactions with the outside world come in many fashions: direct user inputs, network, disk, IPC… Our programs are not self contained: they rely, in some way or another, on external things that are outside of our control. We don’t control what the user chooses to input, nor what the server responds.

However, we can control what is inside our program — i.e. the code we write — and we can limit how far the outside world’s tail extends inside our software.

We can do so by dealing with the unexpected as soon as it is possible: at the edges of the system.

When we get new data we should immediately deal with it: filtering out what our system can’t handle, transforming it to a form that best fits our need, and failing as soon as we can if it’s necessary.

This way we won’t need to litter the codebase with millions of checks, because we will be sure that the data our code deals with is valid and tailored to our domain. In statically-typed languages we get this reassurance from the compiler.

A trivial example

Let’s say we develop a shopping application, with two simple requirements: displaying the available products and allowing the user to buy them.

We need to depend on a backend that is in charge of knowing what products are available and receiving orders. When the app is opened we need make a network request to fetch the products:

GET api.shoppityshop.com/products

The backend will respond with a list of products, in classical JSON style:

[
  {
    "id": 123,
        "name": "Very nice shoes",
        "price": 70.0,
        "image": "/images/ahsfoda83"
  },
  {
    "id": 6456,
        "name": "Banana coat",
        "price": 150.0,
        "image": "/images/a4g8o7bre"
  },
  ...
]

If we are lucky the user may choose something to buy, so it’s the time to talk to the backend again.

POST api.shoppityshop.com/checkout passing the selected products:

{
  "products": [6456, 35756, 7734],
  "delivery": "Express"
}

One-size-fits-all approach#

We decide to use the same model we receive from the backend inside the app too.

We could define a class matching the JSON and pass the instances around as they are.

data class ProductDTO(
    val id: Long?,
    val name: String?,
    val price: Double?,
    val image: String?
)

We are kind-of forced to define the values as nullable because even though we know that we can trust our colleagues that work on the backend, bugs can (and will) happen, so precautions are never enough. We don’t want the app to crash just because one required value is missing in the JSON response.

Using this model everywhere means that we need to perform checks at various stages.

Is it ok to display a product without a name? What about a missing price? And what if the user adds to the cart a product that has id = null? What do we do with it? We have no way of telling the backend what the user intends to buy.

These are all valid questions and they might depend on the needs of the business. The issue is not that these questions are not valid, but that we need to answer them over and over again in the code.

They should be answered once (as soon as possible) and never again. Let’s see how.

Building a domain model#

The ProductDTO model we built above is made to fit a specific need: represent the backend’s response. The business needs, however, are different and they are nothing more than the requirements that the app must satisfy.

The model above is inadequate. So we transform it to the form that is most convenient to us: to the domain model.

Let’s say that these are the requirements:

  • Don’t show products missing the id, because the customer wouldn’t be able to buy them and it would lead to a bad user experience
  • Show a default title for products missing the name
  • Show products missing prices, because the user will be able to see the final order price before they confirm the purchase

Then we could build the corresponding domain model:

data class Product(
    val id: Long,
    val name: String,
    val price: Double?,
    val image: String?
)

As soon as we receive the response from the backend we pass our DTOs to a mapper that will transform them.

fun map(response: List<ProductDTO>): List<Product> {
    return response.mapNotNull(::mapSingle)
}

fun mapSingle(dto: ProductDTO): Product? {
    if (dto.id == null) return null
    else {
        return Product(
            dto.id,
            dto.name ?: "Default title",
            dto.price,
            dto.image
        )
    }
}

By doing so we will make sure that every other part of our code only deals with products having an id and a name to display, making our code simpler overall.

In conclusion

The example above is extremely trivial. The mapping function only does two things:

  • it discards products with id == null
  • it assigns a default value when the name is missing

The network and domain models are almost identical.

The complexity of the transformation can vary widely depending on the specific use case, but the core concept is the same:

Build a model that represents the exact needs you have and aggressively reject and/or discard data that your code wouldn’t be able to handle.