Skip to main content
  1. Posts/

criteria4s. Your domain has no database

·6 mins
Rafael Fernandez
Author
Rafael Fernandez
Mathematics, programming, and life stuff
criteria4s - This article is part of a series.
Part 4: This Article
Part 4: This Article

There is a scene that repeats itself in backend projects all the time. Everything looks clean until someone asks an awkward question: “what if this stops running on PostgreSQL tomorrow?” That is usually the moment you discover that a big part of the domain was not really expressing business rules. It was expressing, in practice, how to talk to one specific database.

I have seen this happen with repository code many times. Domain filter logic ends up written directly in SQL. It lives inside the repository implementation, mixed together with connection handling and result mapping. When someone asks “can we run this against MongoDB?”, the answer is usually the same: “we would have to rewrite the queries.”

criteria4s is useful precisely because it separates those two things. The predicate on one side. The dialect on the other. This post is about wiring that idea into a hexagonal architecture without making it sound like a conference slide.

criteria4s-your-domain-has-no-database-img-3.svg

Where the problem starts
#

Here is a repository that looks clean at first glance:

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

It is not clean. The port accepts Criteria[SQL]. That type parameter is a concrete dialect sneaking into the domain through the back door. Any code that calls findBy now has to produce a Criteria[SQL]. From that moment on, the domain speaks SQL, even if it never opens a connection.

The logic flowing through that port ends up looking like this:

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

repo.findBy(filter)

The domain has imported com.eff3ct.criteria4s.dialect.sql. It knows it is talking to a SQL database. And that is the problem. The architectural boundary is in the wrong place.

The trick is the T
#

The fix is smaller than it sounds. Remove the concrete dialect from the port:

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

UserRepository is now parameterized by a dialect T. It does not know whether T is SQL, MongoDB, or the custom S-expression dialect from the previous post. It only knows that T is some dialect tag, and that it can accept a Criteria[T].

The domain service that uses this port becomes polymorphic in that same 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))
}

Look at the imports in this file: core and functions. No dialect. No sql, no mongodb, no postgresql. UserService does not know and does not care which database it is talking to. It defines the predicate and passes it to the repository. Whatever happens next is infrastructure’s problem.

Where the dialect belongs
#

The infrastructure layer is where concrete dialects actually belong. Here is a PostgreSQL adapter:

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"))
}

This file imports com.eff3ct.criteria4s.dialect.postgresql. It knows it is PostgreSQL. And that is fine. It is infrastructure.

The MongoDB adapter looks almost identical in structure:

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"))
}

Two different databases. Two different rendering conventions. The criteria.value in the PostgreSQL adapter produces "age" >= 18 AND "active" = true. The same criteria.value in the MongoDB adapter produces {$and: [{"age": {$gte: 18}}, {"active": {$eq: true}}]}. And that is the important bit: the UserService that generated that Criteria[T] never saw either of those strings.

Where it all gets wired together
#

The only place in your application that should touch both the domain and a specific dialect is the composition root: your main, your DI configuration, your application factory.

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

val db      = openConnection(config.jdbcUrl)
val repo    = PostgreSQLUserRepository(db)
val service = UserService[PostgreSQL](repo)
// With MongoDB — only this block changes
import com.eff3ct.criteria4s.dialect.mongodb.{given, *}

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

The UserService class does not change between these two configurations. The filter logic does not change. The domain does not change. The only things that change are the repository implementation and the import at the top of the configuration file.

This is the dependency rule of hexagonal architecture stated in terms of types: the domain depends on the abstract T, never on a concrete dialect. Concrete dialects depend on nothing in the domain. The import direction flows only inward.

What the compiler really enforces
#

In a typical hexagonal architecture, the dependency rule is enforced by convention. You draw a boundary on a whiteboard, write “domain” on one side and “infrastructure” on the other, and hope nobody adds the wrong import in the wrong file. That hope is often misplaced.

With criteria4s, the boundary becomes more structural. If someone in UserService adds an import for com.eff3ct.criteria4s.dialect.sql, the code that used to be T <: CriteriaTag will suddenly need to become SQL. The type signatures change. The repository port changes. The caller changes. The compiler tracks the dialect tag through every type it touches.

It does not prevent a bad import with a compile error right on the import line. But it does make the consequence of that import visible everywhere that matters: in every function signature, in every method that produces or consumes a Criteria. The contamination stops being silent.

This is a softer guarantee than a module system with strict visibility rules. But it is still much better than a comment that says “// do not import SQL here.”

Testing it without starting anything
#

The same parameterization that makes the domain portable also makes it much easier to test. You can write a test dialect that collects expressions instead of executing them:

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
  }
}

Your service test never touches a database connection:

import TestDialect.{given, *}

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

service.findActiveAdults()

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

The domain service is tested in complete isolation. The predicate logic is verified without a running database. And to me, this is one of the nicest parts of the approach. This is not a repository mock in the usual sense. It is a real repository backed by a real dialect, one you wrote in a few lines specifically to expose the predicates in tests.

The next step
#

The last post in the series is the most practical one: criteria4s 5. From SQL to MongoDB without rewriting your queries. We take a real codebase, swap the dialect, and look carefully at what actually changes and what does not.

The source is at github.com/eff3ct0/criteria4s. If you want to see hexagonal architecture with criteria4s in a more complete example, the examples/ module has a fuller application that follows exactly the pattern from this post.

criteria4s - This article is part of a series.
Part 4: This Article
Part 4: This Article