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 characters

  • port – number from 1024 to 65535

  • user – a non-empty string containing only letters and numbers

  • password – 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 defined

  • Method builder() creates a constructor from the default config

  • The companion object is private so that the default config is only accessed via builder()

  • In constructor ConnectionConfigBuilder methods are declared with... to set each parameter

  • Method build() gives the final config

  • At ConnectionConfigBuilder private constructor parameters in the first place so that the user “sees” only the methods of setting values with...and the final state of the config was received only through build()

  • Method copy not available outside case class ConnectionConfigBuilder because of the private constructor, which again allows you to set parameters only through with...

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

Similar Posts

Leave a Reply

Your email address will not be published. Required fields are marked *