Ir al contenido
  1. Posts/

criteria4s. Tu dominio no tiene base de datos

·6 mins
Rafael Fernandez
Autor
Rafael Fernandez
Matemáticas, programación y cosas de la vida
criteria4s - Este artículo es parte de una serie.
Parte 4: Este artículo
Parte 4: Este artículo

Hay una escena que se repite mucho en proyectos de backend. Todo parece limpio hasta que alguien hace una pregunta incómoda: “¿y si mañana esto deja de ir contra PostgreSQL?” En ese momento descubres que buena parte del dominio no estaba describiendo reglas de negocio. Estaba describiendo, en realidad, cómo hablar con una base de datos concreta.

Lo he visto muchas veces en código de repositorios. La lógica de filtros del dominio acaba escrita directamente en SQL. Vive dentro de la implementación del repositorio, mezclada con la gestión de conexiones y con el mapeo de resultados. Cuando alguien pregunta “¿podemos ejecutar esto contra MongoDB?”, la respuesta casi siempre es la misma: “habría que reescribir las queries”.

criteria4s sirve precisamente para separar esas dos cosas. El predicado por un lado. El dialecto por otro. Este post va de conectar esa idea con una arquitectura hexagonal sin que suene a diapositiva de conferencia.

criteria4s-tu-dominio-no-tiene-base-de-datos-img-3.svg

Dónde empieza el problema
#

Este repositorio parece limpio a primera vista:

trait UserRepository {
  def findBy(criteria: Criteria[SQL]): List[User]
}

No lo está. El port acepta Criteria[SQL]. Ese type parameter es un dialecto concreto colándose en el dominio por la puerta de atrás. Cualquier código que llame a findBy tiene que producir un Criteria[SQL]. A partir de ese momento, el dominio ya habla SQL, aunque nunca abra una conexión.

La lógica que fluye por ese port termina teniendo este aspecto:

// En un domain service
val filter =
  (F.col[SQL]("age") geq F.lit(18)) and
  (F.col[SQL]("active") === F.lit(true))

repo.findBy(filter)

El dominio ha importado com.eff3ct.criteria4s.dialect.sql. Sabe que está hablando con una base de datos SQL. Y ese es el problema. La frontera arquitectónica está mal dibujada.

El truco está en la T
#

La solución, en realidad, es pequeña. Quita el dialecto concreto del port:

trait UserRepository[T <: CriteriaTag] {
  def findBy(criteria: Criteria[T]): List[User]
}

UserRepository está ahora parametrizado por un dialecto T. No sabe si T es SQL, MongoDB o el dialecto S-expression del post anterior. Solo sabe que T es algún dialect tag y que puede aceptar un Criteria[T].

El service de dominio que usa este port se vuelve polimórfico en ese mismo T:

import com.eff3ct.criteria4s.core.*
import com.eff3ct.criteria4s.functions as F

class UserService[T <: CriteriaTag: GEQ: EQ: AND](
    repo: UserRepository[T]
)(using Show[Column, T]) {

  def findActiveAdults(): List[User] =
    repo.findBy(
      (F.col[T]("age") geq F.lit(18)) and
      (F.col[T]("active") === F.lit(true))
    )

  def findByAge(min: Int): List[User] =
    repo.findBy(F.col[T]("age") geq F.lit(min))
}

Fíjate en los imports de este archivo: core y functions. Sin dialecto. Sin sql, sin mongodb, sin postgresql. UserService no sabe ni le importa contra qué base de datos está hablando. Define el predicado y se lo pasa al repositorio. Lo que ocurra después ya es problema de infraestructura.

El dialecto vive fuera
#

La capa de infraestructura es el lugar donde sí tiene sentido hablar de dialectos concretos. Este es un adapter para PostgreSQL:

import com.eff3ct.criteria4s.dialect.postgresql.{given, *}

class PostgreSQLUserRepository(
    connection: java.sql.Connection
) extends UserRepository[PostgreSQL] {

  override def findBy(criteria: Criteria[PostgreSQL]): List[User] = {
    val sql = s"SELECT * FROM users WHERE ${criteria.value}"
    val rs  = connection.createStatement().executeQuery(sql)
    Iterator.continually(rs).takeWhile(_.next()).map(rowToUser).toList
  }

  private def rowToUser(rs: java.sql.ResultSet): User =
    User(id = rs.getInt("id"), age = rs.getInt("age"), active = rs.getBoolean("active"))
}

Este archivo importa com.eff3ct.criteria4s.dialect.postgresql. Sabe que está en PostgreSQL. Y está bien que lo sepa. Es infraestructura.

El adapter de MongoDB tiene casi la misma estructura:

import com.eff3ct.criteria4s.dialect.mongodb.{given, *}

class MongoDBUserRepository(
    collection: MongoCollection[Document]
) extends UserRepository[MongoDB] {

  override def findBy(criteria: Criteria[MongoDB]): List[User] = {
    val filter = BsonDocument.parse(criteria.value)
    collection.find(filter).map(docToUser).toList
  }

  private def docToUser(doc: Document): User =
    User(id = doc.getInteger("id"), age = doc.getInteger("age"), active = doc.getBoolean("active"))
}

