Una de las pocas cosas buenas que han sucedido durante la pandemia del COVID-19 es que las comunidades están ofreciendo sus eventos online. Normalmente yo estoy atento a los eventos de algunas comunidades en Barcelona, como la Scala Developers Barcelona y la BarcelonaJUG (un saludo para Nacho :hand-wave:), pero esta vez me enteré de la charla Domain Modeling Made Functional de Scott Wlaschin para el meetup de EventDriven París.

También hay un libro relacionado con la charla -que todavía no me planteo leer porque está escrito con la terminología propia de F#, y como yo aún estoy aprendiendo programación funcional me apoyo mucho en cats y Scala para entender los conceptos que no domino.

Volviendo al tema de este post, la charla de Scott Wlaschin propone que el código utilice el lenguaje real del dominio. Esa idea conectó mucho conmigo porque en el pasado intenté usar un glosario para definir el lenguaje ubicuo de un proyecto. Lo que terminó sucediendo es que el código cambió y la documentación se quedó anticuada.

Según Scott Wlaschin, una de las ventajas de representar el dominio en el código es que el lenguaje ubicuo va a evolucionar junto con el diseño -en lugar de por separado, como en el ejemplo de antes.

El mayor descubrimiento para mí ha sido cómo usar un sistema de tipos componibles para representar el dominio, y sobre todo para cambiar el código con confianza.

Resumiéndolo mucho, un dominio representado con tipado estático se caracteriza por lo siguiente:

  • Sustituye la herencia por sum types (esto tiene que ver con usar algebraic data types, como he intentado explicar más adelante en este post).
  • Usa opciones en lugar de null.
  • Envuelve las restricciones en tipos.
  • Asegura que los estados ilegales no puedan ser representados.

Caso de Uso: Programa de Fidelización de Clientes

Aprender me resulta más fácil si practico con algún ejemplo, así que voy a empezar definiendo un caso de uso que creo me dará diferentes opciones para modelar un dominio que no es trivial.

En el blog de Voucherify encontré esta guía para desarrollar un programa de fidelización de clientes. Por lo que cuenta el blog, este tipo de programas puede incrementar los beneficios relacionados con las ventas al consumidor final. Debe de ser por eso que casi todas las empresas ofrecen ya algún tipo de recompensa para que los clientes volvamos a comprarles. Lo que me ofrece este caso de uso es un ejemplo que casi todos conocemos, así que creo que no será difícil de explicar.

Se trata de un programa complicado para abordarlo en su totalidad, así que yo he elegido una pequeña parte: hay unos incentivos (descuentos, cheques regalo… ya veremos más adelante) que funcionan mejor cuando se le ofrecen al cliente en el momento adecuado. Estos incentivos suelen estar ligados a campañas promocionales, como por ejemplo las rebajas estacionales (verano, navidad, etc.) o a eventos relevantes para el cliente (cumpleaños, aniversarios, etc.).

El caso de uso consiste en que como developer nos piden representar unos incentivos personalizados, que luego tendremos que añadir al carro de la compra durante el proceso de checkout (justo antes del pago).

Creo que se entiende muy bien leyendo la sección Personalized Incentives del blog de Voucherify -el blog no tiene enlaces a las secciones, pero se puede buscar este título en el texto.

Talk is Cheap. Show Me the Code

El código completo del proyecto está en este repositorio de GitHub. Es un proyecto de sbt, así que debería de ser fácil de compilar y de ejecutar los tests.

He organizado mi código con la siguiente estructura de paquetes:

Main Packages

Tal y como está ahora el código todavía es manejable y no me planteo modularizarlo, pero si decido seguir implementando otras partes del sistema llegará el día en que me será más cómodo dividirlo en módulos. Si tuviera que hacerlo ahora mismo, mi preferencia sería aislar cada dominio en su propio módulo.

En el primer nivel de la estructura de paquetes he representado los 3 dominios de mi aplicación: checkout, core e incentives. Para la siguiente capa he seguido una estructura semejante a la que plantea DDD (domain-driven design) en su forma canónica: application, domain e infrastructure.

Iré creando las álgebras y demás tipos que representan el dominio dentro del paquete domain de cada dominio, y en caso de crear algún adaptador para la infraestructura, estos irían dentro del paquete infrastructure.

Para empezar, he creado el álgebra AddCampaignsToShoppingCart dentro del paquete application. Esta álgebra representa el caso de uso descrito en la sección anterior: intentará recuperar el carro de la compra asociado al identificador de usuario que recibe como parámetro, y si existe un carro de la compra para el usuario, va a aplicarle las promociones que apliquen.

