├── project ├── build.properties ├── ResolverSettings.scala └── plugins.sbt ├── src ├── main │ ├── scala │ │ └── com │ │ │ └── boldradius │ │ │ ├── util │ │ │ ├── MarshallingSupport.scala │ │ │ └── Logging.scala │ │ │ └── cqrs │ │ │ ├── DomainModel.scala │ │ │ ├── Passivation.scala │ │ │ ├── ClusterBoot.scala │ │ │ ├── ClusterNodeApp.scala │ │ │ ├── HttpApp.scala │ │ │ ├── AuctionCommandQueryProtocol.scala │ │ │ ├── BidView.scala │ │ │ ├── HttpAuctionServiceRoute.scala │ │ │ └── BidProcessor.scala │ └── resources │ │ └── application.conf ├── multi-jvm │ └── scala │ │ └── com │ │ └── boldradius │ │ └── auction │ │ └── cqrs │ │ ├── StMultiNodeSpec.scala │ │ └── AuctionServiceSpec.scala └── test │ ├── resources │ └── testcreatetables.cql │ └── scala │ └── com │ └── boldradius │ └── dddd │ └── HttpAuctionServiceRouteSpec.scala ├── activator.properties ├── LICENSE ├── README.md └── tutorial └── index.html /project/build.properties: -------------------------------------------------------------------------------- 1 | sbt.version = 0.13.7 -------------------------------------------------------------------------------- /src/main/scala/com/boldradius/util/MarshallingSupport.scala: -------------------------------------------------------------------------------- 1 | package com.boldradius.util 2 | 3 | import org.json4s.{DefaultFormats, Formats} 4 | import spray.httpx.Json4sSupport 5 | 6 | /** 7 | * Json marshalling for spray. 8 | */ 9 | object MarshallingSupport extends Json4sSupport { 10 | implicit def json4sFormats: Formats = DefaultFormats 11 | } 12 | -------------------------------------------------------------------------------- /project/ResolverSettings.scala: -------------------------------------------------------------------------------- 1 | import sbt._ 2 | 3 | object ResolverSettings { 4 | 5 | lazy val resolvers = Seq( 6 | Resolver.mavenLocal, 7 | Resolver.sonatypeRepo("releases"), 8 | Resolver.typesafeRepo("releases"), 9 | Resolver.typesafeRepo("snapshots"), 10 | Resolver.sonatypeRepo("snapshots"), 11 | "Linter" at "http://hairyfotr.github.io/linteRepo/releases", 12 | "krasserm" at "http://dl.bintray.com/krasserm/maven" 13 | ) 14 | } -------------------------------------------------------------------------------- /src/main/scala/com/boldradius/cqrs/DomainModel.scala: -------------------------------------------------------------------------------- 1 | package com.boldradius.cqrs 2 | 3 | final case class Bid(price:Double, buyer:String, timeStamp:Long) 4 | 5 | final case class Auction(auctionId:String, 6 | startTime:Long, 7 | endTime:Long, 8 | initialPrice:Double, 9 | acceptedBids:List[Bid], 10 | refusedBids:List[Bid], 11 | ended:Boolean) 12 | 13 | 14 | 15 | 16 | -------------------------------------------------------------------------------- /src/multi-jvm/scala/com/boldradius/auction/cqrs/StMultiNodeSpec.scala: -------------------------------------------------------------------------------- 1 | package com.boldradius.cqrs 2 | 3 | import org.scalatest.{ BeforeAndAfterAll, WordSpecLike } 4 | import org.scalatest.Matchers 5 | import akka.remote.testkit.MultiNodeSpecCallbacks 6 | //#imports 7 | 8 | //#trait 9 | /** 10 | * Hooks up MultiNodeSpec with ScalaTest 11 | */ 12 | trait STMultiNodeSpec extends MultiNodeSpecCallbacks 13 | with WordSpecLike with Matchers with BeforeAndAfterAll { 14 | 15 | override def beforeAll() = multiNodeSpecBeforeAll() 16 | 17 | override def afterAll() = multiNodeSpecAfterAll() 18 | } -------------------------------------------------------------------------------- /src/main/scala/com/boldradius/cqrs/Passivation.scala: -------------------------------------------------------------------------------- 1 | package com.boldradius.cqrs 2 | 3 | import akka.actor.{PoisonPill, Actor, ReceiveTimeout} 4 | import com.boldradius.util.ALogging 5 | import akka.contrib.pattern.ShardRegion.Passivate 6 | 7 | trait Passivation extends ALogging { 8 | this: Actor => 9 | 10 | protected def passivate(receive: Receive): Receive = receive.orElse{ 11 | // tell parent actor to send us a poisinpill 12 | case ReceiveTimeout => 13 | self.logInfo( s => s" $s ReceiveTimeout: passivating. ") 14 | context.parent ! Passivate(stopMessage = PoisonPill) 15 | 16 | // stop 17 | case PoisonPill => context.stop(self.logInfo( s => s" $s PoisonPill")) 18 | } 19 | } -------------------------------------------------------------------------------- /src/test/resources/testcreatetables.cql: -------------------------------------------------------------------------------- 1 | CREATE TABLE IF NOT EXISTS products ( 2 | id bigint PRIMARY KEY, 3 | name text, 4 | description text 5 | ) WITH comment='auction products'; 6 | 7 | CREATE TABLE IF NOT EXISTS auctions ( 8 | id text, 9 | prodid text, 10 | start timestamp, 11 | end timestamp, 12 | initialprice double, 13 | PRIMARY KEY (id) 14 | ) WITH comment='auctions'; 15 | 16 | CREATE TABLE IF NOT EXISTS auctionbids ( 17 | aid text, 18 | bprice double, 19 | btime timestamp, 20 | bbuyer text, 21 | PRIMARY KEY ((aid), bprice, bbuyer, btime) 22 | ) WITH comment='auctions with bids' 23 | AND CLUSTERING ORDER BY (bprice DESC) 24 | AND caching = '{"keys":"ALL", "rows_per_partition":"1"}'; 25 | -------------------------------------------------------------------------------- /project/plugins.sbt: -------------------------------------------------------------------------------- 1 | 2 | // project/plugins.sbt 3 | dependencyOverrides += "org.scala-sbt" % "sbt" % "0.13.7" 4 | 5 | resolvers += "sonatype-releases" at "https://oss.sonatype.org/content/repositories/releases/" 6 | 7 | addSbtPlugin("org.scalastyle" %% "scalastyle-sbt-plugin" % "0.5.0") 8 | 9 | // Dependency graph plugin: https://github.com/jrudolph/sbt-dependency-graph 10 | addSbtPlugin("net.virtual-void" % "sbt-dependency-graph" % "0.7.4") 11 | 12 | //addSbtPlugin("org.brianmckenna" % "sbt-wartremover" % "0.11") 13 | 14 | addSbtPlugin("io.gatling" % "gatling-sbt" % "2.1.0") 15 | 16 | addSbtPlugin("com.typesafe.sbt" % "sbt-multi-jvm" % "0.3.8") 17 | 18 | addSbtPlugin("org.scoverage" % "sbt-scoverage" % "1.0.1") 19 | 20 | addSbtPlugin("org.scala-sbt.plugins" % "sbt-onejar" % "0.8") 21 | -------------------------------------------------------------------------------- /activator.properties: -------------------------------------------------------------------------------- 1 | name=akka-dddd-cqrs 2 | title=Akka Distributed Domain Driven Design with CQRS 3 | description=A starter distributed application with Akka and Spray that demonstrates Command Query Responsibility Segregation using Akka Persistence, Cluster Sharding and a distributed journal for Event Sourcing enabled with Cassandra. 4 | tags=akka,akka-persistence,cluster-sharding,cluster,cqrs,event-sourced 5 | authorName=BoldRadius Solutions 6 | authorLink=http://boldradius.com/ 7 | authorTwitter=@boldradius 8 | authorBio=We are a custom software development, training and consulting firm specializing in the Typesafe Reactive Platform of Scala, Akka and Play Framework. Our mission is to enable our clients to adopt new technologies. We are a committed group of software developers with a mandate of solving problems with innovative, rapid solutions. 9 | authorLogo=http://i59.tinypic.com/m9rc6p.png -------------------------------------------------------------------------------- /src/main/scala/com/boldradius/cqrs/ClusterBoot.scala: -------------------------------------------------------------------------------- 1 | package com.boldradius.cqrs 2 | 3 | import akka.actor.{Props, ActorRef, ActorSystem} 4 | import akka.contrib.pattern.ClusterSharding 5 | 6 | 7 | object ClusterBoot { 8 | 9 | def boot(proxyOnly:Boolean = false)(clusterSystem: ActorSystem):(ActorRef,ActorRef) = { 10 | val view = ClusterSharding(clusterSystem).start( 11 | typeName = BidView.shardName, 12 | entryProps = if(!proxyOnly) Some(BidView.props()) else None, 13 | idExtractor = BidView.idExtractor, 14 | shardResolver = BidView.shardResolver) 15 | val processor = ClusterSharding(clusterSystem).start( 16 | typeName = BidProcessor.shardName, 17 | entryProps = if(!proxyOnly) Some(BidProcessor.props(view)) else None, 18 | idExtractor = BidProcessor.idExtractor, 19 | shardResolver = BidProcessor.shardResolver) 20 | (processor,view) 21 | } 22 | 23 | } 24 | -------------------------------------------------------------------------------- /src/main/scala/com/boldradius/cqrs/ClusterNodeApp.scala: -------------------------------------------------------------------------------- 1 | package com.boldradius.cqrs 2 | 3 | import akka.actor.ActorSystem 4 | import com.typesafe.config._ 5 | 6 | /** 7 | * Start an akka cluster node 8 | * Usage: sbt 'runMain com.boldradius.cqrs.ClusterNodeApp 127.0.0.1 2551' 9 | */ 10 | object ClusterNodeApp extends App { 11 | 12 | 13 | val conf = 14 | """akka.remote.netty.tcp.hostname="%hostname%" 15 | |akka.remote.netty.tcp.port=%port% 16 | """.stripMargin 17 | 18 | val argumentsError = """ 19 | Please run the service with the required arguments: """ 20 | 21 | assert(args.length == 2, argumentsError) 22 | 23 | val hostname = args(0) 24 | val port = args(1).toInt 25 | val config = 26 | ConfigFactory.parseString( conf.replaceAll("%hostname%",hostname) 27 | .replaceAll("%port%",port.toString)).withFallback(ConfigFactory.load()) 28 | 29 | // Create an Akka system 30 | implicit val clusterSystem = ActorSystem("ClusterSystem", config) 31 | ClusterBoot.boot()(clusterSystem) 32 | } 33 | -------------------------------------------------------------------------------- /src/main/scala/com/boldradius/util/Logging.scala: -------------------------------------------------------------------------------- 1 | package com.boldradius.util 2 | 3 | import akka.actor.{Actor, ActorLogging} 4 | import com.typesafe.scalalogging.LazyLogging 5 | import scala.language.implicitConversions 6 | 7 | trait ALogging extends ActorLogging{ this: Actor => 8 | 9 | implicit def toLogging[V](v: V) : FLog[V] = FLog(v) 10 | 11 | case class FLog[V](v : V) { 12 | def logInfo(f: V => String): V = {log.info(f(v)); v} 13 | def logDebug(f: V => String): V = {log.debug(f(v)); v} 14 | def logError(f: V => String): V = {log.error(f(v)); v} 15 | def logWarn(f: V => String): V = {log.warning(f(v)); v} 16 | def logTest(f: V => String): V = {println(f(v)); v} 17 | } 18 | } 19 | trait LLogging extends LazyLogging{ 20 | 21 | implicit def toLogging[V](v: V) : FLog[V] = FLog(v) 22 | 23 | case class FLog[V](v : V) { 24 | def logInfo(f: V => String): V = {logger.info(f(v)); v} 25 | def logDebug(f: V => String): V = {logger.debug(f(v)); v} 26 | def logError(f: V => String): V = {logger.error(f(v)); v} 27 | def logWarn(f: V => String): V = {logger.warn(f(v)); v} 28 | def logTest(f: V => String): V = {println(f(v)); v} 29 | } 30 | } 31 | 32 | 33 | 34 | 35 | -------------------------------------------------------------------------------- /src/main/scala/com/boldradius/cqrs/HttpApp.scala: -------------------------------------------------------------------------------- 1 | package com.boldradius.cqrs 2 | 3 | import akka.actor._ 4 | import spray.routing._ 5 | 6 | import com.typesafe.config.ConfigFactory 7 | import spray.can.Http 8 | import akka.io.IO 9 | import akka.pattern.ask 10 | import akka.util.Timeout 11 | 12 | import scala.concurrent.duration._ 13 | 14 | 15 | class AuctionHttpActor( command:ActorRef, query:ActorRef ) 16 | extends HttpServiceActor 17 | with HttpAuctionServiceRoute { 18 | implicit val ec = context.dispatcher 19 | def receive = runRoute(route(command,query)) 20 | } 21 | 22 | /** 23 | * This spins up the Http server, after connecting to akka cluster. 24 | * Usage: sbt 'runMain com.boldradius.auction.cqrs.HttpApp " "" ' 25 | * 26 | */ 27 | object HttpApp extends App{ 28 | 29 | 30 | private val argumentsError = """ 31 | Please run the service with the required arguments: " " "" """ 32 | 33 | 34 | val conf = 35 | """akka.remote.netty.tcp.hostname="%hostname%" 36 | akka.remote.netty.tcp.port=%port% 37 | """.stripMargin 38 | 39 | 40 | assert(args.length == 4, argumentsError) 41 | 42 | val httpHost = args(0) 43 | val httpPort = args(1).toInt 44 | 45 | val akkaHostname = args(2) 46 | val akkaPort = args(3).toInt 47 | 48 | val config = 49 | ConfigFactory.parseString( conf.replaceAll("%hostname%",akkaHostname) 50 | .replaceAll("%port%",akkaPort.toString)).withFallback(ConfigFactory.load()) 51 | 52 | implicit val system = ActorSystem("ClusterSystem",config) 53 | 54 | val (processor,view) = ClusterBoot.boot(true)(system) 55 | 56 | val service = system.actorOf( Props( classOf[AuctionHttpActor],processor,view), "cqrs-http-actor") 57 | 58 | implicit val timeout = Timeout(5.seconds) 59 | 60 | IO(Http) ? Http.Bind(service, interface = httpHost, port = httpPort) 61 | } 62 | 63 | -------------------------------------------------------------------------------- /src/main/scala/com/boldradius/cqrs/AuctionCommandQueryProtocol.scala: -------------------------------------------------------------------------------- 1 | package com.boldradius.cqrs 2 | 3 | object AuctionCommandQueryProtocol { 4 | 5 | sealed trait AuctionMsg { 6 | val auctionId: String 7 | } 8 | 9 | sealed trait AuctionCmd extends AuctionMsg 10 | 11 | // case class BootInitCmd(auctionId: String) extends AuctionCmd 12 | case class StartAuctionCmd(auctionId: String, start: Long, end: Long, initialPrice: Double, prodId: String) extends AuctionCmd 13 | case class PlaceBidCmd(auctionId: String, buyer: String, bidPrice: Double) extends AuctionCmd 14 | 15 | sealed trait AuctionAck extends AuctionMsg 16 | 17 | case class StartedAuctionAck(auctionId: String) extends AuctionAck 18 | case class InvalidAuctionAck(auctionId: String, msg: String) extends AuctionAck 19 | case class PlacedBidAck(auctionId: String, buyer: String, bidPrice: Double, timeStamp: Long) extends AuctionAck 20 | case class RefusedBidAck(auctionId: String, buyer: String, bidPrice: Double, winningBid: Double) extends AuctionAck 21 | case class FailedBidAck(auctionId: String, buyer: String, bidPrice: Double, message: String) extends AuctionAck 22 | case class AuctionEndedAck(auctionId: String) extends AuctionAck 23 | case class AuctionNotYetStartedAck(auctionId: String) extends AuctionAck 24 | 25 | sealed trait BidQuery extends AuctionMsg 26 | 27 | case class WinningBidPriceQuery(auctionId: String) extends BidQuery 28 | case class GetBidHistoryQuery(auctionId: String) extends BidQuery 29 | case class GetAuctionStartEnd(auctionId: String) extends BidQuery 30 | case class GetProdIdQuery(auctionId: String) extends BidQuery 31 | 32 | sealed trait BidQueryResponse extends AuctionMsg 33 | 34 | case class InvalidBidQueryReponse(auctionId: String, message: String) extends BidQueryResponse 35 | case class AuctionNotStarted(auctionId: String) extends BidQueryResponse 36 | case class WinningBidPriceResponse(auctionId: String, price: Double) extends BidQueryResponse 37 | case class BidHistoryResponse(auctionId: String, bids: List[Bid]) extends BidQueryResponse 38 | case class AuctionStartEndResponse(auctionId: String, start: Long, end: Long) extends BidQueryResponse 39 | case class ProdIdResponse(auctionId: String, prodId: String) extends BidQueryResponse 40 | 41 | } 42 | 43 | -------------------------------------------------------------------------------- /src/test/scala/com/boldradius/dddd/HttpAuctionServiceRouteSpec.scala: -------------------------------------------------------------------------------- 1 | package com.boldradius.auction 2 | 3 | import akka.actor.{ActorSystem, Actor, ActorRef, Props} 4 | import com.boldradius.cqrs.AuctionCommandQueryProtocol._ 5 | import com.boldradius.cqrs._ 6 | import com.typesafe.config.ConfigFactory 7 | import org.scalatest._ 8 | import spray.http.Uri 9 | import spray.routing._ 10 | 11 | import scala.concurrent.duration._ 12 | import spray.json._ 13 | import spray.json.DefaultJsonProtocol 14 | import spray.testkit.ScalatestRouteTest 15 | import com.boldradius.util.MarshallingSupport._ 16 | 17 | object HttpAuctionServiceRouteSpec{ 18 | import spray.util.Utils 19 | 20 | val (_, akkaPort) = Utils temporaryServerHostnameAndPort() 21 | 22 | val config = ConfigFactory.parseString( s""" 23 | akka.remote.netty.tcp.port = $akkaPort 24 | akka.log-dead-letters = off 25 | akka.log-dead-letters-during-shutdown = off 26 | """) 27 | 28 | val testSystem = ActorSystem("offers-route-spec", config) 29 | } 30 | 31 | 32 | class HttpAuctionServiceRouteSpec extends FeatureSpecLike 33 | with GivenWhenThen 34 | with ScalatestRouteTest 35 | with MustMatchers 36 | with BeforeAndAfterAll 37 | with HttpAuctionServiceRoute { 38 | 39 | import HttpAuctionServiceRouteSpec._ 40 | 41 | implicit val ec = system.dispatcher 42 | 43 | override protected def createActorSystem(): ActorSystem = testSystem 44 | 45 | def actorRefFactory = testSystem 46 | 47 | 48 | 49 | val cmdActor:ActorRef = system.actorOf( Props( new Actor { 50 | def receive: Receive = { 51 | case StartAuctionCmd(id, start, end, initialPrice,prodId) => 52 | sender() ! StartedAuctionAck(id) 53 | 54 | case PlaceBidCmd(id,buyer,bidPrice)=> 55 | sender ! PlacedBidAck(id,buyer,bidPrice,1) 56 | } 57 | })) 58 | 59 | val queryActor:ActorRef = system.actorOf( Props( new Actor { 60 | def receive: Receive = { 61 | case WinningBidPriceQuery(id) => 62 | sender() ! WinningBidPriceResponse(id,1) 63 | case GetBidHistoryQuery(id) => 64 | sender() ! BidHistoryResponse(id,List(Bid(1,"buyer",1))) 65 | case GetProdIdQuery(id) => 66 | sender() ! ProdIdResponse(id,"1") 67 | } 68 | })) 69 | 70 | 71 | feature("Good Requests") { 72 | scenario("post is made to create auction") { 73 | Given("route is properly formed") 74 | When("/startAuction is called with POST") 75 | 76 | Post(Uri("/startAuction"),StartAuctionDto("123", "2015-01-20-15:53", "2015-01-20-15:53", 1, "1")) ~> route(cmdActor,queryActor) ~> check { 77 | //responseAs[Any] must be(Map("action" -> "AuctionStarted", "details" -> Map("auctionId" -> "123"))) 78 | responseAs[AuctionStartedDto] must be(AuctionStartedDto("123","AuctionStartedDto")) 79 | } 80 | Then(s"Received POST response: ${AuctionStartedDto("123","AuctionStartedDto")}") 81 | } 82 | 83 | scenario("post is made to bid") { 84 | Given("route is properly formed") 85 | When("/bid is called with POST") 86 | Post(Uri("/bid"),PlaceBidDto("123", "buyer", 1)) ~> route(cmdActor,queryActor) ~> check { 87 | responseAs[SuccessfulBidDto] must be(SuccessfulBidDto("123",1 , "1969-12-31-19:00","SuccessfulBidDto")) 88 | } 89 | Then(s"Received POST response: ${SuccessfulBidDto("123",1 , "1969-12-31-19:00","SuccessfulBidDto")}") 90 | } 91 | } 92 | } 93 | -------------------------------------------------------------------------------- /src/main/scala/com/boldradius/cqrs/BidView.scala: -------------------------------------------------------------------------------- 1 | package com.boldradius.cqrs 2 | 3 | import akka.actor._ 4 | import akka.contrib.pattern.ShardRegion 5 | import akka.persistence.PersistentView 6 | import AuctionCommandQueryProtocol._ 7 | import com.boldradius.cqrs.BidProcessor._ 8 | import com.boldradius.util.ALogging 9 | import scala.concurrent.duration._ 10 | 11 | 12 | /** 13 | * This actor is the Query side of CQRS. 14 | * 15 | * Each possible query result is represented as a case class (BidQueryResponse) 16 | * 17 | * This actor will initialize itself automatically upon startup from the event journal 18 | * stored by the corresponding PersistentActor (BidProcessor). 19 | * 20 | * There are many strategies for keeping this Actor consistent with the Write side, 21 | * this example uses the Update() method called from the Write side, which will cause 22 | * unread journal events to be sent to this actor, which, in turn, can update it's internal state. 23 | * 24 | */ 25 | 26 | /** state requred to satisfy queries */ 27 | final case class BidState(auctionId:String, 28 | start:Long, 29 | end:Long, 30 | product:Double, 31 | acceptedBids:List[Bid], 32 | rejectedBids:List[Bid], 33 | closed:Boolean) 34 | object BidState{ 35 | def apply(auctionId:String,start:Long,end:Long,price:Double):BidState = 36 | BidState(auctionId,start,end,price,Nil,Nil,false) 37 | } 38 | 39 | object BidView { 40 | 41 | def props():Props = Props(classOf[BidView]) 42 | 43 | val idExtractor: ShardRegion.IdExtractor = { 44 | case m : AuctionEvt => (m.auctionId,m) 45 | case m : BidQuery => (m.auctionId,m) 46 | } 47 | 48 | val shardResolver: ShardRegion.ShardResolver = { 49 | case m: AuctionEvt => (math.abs(m.auctionId.hashCode) % 100).toString 50 | case m: BidQuery => (math.abs(m.auctionId.hashCode) % 100).toString 51 | } 52 | 53 | val shardName: String = "BidView" 54 | 55 | } 56 | 57 | /** 58 | * The Query Actor 59 | */ 60 | class BidView extends PersistentView with ALogging with Passivation { 61 | 62 | override val viewId: String = self.path.parent.name + "-" + self.path.name 63 | 64 | /** It is thru this persistenceId that this actor is linked to the PersistentActor's event journal */ 65 | override val persistenceId: String = "BidProcessor" + "-" + self.path.name 66 | 67 | /** passivate the entity when no activity */ 68 | context.setReceiveTimeout(1 minute) 69 | 70 | /** 71 | * This is the initial receive method 72 | * 73 | * It will only process the AuctionStartedEvt or reply to the WinningBidPriceQuery 74 | * 75 | */ 76 | def receive: Receive = passivate(initial).orElse(unknownCommand) 77 | 78 | def initial: Receive = { 79 | 80 | case e @ AuctionStartedEvt(auctionId, started, end,intialPrice, prodId) if isPersistent => 81 | val newState = BidState(auctionId,started,end,intialPrice) 82 | context.become(passivate(auctionInProgress(newState,prodId)).orElse(unknownCommand)) 83 | 84 | case WinningBidPriceQuery(auctionId) => 85 | sender ! AuctionNotStarted(auctionId) 86 | } 87 | 88 | /** 89 | * Also responds to updates to the event journal (AuctionEndedEvt,BidPlacedEvt,BidRefusedEvt), and 90 | * updates internal state as well as responding to queries 91 | */ 92 | def auctionInProgress(currentState:BidState, prodId:String):Receive = { 93 | 94 | case GetProdIdQuery(auctionId) => 95 | sender ! ProdIdResponse(auctionId,prodId) 96 | 97 | 98 | case GetBidHistoryQuery(auctionId) => 99 | sender ! BidHistoryResponse(auctionId,currentState.acceptedBids) 100 | 101 | case WinningBidPriceQuery(auctionId) => 102 | currentState.acceptedBids.headOption.fold( 103 | sender ! WinningBidPriceResponse(auctionId,currentState.product))(b => 104 | sender ! WinningBidPriceResponse(auctionId,b.price)) 105 | 106 | case e: AuctionEndedEvt => 107 | val newState = currentState.copy(closed = true) 108 | context.become(passivate(auctionEnded(newState))) 109 | 110 | case BidPlacedEvt(auctionId,buyer,bidPrice,timeStamp) if isPersistent => 111 | val newState = currentState.copy(acceptedBids = Bid(bidPrice,buyer, timeStamp) :: currentState.acceptedBids) 112 | context.become(passivate(auctionInProgress(newState,prodId))) 113 | 114 | 115 | case BidRefusedEvt(auctionId,buyer,bidPrice,timeStamp) if isPersistent => 116 | val newState = currentState.copy(rejectedBids = Bid(bidPrice,buyer, timeStamp) :: currentState.rejectedBids) 117 | context.become(passivate(auctionInProgress(newState,prodId))) 118 | 119 | } 120 | 121 | def auctionEnded(currentState:BidState):Receive = { 122 | case _ => {} 123 | } 124 | 125 | def unknownCommand:Receive = { 126 | case other => { 127 | sender() ! InvalidAuctionAck("","InvalidAuctionAck") 128 | } 129 | } 130 | } 131 | -------------------------------------------------------------------------------- /src/multi-jvm/scala/com/boldradius/auction/cqrs/AuctionServiceSpec.scala: -------------------------------------------------------------------------------- 1 | package com.boldradius.cqrs 2 | 3 | import java.io.File 4 | import java.util.UUID 5 | import com.boldradius.cqrs.AuctionCommandQueryProtocol._ 6 | import scala.concurrent.duration._ 7 | import org.apache.commons.io.FileUtils 8 | import com.typesafe.config.ConfigFactory 9 | import akka.actor.ActorIdentity 10 | import akka.actor.Identify 11 | import akka.actor.Props 12 | import akka.cluster.Cluster 13 | import akka.contrib.pattern.ClusterSharding 14 | import akka.persistence.Persistence 15 | import akka.persistence.journal.leveldb.SharedLeveldbJournal 16 | import akka.persistence.journal.leveldb.SharedLeveldbStore 17 | import akka.remote.testconductor.RoleName 18 | import akka.remote.testkit.MultiNodeConfig 19 | import akka.remote.testkit.MultiNodeSpec 20 | import akka.testkit.ImplicitSender 21 | 22 | object AuctionServiceSpec extends MultiNodeConfig { 23 | val controller = role("controller") 24 | val node1 = role("node1") 25 | val node2 = role("node2") 26 | 27 | commonConfig(ConfigFactory.parseString(""" 28 | akka.actor.provider = "akka.cluster.ClusterActorRefProvider" 29 | akka.persistence.journal.plugin = "akka.persistence.journal.leveldb-shared" 30 | akka.persistence.journal.leveldb-shared.store { 31 | native = off 32 | dir = "target/test-shared-journal" 33 | } 34 | akka.persistence.snapshot-store.local.dir = "target/test-snapshots" 35 | """)) 36 | } 37 | 38 | class AuctionServiceSpecMultiJvmNode1 extends AuctionServiceSpec 39 | class AuctionServiceSpecMultiJvmNode2 extends AuctionServiceSpec 40 | class AuctionServiceSpecMultiJvmNode3 extends AuctionServiceSpec 41 | 42 | class AuctionServiceSpec extends MultiNodeSpec(AuctionServiceSpec) 43 | with STMultiNodeSpec with ImplicitSender { 44 | 45 | import AuctionServiceSpec._ 46 | 47 | def initialParticipants = roles.size 48 | 49 | val storageLocations = List( 50 | "akka.persistence.journal.leveldb.dir", 51 | "akka.persistence.journal.leveldb-shared.store.dir", 52 | "akka.persistence.snapshot-store.local.dir").map(s => new File(system.settings.config.getString(s))) 53 | 54 | override protected def atStartup() { 55 | runOn(controller) { 56 | storageLocations.foreach(dir => FileUtils.deleteDirectory(dir)) 57 | } 58 | } 59 | 60 | override protected def afterTermination() { 61 | runOn(controller) { 62 | storageLocations.foreach(dir => FileUtils.deleteDirectory(dir)) 63 | } 64 | } 65 | 66 | def join(from: RoleName, to: RoleName): Unit = { 67 | runOn(from) { 68 | Cluster(system) join node(to).address 69 | startSharding() 70 | } 71 | enterBarrier(from.name + "-joined") 72 | } 73 | 74 | def startSharding(): Unit = { 75 | 76 | val view = ClusterSharding(system).start( 77 | typeName = BidView.shardName, 78 | entryProps = Some(BidView.props), 79 | idExtractor = BidView.idExtractor, 80 | shardResolver = BidView.shardResolver) 81 | ClusterSharding(system).start( 82 | typeName = BidProcessor.shardName, 83 | entryProps = Some(BidProcessor.props(view)), 84 | idExtractor = BidProcessor.idExtractor, 85 | shardResolver = BidProcessor.shardResolver) 86 | } 87 | 88 | "Sharded auction service" must { 89 | 90 | "create Auction" in { 91 | // start the Persistence extension 92 | Persistence(system) 93 | runOn(controller) { 94 | system.actorOf(Props[SharedLeveldbStore], "store") 95 | } 96 | enterBarrier("peristence-started") 97 | 98 | runOn(node1, node2) { 99 | system.actorSelection(node(controller) / "user" / "store") ! Identify(None) 100 | val sharedStore = expectMsgType[ActorIdentity].ref.get 101 | SharedLeveldbJournal.setStore(sharedStore, system) 102 | } 103 | 104 | enterBarrier("after-1") 105 | } 106 | 107 | "join cluster" in within(15.seconds) { 108 | join(node1, node1) 109 | join(node2, node1) 110 | enterBarrier("after-2") 111 | } 112 | 113 | val auctionId = UUID.randomUUID().toString 114 | 115 | "start auction" in within(15.seconds) { 116 | 117 | val now = System.currentTimeMillis() 118 | 119 | runOn(node1,node2) { 120 | val auctionRegion = ClusterSharding(system).shardRegion(BidProcessor.shardName) 121 | awaitAssert { 122 | within(5.second) { 123 | auctionRegion ! StartAuctionCmd(auctionId,now + 1000,now + 1000000,1,"1") 124 | expectMsg( StartedAuctionAck(auctionId)) 125 | } 126 | } 127 | 128 | } 129 | 130 | runOn(node1,node2) { 131 | val auctionViewRegion = ClusterSharding(system).shardRegion(BidView.shardName) 132 | awaitAssert { 133 | within(5.second) { 134 | auctionViewRegion ! WinningBidPriceQuery(auctionId) 135 | expectMsg( WinningBidPriceResponse(auctionId,1)) 136 | } 137 | } 138 | } 139 | enterBarrier("after-2") 140 | } 141 | 142 | "bid on auction" in within(15.seconds) { 143 | 144 | runOn(node1,node2) { 145 | val auctionRegion = ClusterSharding(system).shardRegion(BidProcessor.shardName) 146 | auctionRegion ! PlaceBidCmd(auctionId,"dave",3) 147 | } 148 | 149 | runOn(node2,node2) { 150 | val auctionViewRegion = ClusterSharding(system).shardRegion(BidView.shardName) 151 | awaitAssert { 152 | within(5.second) { 153 | auctionViewRegion ! WinningBidPriceQuery(auctionId) 154 | expectMsg( WinningBidPriceResponse(auctionId,3)) 155 | } 156 | } 157 | } 158 | enterBarrier("after-3") 159 | } 160 | 161 | } 162 | } 163 | -------------------------------------------------------------------------------- /src/main/scala/com/boldradius/cqrs/HttpAuctionServiceRoute.scala: -------------------------------------------------------------------------------- 1 | package com.boldradius.cqrs 2 | 3 | import akka.actor.ActorRef 4 | import akka.pattern.ask 5 | import akka.util.Timeout 6 | import com.boldradius.cqrs.AuctionCommandQueryProtocol._ 7 | import com.boldradius.util.LLogging 8 | import org.joda.time.format.DateTimeFormat 9 | import spray.routing._ 10 | 11 | import scala.concurrent.ExecutionContext 12 | import scala.concurrent.duration._ 13 | import scala.util.{Failure, Success} 14 | 15 | 16 | final case class PlaceBidDto(auctionId:String, buyer:String, bidPrice:Double ) 17 | final case class StartAuctionDto(auctionId:String, start:String, end:String, initialPrice: Double, prodId: String) 18 | final case class BidDto(price:Double, buyer:String, timeStamp:String) 19 | 20 | final case class AuctionError(auctionId:String,msg:String,response:String = "AuctionError") 21 | final case class AuctionStartedDto(auctionId:String,response:String = "AuctionStartedDto") 22 | final case class AuctionNotStartedDto(auctionId:String,response:String = "AuctionNotStartedDto") 23 | final case class SuccessfulBidDto(auctionId:String, bidPrice: Double, timeStamp:String,response:String = "SuccessfulBidDto") 24 | final case class RejectedBidDto(auctionId:String, bidPrice: Double, currentBid:Double,response:String = "RejectedBidDto") 25 | final case class FailedBidDto(auctionId:String, bidPrice: Double, currentBid:Double,response:String = "FailedBidDto") 26 | final case class WinningBidDto(auctionId:String,bidPrice: Double,response:String = "WinningBidDto") 27 | final case class BidHistoryDto(auctionId:String,bids: List[BidDto],response:String = "BidHistoryDto") 28 | 29 | 30 | 31 | 32 | trait HttpAuctionServiceRoute extends HttpService with LLogging{ 33 | 34 | 35 | 36 | implicit val ec: ExecutionContext 37 | 38 | import com.boldradius.util.MarshallingSupport._ 39 | 40 | 41 | implicit val timeout = Timeout(30 seconds) 42 | lazy val fmt = DateTimeFormat.forPattern("yyyy-MM-dd-HH:mm") 43 | 44 | def route(command: ActorRef, query:ActorRef) = { 45 | post { 46 | path("startAuction") { 47 | extract(_.request) { e => 48 | entity(as[StartAuctionDto]) { 49 | auction => onComplete( 50 | (command ? StartAuctionCmd(auction.auctionId, 51 | fmt.parseDateTime(auction.start).getMillis, 52 | fmt.parseDateTime(auction.end).getMillis, 53 | auction.initialPrice, auction.prodId)).mapTo[AuctionAck]) { 54 | case Success(ack) => ack match { 55 | case StartedAuctionAck(id) => 56 | complete(AuctionStartedDto(id)) 57 | case InvalidAuctionAck(id, msg) => 58 | complete(AuctionError("ERROR",id, msg)) 59 | case other => 60 | complete(AuctionError("ERROR",ack.auctionId, ack.toString)) 61 | } 62 | case Failure(t) => 63 | t.printStackTrace() 64 | complete(AuctionError("ERROR",auction.auctionId, t.getMessage)) 65 | } 66 | } 67 | } 68 | } ~ 69 | path("bid") { 70 | detach(ec) { 71 | extract(_.request) { e => 72 | entity(as[PlaceBidDto]) { 73 | bid => onComplete( 74 | (command ? PlaceBidCmd(bid.auctionId, bid.buyer, bid.bidPrice)).mapTo[AuctionAck]) { 75 | case Success(ack) => ack.logInfo(s"PlaceBidCmd bid.bidPrice ${bid.bidPrice} id:" + _.auctionId.toString) match { 76 | case PlacedBidAck(id, buyer, bidPrice, timeStamp) => 77 | complete(SuccessfulBidDto(id, bidPrice, fmt.print(timeStamp))) 78 | case RefusedBidAck(id, buyer, bidPrice, winningBid) => 79 | complete(RejectedBidDto(id, bidPrice, winningBid)) 80 | case other => 81 | complete(AuctionError("ERROR",bid.auctionId, other.toString)) 82 | } 83 | case Failure(t) => 84 | complete(AuctionError("ERROR",bid.auctionId, t.getMessage)) 85 | } 86 | } 87 | } 88 | } 89 | } 90 | } ~ 91 | get { 92 | path("winningBid" / Rest) { auctionId => 93 | detach(ec) { 94 | onComplete((query ? WinningBidPriceQuery(auctionId)).mapTo[BidQueryResponse]) { 95 | case Success(s) => s match { 96 | case WinningBidPriceResponse(id, price) => 97 | complete(WinningBidDto(id, price)) 98 | case AuctionNotStarted(id) => 99 | complete(AuctionNotStartedDto(id)) 100 | case _ => 101 | complete(AuctionError("ERROR",auctionId, "")) 102 | } 103 | case Failure(t) => 104 | t.getMessage.logError("WinningBidPriceQuery error: " + _) 105 | complete(AuctionError("ERROR",auctionId, t.getMessage)) 106 | } 107 | } 108 | } ~ 109 | path("bidHistory" / Rest) { auctionId => 110 | onComplete((query ? GetBidHistoryQuery(auctionId)).mapTo[BidQueryResponse]) { 111 | case Success(s) => s match { 112 | case BidHistoryResponse(id, bids) => 113 | complete(BidHistoryDto(id, bids.map(b => 114 | BidDto(b.price, b.buyer, fmt.print(b.timeStamp))))) 115 | case AuctionNotStarted(id) => 116 | complete(AuctionNotStartedDto(id)) 117 | case _ => 118 | complete(AuctionError("ERROR",auctionId, "")) 119 | } 120 | case Failure(t) => 121 | complete(AuctionError("ERROR",auctionId, t.getMessage)) 122 | } 123 | } 124 | } 125 | } 126 | } 127 | 128 | -------------------------------------------------------------------------------- /src/main/resources/application.conf: -------------------------------------------------------------------------------- 1 | akka { 2 | loglevel = INFO 3 | 4 | actor { 5 | provider = "akka.cluster.ClusterActorRefProvider" 6 | } 7 | 8 | remote { 9 | log-remote-lifecycle-events = off 10 | netty.tcp { 11 | hostname = "127.0.0.1" 12 | port = 0 13 | } 14 | } 15 | 16 | remote.watch-failure-detector.threshold = 20 17 | 18 | cluster { 19 | seed-nodes = [ 20 | "akka.tcp://ClusterSystem@127.0.0.1:2551", 21 | "akka.tcp://ClusterSystem@127.0.0.1:2552"] 22 | 23 | auto-down-unreachable-after = 10s 24 | } 25 | 26 | persistence { 27 | 28 | 29 | journal { 30 | max-message-batch-size = 200 31 | max-confirmation-batch-size = 10000 32 | max-deletion-batch-size = 10000 33 | plugin = "cassandra-journal" 34 | } 35 | snapshot-store { 36 | plugin = "cassandra-snapshot-store" 37 | } 38 | 39 | 40 | #journal.plugin = "akka.persistence.journal.leveldb-shared" 41 | #journal.leveldb-shared.store { 42 | # DO NOT USE 'native = off' IN PRODUCTION !!! 43 | #native = off 44 | #dir = "target/shared-journal" 45 | #} 46 | #snapshot-store.local.dir = "target/snapshots" 47 | #view.auto-update-interval = 2s 48 | } 49 | 50 | contrib.cluster.sharding { 51 | # The extension creates a top level actor with this name in top level user scope, 52 | # e.g. '/user/sharding' 53 | guardian-name = sharding 54 | # If the coordinator can't store state changes it will be stopped 55 | # and started again after this duration. 56 | coordinator-failure-backoff = 1 s 57 | # Start the coordinator singleton manager on members tagged with this role. 58 | # All members are used if undefined or empty. 59 | # ShardRegion actor is started in proxy only mode on nodes that are not tagged 60 | # with this role. 61 | role = "" 62 | # The ShardRegion retries registration and shard location requests to the 63 | # ShardCoordinator with this interval if it does not reply. 64 | retry-interval = 1 s 65 | # Maximum number of messages that are buffered by a ShardRegion actor. 66 | buffer-size = 100000 67 | # Timeout of the shard rebalancing process. 68 | handoff-timeout = 60 s 69 | # Time given to a region to acknowdge it's hosting a shard. 70 | shard-start-timeout = 10 s 71 | # If the shard can't store state changes it will retry the action 72 | # again after this duration. Any messages sent to an affected entry 73 | # will be buffered until the state change is processed 74 | shard-failure-backoff = 10 s 75 | # If the shard is remembering entries and an entry stops itself without 76 | # using passivate. The entry will be restarted after this duration or when 77 | # the next message for it is received, which ever occurs first. 78 | entry-restart-backoff = 10 s 79 | # Rebalance check is performed periodically with this interval. 80 | rebalance-interval = 10 s 81 | # How often the coordinator saves persistent snapshots, which are 82 | # used to reduce recovery times 83 | snapshot-interval = 3600 s 84 | # Setting for the default shard allocation strategy 85 | least-shard-allocation-strategy { 86 | # Threshold of how large the difference between most and least number of 87 | # allocated shards must be to begin the rebalancing. 88 | rebalance-threshold = 10 89 | # The number of ongoing rebalancing processes is limited to this number. 90 | max-simultaneous-rebalance = 3 91 | } 92 | } 93 | 94 | 95 | } 96 | 97 | 98 | cassandra-journal { 99 | # FQCN of the cassandra journal plugin 100 | class = "akka.persistence.cassandra.journal.CassandraJournal" 101 | 102 | # Comma-separated list of contact points in the cluster 103 | contact-points = ["127.0.0.1"] 104 | 105 | # Port of contact points in the cluster 106 | port = 9042 107 | 108 | # Name of the keyspace to be created/used by the journal 109 | keyspace = "akka_dddd_template_journal" 110 | 111 | # Name of the table to be created/used by the journal 112 | table = "akka_dddd_template_journal" 113 | 114 | # Replication factor to use when creating a keyspace 115 | replication-factor = 1 116 | 117 | # Write consistency level 118 | write-consistency = "QUORUM" 119 | 120 | # Read consistency level 121 | read-consistency = "QUORUM" 122 | 123 | # Maximum number of entries per partition (= columns per row). 124 | # Must not be changed after table creation (currently not checked). 125 | max-partition-size = 5000000 126 | 127 | # Maximum size of result set 128 | max-result-size = 50001 129 | 130 | # Dispatcher for the plugin actor. 131 | plugin-dispatcher = "akka.actor.default-dispatcher" 132 | 133 | # Dispatcher for fetching and replaying messages 134 | replay-dispatcher = "akka.persistence.dispatchers.default-replay-dispatcher" 135 | } 136 | 137 | cassandra-snapshot-store { 138 | 139 | # FQCN of the cassandra snapshot store plugin 140 | class = "akka.persistence.cassandra.snapshot.CassandraSnapshotStore" 141 | 142 | # Comma-separated list of contact points in the cluster 143 | contact-points = ["127.0.0.1"] 144 | 145 | # Port of contact points in the cluster 146 | port = 9042 147 | 148 | # Name of the keyspace to be created/used by the snapshot store 149 | keyspace = "akka_dddd_template_snapshot" 150 | 151 | # Name of the table to be created/used by the snapshot store 152 | table = "akka_dddd_template_snapshot" 153 | 154 | # Replication factor to use when creating a keyspace 155 | replication-factor = 1 156 | 157 | # Write consistency level 158 | write-consistency = "ONE" 159 | 160 | # Read consistency level 161 | read-consistency = "ONE" 162 | 163 | # Maximum number of snapshot metadata to load per recursion (when trying to 164 | # find a snapshot that matches specified selection criteria). Only increase 165 | # this value when selection criteria frequently select snapshots that are 166 | # much older than the most recent snapshot i.e. if there are much more than 167 | # 10 snapshots between the most recent one and selected one. This setting is 168 | # only for increasing load efficiency of snapshots. 169 | max-metadata-result-size = 10 170 | 171 | # Dispatcher for the plugin actor. 172 | plugin-dispatcher = "cassandra-snapshot-store.default-dispatcher" 173 | 174 | # Default dispatcher for plugin actor. 175 | default-dispatcher { 176 | type = Dispatcher 177 | executor = "fork-join-executor" 178 | fork-join-executor { 179 | parallelism-min = 2 180 | parallelism-max = 8 181 | } 182 | } 183 | } 184 | -------------------------------------------------------------------------------- /src/main/scala/com/boldradius/cqrs/BidProcessor.scala: -------------------------------------------------------------------------------- 1 | package com.boldradius.cqrs 2 | 3 | import akka.actor._ 4 | import akka.contrib.pattern.ShardRegion 5 | 6 | import akka.persistence.{RecoveryCompleted, PersistentActor, SnapshotOffer, Update} 7 | import AuctionCommandQueryProtocol._ 8 | import com.boldradius.util.ALogging 9 | 10 | import scala.concurrent.ExecutionContext.Implicits.global 11 | import scala.concurrent.duration._ 12 | 13 | /** 14 | * 15 | * This is the Command side of CQRS. This actor receives commands only: AuctionStart and BidPlaced Cmds. 16 | * 17 | * These commands are transformed into events and persisted to a cassandra journal. 18 | * Once the events are persisted, the corresponding view is prompted to update itself from this journal 19 | * with Update() 20 | * 21 | * For recovery, the state of the auction is encoded in the var auctionStateMaybe: Option[AuctionBidState] 22 | * 23 | * A tick message is scheduled to signal the end of the auction 24 | * 25 | * This actor will passivate after 1 minute if no messages are received 26 | * 27 | */ 28 | object BidProcessor { 29 | 30 | case object Tick 31 | 32 | def props(readRegion: ActorRef): Props = Props(new BidProcessor(readRegion)) 33 | 34 | sealed trait AuctionEvt { 35 | val auctionId: String 36 | } 37 | 38 | case class AuctionStartedEvt(auctionId: String, started: Long, end: Long, initialPrice: Double, prodId: String) extends AuctionEvt 39 | 40 | case class AuctionEndedEvt(auctionId: String, timeStamp: Long) extends AuctionEvt 41 | 42 | case class BidPlacedEvt(auctionId: String, buyer: String, bidPrice: Double, timeStamp: Long) extends AuctionEvt 43 | 44 | case class BidRefusedEvt(auctionId: String, buyer: String, bidPrice: Double, timeStamp: Long) extends AuctionEvt 45 | 46 | case class BidFailedEvt(auctionId: String, buyer: String, bidPrice: Double, timeStamp: Long, error: String) extends AuctionEvt 47 | 48 | val idExtractor: ShardRegion.IdExtractor = { 49 | case m: AuctionCmd => (m.auctionId, m) 50 | } 51 | 52 | val shardResolver: ShardRegion.ShardResolver = { 53 | case m: AuctionCmd => (math.abs(m.auctionId.hashCode) % 100).toString 54 | } 55 | 56 | val shardName: String = "BidProcessor" 57 | } 58 | 59 | class BidProcessor(readRegion: ActorRef) extends PersistentActor with Passivation with ALogging { 60 | 61 | import BidProcessor._ 62 | 63 | override def persistenceId: String = self.path.parent.name + "-" + self.path.name 64 | 65 | /** passivate the entity when no activity for 1 minute */ 66 | context.setReceiveTimeout(1 minute) 67 | 68 | 69 | /** 70 | * This formalizes the effects of this processor 71 | * Each command results in: 72 | * maybe AuctionEvt, 73 | * an AuctionAck, 74 | * maybe newReceive 75 | */ 76 | private final case class ProcessedCommand(event: Option[AuctionEvt], ack: AuctionAck, newReceive: Option[Receive]) 77 | 78 | 79 | /** 80 | * Updates Auction state 81 | */ 82 | private def updateState(evt: AuctionEvt, state: Auction): Auction = { 83 | 84 | evt match { 85 | case AuctionEndedEvt(auctionId: String, timeStamp) => 86 | state.copy(ended = true) 87 | 88 | case BidPlacedEvt(auctionId: String, buyer: String, bidPrice: Double, timeStamp: Long) => 89 | state.copy(acceptedBids = Bid(bidPrice, buyer, timeStamp) :: state.acceptedBids) 90 | 91 | case BidRefusedEvt(auctionId: String, buyer: String, bidPrice: Double, timeStamp: Long) => 92 | state.copy(refusedBids = Bid(bidPrice, buyer, timeStamp) :: state.refusedBids) 93 | 94 | case BidFailedEvt(auctionId: String, buyer: String, bidPrice: Double, timeStamp: Long, error: String) => 95 | state.copy(refusedBids = Bid(bidPrice, buyer, timeStamp) :: state.refusedBids) 96 | 97 | case _ => state 98 | } 99 | } 100 | 101 | private def getCurrentBid(state: Auction): Double = 102 | state.acceptedBids match { 103 | case Bid(p, _, _) :: tail => p 104 | case _ => state.initialPrice 105 | } 106 | 107 | 108 | /** 109 | * In an attempt to isolate the effects (write to journal, update state, change receive behaviour), 110 | * each case of the PartialFunction[Any,Unit] Receive functions: initial, takingBids call 111 | * handleProcessedCommand ( sender, processedCommand) by convention 112 | * 113 | */ 114 | def handleProcessedCommand(sendr: ActorRef, processedCommand: ProcessedCommand): Unit = { 115 | 116 | // ack whether there is an event or not 117 | processedCommand.event.fold(sender() ! processedCommand.ack) { evt => 118 | persist(evt) { persistedEvt => 119 | readRegion ! Update(await = true) // update read path 120 | sendr ! processedCommand.ack 121 | processedCommand.newReceive.fold()(context.become) // maybe change state 122 | } 123 | } 124 | } 125 | 126 | override def receiveCommand: Receive = passivate(initial).orElse(unknownCommand) 127 | 128 | def initial: Receive = { 129 | 130 | case StartAuctionCmd(id, start, end, initialPrice, prodId) => 131 | val currentTime = System.currentTimeMillis() 132 | 133 | if (currentTime >= end) { 134 | handleProcessedCommand(sender(), 135 | ProcessedCommand(None, InvalidAuctionAck(id, "This auction is already over"), None) 136 | ) 137 | } else { 138 | // Starting the auction, schedule a message to signal auction end 139 | launchLifetime(end) 140 | 141 | handleProcessedCommand( 142 | sender(), 143 | ProcessedCommand( 144 | Some(AuctionStartedEvt(id, start, end, initialPrice, prodId)), 145 | StartedAuctionAck(id), 146 | Some(passivate(takingBids(Auction(id, start, end, initialPrice, Nil, Nil, false))).orElse(unknownCommand)) 147 | ) 148 | ) 149 | } 150 | } 151 | 152 | def takingBids(state: Auction): Receive = { 153 | 154 | case Tick => // end of auction 155 | val currentTime = System.currentTimeMillis() 156 | persist(AuctionEndedEvt(state.auctionId, currentTime)) { evt => 157 | readRegion ! Update(await = true) 158 | context.become(passivate(auctionClosed(updateState(evt, state))).orElse(unknownCommand)) 159 | } 160 | 161 | 162 | case PlaceBidCmd(id, buyer, bidPrice) => { 163 | val timestamp = System.currentTimeMillis() 164 | 165 | handleProcessedCommand(sender(), 166 | if (timestamp < state.endTime && timestamp >= state.startTime) { 167 | val currentPrice = getCurrentBid(state) 168 | if (bidPrice > currentPrice) { 169 | // Successful bid 170 | val evt = BidPlacedEvt(id, buyer, bidPrice, timestamp) 171 | ProcessedCommand( 172 | Some(evt), 173 | PlacedBidAck(id, buyer, bidPrice, timestamp), 174 | // update state 175 | Some(passivate(takingBids(updateState(evt, state))).orElse(unknownCommand)) 176 | ) 177 | } else { 178 | //Unsuccessful bid 179 | val evt = BidRefusedEvt(id, buyer, bidPrice, timestamp) 180 | ProcessedCommand( 181 | Some(evt), 182 | RefusedBidAck(id, buyer, bidPrice, currentPrice), 183 | Some(passivate(takingBids(updateState(evt, state))).orElse(unknownCommand)) 184 | ) 185 | } 186 | } else if (timestamp > state.endTime) { 187 | // auction expired 188 | ProcessedCommand(None, AuctionEndedAck(id), None) 189 | } else { 190 | ProcessedCommand(None, AuctionNotYetStartedAck(id), None) 191 | } 192 | ) 193 | } 194 | } 195 | 196 | def auctionClosed(state: Auction): Receive = { 197 | case a: PlaceBidCmd => sender() ! AuctionEndedAck(state.auctionId) 198 | case a: StartAuctionCmd => sender() ! AuctionEndedAck(state.auctionId) 199 | } 200 | 201 | /** Used only for recovery */ 202 | private var auctionRecoverStateMaybe: Option[Auction] = None 203 | 204 | def receiveRecover: Receive = { 205 | case evt: AuctionStartedEvt => 206 | auctionRecoverStateMaybe = 207 | Some(Auction(evt.logInfo("receiveRecover evt:" + _.toString).auctionId, evt.started, evt.end, evt.initialPrice, Nil, Nil, false)) 208 | 209 | case evt: AuctionEvt => { 210 | auctionRecoverStateMaybe = auctionRecoverStateMaybe.map(state => 211 | updateState(evt.logInfo("receiveRecover evt:" + _.toString), state)) 212 | } 213 | 214 | case RecoveryCompleted => postRecoveryBecome(auctionRecoverStateMaybe) 215 | 216 | // if snapshots are implemented, currently the aren't. 217 | case SnapshotOffer(_, snapshot) => 218 | postRecoveryBecome(snapshot.asInstanceOf[Option[Auction]].logInfo("recovery from snapshot state:" + _.toString)) 219 | } 220 | 221 | 222 | /** 223 | * Once recovery is complete, check the state to become the appropriate behaviour 224 | */ 225 | def postRecoveryBecome(auctionRecoverStateMaybe: Option[Auction]): Unit = 226 | auctionRecoverStateMaybe.fold[Unit]({}) { auctionState => 227 | log.info("postRecoveryBecome") 228 | if (auctionState.ended) 229 | context.become(passivate(auctionClosed(auctionState)).orElse(unknownCommand)) 230 | else { 231 | launchLifetime(auctionState.endTime) 232 | context.become(passivate(takingBids(auctionState)).orElse(unknownCommand)) 233 | } 234 | } 235 | 236 | 237 | def unknownCommand: Receive = { 238 | case other => { 239 | other.logInfo("unknownCommand: " + _.toString) 240 | sender() ! InvalidAuctionAck("", "InvalidAuctionAck") 241 | } 242 | } 243 | 244 | /** auction lifetime tick will send message when auction is over */ 245 | def launchLifetime(time: Long) = { 246 | val auctionEnd = (time - System.currentTimeMillis()).logInfo("launchLifetime over in:" + _.toString + "ms") 247 | if (auctionEnd > 0) { 248 | context.system.scheduler.scheduleOnce(auctionEnd.milliseconds, self, Tick) 249 | } 250 | } 251 | } 252 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "{}" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright {yyyy} {name of copyright owner} 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. 202 | 203 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # akka-dddd-template 2 | Akka DDDD template using CQRS/ES with a Distributed Domain 3 | 4 | Scala Version = 2.11.6 5 | 6 | Akka Version = 2.3.9 7 | 8 | Spray Version = 1.3.1 9 | 10 | 11 | ## Background 12 | 13 | ### Distributed Domain Driven Design 14 | 15 | Distributed Domain Driven Design takes the existing DDD concept and applies it ot an application intended to have each domain instance represented by an actor, as opposed to a class instance that must be synchronized via a backing persistent store. In this pattern, each domain instance is a cluster singleton, meaning updates can be made to it's state without fear of conflict. 16 | 17 | ### CQRS/ES Command Query Responsibility Segregation / Event Sourcing 18 | 19 | This is a pattern that uses Command and Query objects to apply the [CQS](http://en.wikipedia.org/wiki/Command%E2%80%93query_separation) principle 20 | for modifying and retrieving data. 21 | 22 | Event Sourcing is an architectural pattern in which state is tracked with an immutable event log instead of 23 | destructive updates (mutable). 24 | 25 | ## Getting Started 26 | 27 | To get this application going, you will need to: 28 | 29 | * Set up the datastore 30 | * Boot the cluster nodes 31 | * Boot the Http microservice node 32 | 33 | ### DataStore 34 | 35 | This application requires a distributed journal. Storage backends for journals and snapshot stores are pluggable in Akka persistence. In this case we are using [Cassandra](http://cassandra.apache.org/download/). 36 | You can find other journal plugins [here](http://akka.io/community/?_ga=1.264939791.1443869017.1408561680). 37 | 38 | The datastore is specified in **application.conf** 39 | 40 | cassandra-journal.contact-points = ["127.0.0.1"] 41 | 42 | cassandra-snapshot-store.contact-points = ["127.0.0.1"] 43 | 44 | As you can see, the default is localhost. In a cloud deployment, you could add several addresses to a cassandra cluster. 45 | 46 | This application uses a simple domain to demonstrate CQRS and event sourcing with Akka Persistence. This domain is an online auction: 47 | 48 | final case class Bid(price:Double, buyer:String, timeStamp:Long) 49 | 50 | final case class Auction(auctionId:String, 51 | startTime:Long, 52 | endTime:Long, 53 | initialPrice:Double, 54 | acceptedBids:List[Bid], 55 | refusedBids:List[Bid], 56 | ended:Boolean) 57 | 58 | This is a distributed application, leveraging **Akka Cluster**. 59 | 60 | The **Command** path of this application is illustrated by the creation of an auction, and placing bids. 61 | 62 | The **Query** path of this application is illustrated by the querying of winning bid and bid history. 63 | 64 | In order to distribute and segregate these paths, we leverage **Akka Cluster**, as well as **Cluster Sharding**. 65 | 66 | Cluster Sharding enables the distribution of the command and query actors across several nodes in the cluster, 67 | supporting interaction using their logical identifier, without having to care about their physical location in the cluster. 68 | 69 | ### Cluster Nodes 70 | 71 | You must first boot some cluster nodes (as many as you want). Running locally, these are distinguished by port eg:[2551,2552,...]. 72 | 73 | This cluster must specify one or more **seed nodes** in **application.conf** 74 | 75 | akka.cluster { 76 | seed-nodes = [ 77 | "akka.tcp://ClusterSystem@127.0.0.1:2551", 78 | "akka.tcp://ClusterSystem@127.0.0.1:2552"] 79 | } 80 | 81 | The Cluster Nodes are bootstrapped in **ClusterNode.scala**. 82 | 83 | To boot each cluster node locally: 84 | 85 | sbt 'runMain com.boldradius.cqrs.ClusterNodeApp nodeIpAddress port' 86 | 87 | for example: 88 | 89 | sbt 'runMain com.boldradius.cqrs.ClusterNodeApp 127.0.0.1 2551' 90 | 91 | ### Http Microservice Node 92 | 93 | The HTTP front end is implemented as a **Spray** microservice and is bootstrapped in **HttpApp.scala**.It participates in the Cluster, but as a proxy. 94 | 95 | To run the microservice locally: 96 | 97 | sbt 'runMain com.boldradius.cqrs.HttpApp httpIpAddress httpPort akkaIpAddres akkaPort' 98 | 99 | for example: 100 | 101 | sbt 'runMain com.boldradius.cqrs.HttpApp 127.0.0.1 9000 127.0.0.1 0' 102 | 103 | 104 | The HTTP API enables the user to: 105 | 106 | * Create an Auction 107 | * Place a bid 108 | * Query for the current winning bid 109 | * Query for the bid history 110 | 111 | #### Create Auction 112 | 113 | POST http://127.0.0.1:9000/startAuction 114 | 115 | {"auctionId":"123", 116 | "start":"2015-01-20-16:25", 117 | "end":"2015-07-20-16:35", 118 | "initialPrice" : 2, 119 | "prodId" : "3"} 120 | 121 | #### Place Bid 122 | 123 | POST http://127.0.0.1:9000/bid 124 | 125 | {"auctionId":"123", 126 | "buyer":"dave", 127 | "bidPrice":6} 128 | 129 | #### Query for the current winning bid 130 | 131 | GET http://127.0.0.1:9000/winningBid/123 132 | 133 | #### Query for the bid history 134 | 135 | http://127.0.0.1:9000/bidHistory/123 136 | 137 | ### Spray service fowards to the cluster 138 | 139 | The trait **HttpAuctionServiceRoute.scala** implements a route that takes ActorRefs (one for command and query) as input. 140 | Upon receiving an Http request, it either sends a command message to the **command** actor, or a query message to the **query** actor. 141 | 142 | def route(command: ActorRef, query:ActorRef) = { 143 | post { 144 | path("startAuction") { 145 | extract(_.request) { e => 146 | entity(as[StartAuctionDto]) { 147 | auction => onComplete( 148 | (command ? StartAuctionCmd(auction.auctionId,.... 149 | 150 | ## Exploring the Command path in the Cluster 151 | 152 | The command path is implemented in **BidProcessor.scala**. This is a **PersistentActor** that receives commands: 153 | 154 | def initial: Receive = { 155 | case a@StartAuctionCmd(id, start, end, initialPrice, prodId) => ... 156 | } 157 | 158 | def takingBids(state: Auction): Receive = { 159 | case a@PlaceBidCmd(id, buyer, bidPrice) => ... 160 | } 161 | 162 | and produces events, writing them to the event journal, and notifying the **Query** Path of the updated journal: 163 | 164 | def handleProcessedCommand(sendr: ActorRef, processedCommand: ProcessedCommand): Unit = { 165 | 166 | // ack whether there is an event or not 167 | processedCommand.event.fold(sender() ! processedCommand.ack) { evt => 168 | persist(evt) { persistedEvt => 169 | readRegion ! Update(await = true) 170 | sendr ! processedCommand.ack 171 | processedCommand.newReceive.fold({})(context.become) 172 | } 173 | } 174 | } 175 | 176 | This actor is cluster sharded on auctionId as follows: 177 | 178 | val idExtractor: ShardRegion.IdExtractor = { 179 | case m: AuctionCmd => (m.auctionId, m) 180 | } 181 | 182 | val shardResolver: ShardRegion.ShardResolver = msg => msg match { 183 | case m: AuctionCmd => (math.abs(m.auctionId.hashCode) % 100).toString 184 | } 185 | 186 | val shardName: String = "BidProcessor" 187 | 188 | This means, there is only one instance of this actor in the cluster, and all commands with the same **auctionId** will be routed to the same actor. 189 | 190 | If this actor receives no commands for 1 minute, it will **passivate** ( a pattern enabling the parent to stop the actor, in order to reduce memory consumption without losing any commands it is currently processing): 191 | 192 | /** passivate the entity when no activity */ 193 | context.setReceiveTimeout(1 minute) // this will send a ReceiveTimeout message after one minute, if no other messages come in 194 | 195 | The timeout is handled in the **Passivation.scala** trait: 196 | 197 | protected def withPassivation(receive: Receive): Receive = receive.orElse{ 198 | // tell parent actor to send us a poisinpill 199 | case ReceiveTimeout => context.parent ! Passivate(stopMessage = PoisonPill) 200 | 201 | // stop 202 | case PoisonPill => context.stop(self) 203 | } 204 | 205 | 206 | If this actor fails, or is passivated, and then is required again (to handle a command), the cluster will spin it up, and it will replay the event journal. In this case we make use a var: auctionRecoverStateMaybe to capture the state while we replay. When the replay is finished, the actor is notified with the RecoveryCompleted message and we can then "become" appropriately to reflect this state. 207 | 208 | def receiveRecover: Receive = { 209 | case evt:AuctionStartedEvt => 210 | auctionRecoverStateMaybe = Some(Auction(evt.auctionId,evt.started,evt.end,evt.initialPrice,Nil,Nil,false)) 211 | 212 | case evt: AuctionEvt => { 213 | auctionRecoverStateMaybe = auctionRecoverStateMaybe.map(state => 214 | updateState(evt.logInfo("receiveRecover" + _.toString),state)) 215 | } 216 | 217 | // Once recovery is complete, check the state to become the appropriate behaviour 218 | case RecoveryCompleted => { 219 | auctionRecoverStateMaybe.fold[Unit]({}) { auctionState => 220 | if (auctionState.logInfo("receiveRecover RecoveryCompleted state: " + _.toString).ended) 221 | context.become(passivate(auctionClosed(auctionState)).orElse(unknownCommand)) 222 | else { 223 | launchLifetime(auctionState.endTime) 224 | context.become(passivate(takingBids(auctionState)).orElse(unknownCommand)) 225 | } 226 | } 227 | } 228 | 229 | ## Exploring the Query path in the Cluster 230 | 231 | The Queries are handled in a different Actor: **BidView.scala**. This is a **PersistentView** that handles query messages, or prompts from it's companion **PersistentActor** to update itself. 232 | 233 | **BidView.scala** is linked to the **BidProcessor.scala** event journal via it's **persistenceId** 234 | 235 | override val persistenceId: String = "BidProcessor" + "-" + self.path.name 236 | 237 | This means it has access to this event journal, and can maintain, and recover state from this journal. 238 | 239 | It is possible for a PersistentView to save it's own snapshots, but, in our case, it isn't required. 240 | 241 | This PersistentView is sharded in the same way the PersistentActor is: 242 | 243 | val idExtractor: ShardRegion.IdExtractor = { 244 | case m : AuctionEvt => (m.auctionId,m) 245 | case m : BidQuery => (m.auctionId,m) 246 | } 247 | 248 | val shardResolver: ShardRegion.ShardResolver = { 249 | case m: AuctionEvt => (math.abs(m.auctionId.hashCode) % 100).toString 250 | case m: BidQuery => (math.abs(m.auctionId.hashCode) % 100).toString 251 | } 252 | 253 | One could have used a different shard strategy here, but a consequence of the above strategy is that the Query Path will 254 | reside in the same Shard Region as the command path, reducing latency of the Update() message from Command to Query. 255 | 256 | The PersistentView maintains the following model in memory: 257 | 258 | final case class BidState(auctionId:String, 259 | start:Long, 260 | end:Long, 261 | product:Double, 262 | acceptedBids:List[Bid], 263 | rejectedBids:List[Bid], 264 | closed:Boolean) 265 | 266 | This model is sufficient to satisfy both queries: Winning Bid, and Bid History: 267 | 268 | def auctionInProgress(currentState:BidState, prodId:String):Receive = { 269 | 270 | case GetBidHistoryQuery(auctionId) => sender ! BidHistoryResponse(auctionId,currentState.acceptedBids) 271 | 272 | case WinningBidPriceQuery(auctionId) => 273 | currentState.acceptedBids.headOption.fold( 274 | sender ! WinningBidPriceResponse(auctionId,currentState.product))(b => 275 | sender ! WinningBidPriceResponse(auctionId,b.price)) 276 | .... 277 | } 278 | -------------------------------------------------------------------------------- /tutorial/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 |
6 |

