La Coffee Machine kata se hace en 5 iteraciones, la primera de ellas consiste en preparar las bebidas que nos piden. Las siguientes iteraciones van añadiendo funcionalidades nuevas que ponen a prueba las decisiones que hemos ido tomando.

El código completo de mi solución está en este repositorio de GitHub.

Primera iteración: Preparando las bebidas

Yo he empezado definiendo el álgebra DrinkMaker que permite convertir comandos en pedidos:

trait DrinkMaker[F[_]] {
  def make(command: Command): F[Order]
}

Un comando no es más que un texto codificado. Por ejemplo, T:1:0 le indica a la máquina de bebidas que prepare un té con azúcar y que además le ponga un removedor.

El tipo Command encapsula el texto del comando y permite tener una función fuertemente tipada:

@newtype case class Command(toText: String)

Para definir este tipo he usado la macro @newtype que viene de NewType, una librería que permite tener tipos con un único valor (el texto en este caso) sin sobrecostes en tiempo de ejecución.

También he definido el ADT (Algebraic Data Type) de todos los pedidos:

sealed trait Order extends Product with Serializable

object Order {
  final case class DrinkOrder(drink: Drink, sugar: Sugar, stick: Stick)
    extends Order
  final case class MessageOrder(content: String) extends Order
}

El tipo base Order extiende de Product y Serializable para ayudar al compilador con la inferencia de tipos (la explicación en este enlace).

De manera semejante, definí el ADT para todos los tipos de bebida. Además, el companion object de este ADT proporciona la función fromCode que puede decodificar el código de cada bebida y emitir el tipo asociado dentro de un Option:

sealed trait Drink extends Product with Serializable

object Drink {
  case object Chocolate extends Drink
  case object Coffee extends Drink
  case object Tea extends Drink

  def fromCode(code: Char): Option[Drink] = code match {
    case 'H' => Chocolate.some
    case 'C' => Coffee.some
    case 'T' => Tea.some
    case _ => none[Drink]
  }
}

Más adelante en este post, voy a aprovechar esta capacidad de decodificar un código de bebida a un Option[Drink] para componer funciones, y cortocircuitar el flujo de la aplicación cuando no se puede asociar un código con una bebida. La idea general de esta técnica está explicada en este enlace.

Finalmente, he definido el siguiente intérprete para el álgebra DrinkMaker:

object DrinkMaker {
  def impl[F[_]: MonadError[*[_], Throwable]]: DrinkMaker[F] =
    (command: Command) => {
      for {
        tokens <- maybeTokens[F](command)
        order <- tokens.fold(
          InvalidCommand(s"Invalid command received ${command.toText.value}")
            .raiseError[F, Order]
        )(orderFrom[F])
      } yield order
    }
}

Este intérprete limita el tipo F (que en el tagless algebra no tenía limitaciones) con el typeclass MonadError, que además de ser una Monad permite gestionar los errores.

La función maybeTokens es una función pura que divide el comando en tokens haciendo pattern matching de todos las posibles formas en que pueden ser dadas las instrucciones:

type Tokens = (String, Int, Boolean, Option[String])

private[this] def maybeTokens[F[_]: Monad](command: Command) = Monad[F].pure(
  command.toText.value.split(":").toList match {
    case x :: y :: _ :: Nil =>
      Try(y.toInt).fold(
        _ => none[Tokens],
        sugar => (x, sugar, if (sugar > 0) true else false, none[String]).some
      )
    case x :: Nil => (x, 0, false, none[String]).some
    case "M" :: y :: Nil => ("M", 0, false, y.some).some
    case _ => none[Tokens]
  }
)

El intérprete genera el pedido a partir de las instrucciones codificadas en los tokens, y falla cuando maybeTokens no puede generar tokens para el comando.

Yo he preferido fallar en el intérprete e incluir el comando en el mensaje de error, en lugar de fallar en cada uno de los posibles lugares donde pueden ocurrir errores en los tokens. Me parece que así queda un código más mantenible, y además prefiero documentar el formato de los comandos a detallar estos errores en el código.

