In the previous post in our series on builders, we added some considerable flexibility to our builder’s API.
Guest post by Morgen Peschke
The basic builder pattern we’ve built on so for as been focused on keeping track of what needs to be done, but it doesn’t have any opinions on the order of when things happen, which is generally good, but occasionally we need to be a bit more opinionated.
We’re going to add some (admittedly arbitrary) constraints:
host
andport
must be configured first, though the order in which they are configured does not matter.maxConnections
andconnectionTimeoutSeconds
must be configured before the algebras, again the order in which they are configured does not matter.- HealthChecks must be configured last
Controlling Order With Parameters
Similar to how build
was restricted to only be accessible after all fields were present in the initial builder implementation, we can adjust the parameters of the other extension methods to add constraints.
For host
and port
, this is fairly straightforward - just set every other parameter to None.type
:
implicit final class AppBuilderHostOps[
F[_],
P <: Option[Int]
](private val builder: AppBuilder[F, None.type, P, None.type, None.type, None.type, None.type, None.type]) extends AnyVal {
def host(h: String): AppBuilder[F, Some[String], P, None.type, None.type, None.type, None.type, None.type] = builder.withHost(h)
}
implicit final class AppBuilderPortOps[
F[_],
H <: Option[String]
](private val builder: AppBuilder[F, H, None.type, None.type, None.type, None.type, None.type, None.type]) extends AnyVal {
def port(p: Int): AppBuilder[F, H, Some[Int], None.type, None.type, None.type, None.type, None.type] = builder.withPort(p)
}
Note that the P
and H
parameters allow host
and port
to be called in any order relative to each other.
maxConnections
and connectionTimeoutSeconds
are similar:
implicit final class AppBuilderMaxConnOps[
F[_],
H <: Option[String],
P <: Option[Int],
CT <: Option[Int],
HC <: Option[NonEmptyChain[HealthCheck[F]]]
](private val builder: AppBuilder[F, H, P, None.type, CT, None.type, None.type, HC]) extends AnyVal {
def maxConnections(m: Int): AppBuilder[F, H, P, Some[Int], CT, None.type, None.type, HC] = builder.withMaxConnections(m)
}
implicit final class AppBuilderConnTimeoutOps[
F[_],
H <: Option[String],
P <: Option[Int],
MC <: Option[Int],
HC <: Option[NonEmptyChain[HealthCheck[F]]]
](private val builder: AppBuilder[F, H, P, MC, None.type, None.type, None.type, HC]) extends AnyVal {
def connectionTimeoutSeconds(ct: Int): AppBuilder[F, H, P, MC, Some[Int], None.type, None.type, HC] = builder.withConnectionTimeout(ct)
}
We don’t constrain them to be called after host
and port
, since that’s already taken care of, and should help avoid duplicate errors if they get out of order.
The constraints for the extension methods for books
and users
are unchanged, for the same reason.
The constraints for healthChecks
are the inverse of host
and port
:
implicit final class AppBuilderHealthChecksOps[
F[_],
HC <: Option[NonEmptyChain[HealthCheck[F]]]
](private val builder: AppBuilder[F, Some[String], Some[Int], Some[Int], Some[Int], Some[Users[F]], Some[Books[F]], HC]) extends AnyVal {
def healthCheck(hc: HealthCheck[F]): AppBuilder[F, Some[String], Some[Int], Some[Int], Some[Int], Some[Users[F]], Some[Books[F]], Some[NonEmptyChain[HealthCheck[F]]]] =
builder.withHealthChecks(NonEmptyChain.one(hc))
def healthChecks(hc: NonEmptyList[HealthCheck[F]]): AppBuilder[F, Some[String], Some[Int], Some[Int], Some[Int], Some[Users[F]], Some[Books[F]], Some[NonEmptyChain[HealthCheck[F]]]] =
builder.withHealthChecks(NonEmptyChain.fromNonEmptyList(hc))
}
Controlling Order With Typeclasses
If the similarity with build
in the previous section made you think, “they’re going to talk about typeclasses again,” congratulations!
Your prize is: getting to hear more about typeclasses 🎉
The transformation is similar to what we did for build
, first introducing a typeclass to encode the constraint, then moving the implementation to a method on the class which delegates to the typeclass:
@scala.annotation.implicitNotFound(
"""AppConfig[+A].host can only be set if all of these fields have not been set:
host: ${H}
maxConnections: ${MC}
connectionTimeout: ${CT}
users: ${U}
books: ${B}
healthChecks: ${HC}
""")
sealed trait CanSetHost[
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]]]
] {
def host(h: String, builder: AppBuilder[F, H, P, MC, CT, U, B, HC])
: AppBuilder[F, Some[String], P, MC, CT, U, B, HC]
}
object CanSetHost {
implicit def instance[F[_], P <: Option[Int]]: CanSetHost[F, None.type, P, None.type, None.type, None.type, None.type, None.type] =
new CanSetHost[F, None.type, P, None.type, None.type, None.type, None.type, None.type] {
override def host(h: String, builder: AppBuilder[F, None.type, P, None.type, None.type, None.type, None.type, None.type]): AppBuilder[F, Some[String], P, None.type, None.type, None.type, None.type, None.type] =
builder.withHost(h)
}
}
def host(value: String)(implicit canSetHost: AppBuilder.CanSetHost[F, H, P, MC, CT, U, B, HC]): AppBuilder[F, Some[String], P, MC, CT, U, B, HC] =
canSetHost.host(value, this)
The only differences are the error message and the way that instance
is defined.
The compile errors this produces are quite nice:
AppConfig[+A].host can only be set if all of these fields have not been set:
host: None.type
maxConnections: Some[Int]
connectionTimeout: Some[Int]
users: Some[models.Users[[+A]cats.effect.IO[A]]]
books: None.type
healthChecks: None.type
[12:3]
The boilerplate this requires is substantial, so we’ll leave converting the rest of the fields as an exercise for the reader.
Next time, we’ll look to see if we can make our builder a bit smarter and offload handling some intermediate dependencies.