PUBLIC OBJECT

Decisions, Deciders, and Executors

Most of the software I write exists to automate a process. It might automate a high-level workflow like peer-to-peer money transfers, or a low-level implementation detail like encoding an HTTP request as bytes.

I’ve learned that my code is healthier when it’s strictly divided into value objects, service objects, and glue. But even with this rule I struggle with service objects that are difficult to write & maintain.

I’m building a chess web service. I’ve decided to send each player a monthly email with their stats & stuff. Here’s the service object that implements that automation:

class MonthlySummaryEmailer(
  val db: ChessDatabase,
  val emailSender: EmailSender,
) {
  fun sendEmail(playerId: PlayerId, month: Int, year: Int) {
    val player = db.playerQueries.selectPlayer(playerId).executeAsOne()
    val emailAddress = player.email_address ?: return // No email address.

    val from = LocalDateTime.of(year, month, 1, 0, 0)
    val until = from.plusMonths(1)

    val (wins, losses) = db.gameQueries
      .selectWinsAndLosses(
        player_id = playerId,
        from = from.atZone(player.zone_id).toInstant(),
        until = until.atZone(player.zone_id).toInstant(),
      )
      .executeAsOne()

    if (wins == 0L && losses == 0L) return // No games in this month.

    val incompleteGames = db.gameQueries
      .selectIncompleteGames(playerId)
      .executeAsList()

    val message = createHTML().body {
      h1 { +"This month you won $wins games!" }
      p { +"Your win rate is ${100 * wins / (wins + losses)}%" }

      if (incompleteGames.isNotEmpty()) {
        p {
          +"You've got games to finish with "
          for (game in incompleteGames) {
            a(href = "https://publicobject.com/chess/game/${game.id}") {
              +game.opponent_username
            }
          }
        }
      }
    }

    emailSender.sendEmail(
      emailAddress = emailAddress,
      subject = "Your Chess summary for ${from.month}",
      body = message,
    )
  }
}

I’ve written tons of code like this. This is how the first draft comes out when I build software: gather inputs, make decisions, and perform actions.

But I don’t like this code! I don’t like testing it and I don’t like changing it.

I don’t like what happens in the unit test for customers that haven’t played any games. With this structure, that test needs to assert that EmailSender.sendEmail() is never called. That feels weird!

Let’s borrow some ideas from the functional programming nerds and refactor this thing. I like to separate the code that makes decisions from the code that effects those decisions.

Decisions

This is a value object – or a hierarchy of value objects – that describe the action to take.

I call this a Decision, Plan or Action and use that word as a suffix.

sealed interface MonthlyEmailDecision

object SkipReportNoEmailAddress : MonthlyEmailDecision

object SkipReportNoGamesPlayed : MonthlyEmailDecision

data class SendPlayReport(
  val emailAddress: String,
  val month: Month,
  val wins: Long,
  val losses: Long,
  val incompleteGames: List<IncompleteGame>,
) : MonthlyEmailDecision {
  data class IncompleteGame(
    val gameId: GameId,
    val opponentUsername: String,
  )
}

Kotlin’s rich type system is awesome for describing value objects like these. I enjoy it all: sealed types, data classes, objects, named & default parameters, and concise property declarations.

When I was writing Java full time, the amount of boilerplate required to model something like this was prohibitive! But in 2024 Java is better now too.

I’ll extract a Decision object after writing it the wrong way first.

Deciders

This is a service that makes a decision. It gathers input data that it needs to make a decision, but doesn’t act upon that decision. It’s the code base’s chief analyst.

I’ll use the suffix Decider or a Planner on these.

I don’t let any side-effects into these. I don’t even like logging here. That way I can use it to dry-run decisions: ‘how would our email volume change if we notified everyone who’s played in the last 90 days?’ From the perspective of the decider, its decision making could be hypothetical!

class MonthlySummaryDecider(
  val db: ChessDatabase,
) {
  fun decide(playerId: PlayerId, month: Int, year: Int): MonthlyEmailDecision {
    val player = db.playerQueries.selectPlayer(playerId).executeAsOne()
    val emailAddress = player.email_address ?: return SkipReportNoEmailAddress

    val from = LocalDateTime.of(year, month, 1, 0, 0)
    val until = from.plusMonths(1)

    val (wins, losses) = db.gameQueries
      .selectWinsAndLosses(
        player_id = playerId,
        from = from.atZone(player.zone_id).toInstant(),
        until = until.atZone(player.zone_id).toInstant(),
      )
      .executeAsOne()

    if (wins == 0L && losses == 0L) return SkipReportNoGamesPlayed

    val incompleteGames = db.gameQueries
      .selectIncompleteGames(playerId)
      .executeAsList()

    return SendPlayReport(
      emailAddress = emailAddress,
      month = from.month,
      wins = wins,
      losses = losses,
      incompleteGames = incompleteGames.map {
        SendPlayReport.IncompleteGame(it.id, it.opponent_username)
      },
    )
  }
}

This implementation isn’t strictly functional! It makes DB queries which means I’ll use a real database when I test it. I’m okay with that, especially since that gives me coverage for my fancy SQL.

Executors

Now it’s time to take action. This is a service that takes a decision and makes it real. It sends messages, calls APIs, or positions widgets on the screen. It’s the code base’s dumb muscle.

I don’t have a beloved naming convention for these. Usually it’s something task-specific.

class MonthlyEmailSender(
  val emailSender: EmailSender,
) {
  fun execute(decision: MonthlyEmailDecision) {
    when (decision) {
      SkipReportNoEmailAddress,
      SkipReportNoGamesPlayed -> return

      is SendPlayReport -> {
        val message = createHTML().body {
          val wins = decision.wins
          val losses = decision.losses
          h1 { +"This month you won $wins games!" }
          p { +"Your win rate is ${100 * wins / (wins + losses)}%" }

          if (decision.incompleteGames.isNotEmpty()) {
            p {
              +"You've got games to finish with "
              for (game in decision.incompleteGames) {
                a(href = "https://publicobject.com/chess/game/${game.gameId}") {
                  +game.opponentUsername
                }
              }
            }
          }
        }

        emailSender.sendEmail(
          emailAddress = decision.emailAddress,
          subject = "Your Chess summary for ${decision.month}",
          body = message,
        )
      }
    }
  }
}

This particular executor is still too smart! If you’re asked to code review the above code, tell the author to extract a MonthlyEmailMessageFormatter class too.

Optimistic Locking and Transactions

This approach breaks atomicity between making decisions and acting upon them. It’s clumsy and awkward to put a database transaction that spans the decide and execute parts.

I tend to give in and add optimistic locking metadata to my decision models. I’ll add an idempotence token or row version to the value object and let the execution step fail if these invariants have changed.

Decisions in Distributed Systems

On the services I’ve worked on, sometimes one microservice wants to collect & process data from another microservice. Decision objects are quite helpful here! Publish ’em on your event bus thing, possibly with extra context on what the inputs were that lead to each decision.

More Code to Write, Easier to Read

If I get another programming tattoo, it’ll be this gem from Wil Shipley:

Less code is better code
∴ No code is the best code

But by introducing a decision object, we’ve added more code. Fortunately, when you read the (small) decision model code, you learn how the larger decider and executor code must work.

I’m okay with this.