VaaaS: Vickrey auctions in Scala using aecor, ZIO and cats

Vickrey-auction-as-a-Service or less (or more?) obnoxiously: VaaaS is a small side project intended as a showcase in:

  • Modern pure functional Scala using ZIO, cats
  • Event-sourced algebras using aecor, the pure functional event-sourcing runtime
  • Domain-driven design: bounded contexts and write/read model separation (not covered in this post)

The accompanying codebase can be found on github.

Background

This project was inspired by a post by Kevin Lynagh in which he advises his friend to trial out the Vickrey auction as a price discovery mechanism for his niche goods.

Very briefly:

A Vickrey auction is a type of sealed-bid auction. Bidders submit written bids without knowing the bid of the other people in the auction. The highest bidder wins but the price paid is the second-highest bid.

The above can be generalised to N items where top N bidders get the item but pay the N+1‘th bid.

Leveraging FP

ZIO

Having been exposed to the maintenance nightmare that is the abuse of untyped Akka actors there is little in life that calms my soul like ZIO[-R, +E, +A].

ZIO (at its core) is yet another effect type library (like the IO of cats-effect) but this one has an extra beefy type signature. So why the three type holes?

  • The +A is the happy-path promise-resolved-successfully return type, just like in IO[+A]

  • The +E is the exceptional result type and we can use it to encode failures. This could be a Throwable so we can return an exception without actually throwing a JVM exception (impure behaviour!) or some sort of domain/problem-specific type like a user-defined ADT hierarchy.

    What about just using IO[Either[E, A]]? They’re semantically equivalent (ignoring concurrency concerns) but practically different. The ZIO type is already well equipped for all your E and A massaging needs. On the other hand, you’ll have to wrap the IO flavour in an EitherT monad transformer to achieve similar behaviour and functionality.

  • The -R is the environment type which is used to specify a dependency. An effect typed ZIO[Clock, E, A] will need to be supplied an instance of Clock at some point (becoming ZIO[Any, E, A]) before being runnable.

“I probably won’t need all three of these at all times.”

No, you won’t. That’s why ZIO ships with these:

type IO[+E, +A]   = ZIO[Any, E, A]
type Task[+A]     = ZIO[Any, Throwable, A]
type RIO[-R, +A]  = ZIO[R, Throwable, A]
type UIO[+A]      = ZIO[Any, Nothing, A]
type URIO[-R, +A] = ZIO[R, Nothing, A]

I’ve conveniently not talked about the pluses and minuses on the type parameters. You can have some fun learning about variance here.

Example

First, let’s define Context, a case class to capture the UserId of the user invoking the task while Has[_] is some ZIO dependency-specification machinery that you can learn more about here.

object UserContext {
  type UserContext = Has[Context]
  case class Context(id: UserId)
}

Now we’re ready to define our own service-specific UserTask alias. Here’s the one out of vaaas:

package object context {
  type UserTask[A] = RIO[ZEnv with UserContext, A]

  def ctx: URIO[UserContext.UserContext, Context] =
    ZIO.service[Context]

  def userId: ZIO[UserContext.UserContext, Nothing, UserId] =
    ctx.map(_.id)
}

Where ZEnv = Clock with Console with System with Random with Blocking

The above UserTask alias specifies a Task that can fail with a Throwable and additionally requires all of ZEnv as well as some user context, very suitable for defining service calls scoped to an authenticated user. Here’s an example from the codebase:

class LiveUserService(repo: UserRepository) extends UserService {
  override def userProfile(): UserTask[User] = for {
    userId: UserId <- context.userId
    user: User <- repo.byId(userId)
  } yield user
}

As you can see we didn’t explicitly pass userId to the service method, but because it’s been supplied as part of the R cake we can fetch it using our previously defined userId accessor.

cats

One of the (many) places the affectionately named cats (in reference to category theory) can be found in this project is password strength validation.

First, a PasswordRule is defined like so:

case class PasswordRule(message: String, pattern: String)

And then loaded in from configuration that may look like so:

password-rules = [
  {
    message = "Password must be 8+ characters in length"
    pattern = "^.{8,}$"
  },
  {
    message = "Password must contain special character"
    pattern = ".*[!@#$%^&*].*"
  },
  {
    message = "Password must contain number"
    pattern = ".*[0-9].*"
  }
]

What we’ve done here is defined a set of regular expressions and a corresponding natural language explanation for why the pattern wasn’t satisfied.

