Prefer Builders over DSLs

Colin White
3 min readApr 20, 2020

When I started working on Coil I thought domain-specific languages (DSLs) were a Kotlinesque replacement for builders. As a result, many of Coil’s public functions accept a trailing lambda for object construction:

However, over the past few months I’ve come to prefer builders over DSLs and as of Coil 0.10.0 many of the DSL functions are deprecated in favour using the builders directly:

This is a big change so I wanted to write up my reasoning about why I think you should prefer builders over DSLs.

Combining Scopes

DSLs combine the receiver’s scope with any outer scopes. As a result, the functions from any containing classes and the global scope are available inside the DSL. This makes it tougher to find specific functions when using autocomplete. Also, it can be confusing when you expect a function to exist on the receiver that doesn’t. For example, LoadRequest.Builder has an error function that allows you to specify a drawable to set on the Target if the request fails. Using the DSL, we can create a request like so:

Coil has another request type, GetRequest, which doesn’t have this function. However, attempting to call error inside the DSL will resolve to Kotlin’s globally-scoped error function which throws an exception:

Kotlin has the @DslMarker meta annotation to help solve this problem. It restricts implicitly calling functions from the outer scope. However, the receiver class of the outer scope has to be marked with an @DslMarker annotation so this can’t prevent accidentally resolving to global functions. With a builder, the error function doesn’t resolve and the code doesn’t compile:

Combining scopes also has a practical limitation. More available functions means slower autocomplete! When you chain functions on a builder, there is always exactly one receiver which is a much smaller set of available functions.

Creating Multiple Instances

DSLs create a single instance. Builders are factories. For example, say we want to create 3 requests with the same base configuration but different targets. This is straightforward with a builder:

Each call to build creates a new instance. Since DSLs package configuration and creation into one function, we need to extract common configuration code into a separate function:

Overall the code becomes less concise when using the DSL. It’s also more error-prone as you have to remember to call configure.

Java Compatibility

Kotlin DSL syntax is not (nicely) compatible with Java, whereas builders are. Most codebases aren’t 100% Kotlin (Instacart included) and having the same API for Java and Kotlin is more consistent and intuitive.

When to use DSLs

With all of this said, DSLs do have their use cases - they just aren’t a good substitute for most builders. If you need to describe document structure/ordering of elements (e.g. HTML DSL, Jetpack Compose), DSLs are significantly more concise than a builder:

Coil’s LoadRequest.Builder, GetRequest.Builder, and Coil’s other model classes don’t describe a document structure and their methods can be called in any order. Therefore it doesn’t make sense for Coil to have a DSL.

Agree/disagree with me? Let me know on Twitter!

--

--