Basic approach
If all the methods in the interface of the builder (except maybe build
itself) just mutate the builder instance and return this
, then they can be abstracted as Builder => Unit
functions. This is true for NettyChannelBuilder
, if I'm not mistaken. What you want to do in this case is to combine a bunch of those Builder => Unit
into a single Builder => Unit
, which runs the original ones consecutively.
Here is a direct implementation of this idea for NettyChannelBuilder
:
object Builder {
type Input = NettyChannelBuilder
type Output = ManagedChannel
case class Op(run: Input => Unit) {
def and(next: Op): Op = Op { in =>
this.run(in)
next.run(in)
}
def runOn(in: Input): Output = {
run(in)
in.build()
}
}
// combine several ops into one
def combine(ops: Op*): Op = Op(in => ops.foreach(_.run(in)))
// wrap methods from the builder interface
val addTransportSecurity: Op = Op(_.useTransportSecurity())
def addSslContext(sslContext: SslContext): Op = Op(_.sslContext(sslContext))
}
And you can use it like this:
val builderPipeline: Builder.Op =
Builder.addTransportSecurity and
Builder.addSslContext(???)
builderPipeline runOn NettyChannelBuilder.forAddress("localhost", 80)
Reader Monad
It's also possible to use the Reader monad here. Reader monad allows combining two functions Context => A
and A => Context => B
into Context => B
. Of course every function you want to combine here is just Context => Unit
, where the Context
is NettyChannelBuilder
. But the build
method is NettyChannelBuilder => ManagedChannel
, and we can add it into the pipeline with this approach.
Here is an implementation without any third-party libraries:
object MonadicBuilder {
type Context = NettyChannelBuilder
case class Op[Result](run: Context => Result) {
def map[Final](f: Result => Final): Op[Final] =
Op { ctx =>
f(run(ctx))
}
def flatMap[Final](f: Result => Op[Final]): Op[Final] =
Op { ctx =>
f(run(ctx)).run(ctx)
}
}
val addTransportSecurity: Op[Unit] = Op(_.useTransportSecurity())
def addSslContext(sslContext: SslContext): Op[Unit] = Op(_.sslContext(sslContext))
val build: Op[ManagedChannel] = Op(_.build())
}
It's convenient to use it with the for-comprehension syntax:
val pipeline = for {
_ <- MonadicBuilder.addTransportSecurity
sslContext = ???
_ <- MonadicBuilder.addSslContext(sslContext)
result <- MonadicBuilder.build
} yield result
val channel = pipeline run NettyChannelBuilder.forAddress("localhost", 80)
This approach can be useful in more complex scenarios, when some of the methods return other variables, which should be used in later steps. But for NettyChannelBuilder
where most functions are just Context => Unit
, it only adds unnecessary boilerplate in my opinion.
As for other monads, the main purpose of State is to track changes to a reference to an object, and it's useful because that object is normally immutable. For a mutable object Reader works just fine.
Free monad is used in similar scenarios as well, but it adds much more boilerplate, and its usual usage scenario is when you want to build an abstract syntax tree object with some actions/commands and then execute it with different interpreters.
Generic builder
It's quite simple to adapt the previous two approaches to support any builder or mutable class in general. Though without creating separate wrappers for mutating methods, the boilerplate for using it grows quite a bit. For example, with the monadic builder approach:
class GenericBuilder[Context] {
case class Op[Result](run: Context => Result) {
def map[Final](f: Result => Final): Op[Final] =
Op { ctx =>
f(run(ctx))
}
def flatMap[Final](f: Result => Op[Final]): Op[Final] =
Op { ctx =>
f(run(ctx)).run(ctx)
}
}
def apply[Result](run: Context => Result) = Op(run)
def result: Op[Context] = Op(identity)
}
Using it:
class Person {
var name: String = _
var age: Int = _
var jobExperience: Int = _
def getYearsAsAnAdult: Int = (age - 18) max 0
override def toString = s"Person($name, $age, $jobExperience)"
}
val build = new GenericBuilder[Person]
val builder = for {
_ <- build(_.name = "John")
_ <- build(_.age = 36)
adultFor <- build(_.getYearsAsAnAdult)
_ <- build(_.jobExperience = adultFor)
result <- build.result
} yield result
// prints: Person(John, 36, 18)
println(builder.run(new Person))