Background

7 |

Distributed Domain Driven Design

8 |

CQRS/ES Command Query Responsibility Segregation / Event Sourcing

9 |

This is a pattern that uses Command and Query objects to apply the CQS principle 10 | for modifying and retrieving data. 11 |

12 | 13 |

14 | Event Sourcing is an architectural pattern in which state is tracked with an immutable event log instead of 15 | destructive updates (mutable). 16 |

17 |
18 | 19 |
20 |

Getting Started

21 | 22 |

To get this application going, you will need to: 23 | 24 |

    25 |
  • Set up the datastore
  • 26 |
  • Boot the cluster nodes
  • 27 |
  • Boot the Http microservice node
  • 28 |
29 | 30 |

31 | 32 | 33 |

DataStore

34 |

This application requires a distributed journal. Storage backends for journals and snapshot stores are pluggable in Akka persistence. In this case we are using Cassandra. 35 | You can find other journal plugins here. 36 |

37 |

38 | The datastore is specified in application.conf 39 |

cassandra-journal.contact-points = ["127.0.0.1"]
40 |
cassandra-snapshot-store.contact-points = ["127.0.0.1"]
41 | As you can see, the default is localhost. In a cloud deployment, you could add several addresses to a cassandra cluster. 42 |

43 | 44 | 45 |

