Scala 3 Builder Pattern
By definition template Builder separates the construction of a complex object from its representation, which is especially good when you need to validate parameters before getting the final instance. It is especially convenient to combine the Builder pattern with qualifying types.
Consider using the Builder on the Scala version 3.2.2
.
Let’s imagine that we have a config:
final case class ConnectionConfig (
host: String,
port: Int,
user: String,
password: String
)
And we want to give the user the ability to create a config in various ways, but at the same time validate the values before generating the final result. For example, according to the following rules:
host
– a string of 4 charactersport
– number from 1024 to 65535user
– a non-empty string containing only letters and numberspassword
– a string containing only letters and numbers, from 8 to 16 characters long
It is very convenient to use qualifying types for this:
final case class ConnectionConfig(
host: Host,
port: Port,
user: User,
password: Password
)
object ConnectionConfig:
opaque type Host = String :| MinLength[4]
opaque type Port = Int :| GreaterEqual[1024] & LessEqual[65535]
opaque type User = String :| Alphanumeric & MinLength[1]
opaque type Password = String :| Alphanumeric & MinLength[8] & MaxLength[16]
For case class ConnectionConfig
the constructor can be defined as private to limit the creation of the config only by a template.
Then the Builder template itself can be defined like this:
object ConnectionConfig:
...
def builder(): ConnectionConfigBuilder = ConnectionConfigBuilder()
final case class ConnectionConfigBuilder private (
private val host: String,
private val port: Int,
private val user: String,
private val password: String
):
def withHost(host: String): ConnectionConfigBuilder =
copy(host = host)
def withPort(port: Int): ConnectionConfigBuilder =
copy(port = port)
def withUser(user: String): ConnectionConfigBuilder =
copy(user = user)
def withPassword(password: String): ConnectionConfigBuilder =
copy(password = password)
def build(): ConnectionConfig =
new ConnectionConfig(
host = ???,
port = ???,
user = ???,
password = ???
)
end ConnectionConfigBuilder
private object ConnectionConfigBuilder:
def apply(): ConnectionConfigBuilder =
new ConnectionConfigBuilder(
host = "localhost",
port = 8080,
user = "root",
password = "root"
)
end ConnectionConfigBuilder
end ConnectionConfig
There are a few things to pay attention to here:
In companion object
ConnectionConfigBuilder
default config definedMethod
builder()
creates a constructor from the default configThe companion object is private so that the default config is only accessed via
builder()
In constructor
ConnectionConfigBuilder
methods are declaredwith...
to set each parameterMethod
build()
gives the final configAt
ConnectionConfigBuilder
private constructor parameters in the first place so that the user “sees” only the methods of setting valueswith...
and the final state of the config was received only throughbuild()
Method
copy
not available outsidecase class ConnectionConfigBuilder
because of the private constructor, which again allows you to set parameters only throughwith...
So build ConnectionConfig
the template can be like this:
ConnectionConfig
.builder()
.withHost("localhost")
.withPort(9090)
.withUser("user")
.withPassword("12345")
.build()
Other ways to create ConnectionConfig
are not available, as there are no other methods of working with ConnectionConfigBuilder
.
What about parameter validation?
As mentioned in the article on qualifying types, it is desirable to save all validation errors and then either return the correct result or a list of errors. Therefore, we will follow the same path as in this article.
Out type Host
select the type that describes the refinement rules and, if necessary, redefine the error message:
opaque type HostRule = MinLength[4] DescribedAs "Invalid host"
opaque type Host = String :| HostRule
In constructor ConnectionConfigBuilder
change the parameter type host
on ValidatedNel[String, Host]
and rename it to validatedHost
. Then the value setting method can be replaced with:
def withHost(host: String): ConnectionConfigBuilder =
copy(validatedHost = host.refineValidatedNel[HostRule])
Let’s make exactly the same changes for the rest of the parameters.
Builder will look like this:
final case class ConnectionConfigBuilder private (
private val validatedHost: ValidatedNel[String, Host],
private val validatedPort: ValidatedNel[String, Port],
private val validatedUser: ValidatedNel[String, User],
private val validatedPassword: ValidatedNel[String, Password]
)
The default config will be:
def apply(): ConnectionConfigBuilder =
new ConnectionConfigBuilder(
validatedHost = Validated.Valid("localhost"),
validatedPort = Validated.Valid(8080),
validatedUser = Validated.Valid("root"),
validatedPassword = Validated.Valid("password")
)
At the same time, invalid values can also be specified in the default config if there is no default value for the specified parameter and it needs to be set by the user.
For example:
validatedPassword = Validated.Invalid(NonEmptyList.one("Invalid password"))
Or:
validatedPassword = "".refineValidatedNel[PasswordRule]
It remains only to define the method build()
:
def build(): ValidatedNel[String, ConnectionConfig] =
(
validatedHost,
validatedPort,
validatedUser,
validatedPassword
).mapN(ConnectionConfig.apply)
As a result of using the Builder pattern, either a list of all errors will be displayed:
val invalidConfig = ConnectionConfig
.builder()
.withHost("")
.withPort(-1)
.withUser("")
.withPassword("")
.build()
// Invalid(NonEmptyList(Invalid host, Invalid port, Invalid user, Invalid password))
Or the correct config:
val validConfig = ConnectionConfig
.builder()
.withHost("127.0.0.1")
.withPort(8081)
.withUser("user")
.withPassword("password")
.build()
// Valid(ConnectionConfig(127.0.0.1,8081,user,password))
Full example available on Scastie