trait AddCampaignsToShoppingCart[F[_]] {
  def shoppingCartWithPromotionsFor(userId: UserId): 
    F[Option[ShoppingCartWithPromotions]]
}

(Un detalle que no es importante para el objetivo del post es que he hecho dos intérpretes utilizando algoritmos diferentes. Me centraré solamente en AddCampaignsToShoppingCartImplT porque es el más eficiente).

El tipo resultante Option[ShoppingCartWithPromotions] representa la opción de que en el momento del checkout no exista un carro de la compra asociado al identificador de usuario -el caso más común es que haya expirado tras un período de inactividad. En caso de existir, el carro de la compra deberá contener artículos que podrían o no tener promociones. Yo he representado esta regla del dominio checkout con el tipo:

type ShoppingCartItemsWithPromotions = 
  Map[ItemId, (SomeQuantity, List[PromotionalOffer])]

El tipo SomeQuantity envuelve la restricción de que la cantidad mínima de un artículo en el carro de la compra es uno. He utilizado la librería refined para restringir los valores de este tipo a enteros positivos:

@newtype case class SomeQuantity(toInt: Int Refined Positive)

De esta manera, el tipo SomeQuantity asegura que no se pueda representar un estado ilegal en el que el carro de la compra pueda llegar a tener un artículo con cantidad cero (o menor que cero).

Una lista List[PromotionalOffer] vacía significa que no hay promociones que se apliquen al artículo.

Por último, el tipo PromotionalOffer representa las diferentes promociones que se pueden aplicar a un artículo.

Incentivos Personalizados

La prioridad del Product Management es crear una nueva línea de incentivos personalizados. Para ello, se han definido 7 incentivos diferentes (FreeDelivery, FreeSamples, BuyOneGetOneFree, BuyOneGetOneHalfPrice, VolumeDiscount, GiftCard, y LoyaltyPoints) que yo he implementado en la aplicación. Siguiendo el razonamiento de Scott Wlaschin, cada incentivo es un tipo que extiende del tipo base: PromotionalOffer. Haciendo esto se consigue definir el ADT (Algebraic Data Type) de todos los incentivos:

sealed trait MoreOfTheSame {
  val discountOnAdditionalItems: Dimensionless = 0.percent
}

sealed trait PromotionalOffer extends MoreOfTheSame with Product with Serializable

object PromotionalOffer {
  case object FreeDelivery extends PromotionalOffer

  final case class FreeSamples(itemIds: ItemIds) extends PromotionalOffer

  case object BuyOneGetOneFree extends PromotionalOffer {
    override val discountOnAdditionalItems: Dimensionless = 100.percent
  }

  case object BuyOneGetOneHalfPrice extends PromotionalOffer {
    override val discountOnAdditionalItems: Dimensionless = 50.percent
  }

  final case class VolumeDiscount(minimumSpend: Money, totalDiscount: Dimensionless)
    extends PromotionalOffer

  final case class GiftCard(value: Money, maximumRedeems: SomeQuantity) 
    extends PromotionalOffer

  final case class LoyaltyPoints(amount: SomeQuantity) extends PromotionalOffer
}

Al definir PromotionalOffer como un sealed trait estoy apoyándome en el compilador de Scala para que compruebe que todos los incentivos (recordad que tienen que extender de este tipo) están definidos en el mismo archivo. Esto debería ayudarme a mantener el lenguaje ubicuo sincronizado con el diseño porque solamente tendría que mirar en un único lugar del código. Además, definir el tipo de esta manera me permite hacer pattern matching exahustivo, lo cual me da la confianza de que el compilador va a comprobar todos los lugares del código donde necesito tener en cuenta los distintos incentivos.

Por ejemplo, se podría definir un método que describa cada uno de los incentivos haciendo un pattern matching exahustivo del tipo PromotionalOffer de esta manera:

def describe(promotionalOffer: PromotionalOffer): String = promotionalOffer match {
  case FreeDelivery => "You get your items delivered with no cost"
  case FreeSamples(itemIds) => s"You get free samples of ${itemIds.toString}"
  case BuyOneGetOneFree => "Buy one, get one free"
  case BuyOneGetOneHalfPrice => "Buy one, get one half-price"
  case VolumeDiscount(minimumSpend, totalDiscount) =>
    s"Save ${totalDiscount.toString} when you spend ${minimumSpend.toString}"
  case GiftCard(value, maximumRedeems) =>
    s"You get a gift card for the value of ${value.toString}, redeemable ${maximumRedeems.toString}"
  case LoyaltyPoints(amount) => s"You get ${amount.toString} loyalty points"
}

Si en algún momento tuviese que modificar los parámetros de un incentivo o añadir un incentivo nuevo, este código dejaría de compilar, avisándome de que tengo que actualizar este fragmento para que el código vuelva a representar al dominio.