This application uses a simple domain to demonstrate CQRS and event sourcing with Akka Persistence. This domain is an online auction:

46 |

 47 | final case class Bid(price:Double, buyer:String, timeStamp:Long)
 48 | 
 49 | final case class Auction(auctionId:String,
 50 |                          startTime:Long,
 51 |                          endTime:Long,
 52 |                          initialPrice:Double,
 53 |                          acceptedBids:List[Bid],
 54 |                          refusedBids:List[Bid],
 55 |                          ended:Boolean)
 56 |         
57 |

This is a distributed application, leveraging Akka Cluster.

58 | 59 |

The Command path of this application is illustrated by the creation of an auction, and placing bids.

60 |

The Query path of this application is illustrated by the querying of winning bid and bid history.

61 |

In order to distribute and segregate these paths, we leverage Akka Cluster, as well as Cluster Sharding.

62 |

Cluster Sharding enables the distribution of the command and query actors across several nodes in the cluster, 63 | supporting interaction using their logical identifier, without having to care about their physical location in the cluster. 64 |

65 | 66 |

Cluster Nodes

67 |

68 |

You must first boot some cluster nodes (as many as you want). Running locally, these are distinguished by port eg:[2551,2552,...].
69 | This cluster must specify one or more seed nodes in 70 | application.conf 71 |


 72 | akka.cluster {
 73 | seed-nodes = [
 74 | "akka.tcp://ClusterSystem@127.0.0.1:2551",
 75 | "akka.tcp://ClusterSystem@127.0.0.1:2552"]
 76 | 
 77 | auto-down-unreachable-after = 10s
 78 | }
 79 | 