Este es el resto del código que he necesitado para generar los pedidos:

private[this] def orderFrom[F[_]: MonadError[*[_], Throwable]](tokens: Tokens) = 
  tokens match {
    case ("M", _, _, messageContent) => messageOrder[F](messageContent)
    case (drink, sugar, stick, _) => drinkOrder[F](drink, sugar, stick)
  }

private[this] def messageOrder[F[_]: MonadError[*[_], Throwable]](
  content: Option[String]
) =
  content.fold(InvalidCommand("Content expected").raiseError[F, Order])(c =>
    Monad[F].pure(MessageOrder(c))
  )

private[this] def drinkOrder[F[_]: MonadError[*[_], Throwable]](
  drink: String,
  sugar: Int,
  stick: Boolean
) =
  Drink
    .fromCode(drink)
    .fold(InvalidCommand(s"Unknown drink ${drink.toString}").raiseError[F, Order])(
      d => Monad[F].pure(DrinkOrder(d, Sugar(unsafeApply(sugar)), Stick(stick)))
    )

Otra vez pattern matching y funciones puras, esta vez para crear el tipo de pedido a partir de las instrucciones dadas en los tokens. He controlado dos posibles fallos que son que el contenido del mensaje no sea vacío y que la bebida sea una de las que la máquina puede preparar.

Para probar que el funcionamiento de la aplicación coincide con el esperado, he creado esta suite de pruebas de aceptación para todos los casos de uso que implementé en esta primera iteración:

object DrinkMakerSuite extends IOSuite {
  override type Res = DrinkMaker[IO]

  override def sharedResource: Resource[IO, Res] =
    DrinkMaker.impl[IO].toResource

  test("Drink maker makes 1 tea with 1 sugar and a stick") {
    _.make(Command("T:1:0")).map { order =>
      expect(order == DrinkOrder(Tea, Sugar(1), Stick(true)))
    }
  }

  test("Drink maker makes 1 chocolate with no sugar and therefore no stick") {
    _.make(Command("H::")).map { order =>
      expect(order == DrinkOrder(Chocolate, Sugar(0), Stick(false)))
    }
  }

  test("Drink maker makes 1 coffee with 2 sugars and a stick") {
    _.make(Command("C:2:0")).map { order =>
      expect(order == DrinkOrder(Coffee, Sugar(2), Stick(true)))
    }
  }

  test(
    "Drink maker forwards any message received onto the coffee machine interface for the customer to see"
  ) {
    _.make(Command("M:message-content")).map { order =>
      expect(order == CoffeeMachineMessage("message-content"))
    }
  }
}

Me hubiera gustado decir que implementé las pruebas antes que el código de producción, pero no fue así. Fue mi primera vez trabajando en tagless-final y tampoco tengo mucha experiencia con cats-effect, así que preferí invertir en aprender y dejar los tests para el final.

Viendo que me ha quedado un post largo, he decidido que voy a contar el resto de las iteraciones en otro post.

Conclusiones de la primera parte

A modo de conclusiones de esta primera parte, me ha sorprendido lo cómodo que ha sido escribir la solución. Me he apoyado mucho en los tipos tanto para construir el flujo como para gestionar los errores.

Sin embargo, tener que trabajar con type parameters como este: MonadError[*[_], Throwable] me ha supuesto un reto, además de una distracción. Supongo que tiene valor para desarrollar librerías que no estén atadas a una implementación específica (me ha gustado mucho el razonamiento de este post), pero creo que para aplicaciones empresariales (lo que Scott Wlaschin llamaría BLOBAs o Boring Line of Business Applications) utilizaría un tipo concreto como cats.effect.IO.

Agradecimientos

A Dani y a Victor (ambos de Agilogy), por descubrirme el mundo de la programación funcional, por todas las charlas y por la motivación.

Scala