He usado la librería squants para representar los tipos que tienen que ver con dinero, pero además he hecho otro uso de esta librería que me parece aún más interesante. Los incentivos BuyOneGetOneFree y BuyOneGetOneHalfPrice forman parte de la misma familia de promociones que consiste en que si compras algo, recibes más de lo mismo por un precio reducido (gratis en el primer caso, y a mited de precio en el segundo). El tipo MoreOfTheSame permite fijar el descuento que reciben los artículos adicionales expresado como un tanto por ciento.

Finalmente, el intérprete del álgebra AddCampaignsToShoppingCart que me interesa comentar es el siguiente:

trait AddCampaignsToShoppingCartImplT {
  def implT[F[_]: Monad](
    eligibilityEngine: EligibilityEngine[F],
    rewardCatalog: RewardCatalog[F],
    shoppingCartRepository: ShoppingCartRepository[F]
  ): AddCampaignsToShoppingCart[F] =
    (userId: UserId) =>
      (for {
        shoppingCart <- OptionT(shoppingCartRepository.shoppingCartFor(userId))
        rewardCampaigns <- OptionT.liftF(
          rewardCatalog.rewardCampaignsFor(shoppingCart.shoppingCartItems.keySet.toList)
        )
        userEligibleRewards <- OptionT.liftF(
          rewardCampaigns.values.flatten.toList.distinct
            .map(x => eligibilityEngine.isEligible(userId, x).map(Option.when(_)(x)))
            .sequence
            .map(_.flatten)
        )
        eligibleCampaigns = rewardCampaigns.map {
          case (item, rewards) => (item, rewards.intersect(userEligibleRewards))
        }
        shoppingCartWithPromotions = {
          val itemsWithPromotions = shoppingCart.shoppingCartItems.map {
            case (itemId, quantity) =>
              (
                itemId,
                (quantity, eligibleCampaigns.getOrElse(itemId, List.empty))
              )
          }
          ShoppingCartWithPromotions(
            shoppingCart.shoppingCartId,
            shoppingCart.userId,
            itemsWithPromotions
          )
        }
      } yield shoppingCartWithPromotions).value
}

El intérprete combina tres álgebras para añadir las promociones al carro de la compra. Le he dado a las álgebras los mismos nombres que tienen en el blog de Voucherify, siguiendo la idea de usar el lenguaje real en el código. Las de incentivos son: EligibilityEngine y RewardCatalog. El blog de Voucherify no describe el dominio checkout, así que yo he definido el álgebra: ShoppingCartRepository para obtener el carro de la compra del cliente.

Se pueden distinguir tres grandes bloques en el código del intérprete:

  • Obtener el carro de la compra del cliente, utilizando el álgebra ShoppingCartRepository.
  • Obtener las campañas activas para todos los artículos del carro, utilizando el álgebra RewardCatalog.
  • Filtrar las campañas, quedándose solamente con aquellas que son elegibles para el cliente, utilizando el álgebra: EligibilityEngine.
  • Finalmente, añadir las promociones al carro de la compra.

Una de las primeras cosas que pregunté cuando empecé a aprender programación funcional fue cómo se le añaden los colaboradores a un álgebra. En el mundo de la orientación a objetos tenemos el modelo Class Responsibility Collaborator (CRC) en el cual un colaborador es otra clase con la que una clase interactúa para cumplir con sus responsabilidades. El principio de inversión de la dependencia -parte de los principios SOLID- establece que la clase recibe sus colaboradores (y no viceversa), y una de las formas más comunes de hacerlo es inyectando las dependencias de una clase a través de sus constructores.

Algunas de estas ideas tienen aplicación dentro de la programación funcional. Por ejemplo, en Scala las álgebras se pueden componer de muchas maneras, y una de ellas es inyectando el intérprete de un álgebra mediante los constructores de la otra.

Conclusiones

Recuerdo una conversación con Victor en la que me contó su idea de que el domain-driven design (DDD) y la programación funcional se complementan alrededor de un objetivo común: razonar localmente acerca de un problema. La programación funcional proporciona abstracciones para componer código de manera segura, pero no tiene en cuenta los límites del sistema. Por su parte, DDD aporta una forma de establecer límites lógicos mediante los bounded context.

Se podría suponer entonces que la programación funcional permite razonar localmente dentro de un bounded context, mientras que DDD ayuda a definir las interfaces entre los diferentes bounded contexts.

Yo he intentado seguir esa idea en este post, combinándola con las recomendaciones de Scott Wlaschin para representar un dominio mediante un sistema de tipos componibles.

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.

Voucherify