80 |

81 | 82 |

83 | The Cluster Nodes are bootstrapped in ClusterNode.scala. 84 |

85 | 86 |

87 | To boot each cluster node locally: 88 |


 89 | sbt 'runMain com.boldradius.cqrs.ClusterNodeApp nodeIpAddress port'
 90 | 
91 | for example: 92 |

 93 | sbt 'runMain com.boldradius.cqrs.ClusterNodeApp 127.0.0.1 2551'
 94 | 
95 |

96 | 97 | 98 |

Http Microservice Node

99 | 100 |

The HTTP front end is implemented as a Spray microservice and is bootstrapped in HttpApp.scala.It participates in the Cluster, but as a proxy.

101 |

To run the microservice locally: 102 |


103 | sbt 'runMain com.boldradius.cqrs.HttpApp httpIpAddress httpPort akkaIpAddres akkaPort'
104 | 
105 | for example: 106 |

107 | sbt 'runMain com.boldradius.cqrs.HttpApp 127.0.0.1 9000 127.0.0.1 0'
108 | 
109 | 110 | 111 | 112 |

113 |

The HTTP API enables the user to: 114 |

    115 |
  • Create an Auction
  • 116 |
  • Place a did
  • 117 |
  • Query for the current winning bid
  • 118 |
  • Query for the bid history
  • 119 |