Dos bases de datos distintas. Dos convenciones de renderizado distintas. El criteria.value en el adapter de PostgreSQL produce "age" >= 18 AND "active" = true. El mismo criteria.value en el adapter de MongoDB produce {$and: [{"age": {$gte: 18}}, {"active": {$eq: true}}]}. Y lo importante es esto: UserService, que generó ese Criteria[T], no vio nunca ninguno de esos strings.

Dónde se conecta todo
#

El único lugar de tu aplicación que debería tocar a la vez el dominio y un dialecto concreto es el composition root: tu main, tu configuración de DI, tu application factory.

// Con PostgreSQL
import com.eff3ct.criteria4s.dialect.postgresql.{given, *}

val db      = openConnection(config.jdbcUrl)
val repo    = PostgreSQLUserRepository(db)
val service = UserService[PostgreSQL](repo)
// Con MongoDB — solo cambia este bloque
import com.eff3ct.criteria4s.dialect.mongodb.{given, *}

val collection = mongoClient.getDatabase("app").getCollection("users")
val repo       = MongoDBUserRepository(collection)
val service    = UserService[MongoDB](repo)

La clase UserService no cambia entre las dos configuraciones. La lógica de filtros no cambia. El dominio no cambia. Lo único que cambia es la implementación del repositorio y el import al principio del archivo de configuración.

Esta es la dependency rule de la arquitectura hexagonal expresada en términos de tipos: el dominio depende del T abstracto, nunca de un dialecto concreto. Los dialectos concretos no dependen de nada en el dominio. La dirección de los imports fluye solo hacia dentro.

Lo que aquí sí obliga el compilador
#

En la arquitectura hexagonal clásica, la dependency rule suele imponerse por convención. Dibujas una frontera en una pizarra, escribes “dominio” a un lado e “infraestructura” al otro, y cruzas los dedos para que nadie meta un import donde no toca. Esa confianza, a veces, sale cara.

Con criteria4s, la frontera pasa a ser más estructural. Si alguien en UserService añade un import de com.eff3ct.criteria4s.dialect.sql, el código que antes era T <: CriteriaTag de repente necesita convertirse en SQL. Cambian las firmas de tipos. Cambia el port del repositorio. Cambia el caller. El compilador rastrea el dialect tag a través de cada tipo que lo toca.

No evita un import incorrecto con un error justo en la línea del import. Pero sí hace visible la consecuencia de ese import en todos los sitios importantes: en cada firma de función, en cada método que produce o consume un Criteria. La contaminación deja de ser silenciosa.

Es una garantía más suave que un module system con reglas de visibilidad estrictas. Pero sigue siendo bastante mejor que un comentario que diga // no importes SQL aquí.

Probarlo sin levantar nada
#

La misma parametrización que hace portable al dominio también lo hace mucho más fácil de testear. Puedes escribir un dialect de test que capture expresiones en vez de ejecutarlas:

trait TestDialect extends CriteriaTag

object TestDialect {
  given showColumn: Show[Column, TestDialect] = Show.create(_.colName)
  given eqPred: EQ[TestDialect]   = build[TestDialect, EQ]((l, r)  => s"$l == $r")
  given geqPred: GEQ[TestDialect] = build[TestDialect, GEQ]((l, r) => s"$l >= $r")
  given andConj: AND[TestDialect] = build[TestDialect, AND]((l, r) => s"($l AND $r)")
}

class InMemoryUserRepository extends UserRepository[TestDialect] {
  val capturedCriteria = mutable.ListBuffer.empty[String]

  override def findBy(criteria: Criteria[TestDialect]): List[User] = {
    capturedCriteria += criteria.value
    List.empty
  }
}

El test del service no toca ninguna conexión a base de datos:

import TestDialect.{given, *}

val repo    = InMemoryUserRepository()
val service = UserService[TestDialect](repo)

service.findActiveAdults()

assert(repo.capturedCriteria.head == "(age >= 18 AND active == true)")

El domain service se testa en aislamiento completo. La lógica del predicado se verifica sin ninguna base de datos en marcha. Y esto, para mí, es una de las mejores partes del enfoque. No estás usando un mock de repositorio en el sentido habitual. Estás usando un repositorio real respaldado por un dialecto real, escrito en unas pocas líneas para exponer los predicados de forma legible en los tests.

El siguiente paso
#

El último post de la serie es el más práctico: criteria4s 5. De SQL a MongoDB sin reescribir tus queries. Vamos a coger un codebase real, cambiar el dialecto y mirar con calma qué cambia de verdad y qué no.

El código fuente está en github.com/eff3ct0/criteria4s. Si quieres ver arquitectura hexagonal con criteria4s en un ejemplo más completo, el módulo examples/ tiene una aplicación más trabajada que sigue exactamente el patrón de este post.

criteria4s - Este artículo es parte de una serie.
Parte 4: Este artículo
Parte 4: Este artículo