Now when the user fails to supply a “strong” enough password we want to tell them all the different ways their password is not good enough, all in one go. Sounds like ValidatedNel[E, A] will be a fit.

ValidatedNel[E, A] takes on one of two values: Valid(a: A) or Invalid(e: NonEmptyList[E]) and is a useful type for chaining validations and collecting errors along the way (as opposed to Either which short-circuits/fails fast).

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
// A wrapper for a `Password` type, private constructor
// Can only be instantiated from this file!
case class Password private (hash: PasswordHash[SCrypt]) extends AnyVal

object Password {

  def apply(rawPassword: String)(rules: List[PasswordRule]): ValidatedNel[RequestError, Password] = {
    def applyRule(rule: PasswordRule): ValidatedNel[RequestError, Unit] =
      // The following evaluates the password against a particular rule
      // And returns a Valid(Unit) or an Invalid capturing the failure reason
      Validated.condNel(
        rule.pattern.r.findFirstIn(rawPassword).isDefined,
        (),
        RequestError("password", rule.message)
      )

    // This is the fun bit
    rules.map(applyRule)                // List[ValidatedNel[RequestError, Unit]]
      .sequence                         // ValidatedNel[RequestError, List[Unit]]
      .as(Password.unsafe(rawPassword)) // ValidatedNel[RequestError, Password]
  }

  def unsafe(rawPassword: String): Password = {
    new Password(User.hashPassword(rawPassword))
  }

}
  • Line 18: Apply each individual rule validation against raw input password
  • Line 19: Use cats’ .sequence so aggregate the nested validations into one. You’ll notice we now have a List[Unit] which would be more useful if we cared about the result of the individual validations but here we don’t.
  • Line 20: As mentioned, we don’t care about List[Unit] and we’re more interested in returning a validated password. We can override the final valid result with .as. Let’s call unsafe with the raw password which whill hash it and put it in our wrapper. This method is unsafe because the password passed in might not be strong enough! Luckily in this context we know that it is because we’ve just validated it.

Full code listing available here

Event-sourcing

The following section assumes knowledge of event-sourcing. If this is not the case you can find a cursory overview in my previous post. There’s also the following:

First, I’m going to present how you could define a basic event-sourced auction behaviour using simple Scala constructs without the use of aecor.

State, commands, events and rejections
case class AuctionState
(
  ownerId: UserId,
  endsAt: Instant,
  reserve: Money,
  status: AuctionStatus,
  bids: Map[UserId, Money]
)

type State = Option[AuctionState]

sealed trait Command
object Command {
    case class StartAuction(userId: UserId, reserve: Money, endsAt: Instant) extends Command
    case class PlaceBid(userId: UserId, bid: Money) extends Command
    case class EndAuction(now: Instant) extends Command
}

sealed trait Event
object Event {
    case class AuctionStarted(userId: UserId, reserve: Money, endsAt: Instant) extends Event
    case class BidPlaced(userId: UserId, bid: Money) extends Event
    case class AuctionEnded(now: Instant) extends Event
}

sealed trait Rejection
object Rejection {
    case object AuctionExists extends Rejection
    case object AuctionNotFound extends Rejection
    case object AuctionHasEnded extends Rejection
    case object TooEarlyToExpire extends Rejection
    
    case object BidBelowReserve extends Rejection
    
    case class Other(reason: String) extends Rejection
}
Behaviour
trait CommandHandler[C, S, E, R] {
    def handle(command: C, state: S): Either[R, List[E]]
}

Given some command C (captures intent to modify an entity) and the present state S and what is the sequence of events E that describes the changes the entity would go through? Alternatively, R encodes why the command rejected

We now have what we need to define the behaviour of our Vickrey auction:

  • Starting an auction
  • Placing bids
  • Expiring an auction so that no further bids are accepted.

Let’s implement our CommandHandler

private def append(e: Event, es: Event*): Either[Rejection, List[Event]] = {
    (e +: es).toList.asRight
}

private def reject(reason: String): Either[Rejection, List[Event]] = {
    Rejection(reason).asLeft
}

val commandHandler: CommandHandler[Command, State, Event, Rejection] = {
    case (StartAuction(userId, reserve, endsAt), None) =>
      append {
        AuctionStarted(userId, reserve, endsAt)
      }
    case (_: StartAuction, Some(_)) =>
      reject(AuctionExists)

    case (PlaceBid(userId, bid: Money), Some(auction)) =>
      if (auction.status == Live && bid > auction.reserve) {
        append {
          BidPlaced(userId, bid)
        }
      } else reject(BidBelowReserve)

    case (EndAuction(now), Some(auction)) if auction.isLive =>
      if (now.isAfter(auction.endsAt)) {
        append {
          AuctionEnded(now)
        }
      } else reject(TooEarlyToExpire)
    case (EndAuction(_), Some(_)) =>
      reject(AuctionHasEnded)

    case (command, _) =>
      reject(Other(s"Cannot apply $command against current state"))
  }

