A typesafe idiom for the Builder Pattern was explained in the previous post, and it’s wonderful for straightforward construction, particularly when we don’t care what order the fields are provided in and we want to prevent duplicate calls which could lead to unexpected behavior.
However, once the class being built progress beyond a certain level of complexity, we run into 4 big limitations:
- Error messages which don’t scale
- Sometimes being opinionated about order can be helpful
- Rigidity in how the fields can be provided
- Dependencies can make it feel like you’re building the object graph twice
Guest post by Morgen Peschke
In this post we’ll introduce a more complex example, and show what it looks like when the task starts to overtake the capability of a basic builder.
In the next series of posts, we’ll show how we can improve our builders to handle the additional complexity.
A More Complex Goal
We’ll use this hypothetical application config in our examples:
package models
import cats.data.NonEmptyList
sealed trait Auth[F[_]]
object Auth {
def impl[F[_]]: Auth[F] = new Auth[F] {}
}
sealed trait Users[F[_]]
object Users {
def impl[F[_]]: Auth[F] => Users[F] = _ => new Users[F] {}
}
sealed trait Books[F[_]]
object Books {
def impl[F[_]]: (Auth[F], Users[F]) => Books[F] = (_, _) => new Books[F] {}
}
trait HealthCheck[F[_]]
object HealthCheck {
def impl[F[_]]: HealthCheck[F] = new HealthCheck[F] {}
def users[F[_]]: (Auth[F], Users[F]) => HealthCheck[F] = (_, _) => new HealthCheck[F] {}
def books[F[_]]: (Auth[F], Books[F]) => HealthCheck[F] = (_, _) => new HealthCheck[F] {}
}
final case class AppConfig[F[_]](
host: String,
port: Int,
maxConnections: Int,
connectionTimeout: Int,
users: Users[F],
books: Books[F],
healthChecks: NonEmptyList[HealthCheck[F]]
)
A Basic Builder
We’ll start with a basic builder, as detailed in the previous post:
package builder
import cats.data.{NonEmptyChain, NonEmptyList}
import models._
final class AppBuilder[
F[_],
H <: Option[String],
P <: Option[Int],
MC <: Option[Int],
CT <: Option[Int],
U <: Option[Users[F]],
B <: Option[Books[F]],
HC <: Option[NonEmptyChain[HealthCheck[F]]]
] private[builder](
private[builder] val hostOpt: H,
private[builder] val portOpt: P,
private[builder] val maxConnectionsOpt: MC,
private[builder] val connectionTimeoutOpt: CT,
private[builder] val usersOpt: U,
private[builder] val booksOpt: B,
private[builder] val healthChecksOpt: HC
) {
private def copy[
H2 <: Option[String],
P2 <: Option[Int],
MC2 <: Option[Int],
CT2 <: Option[Int],
U2 <: Option[Users[F]],
B2 <: Option[Books[F]],
HC2 <: Option[NonEmptyChain[HealthCheck[F]]]
](
hostOpt: H2 = hostOpt,
portOpt: P2 = portOpt,
maxConnectionsOpt: MC2 = maxConnectionsOpt,
connectionTimeoutOpt: CT2 = connectionTimeoutOpt,
usersOpt: U2 = usersOpt,
booksOpt: B2 = booksOpt,
healthChecksOpt: HC2 = healthChecksOpt
): AppBuilder[F, H2, P2, MC2, CT2, U2, B2, HC2] = new AppBuilder(hostOpt, portOpt, maxConnectionsOpt, connectionTimeoutOpt, usersOpt, booksOpt, healthChecksOpt)
private[builder] def withHost(value: String): AppBuilder[F, Some[String], P, MC, CT, U, B, HC] =
copy(hostOpt = Some(value))
private[builder] def withPort(value: Int): AppBuilder[F, H, Some[Int], MC, CT, U, B, HC] =
copy(portOpt = Some(value))
private[builder] def withMaxConnections(value: Int): AppBuilder[F, H, P, Some[Int], CT, U, B, HC] =
copy(maxConnectionsOpt = Some(value))
private[builder] def withConnectionTimeout(value: Int): AppBuilder[F, H, P, MC, Some[Int], U, B, HC] =
copy(connectionTimeoutOpt = Some(value))
private[builder] def withUsers(value: Users[F]): AppBuilder[F, H, P, MC, CT, Some[Users[F]], B, HC] =
copy(usersOpt = Some(value))
private[builder] def withBooks(value: Books[F]): AppBuilder[F, H, P, MC, CT, U, Some[Books[F]], HC] =
copy(booksOpt = Some(value))
private[builder] def withHealthChecks(value: NonEmptyChain[HealthCheck[F]]): AppBuilder[F, H, P, MC, CT, U, B, Some[NonEmptyChain[HealthCheck[F]]]] =
copy(healthChecksOpt = Some(value))
}
object AppBuilder {
type Empty[F[_]] = AppBuilder[
F,
None.type,
None.type,
None.type,
None.type,
None.type,
None.type,
None.type
]
def init[F[_]]: Empty[F] = new AppBuilder(None, None, None, None, None, None, None)
implicit final class AppBuilderHostOps[
F[_],
P <: Option[Int],
MC <: Option[Int],
CT <: Option[Int],
U <: Option[Users[F]],
B <: Option[Books[F]],
HC <: Option[NonEmptyChain[HealthCheck[F]]]
](private val builder: AppBuilder[F, None.type, P, MC, CT, U, B, HC]) extends AnyVal {
def host(h: String): AppBuilder[F, Some[String], P, MC, CT, U, B, HC] = builder.withHost(h)
}
implicit final class AppBuilderPortOps[
F[_],
H <: Option[String],
MC <: Option[Int],
CT <: Option[Int],
U <: Option[Users[F]],
B <: Option[Books[F]],
HC <: Option[NonEmptyChain[HealthCheck[F]]]
](private val builder: AppBuilder[F, H, None.type, MC, CT, U, B, HC]) extends AnyVal {
def port(p: Int): AppBuilder[F, H, Some[Int], MC, CT, U, B, HC] = builder.withPort(p)
}
implicit final class AppBuilderMaxConnOps[
F[_],
H <: Option[String],
P <: Option[Int],
CT <: Option[Int],
U <: Option[Users[F]],
B <: Option[Books[F]],
HC <: Option[NonEmptyChain[HealthCheck[F]]]
](private val builder: AppBuilder[F, H, P, None.type, CT, U, B, HC]) extends AnyVal {
def maxConnections(m: Int): AppBuilder[F, H, P, Some[Int], CT, U, B, HC] = builder.withMaxConnections(m)
}
implicit final class AppBuilderConnTimeoutOps[
F[_],
H <: Option[String],
P <: Option[Int],
MC <: Option[Int],
U <: Option[Users[F]],
B <: Option[Books[F]],
HC <: Option[NonEmptyChain[HealthCheck[F]]]
](private val builder: AppBuilder[F, H, P, MC, None.type, U, B, HC]) extends AnyVal {
def connectionTimeoutSeconds(ct: Int): AppBuilder[F, H, P, MC, Some[Int], U, B, HC] = builder.withConnectionTimeout(ct)
}
implicit final class AppBuilderUsersOps[
F[_],
H <: Option[String],
P <: Option[Int],
MC <: Option[Int],
CT <: Option[Int],
B <: Option[Books[F]],
HC <: Option[NonEmptyChain[HealthCheck[F]]]
](private val builder: AppBuilder[F, H, P, MC, CT, None.type, B, HC]) extends AnyVal {
def users(u: Users[F]): AppBuilder[F, H, P, MC, CT, Some[Users[F]], B, HC] = builder.withUsers(u)
}
implicit final class AppBuilderBooksOps[
F[_],
H <: Option[String],
P <: Option[Int],
MC <: Option[Int],
CT <: Option[Int],
U <: Option[Users[F]],
HC <: Option[NonEmptyChain[HealthCheck[F]]]
](private val builder: AppBuilder[F, H, P, MC, CT, U, None.type, HC]) extends AnyVal {
def books(b: Books[F]): AppBuilder[F, H, P, MC, CT, U, Some[Books[F]], HC] = builder.withBooks(b)
}
implicit final class AppBuilderHealthChecksOps[
F[_],
H <: Option[String],
P <: Option[Int],
MC <: Option[Int],
CT <: Option[Int],
U <: Option[Users[F]],
B <: Option[Books[F]],
](private val builder: AppBuilder[F, H, P, MC, CT, U, B, None.type]) extends AnyVal {
def healthChecks(hc: NonEmptyList[HealthCheck[F]]): AppBuilder[F, H, P, MC, CT, U, B, Some[NonEmptyChain[HealthCheck[F]]]] =
builder.withHealthChecks(NonEmptyChain.fromNonEmptyList(hc))
}
implicit final class AppBuilderBuildOps[F[_]](private val builder: AppBuilder[
F,
Some[String],
Some[Int],
Some[Int],
Some[Int],
Some[Users[F]],
Some[Books[F]],
Some[NonEmptyChain[HealthCheck[F]]]
]) extends AnyVal {
def build: AppConfig[F] = AppConfig[F](
host = builder.hostOpt.value,
port = builder.portOpt.value,
maxConnections = builder.maxConnectionsOpt.value,
connectionTimeout = builder.connectionTimeoutOpt.value,
users = builder.usersOpt.value,
books = builder.booksOpt.value,
healthChecks = builder.healthChecksOpt.value.toNonEmptyList,
)
}
}
There are two points worth commenting on this implementation that weren’t covered in the previous post.
First, the overridden copy
method both hides it from editor auto-complete, and preserves the value of F
. This is quite handy when implementing the with*
methods, because it will help the editor fill in the correct type for each method, so you don’t have to go back and update AppBuilder[Nothing, ...]
to AppBuilder[F, ...]
every time.
The second is a tip that is mostly relevant to larger builders, which is to create a template for the AppBuilder*Ops
implicit classes which contains all the type parameters, so that you can duplicate it and delete the type parameter you don’t need:
implicit final class AppBuilder_Ops[
F[_],
H <: Option[String],
P <: Option[Int],
MC <: Option[Int],
CT <: Option[Int],
U <: Option[Users[F]],
B <: Option[Books[F]],
HC <: Option[NonEmptyChain[HealthCheck[F]]]
](private val builder: AppBuilder[F, H, P, MC, CT, U, B, HC]) extends AnyVal {
}
The Cracks Start To Show
Because our builder doesn’t understand the context of it’s dependencies, we end up needing to do prep work before we can use it:
import cats.data.NonEmptyList
import cats.effect.IO
import models._
import builder.AppBuilder
object Example {
private val auth = Auth.impl[IO]
private val users = Users.impl(auth)
private val books = Books.impl(auth, users)
AppBuilder
.init[IO]
.healthChecks(NonEmptyList.of(
HealthCheck.users(auth, users),
HealthCheck.books(auth, books),
HealthCheck.impl
))
.users(users)
.host("Test")
.books(books)
.maxConnections(5)
.build
}
Now that there are a bunch of fields with identical types, the error messages start becoming much less friendly. For example, in this error message, can you tell which field was omitted?
value build is not a member of builder.AppBuilder[[+A]cats.effect.IO[A],Some[String],None.type,Some[Int],None.type,Some[models.Users[[+A]cats.effect.IO[A]]],Some[models.Books[[+A]cats.effect.IO[A]]],Some[cats.data.NonEmptyChain[models.HealthCheck[[+A]cats.effect.IO[A]]]]]
possible cause: maybe a semicolon is missing before `value build`? [12:3]
Most editors filter out autocomplete options that won’t compile, so this can help guide us. Depending on your editor, if extension methods are prioritized over superclass methods, the missing fields should be easy to find, but there’s still room for improvement.
In our next post, we’ll start to explore what these improvements are, and how to make them.