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
+Ais the happy-path promise-resolved-successfully return type, just like inIO[+A]The
+Eis the exceptional result type and we can use it to encode failures. This could be aThrowableso 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 yourEandAmassaging needs. On the other hand, you’ll have to wrap the IO flavour in anEitherTmonad transformer to achieve similar behaviour and functionality.The
-Ris theenvironmenttype which is used to specify a dependency. An effect typedZIO[Clock, E, A]will need to be supplied an instance ofClockat some point (becomingZIO[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).
| |
- Line 18: Apply each individual rule validation against raw input password
- Line 19: Use cats’
.sequenceso aggregate the nested validations into one. You’ll notice we now have aList[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 callunsafewith 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]
}
- Commands are defined as methods
- The
State,EventandRejectiontypes 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:
readthe currentSappend(events)some sequence of events typedEreject(reason)the command with some rejection typedR
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
purewhich takes an arbitraryAand wraps it withF. If we dopure(())we’ll getF[Unit]which happens to be what ourAuctionmethods 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.
| |
The below snippet features the following:
- Line 1: import the members of the supplied MonadActionReject instance. We’ll be using
pureandrejectshortly. - Line 3: define a method for testing some condition and sending the appropriate rejection when it passes.
- Line 6: define a convenience accessor
auctionExistsfor the case whereS == Some(_: AuctionState)and reject the command withAuctionNotFoundotherwise - 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 withAuctionHasEnded.
| |
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
readorauctionIsLive) - verify the preconditions are met with
rejectIf - capture the state change using appropriate event(s)
- reject with some reason otherwise
| |
And that about captures it.
Full code listing available here