And that’s the bare minimum definition of an event-sourced behaviour with typed rejections!

Full code listing available here

Enter aecor

Aecor does something quite different.

trait Auction[F[_]] {
  def start(userId: UserId, reserve: Money, endsAt: Instant): F[Unit]

  def placeBid(userId: UserId, size: Money): F[Unit]

  def expire(now: Instant): F[Unit]
}
  1. Commands are defined as methods
  2. The State, Event and Rejection types are nowhere to be seen on the methods.

Enter MonadAction and MonadActionReject

trait MonadAction[F[_], S, E] extends Monad[F] {
  def read: F[S]
  def append(es: E, other: E*): F[Unit]
  def reset: F[Unit]
}

trait MonadActionReject[F[_], S, E, R] extends MonadAction[F, S, E] {
  def reject[A](r: R): F[A]
}

MonadActionReject[F[_], S, E, R] grants us the ability to:

  • read the current S
  • append(events) some sequence of events typed E
  • reject(reason) the command with some rejection typed R

You’ll also notice that MonadAction inherits from Monad[F] which itself inherits from Applicative[F]:

trait Applicative[F[_]] {
    def pure[A](x: A): F[A]
}

Applicative[F[_]] grants us the ability to:

  • call pure which takes an arbitrary A and wraps it with F. If we do pure(()) we’ll get F[Unit] which happens to be what our Auction methods are supposed to return!

EventsourcedAuction[F]

We now have what we need to now implement our previously defined Auction[F[_]] as EventsourcedAuction[F[_]]. It is here that we specify our requirement on an appropriately typed instance of MonadActionReject.

1
2
3
class EventsourcedAuction[F[_]](implicit
  F: MonadActionReject[F, Option[AuctionState], Event, Rejection]
) extends Auction[F] {

The below snippet features the following:

  • Line 1: import the members of the supplied MonadActionReject instance. We’ll be using pure and reject shortly.
  • Line 3: define a method for testing some condition and sending the appropriate rejection when it passes.
  • Line 6: define a convenience accessor auctionExists for the case where S == Some(_: AuctionState) and reject the command with AuctionNotFound otherwise
  • Line 11: define another convenience accessor auctionIsLive. This one uses the previous accessor and then ensures the auction is in its Live state otherwise rejects with AuctionHasEnded.
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
  import F._

  def rejectIf(condition: => Boolean, reason: Rejection): F[Unit] =
    if (condition) pure() else reject(reason)

  def auctionExists: F[AuctionState] = OptionT(read) // OptionT[F, AuctionState]
  .getOrElseF {
    reject(AuctionNotFound) // F[AuctionState]
  }

  def auctionIsLive: F[AuctionState] = for {
    auction <- auctionExists // F[AuctionState]
    _ <- rejectIf(auction.hasEnded, reason = AuctionHasEnded) 
  } yield auction

The following snippet features the behaviour that corresponds to what was in our previous non-aecor example done by extracting StartAuction, PlaceBid and Expire out of the Command parameter.

For each command you’ll see

  • some transformation of the state being accessed (either read or auctionIsLive)
  • verify the preconditions are met with rejectIf
  • capture the state change using appropriate event(s)
  • reject with some reason otherwise
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
  override def start(sellerId: UserId, reserve: Money, endsAt: Instant): F[Unit] = for {
    state <- read // F[Option[AuctionState]]
    _ <- rejectIf(state.isEmpty, reason = AuctionExists)
    _ <- append(AuctionStarted(sellerId, reserve, endsAt))
  } yield ()

  override def placeBid(userId: UserId, size: Money): F[Unit] = for {
    auction <- auctionIsLive // F[AuctionState]
    _ <- rejectIf(size < auction.reserve, reason = BidBelowReserve)
    _ <- append(BidPlaced(userId, size))
  } yield ()

  override def expire(now: Instant): F[Unit] = for {
    auction <- auctionIsLive // F[AuctionState]
    _ <- rejectIf(now.isBefore(auction.endsAt), reason = TooEarlyToExpire)
    _ <- append(AuctionEnded(now))
  } yield ()

And that about captures it.

Full code listing available here