120 | 121 |

Create Auction

122 |

123 |  POST http://127.0.0.1:9000/startAuction
124 | 
125 |  {"auctionId":"123",
126 |  "start":"2015-01-20-16:25",
127 |  "end":"2015-07-20-16:35",
128 |  "initialPrice" : 2,
129 |  "prodId" : "3"}
130 |              
131 | 132 |

Place Bid

133 |

134 | POST http://127.0.0.1:9000/bid
135 | 
136 | {"auctionId":"123",
137 | "buyer":"dave",
138 | "bidPrice":6}
139 |              
140 | 141 |

Query for the current winning bid

142 |

143 | GET http://127.0.0.1:9000/winningBid/123
144 |              
145 | 146 | 147 |

Query for the bid history

148 |

149 | http://127.0.0.1:9000/bidHistory/123
150 |              
151 |

152 | 153 | 154 |

Spray service fowards to the cluster

155 | The trait HttpAuctionServiceRoute.scala implements a route that takes ActorRefs (one for command and query) as input. 156 | Upon receiving an Http request, it either sends a command message to the command actor, or a query message to the query actor. 157 | 158 | 159 |

160 |  def route(command: ActorRef, query:ActorRef) = {
161 |      post {
162 |         path("startAuction") {
163 |             extract(_.request) { e =>
164 |                 entity(as[StartAuctionDto]) {
165 |                     auction => onComplete(
166 |                         (command ? StartAuctionCmd(auction.auctionId,....
167 | 
168 |          
169 | 170 |
171 | 172 |
173 |

Exploring the Command path in the Cluster

174 |

The command path is implemented in BidProcessor.scala. This is a PersistentActor that receives commands: 175 | 176 |


177 | def initial: Receive = {
178 |     case a@StartAuctionCmd(id, start, end, initialPrice, prodId) => ...
179 | }
180 | 
181 | def takingBids(auctionId: String, startTime: Long, closeTime: Long): Receive = {
182 |             case a@PlaceBidCmd(id, buyer, bidPrice) => ...
183 | }
184 | 
185 | 186 | and produces events, writing them to the event journal, and notifying the Query Path of the updated journal: 187 | 188 |

189 | val event = AuctionStartedEvt(id, start, end, initialPrice, prodId)   // the event to be persisted
190 | persistAsync(event) { evt =>                                          // block that will run once event has been written to journal
191 | readRegion ! Update(await = true)                                   // update the Query path
192 | auctionStateMaybe = startMaybeState(id, start, end, initialPrice)   // update internal state
193 | ...
194 | }
195 |   
196 | 197 |

198 | This actor is cluster sharded on auctionId as follows: 199 |

200 | val idExtractor: ShardRegion.IdExtractor = {
201 |     case m: AuctionCmd => (m.auctionId, m)
202 | }
203 | 
204 | val shardResolver: ShardRegion.ShardResolver = msg => msg match {
205 |     case m: AuctionCmd => (math.abs(m.auctionId.hashCode) % 100).toString
206 | }
207 | 
208 | val shardName: String = "BidProcessor"
209 |             
210 | This means, there is only one instance of this actor in the cluster, and all commands with the same auctionId will 211 | be routed to the same actor. 212 |

213 |

214 | If this actor receives no commands for 1 minute, it will passivate ( a pattern enabling the parent to stop the actor, in order to reduce memory consumption without losing any commands it is currently processing): 215 |


216 | /** passivate the entity when no activity */
217 | context.setReceiveTimeout(1 minute)     // this will send a ReceiveTimeout message after one minute, if no other messages come in
218 |         
219 | The timeout is handled in the Passivation.scala trait: 220 |

221 | protected def withPassivation(receive: Receive): Receive = receive.orElse{
222 |     // tell parent actor to send us a poisinpill
223 |     case ReceiveTimeout => context.parent ! Passivate(stopMessage = PoisonPill)
224 | 
225 |     // stop
226 |     case PoisonPill => context.stop(self)
227 | }
228 |              
229 |

230 | If this actor fails, or is passivated, and then is required again (to handle a command), the cluster will spin it up, and it will replay the 231 | event journal, updating it's internal state: 232 |

233 | def receiveRecover: Receive = {
234 |     case evt: AuctionEvt => updateState(evt)
235 | 
236 |     case RecoveryCompleted => {
237 |         auctionStateMaybe.fold[Unit]({}) { auctionState =>
238 |             if (auctionState.ended)
239 |                 context.become(passivate(auctionClosed(auctionState.auctionId, auctionState.endTime)).orElse(unknownCommand))
240 |             else{
241 |                 context.become(passivate(takingBids(auctionState.auctionId, auctionState.startTime, auctionState.endTime)).orElse(unknownCommand))
242 |                 }
243 |             }
244 |         }
245 | }
246 |               
247 | 248 |

249 | 250 |
251 |
252 |

Exploring the Query path in the Cluster

253 | The Queries are handled in a different Actor: BidView.scala. This is a PersistentView that handles query messages, or prompts from 254 | it's companion PersistentActor to update itself. 255 |

256 | BidView.scala is linked to the BidProcessor.scala event journal via it's persistenceId 257 |


258 | override val persistenceId: String = "BidProcessor" + "-" + self.path.name
259 |     
260 | This means it has access to this event journal, and can maintain, and recover state from this journal. 261 |

262 |

263 | It is possible for a PersistentView to save it's own snapshots, but, in our case, it isn't required. 264 |

265 | 266 |

267 | This PersistentView is sharded in the same way the PersistentActor is: 268 |


269 | val idExtractor: ShardRegion.IdExtractor = {
270 |     case m : AuctionEvt => (m.auctionId,m)
271 |     case m : BidQuery => (m.auctionId,m)
272 | }
273 | 
274 | val shardResolver: ShardRegion.ShardResolver = {
275 |     case m: AuctionEvt => (math.abs(m.auctionId.hashCode) % 100).toString
276 |     case m: BidQuery => (math.abs(m.auctionId.hashCode) % 100).toString
277 | }
278 |         
279 | One could have used a different shard strategy here, but a consequence of the above strategy is that the Query Path will 280 | reside in the same Shard Region as the command path, reducing latency of the Update() message from Command to Query. 281 |

282 | 283 |

284 | The PersistentView maintains the following model in memory: 285 |


286 | final case class BidState(auctionId:String,
287 |                          start:Long,
288 |                          end:Long,
289 |                          product:Double,
290 |                          acceptedBids:List[Bid],
291 |                          rejectedBids:List[Bid],
292 |                          closed:Boolean)
293 | 
294 | 295 | This model is sufficient to satisfy both queries: Winning Bid, and Bid History: 296 | 297 |

298 | 
299 |   def auctionInProgress(currentState:BidState, prodId:String):Receive = {
300 | 
301 |     case  GetBidHistoryQuery(auctionId) =>  sender ! BidHistoryResponse(auctionId,currentState.acceptedBids)
302 | 
303 |     case  WinningBidPriceQuery(auctionId) =>
304 |         currentState.acceptedBids.headOption.fold(
305 |         sender ! WinningBidPriceResponse(auctionId,currentState.product))(b =>
306 |         sender ! WinningBidPriceResponse(auctionId,b.price))
307 | 
308 |           ....
309 | 
310 |   }
311 |       
312 | 313 |

314 |
315 | 316 | 317 | 318 | 319 | 320 | --------------------------------------------------------------------------------