├── .gitignore ├── .travis.yml ├── LICENSE ├── README.md ├── build.sbt ├── mongo-cqrs-cs-app ├── README.md ├── build.sbt └── src │ ├── main │ └── scala │ │ └── com │ │ └── github │ │ └── ironfish │ │ └── akka │ │ └── persistence │ │ └── cqrscs │ │ └── sample │ │ ├── Benefits.scala │ │ ├── Confirmations.scala │ │ ├── Employee.scala │ │ ├── Messages.scala │ │ └── package.scala │ └── test │ └── scala │ └── com │ └── github │ └── ironfish │ └── akka │ └── persistence │ └── cqrscs │ └── sample │ └── EmployeeSpec.scala ├── mongo-cqrs-es-app ├── README.md ├── build.sbt └── src │ ├── main │ └── scala │ │ └── com │ │ └── github │ │ └── ironfish │ │ └── akka │ │ └── persistence │ │ └── cqrses │ │ └── sample │ │ ├── Benefits.scala │ │ ├── BenefitsProtocol.scala │ │ ├── Employee.scala │ │ ├── EmployeeProtocol.scala │ │ └── package.scala │ └── test │ └── scala │ └── com │ └── github │ └── ironfish │ └── akka │ └── persistence │ └── cqrses │ └── sample │ ├── BenefitsSpec.scala │ └── EmployeeSpec.scala └── project ├── Common.scala ├── build.properties └── plugins.sbt /.gitignore: -------------------------------------------------------------------------------- 1 | *.class 2 | *.log 3 | 4 | # sbt specific 5 | dist/* 6 | target/ 7 | lib_managed/ 8 | src_managed/ 9 | project/boot/ 10 | project/plugins/project/ 11 | 12 | # Scala-IDE specific 13 | .scala_dependencies 14 | .target 15 | .cache 16 | .classpath 17 | .project 18 | .settings/ 19 | 20 | # Intellij specific 21 | *.iml 22 | *.idea/ 23 | *.idea_modules/ 24 | 25 | # Sublime specific 26 | *.sublime-project 27 | *.sublime-workspace 28 | 29 | # OS specific 30 | .DS_Store 31 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: scala 2 | scala: 3 | - "2.11.4" 4 | jdk: 5 | - oraclejdk7 6 | - oraclejdk8 7 | - openjdk7 8 | branches: 9 | except: 10 | - /^wip-.*$/ 11 | -------------------------------------------------------------------------------- /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 Persistence Sample Applications 2 | 3 | [![Build Status](https://travis-ci.org/ironfish/akka-persistence-mongo-samples.png?branch=master)](https://travis-ci.org/ironfish/akka-persistence-mongo-samples) 4 | 5 | ## Overview 6 | 7 | This repository acts as a playground to explore different ways to implement [Akka Persistence](http://doc.akka.io/docs/akka/current/scala/persistence.html) based applications. In addition to Akka Persistence, other topics such as [CQRS](http://msdn.microsoft.com/en-us/library/dn568103.aspx) (Command Query Responsibility Segregation), Command Sourcing, [Event Sourcing](http://doc.akka.io/docs/akka/snapshot/scala/persistence.html#Event_sourcing), and Distributed Domain Models are explored as well. 8 | 9 | ## Disclaimer 10 | Please **note** while all sample code compiles and tests pass, it is intended for example purposes only, and in no way should be construed as production ready code. 11 | 12 | ## Table of Contents 13 | 14 | 1. [Coming Soon] Cluster Sharded CQRS/ES with Distributed Domain Model - This application will implement an `actor-as-aggregate` model allowing for the distribution of `actor aggregates` across several nodes in a cluster. 15 | 16 | 2. [CQRS/Event Sourcing Sample Application](https://github.com/ironfish/akka-persistence-mongo-samples/tree/master/mongo-cqrs-es-app) implements [CQRS](http://msdn.microsoft.com/en-us/library/dn568103.aspx)(Command Query Responsibility Segregation) with Akka-Persistence [Event Sourcing](http://doc.akka.io/docs/akka/current/scala/persistence.html#Event_sourcing). 17 | 18 | 3. [Command Sourcing Sample Application](https://github.com/ironfish/akka-persistence-mongo-samples/tree/master/mongo-cqrs-cs-app) implements the `command sourcing` pattern. This pattern is typically used when local consistency requirements can be relaxed for high throughput use-cases. **Note** In order to implement the pattern known as *"command sourcing..."*, in this example, the journal acts as a `write-ahead-log` for whatever persisted messages it receives. 19 | 20 | ## Author / Maintainer 21 | 22 | * [Duncan DeVore (@ironfish)](https://github.com/ironfish/) 23 | 24 | ## Contributors 25 | 26 | * [Sean Walsh (@SeanWalshEsq)](https://github.com/sean-walsh/) 27 | * [Al Iacovella](https://github.com/aiacovella/) 28 | -------------------------------------------------------------------------------- /build.sbt: -------------------------------------------------------------------------------- 1 | lazy val mongoCqrsCsApp = project in file(Common.NameMongoCqrsCsApp) 2 | 3 | lazy val mongoCqrsEsApp = project in file(Common.NameMongoCqrsEsApp) 4 | -------------------------------------------------------------------------------- /mongo-cqrs-cs-app/README.md: -------------------------------------------------------------------------------- 1 | # Command Sourcing Sample Application 2 | 3 | ## Technology Stack 4 | 5 | * Akka-Persistence 6 | * Uses [Mongo Journal](https://github.com/ironfish/akka-persistence-mongo/) plugin for Akka Persistence implementation. 7 | * Uses [`PersistentActor`](http://doc.akka.io/docs/akka/current/scala/persistence.html#Event_sourcing) for processing CQRS command-side commands, journaling commands, recovery and snapshots. 8 | + Uses [ScalaStm](http://nbronson.github.io/scala-stm/) for managing runtime state. 9 | * Uses [Scalaz](https://github.com/scalaz/scalaz) for non-breaking error validation of commands. 10 | * Uses [Salat](https://github.com/novus/salat) for case class serialization atop the mongo casbah driver on the query-side aggregation. 11 | * Uses [Embedded Mongo](https://github.com/flapdoodle-oss/de.flapdoodle.embed.mongo) for test persistence store. 12 | * Uses [ScalaTest](http://www.scalatest.org/) as test toolkit. 13 | 14 | ## Description 15 | 16 | This sample project implements the Command Sourcing pattern using Akka Persistence. It is a re-write of the original sample based on the [Eventsourced](https://github.com/eligosource/eventsourced) library originally written by Martin Krasser. 17 | 18 | This example shows some of the ins and outs of an employee domain and a read side (see CQRS) construct for the separate concern of HR (human resources) tracking the overall employment length of employees. 19 | 20 | Command Sourcing was successfully used in our production systems and effectively meant the journaling of all message received by the processor. Since this application is Command Sourced, `PersistentViews` are not used. As a result, the read side construct is more of a home grown type of thing and uses basic Akka functionality. 21 | 22 | The Command Sourcing pattern is typically used when local consistency requirements can be relaxed for high throughput use-cases. **Note** In order to implement the pattern known as *"Command Sourcing"*, in this example, the journal acts as a `write-ahead-log` for whatever persisted messages it receives. 23 | 24 | ## Status 25 | 26 | * Initial commit with base functionality. 27 | 28 | ## Author / Maintainer 29 | 30 | * [Sean Walsh (@SeanWalshEsq)](https://github.com/sean-walsh/) 31 | 32 | ## Contributors 33 | 34 | * [Duncan DeVore (@ironfish)](https://github.com/ddevore/) 35 | * [Al Iacovella](https://github.com/aiacovella/) 36 | -------------------------------------------------------------------------------- /mongo-cqrs-cs-app/build.sbt: -------------------------------------------------------------------------------- 1 | organization := Common.Organization 2 | 3 | name := Common.NameMongoCqrsCsApp 4 | 5 | scalaVersion := Common.ScalaVersion 6 | 7 | crossScalaVersions := Common.CrossScalaVersions 8 | 9 | version := "0.3.1-SNAPSHOT" 10 | 11 | parallelExecution in Test := Common.ParallelExecutionInTest 12 | 13 | scalacOptions ++= Common.ScalaCOptions 14 | 15 | publishLocal := {} 16 | 17 | publish := {} 18 | 19 | publishArtifact := false 20 | 21 | resolvers += "Sonatype OSS Releases" at "https://oss.sonatype.org/content/repositories/releases" 22 | 23 | libraryDependencies ++= Seq( 24 | "com.typesafe.akka" %% "akka-persistence-experimental" % Common.AkkaVersion % "compile", 25 | "com.github.ironfish" %% "akka-persistence-mongo-casbah" % Common.PluginVersion % "compile", 26 | "org.scalaz" %% "scalaz-core" % Common.ScalazVersion % "compile", 27 | "com.novus" %% "salat" % Common.SalatVersion % "compile", 28 | "org.scala-stm" %% "scala-stm" % Common.ScalaStmVersion % "compile", 29 | "com.typesafe.akka" %% "akka-slf4j" % Common.AkkaVersion % "compile", 30 | "com.typesafe.akka" %% "akka-testkit" % Common.AkkaVersion % "test", 31 | "de.flapdoodle.embed" % "de.flapdoodle.embed.mongo" % Common.EmbeddedMongoVersion % "test", 32 | "org.scalatest" %% "scalatest" % Common.ScalatestVersion % "test" 33 | ) 34 | -------------------------------------------------------------------------------- /mongo-cqrs-cs-app/src/main/scala/com/github/ironfish/akka/persistence/cqrscs/sample/Benefits.scala: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright (C) 2013-2014 Duncan DeVore. 3 | */ 4 | package com.github.ironfish.akka.persistence.cqrscs.sample 5 | 6 | import akka.actor.Actor 7 | import com.novus.salat._ 8 | import com.novus.salat.global._ 9 | import com.mongodb.casbah.Imports._ 10 | 11 | sealed trait BenefitsMessages 12 | case class EmployeeBenefits( 13 | employeeId: String, 14 | startDate: Long, 15 | termDates: List[Long], 16 | rehireDates: List[Long]) 17 | extends BenefitsMessages 18 | 19 | /** 20 | * This read side representation is interested in maintaining a true employment duration, which takes into account periods of 21 | * non-employment. We could get fancy here and schedule updates to an employmentLength field if we wanted but for the sake of 22 | * this sample we'll just accumulate the pertinent dates. 23 | */ 24 | class Benefits(mongoHost: String, mongoPort: Int) extends Actor { 25 | 26 | implicit val writeConcern = WriteConcern.JournalSafe 27 | 28 | def receive: Actor.Receive = { 29 | case Msg(deliveryId, payload) => 30 | val con = MongoClient(mongoHost, mongoPort) 31 | val col = con("hr")("benefits") 32 | 33 | payload match { 34 | case cmd: EmployeeHired => 35 | val eb = EmployeeBenefits(cmd.id, cmd.startDate, Nil, Nil) 36 | val dbo = grater[EmployeeBenefits].asDBObject(eb) 37 | col.insert(dbo) 38 | sender() ! Confirm(deliveryId) 39 | 40 | case cmd: EmployeeTerminated => 41 | val dbo = col.findOne(MongoDBObject("employeeId" -> cmd.id)).get 42 | val eb = grater[EmployeeBenefits].asObject(dbo) 43 | val up = eb.copy(termDates = eb.termDates :+ cmd.termDate) 44 | col.update(MongoDBObject("employeeId" -> cmd.id), grater[EmployeeBenefits].asDBObject(up)) 45 | sender() ! Confirm(deliveryId) 46 | 47 | case cmd: EmployeeRehired => 48 | val dbo = col.findOne(MongoDBObject("employeeId" -> cmd.id)).get 49 | val eb = grater[EmployeeBenefits].asObject(dbo) 50 | val up = eb.copy(rehireDates = eb.rehireDates :+ cmd.rehireDate) 51 | col.update(MongoDBObject("employeeId" -> cmd.id), grater[EmployeeBenefits].asDBObject(up)) 52 | sender() ! Confirm(deliveryId) 53 | 54 | case _ => // Not interested 55 | } 56 | } 57 | 58 | /** 59 | * In the waiting state we ensure ordering in the database so that creates happen before updates, etc. 60 | */ 61 | def waiting: Actor.Receive = { 62 | case _ => println("waiting state. No messages expected in the waiting state!") 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /mongo-cqrs-cs-app/src/main/scala/com/github/ironfish/akka/persistence/cqrscs/sample/Confirmations.scala: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright (C) 2013-2014 Duncan DeVore. 3 | */ 4 | package com.github.ironfish.akka.persistence.cqrscs.sample 5 | 6 | import akka.persistence.SnapshotMetadata 7 | 8 | /** 9 | * Common messages dispatched to the event stream for probing in tests 10 | */ 11 | sealed trait Confirmations 12 | case class MsgConfirmed(deliveryId: Long) extends Confirmations 13 | case class SnapshotConfirmed(snap: SnapshotMetadata) extends Confirmations 14 | -------------------------------------------------------------------------------- /mongo-cqrs-cs-app/src/main/scala/com/github/ironfish/akka/persistence/cqrscs/sample/Employee.scala: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright (C) 2013-2014 Duncan DeVore. 3 | */ 4 | package com.github.ironfish.akka.persistence.cqrscs.sample 5 | 6 | import scala.concurrent.duration._ 7 | import scala.concurrent.stm.Ref 8 | import scala.concurrent.Future 9 | import akka.actor.{ActorPath, ActorRef, ActorSystem} 10 | import akka.pattern.ask 11 | import akka.persistence._ 12 | import akka.util.Timeout 13 | import scalaz._ 14 | import Scalaz._ 15 | import org.joda.time.{DateTimeZone, DateTime} 16 | 17 | /** 18 | * An employee domain aggregate to be extended by NewHire, Termination, etc. 19 | */ 20 | sealed trait Employee { 21 | def id: String 22 | def lastName: String 23 | def firstName: String 24 | def address: Address 25 | def startDate: Long 26 | def dept: String 27 | def title: String 28 | def salary: BigDecimal 29 | def version: Long 30 | } 31 | 32 | /** 33 | * A companion object shared among Employee implementations for version management. 34 | */ 35 | object Employee { 36 | def requireVersion[T <: Employee](emp: T, expectedVersion: Long): DomainValidation[T] = { 37 | if (expectedVersion == emp.version) emp.successNel 38 | else "ExpectedVersionMismatch".failureNel 39 | } 40 | } 41 | 42 | /** 43 | * A trait for validating salary. 44 | */ 45 | trait EmployeeValidations { 46 | 47 | def checkSalary(s: BigDecimal): Validation[String, BigDecimal] = { 48 | val OneMillionDollars = BigDecimal(1000000) 49 | if (s < OneMillionDollars && s > 0) s.success else "InvalidSalary".failure 50 | } 51 | 52 | def checkStartDate(d: Long): Validation[String, Long] = { 53 | val dt = new DateTime(d, DateTimeZone.UTC) 54 | if (dt.getMillis == dt.withTimeAtStartOfDay.getMillis) d.success else "StartDateNotOnDayBoundry".failure 55 | } 56 | 57 | def checkAndIncrementVersion(l: Long): Validation[String, Long] = 58 | if (1 > -1) (l + 1).success else "InvalidLongValue".failure 59 | } 60 | 61 | /** 62 | * An active employee. 63 | */ 64 | case class ActiveEmployee private ( 65 | id: String, 66 | lastName: String, 67 | firstName: String, 68 | address: Address, 69 | startDate: Long, 70 | dept: String, 71 | title: String, 72 | salary: BigDecimal, 73 | version: Long 74 | ) extends Employee with EmployeeValidations { 75 | 76 | import CommonValidations._ 77 | 78 | def withLastName(lastName: String): DomainValidation[ActiveEmployee] = 79 | checkString(lastName).fold(e => e.failureNel, l => copy(version = version + 1, lastName = l).success) 80 | 81 | def withFirstName(firstName: String): DomainValidation[ActiveEmployee] = 82 | checkString(lastName).fold(e => e.failureNel, f => copy(version = version + 1, firstName = f).success) 83 | 84 | def withAddress(line1: String, line2: String, city: String, stateOrProvince: String, country: String, postalCode: String): DomainValidation[ActiveEmployee] = 85 | Address.create(line1, line2, city, stateOrProvince, country, postalCode).fold(e => e.failure, a => copy(version = version + 1, address = a).success) 86 | 87 | def withStartDate(startDate: Long): DomainValidation[ActiveEmployee] = 88 | checkStartDate(startDate).fold(e => e.failureNel, s => copy(version = version + 1, startDate = s).success) 89 | 90 | def withDept(dept: String): DomainValidation[ActiveEmployee] = 91 | checkString(dept).fold(e => e.failureNel, d => copy(version = version + 1, dept = d).success) 92 | 93 | def withTitle(title: String): DomainValidation[ActiveEmployee] = 94 | checkString(dept).fold(e => e.failureNel, t => copy(version = version + 1, title = t).success) 95 | 96 | def withSalary(salary: BigDecimal): DomainValidation[ActiveEmployee] = 97 | checkSalary(salary).fold(e => e.failureNel, s => copy(version = version + 1, salary = s).success) 98 | 99 | def leaveOfAbsence: DomainValidation[InactiveEmployee] = 100 | InactiveEmployee.create(this) 101 | 102 | def terminate(terminationDate: Long, terminationReason: String): DomainValidation[Termination] = 103 | Termination.create(this, terminationDate, terminationReason) 104 | } 105 | 106 | /** 107 | * Companion object for NewHire used for creation. 108 | */ 109 | object ActiveEmployee extends EmployeeValidations { 110 | import CommonValidations._ 111 | 112 | /** 113 | * Creates a new hire using non breaking validations. 114 | */ 115 | def create(id: String, lastName: String, firstName: String, addressLine1: String, addressLine2: String, city: String, stateOrProvince: String, 116 | country: String, postalCode: String, startDate: Long, dept: String, title: String, salary: BigDecimal, version: Long): DomainValidation[ActiveEmployee] = 117 | (checkString(id).toValidationNel |@| 118 | checkString(lastName).toValidationNel |@| 119 | checkString(firstName).toValidationNel |@| 120 | Address.create(addressLine1, addressLine2, city, stateOrProvince, country, postalCode) |@| 121 | checkStartDate(startDate).toValidationNel |@| 122 | checkString(dept).toValidationNel |@| 123 | checkString(title).toValidationNel |@| 124 | checkSalary(salary).toValidationNel |@| 125 | checkAndIncrementVersion(version).toValidationNel) { 126 | ActiveEmployee(_, _, _, _, _, _, _, _, _) 127 | } 128 | } 129 | 130 | /** 131 | * This represents an inactive employee that does not collect paychecks but is accumulating benefits. 132 | */ 133 | case class InactiveEmployee private ( 134 | id: String, 135 | lastName: String, 136 | firstName: String, 137 | address: Address, 138 | startDate: Long, 139 | dept: String, 140 | title: String, 141 | salary: BigDecimal, 142 | version: Long 143 | ) extends Employee { 144 | 145 | def activate: DomainValidation[ActiveEmployee] = 146 | ActiveEmployee.create(id, lastName, firstName, address.line1, address.line2, address.city, address.stateOrProvince, address.country, address.postalCode, 147 | startDate, dept, title, salary, version) 148 | } 149 | 150 | /** 151 | * Companion object for InactiveEmployee used for creation. 152 | */ 153 | object InactiveEmployee extends EmployeeValidations { 154 | import CommonValidations._ 155 | 156 | /** 157 | * Creates a inactive employee using non breaking validations. 158 | */ 159 | def create(ae: ActiveEmployee): DomainValidation[InactiveEmployee] = 160 | (checkString(ae.id).toValidationNel |@| 161 | checkString(ae.lastName).toValidationNel |@| 162 | checkString(ae.firstName).toValidationNel |@| 163 | Address.create(ae.address.line1, ae.address.line2, ae.address.city, ae.address.stateOrProvince, ae.address.country, ae.address.postalCode) |@| 164 | checkStartDate(ae.startDate).toValidationNel |@| 165 | checkString(ae.dept).toValidationNel |@| 166 | checkString(ae.title).toValidationNel |@| 167 | checkSalary(ae.salary).toValidationNel |@| 168 | checkAndIncrementVersion(ae.version).toValidationNel) { 169 | InactiveEmployee(_, _, _, _, _, _, _, _, _) 170 | } 171 | } 172 | 173 | /** 174 | * This represents a terminated employee. 175 | */ 176 | case class Termination private ( 177 | id: String, 178 | lastName: String, 179 | firstName: String, 180 | address: Address, 181 | startDate: Long, 182 | dept: String, 183 | title: String, 184 | salary: BigDecimal, 185 | termDate: Long, 186 | termReason: String, 187 | version: Long 188 | ) extends Employee { 189 | 190 | def rehire(rehireDate: Long): DomainValidation[ActiveEmployee] = 191 | ActiveEmployee.create(id, lastName, firstName, address.line1, address.line2, address.city, address.stateOrProvince, address.country, address.postalCode, 192 | rehireDate, dept, title, salary, version) 193 | } 194 | 195 | /** 196 | * Companion object for Termination used for creation. 197 | */ 198 | object Termination extends EmployeeValidations { 199 | import CommonValidations._ 200 | 201 | /** 202 | * Creates a termination using non breaking validations. 203 | */ 204 | def create(ae: ActiveEmployee, termDate: Long, termReason: String): DomainValidation[Termination] = 205 | (checkString(ae.id).toValidationNel |@| 206 | checkString(ae.lastName).toValidationNel |@| 207 | checkString(ae.firstName).toValidationNel |@| 208 | Address.create(ae.address.line1, ae.address.line2, ae.address.city, ae.address.stateOrProvince, ae.address.country, ae.address.postalCode) |@| 209 | checkDate(ae.startDate).toValidationNel |@| 210 | checkString(ae.dept).toValidationNel |@| 211 | checkString(ae.title).toValidationNel |@| 212 | checkSalary(ae.salary).toValidationNel |@| 213 | checkStartDate(termDate).toValidationNel |@| 214 | checkString(termReason).toValidationNel |@| 215 | checkAndIncrementVersion(ae.version).toValidationNel) { 216 | Termination(_, _, _, _, _, _, _, _, _, _, _) 217 | } 218 | } 219 | 220 | /** 221 | * These are the sealed commands of every action that may be performed by an employee. 222 | */ 223 | sealed trait EmployeeCommand 224 | case class HireEmployee(id: String, lastName: String, firstName: String, addressLine1: String, addressLine2: String, city: String, stateOrProvince: String, 225 | postalCode: String, country: String, startDate: Long, dept: String, title: String, salary: BigDecimal) extends EmployeeCommand 226 | case class ChangeEmployeeLastName(id: String, lastName: String, expectedVersion: Long) extends EmployeeCommand 227 | case class ChangeEmployeeFirstName(id: String, firstName: String, expectedVersion: Long) extends EmployeeCommand 228 | case class ChangeEmployeeAddress(id: String, line1: String, line2: String, city: String, stateOrProvince: String, country: String, postalCode: String, expectedVersion: Long) extends EmployeeCommand 229 | case class ChangeEmployeeStartDate(id: String, startDate: Long, expectedVersion: Long) extends EmployeeCommand 230 | case class ChangeEmployeeDept(id: String, dept: String, expectedVersion: Long) extends EmployeeCommand 231 | case class ChangeEmployeeTitle(id: String, title: String, expectedVersion: Long) extends EmployeeCommand 232 | case class ChangeEmployeeSalary(id: String, salary: BigDecimal, expectedVersion: Long) extends EmployeeCommand 233 | case class DeactivateEmployee(id: String, expectedVersion: Long) extends EmployeeCommand 234 | case class ActivateEmployee(id: String, expectedVersion: Long) extends EmployeeCommand 235 | case class TerminateEmployee(id: String, termDate: Long, termReason: String, expectedVersion: Long) extends EmployeeCommand 236 | case class RehireEmployee(id: String, rehireDate: Long, expectedVersion: Long) extends EmployeeCommand 237 | case class RunPayroll() extends EmployeeCommand 238 | 239 | /** 240 | * These are the resulting events from commands performed by an employee. Since this application is command sourcing 241 | * these events are informational only upon successful validations on the commands. 242 | */ 243 | sealed trait EmployeeEvent { def version: Long } 244 | case class EmployeeHired(id: String, lastName: String, firstName: String, addressLine1: String, addressLine2: String, city: String, 245 | stateOrProvince: String, postalCode: String, country: String, startDate: Long, dept: String, title: String, salary: BigDecimal, version: Long) extends EmployeeEvent 246 | case class EmployeeLastNameChanged(id: String, lastName: String, version: Long) extends EmployeeEvent 247 | case class EmployeeFirstNameChanged(id: String, firstName: String, version: Long) extends EmployeeEvent 248 | case class EmployeeAddressChanged(id: String, line1: String, line2: String, city: String, stateOrProvince: String, country: String, postalCode: String, version: Long) extends EmployeeEvent 249 | case class EmployeeStartDateChanged(id: String, startDate: Long, version: Long) extends EmployeeEvent 250 | case class EmployeeDeptChanged(id: String, dept: String, version: Long) extends EmployeeEvent 251 | case class EmployeeTitleChanged(id: String, title: String, version: Long) extends EmployeeEvent 252 | case class EmployeeSalaryChanged(id: String, salary: BigDecimal, version: Long) extends EmployeeEvent 253 | case class EmployeeDeactivated(id: String, version: Long) extends EmployeeEvent 254 | case class EmployeeActivated(id: String, version: Long) extends EmployeeEvent 255 | case class EmployeeTerminated(id: String, termDate: Long, termReason: String, version: Long) extends EmployeeEvent 256 | case class EmployeeRehired(id: String, rehireDate: Long, version: Long) extends EmployeeEvent 257 | case class EmployeePaid(id: String, salary: BigDecimal, version: Long) extends EmployeeEvent 258 | 259 | sealed trait SnapshotEvent 260 | case class SnapshotEmployee(id: String) extends SnapshotEvent 261 | 262 | /** 263 | * This is the command sourcing processor for employees. 264 | * @param ref Ref[Map[String, Employee]] the in memory current state of all employees. 265 | * @param eventDestination ActorPath the listener of the events. 266 | */ 267 | class EmployeeProcessor(ref: Ref[Map[String, Employee]], eventDestination: ActorPath) 268 | extends PersistentActor 269 | with AtLeastOnceDelivery { 270 | 271 | override def persistenceId = "employee-persistence" 272 | 273 | val receiveCommand: Receive = { 274 | case cmd => 275 | cmd match { 276 | case cmd: HireEmployee => process(hire(cmd)) { emp ⇒ 277 | persist(EmployeeHired(emp.id, emp.lastName, emp.firstName, emp.address.line1, emp.address.line2, emp.address.city,emp.address.stateOrProvince, emp.address.country, emp.address.postalCode, emp.startDate, emp.dept, emp.title, emp.salary, emp.version))(updateState) 278 | } 279 | case cmd: ChangeEmployeeLastName => process(changeLastName(cmd)) { emp ⇒ 280 | persist(EmployeeLastNameChanged(emp.id, emp.lastName, emp.version))(updateState) 281 | } 282 | case cmd: ChangeEmployeeFirstName => process(changeFirstName(cmd)) { emp ⇒ 283 | persist(EmployeeFirstNameChanged(emp.id, emp.firstName, emp.version))(updateState) 284 | } 285 | case cmd: ChangeEmployeeStartDate => process(changeStartDate(cmd)) { emp ⇒ 286 | persist(EmployeeStartDateChanged(emp.id, emp.startDate, emp.version))(updateState) 287 | } 288 | case cmd: ChangeEmployeeDept => process(changeDept(cmd)) { emp ⇒ 289 | persist(EmployeeDeptChanged(emp.id, emp.dept, emp.version))(updateState) 290 | } 291 | case cmd: ChangeEmployeeTitle => process(changeTitle(cmd)) { emp ⇒ 292 | persist(EmployeeTitleChanged(emp.id, emp.title, emp.version))(updateState) 293 | } 294 | case cmd: ChangeEmployeeSalary => process(changeSalary(cmd)) { emp ⇒ 295 | persist(EmployeeSalaryChanged(emp.id, emp.salary, emp.version))(updateState) 296 | } 297 | case cmd: ChangeEmployeeAddress => process(changeAddress(cmd)) { emp ⇒ 298 | persist(EmployeeAddressChanged(emp.id, emp.address.line1, emp.address.line2, emp.address.city, emp.address.stateOrProvince, 299 | emp.address.postalCode, emp.address.country, emp.version))(updateState) 300 | } 301 | case cmd: DeactivateEmployee => process(deactivate(cmd)) { emp ⇒ 302 | persist(EmployeeDeactivated(emp.id, emp.version))(updateState) 303 | } 304 | case cmd: ActivateEmployee => process(activate(cmd)) { emp ⇒ 305 | persist(EmployeeActivated(emp.id, emp.version))(updateState) 306 | } 307 | case cmd: TerminateEmployee => process(terminate(cmd)) { emp ⇒ 308 | persist(EmployeeTerminated(emp.id, cmd.termDate, cmd.termReason, emp.version))(updateState) 309 | } 310 | case cmd: RehireEmployee => process(rehire(cmd)) { emp ⇒ 311 | persist(EmployeeRehired(emp.id, cmd.rehireDate, emp.version))(updateState) 312 | } 313 | case cmd: RunPayroll => 314 | readEmployees.values.filter(_.isInstanceOf[ActiveEmployee]).foreach { e => 315 | persist(EmployeePaid(e.id, e.salary, e.version))(updateState) 316 | } 317 | case SnapshotEmployee(id) ⇒ 318 | saveSnapshot(readEmployees.get(id).get) 319 | case Confirm(deliveryId) ⇒ 320 | persist(MsgConfirmed(deliveryId)){evt => 321 | confirmDelivery(deliveryId) 322 | context.system.eventStream.publish(evt) 323 | } 324 | case SaveSnapshotSuccess(snap) => 325 | context.system.eventStream.publish(SnapshotConfirmed(snap)) 326 | } 327 | } 328 | 329 | /** 330 | * Delivers the event to the destination. 331 | * @param evt event to deliver 332 | */ 333 | def updateState(evt: EmployeeEvent): Unit = evt match { 334 | case e:EmployeeEvent ⇒ deliver(eventDestination, deliveryId ⇒ Msg(deliveryId, evt)) 335 | } 336 | 337 | val receiveRecover: Receive = { 338 | case SnapshotOffer(metadata, offeredSnapshot) => updateEmployees(offeredSnapshot.asInstanceOf[Employee]) 339 | } 340 | 341 | def hire(cmd: HireEmployee): DomainValidation[ActiveEmployee] = 342 | readEmployees.values.find(emp ⇒ emp.id == cmd.id) match { 343 | case Some(emp) ⇒ "EmployeeExists".failureNel 344 | case None ⇒ ActiveEmployee.create(cmd.id, cmd.lastName, cmd.firstName, cmd.addressLine1, cmd.addressLine2, cmd.city, cmd.stateOrProvince, 345 | cmd.country, cmd.postalCode, cmd.startDate, cmd.dept, cmd.title, cmd.salary, -1L) 346 | } 347 | 348 | def changeLastName(cmd: ChangeEmployeeLastName): DomainValidation[ActiveEmployee] = 349 | updateActive(cmd.id, cmd.expectedVersion) { emp ⇒ emp.withLastName(cmd.lastName) } 350 | 351 | def changeFirstName(cmd: ChangeEmployeeFirstName): DomainValidation[ActiveEmployee] = 352 | updateActive(cmd.id, cmd.expectedVersion) { emp ⇒ emp.withFirstName(cmd.firstName) } 353 | 354 | def changeStartDate(cmd: ChangeEmployeeStartDate): DomainValidation[ActiveEmployee] = 355 | updateActive(cmd.id, cmd.expectedVersion) { emp ⇒ emp.withStartDate(cmd.startDate) } 356 | 357 | def changeAddress(cmd: ChangeEmployeeAddress): DomainValidation[Employee] = 358 | updateActive(cmd.id, cmd.expectedVersion) { emp ⇒ emp.withAddress(cmd.line1, cmd.line2, cmd.city, cmd.stateOrProvince, cmd.country, cmd.postalCode) } 359 | 360 | def changeDept(cmd: ChangeEmployeeDept): DomainValidation[ActiveEmployee] = 361 | updateActive(cmd.id, cmd.expectedVersion) { emp ⇒ emp.withDept(cmd.dept) } 362 | 363 | def changeTitle(cmd: ChangeEmployeeTitle): DomainValidation[ActiveEmployee] = 364 | updateActive(cmd.id, cmd.expectedVersion) { emp ⇒ emp.withTitle(cmd.title) } 365 | 366 | def changeSalary(cmd: ChangeEmployeeSalary): DomainValidation[ActiveEmployee] = 367 | updateActive(cmd.id, cmd.expectedVersion) { emp ⇒ emp.withSalary(cmd.salary) } 368 | 369 | def deactivate(cmd: DeactivateEmployee): DomainValidation[InactiveEmployee] = 370 | updateActive(cmd.id, cmd.expectedVersion) { emp ⇒ emp.leaveOfAbsence } 371 | 372 | def activate(cmd: ActivateEmployee): DomainValidation[ActiveEmployee] = 373 | updateInactive(cmd.id, cmd.expectedVersion) { emp ⇒ emp.activate } 374 | 375 | def terminate(cmd: TerminateEmployee): DomainValidation[Termination] = 376 | updateActive(cmd.id, cmd.expectedVersion) { emp ⇒ emp.terminate(cmd.termDate, cmd.termReason) } 377 | 378 | def rehire(cmd: RehireEmployee): DomainValidation[ActiveEmployee] = 379 | updateTermination(cmd.id, cmd.expectedVersion) { emp ⇒ emp.rehire(cmd.rehireDate) } 380 | 381 | def process(validation: DomainValidation[Employee])(onSuccess: Employee ⇒ Unit) { 382 | validation foreach { employee ⇒ 383 | updateEmployees(employee) 384 | onSuccess(employee) 385 | } 386 | sender ! validation 387 | } 388 | 389 | def updateEmployee[B <: Employee](id: String, expectedVersion: Long)(fn: Employee ⇒ DomainValidation[B]): DomainValidation[B] = 390 | readEmployees.get(id) match { 391 | case Some(emp) => Employee.requireVersion(emp, expectedVersion) fold (f => f.failure, s => fn(s)) 392 | case None => s"employee for $id does not exist".failureNel 393 | // case Some(emp) ⇒ for { 394 | // current ← Employee.requireVersion(emp, expectedVersion) 395 | // updated ← f(emp) 396 | // } yield updated 397 | // case None ⇒ s"employee $id does not exist".failureNel 398 | } 399 | 400 | def updateActive[B <: Employee](id: String, version: Long)(f: ActiveEmployee ⇒ DomainValidation[B]): DomainValidation[B] = 401 | updateEmployee(id, version) { 402 | case emp: ActiveEmployee ⇒ f(emp) 403 | case emp: Employee ⇒ "EmployeeNotActive".failureNel 404 | } 405 | 406 | def updateInactive[B <: Employee](id: String, version: Long)(f: InactiveEmployee ⇒ DomainValidation[B]): DomainValidation[B] = 407 | updateEmployee(id, version) { 408 | case emp: InactiveEmployee ⇒ f(emp) 409 | case emp: Employee ⇒ "EmployeeNotInactive".failureNel 410 | } 411 | 412 | def updateTermination[B <: Employee](id: String, version: Long)(f: Termination ⇒ DomainValidation[B]): DomainValidation[B] = 413 | updateEmployee(id, version) { 414 | case emp: Termination ⇒ f(emp) 415 | case emp: Employee ⇒ "EmployeeNotTerminated".failureNel 416 | } 417 | 418 | private def updateEmployees(employee: Employee) { 419 | ref.single.transform(employees ⇒ employees + (employee.id -> employee)) 420 | } 421 | 422 | private def readEmployees = 423 | ref.single.get 424 | } 425 | 426 | /** 427 | * This is the service in which all domain functionality is accessed. 428 | */ 429 | class EmployeeService(ref: Ref[Map[String, Employee]], processor: ActorRef)(implicit system: ActorSystem) { 430 | 431 | import system.dispatcher 432 | 433 | implicit val timeout = Timeout(5 seconds) 434 | 435 | def sendCommand(cmd: EmployeeCommand): Future[DomainValidation[Employee]] = processor ? cmd map (_.asInstanceOf[DomainValidation[Employee]]) 436 | 437 | def getMap = ref.single.get 438 | 439 | def get(id: String): Option[Employee] = getMap.get(id) 440 | 441 | def getAll: Iterable[Employee] = getMap.values 442 | 443 | def getAllActive = getAll.filter(_.isInstanceOf[ActiveEmployee]) 444 | 445 | def getAllInactive = getAll.filter(_.isInstanceOf[InactiveEmployee]) 446 | 447 | def getAllTerminated = getAll.filter(_.isInstanceOf[Termination]) 448 | } 449 | -------------------------------------------------------------------------------- /mongo-cqrs-cs-app/src/main/scala/com/github/ironfish/akka/persistence/cqrscs/sample/Messages.scala: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright (C) 2013-2014 Duncan DeVore. 3 | */ 4 | package com.github.ironfish.akka.persistence.cqrscs.sample 5 | 6 | /** 7 | * common messages used in testing 8 | */ 9 | case class Msg(deliveryId: Long, payload: Any) 10 | case class Confirm(deliveryId: Long) 11 | -------------------------------------------------------------------------------- /mongo-cqrs-cs-app/src/main/scala/com/github/ironfish/akka/persistence/cqrscs/sample/package.scala: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright (C) 2013-2014 Duncan DeVore. 3 | */ 4 | package com.github.ironfish.akka.persistence.cqrscs 5 | 6 | import scalaz._ 7 | import Scalaz._ 8 | 9 | package object sample { 10 | type DomainValidation[+α] = Validation[NonEmptyList[String], α] 11 | 12 | object CommonValidations { 13 | def checkString(s: String): Validation[String, String] = 14 | if (s == null || s.isEmpty) "InvalidString".failure else s.success 15 | 16 | def checkDate(d: Long): Validation[String, Long] = 17 | if (d > 0) d.success else "InvalidDate".failure 18 | } 19 | 20 | sealed case class Address private ( 21 | line1: String, 22 | line2: String, 23 | city: String, 24 | stateOrProvince: String, 25 | country: String, 26 | postalCode: String 27 | ) 28 | 29 | object Address { 30 | import CommonValidations._ 31 | def create(line1: String, line2: String, city: String, stateOrProvince: String, country: String, postalCode: String): ValidationNel[String, Address] = 32 | (checkString(line1).toValidationNel |@| 33 | checkString(line2).toValidationNel |@| 34 | checkString(city).toValidationNel |@| 35 | checkString(stateOrProvince).toValidationNel |@| 36 | checkString(country).toValidationNel |@| 37 | checkString(postalCode).toValidationNel) { 38 | Address(_, _, _, _, _, _) 39 | } 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /mongo-cqrs-cs-app/src/test/scala/com/github/ironfish/akka/persistence/cqrscs/sample/EmployeeSpec.scala: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright (C) 2013-2014 Duncan DeVore. 3 | */ 4 | package com.github.ironfish.akka.persistence.cqrscs.sample 5 | 6 | import concurrent.duration._ 7 | import org.scalatest._ 8 | import akka.actor.{PoisonPill, ActorRef, Props, ActorSystem} 9 | import scala.concurrent.stm.Ref 10 | import scala.concurrent.Await 11 | import akka.testkit.{TestProbe, TestKit} 12 | import de.flapdoodle.embed.process.runtime.Network 13 | import de.flapdoodle.embed.process.io.directories.PlatformTempDir 14 | import de.flapdoodle.embed.process.extract.UUIDTempNaming 15 | import de.flapdoodle.embed.mongo.{MongodStarter, Command} 16 | import de.flapdoodle.embed.mongo.distribution.Version 17 | import de.flapdoodle.embed.process.config.io.ProcessOutput 18 | import de.flapdoodle.embed.process.io.{NullProcessor, Processors} 19 | import de.flapdoodle.embed.process.config.IRuntimeConfig 20 | import de.flapdoodle.embed.mongo.config._ 21 | import com.typesafe.config.ConfigFactory 22 | import com.mongodb.casbah.Imports._ 23 | import com.novus.salat._ 24 | import com.novus.salat.global._ 25 | 26 | /** 27 | * This spec uses embedded mongo on a random open port to simulate a new hire, termination, and rehire of 28 | * an employee. Recover is also demonstrated. 29 | */ 30 | class EmployeeSpec extends TestKit(ActorSystem("test", EmployeeSpec.config(EmployeeSpec.freePort))) 31 | with WordSpecLike 32 | with MustMatchers 33 | with BeforeAndAfterAll 34 | with BeforeAndAfterEach { 35 | 36 | import EmployeeSpec._ 37 | 38 | // Begin embedded mongo setup. 39 | lazy val host = "localhost" 40 | lazy val port = freePort 41 | lazy val localHostIPV6 = Network.localhostIsIPv6() 42 | val artifactStorePath = new PlatformTempDir() 43 | val executableNaming = new UUIDTempNaming() 44 | val command = Command.MongoD 45 | val version = Version.Main.PRODUCTION 46 | 47 | // Used to filter out console output messages. 48 | val processOutput = new ProcessOutput( 49 | Processors.named("[mongod>]", new NullProcessor), 50 | Processors.named("[MONGOD>]", new NullProcessor), 51 | Processors.named("[console>]", new NullProcessor)) 52 | 53 | val runtimeConfig: IRuntimeConfig = 54 | new RuntimeConfigBuilder() 55 | .defaults(command) 56 | .processOutput(processOutput) 57 | .artifactStore(new ArtifactStoreBuilder() 58 | .defaults(command) 59 | .download(new DownloadConfigBuilder() 60 | .defaultsForCommand(command) 61 | .artifactStorePath(artifactStorePath)) 62 | .executableNaming(executableNaming)) 63 | .build() 64 | 65 | val mongodConfig = 66 | new MongodConfigBuilder() 67 | .version(version) 68 | .net(new Net(port, localHostIPV6)) 69 | .cmdOptions(new MongoCmdOptionsBuilder() 70 | .syncDelay(1) 71 | .useNoPrealloc(false) 72 | .useSmallFiles(false) 73 | .useNoJournal(false) 74 | .enableTextSearch(true) 75 | .build()) 76 | .build() 77 | 78 | lazy val mongodStarter = MongodStarter.getInstance(runtimeConfig) 79 | lazy val mongod = mongodStarter.prepare(mongodConfig) 80 | lazy val mongodExe = mongod.start() 81 | // End embedded mongo. 82 | 83 | val duration = 10.seconds 84 | 85 | var ref: Ref[Map[String, Employee]] = _ 86 | var employeeProcessor, benefits: ActorRef = _ 87 | var employeeService: EmployeeService = _ 88 | 89 | val Id = "111-222-3333" 90 | val LastName = "Walsh" 91 | val LastNameChanged = "Doback" 92 | val FirstName = "Sean" 93 | val Line1 = "200 Park Ave" 94 | val Line2 = "Apt 3C" 95 | val City = "New York" 96 | val State = "NY" 97 | val Country = "USA" 98 | val Postal = "10030" 99 | val StartDateMarch1_2014 = 1393632000000L 100 | val Dept = "Technology" 101 | val Title = "King" 102 | val TermDateApril1_2014 = 1396396800000L 103 | val RehireDateMay1_2014 = 1398902400000L 104 | 105 | override def beforeAll(): Unit = { 106 | // Here we essentially bootstrap our test application. 107 | mongodExe 108 | ref = Ref(Map.empty[String, Employee]) 109 | benefits = system.actorOf(Props(new Benefits("localhost", freePort)), name = "benefits") 110 | 111 | employeeProcessor = system.actorOf(Props(new EmployeeProcessor(ref, benefits.path)), name = "employee-processor") 112 | employeeService = new EmployeeService(ref, employeeProcessor) 113 | } 114 | 115 | override def afterAll(): Unit = { 116 | // Cleanup 117 | system.shutdown() 118 | system.awaitTermination(10.seconds) 119 | mongod.stop() 120 | mongodExe.stop() 121 | } 122 | 123 | def subscribeToEventStream(probe: TestProbe, clazz: Class[_]) = system.eventStream.subscribe(probe.ref, clazz) 124 | 125 | "The Application" must { 126 | "hire a new employee and update CQRS read side benefits persistence" in { 127 | val confirmProbe = TestProbe() 128 | subscribeToEventStream(confirmProbe, classOf[MsgConfirmed]) 129 | 130 | Await.result(employeeService.sendCommand(HireEmployee(Id, LastName, FirstName, Line1, Line2, City, State, Country, 131 | Postal, StartDateMarch1_2014, Dept, Title, BigDecimal(50000))), duration).isSuccess must be(right = true) 132 | employeeService.getAll.size must be(1) 133 | employeeService.getAllActive.size must be(1) 134 | 135 | val con = MongoClient("localhost", freePort) 136 | 137 | val col = con("hr")("benefits") 138 | val Expected = Some(EmployeeBenefits(Id, StartDateMarch1_2014, Nil, Nil)) 139 | 140 | awaitCond({ 141 | val dbo = col.findOne(MongoDBObject("employeeId" -> Id)) 142 | if (!dbo.isDefined) false 143 | else if (Some(grater[EmployeeBenefits].asObject(dbo.get)) == Expected) true 144 | else false 145 | }, duration, 100 milliseconds, "Benefits read side failure.") 146 | 147 | confirmProbe.expectMsgClass(3 seconds, classOf[MsgConfirmed]) 148 | } 149 | 150 | "fail change the employee last name with null and receive validation" in { 151 | Await.result(employeeService.sendCommand(ChangeEmployeeLastName(Id, null, 0L)), duration).isFailure must be(true) 152 | } 153 | 154 | "succeed in changing the employee last name" in { 155 | Await.result(employeeService.sendCommand(ChangeEmployeeLastName(Id, LastNameChanged, 0L)), duration).isSuccess must be(right = true) 156 | val emp = employeeService.get(Id) 157 | emp.isDefined must be(true) 158 | emp.get.lastName must be(LastNameChanged) 159 | emp.get.version must be(1L) 160 | } 161 | 162 | "terminate the employee and update CQRS read side benefits persistence" in { 163 | val confirmProbe = TestProbe() 164 | subscribeToEventStream(confirmProbe, classOf[MsgConfirmed]) 165 | 166 | Await.result(employeeService.sendCommand(TerminateEmployee(Id, TermDateApril1_2014, "too studly", 1L)), duration).isSuccess must be(right = true) 167 | employeeService.getAll.size must be(1) 168 | employeeService.getAllActive.size must be(0) 169 | employeeService.getAllTerminated.size must be(1) 170 | 171 | val con = MongoClient("localhost", freePort) 172 | val col = con("hr")("benefits") 173 | val Expected = Some(EmployeeBenefits(Id, StartDateMarch1_2014, List(TermDateApril1_2014), Nil)) 174 | 175 | awaitCond({ 176 | val dbo = col.findOne(MongoDBObject("employeeId" -> Id)) 177 | if (!dbo.isDefined) false 178 | else if (Some(grater[EmployeeBenefits].asObject(dbo.get)) == Expected) true 179 | else false 180 | }, duration, 100 milliseconds, "Benefits read side failure.") 181 | 182 | confirmProbe.expectMsgClass(3 seconds, classOf[MsgConfirmed]) 183 | } 184 | 185 | "rehire the employee and update CQRS read side benefits persistence" in { 186 | val confirmProbe = TestProbe() 187 | subscribeToEventStream(confirmProbe, classOf[MsgConfirmed]) 188 | 189 | val res = Await.result(employeeService.sendCommand(RehireEmployee(Id, RehireDateMay1_2014, 2L)), duration) 190 | res.isSuccess must be(right = true) 191 | employeeService.getAll.size must be(1) 192 | employeeService.getAllActive.size must be(1) 193 | employeeService.getAllTerminated.size must be(0) 194 | 195 | val con = MongoClient("localhost", freePort) 196 | val col = con("hr")("benefits") 197 | val Expected = Some(EmployeeBenefits(Id, StartDateMarch1_2014, List(TermDateApril1_2014), List(RehireDateMay1_2014))) 198 | 199 | awaitCond({ 200 | val dbo = col.findOne(MongoDBObject("employeeId" -> Id)) 201 | if (!dbo.isDefined) false 202 | else if (Some(grater[EmployeeBenefits].asObject(dbo.get)) == Expected) true 203 | else false 204 | }, duration, 100 milliseconds, "Benefits read side failure.") 205 | 206 | confirmProbe.expectMsgClass(3 seconds, classOf[MsgConfirmed]) 207 | } 208 | 209 | "snapshot the employee" in { 210 | val confirmProbe = TestProbe() 211 | subscribeToEventStream(confirmProbe, classOf[SnapshotConfirmed]) 212 | 213 | employeeProcessor ! SnapshotEmployee(Id) 214 | confirmProbe.expectMsgAllClassOf(3 seconds, classOf[SnapshotConfirmed]) 215 | } 216 | 217 | "recover with no updates to CQRS read side benefits persistence" in { 218 | // Kill off old persistence infrastructure. 219 | val probe1, probe2 = TestProbe() 220 | probe1 watch benefits 221 | probe2 watch employeeProcessor 222 | benefits ! PoisonPill 223 | employeeProcessor ! PoisonPill 224 | probe1.expectTerminated(benefits) 225 | probe2.expectTerminated(employeeProcessor) 226 | 227 | // Reinitialize persistence infrastructure and ensure recovery. 228 | ref = Ref(Map.empty[String, Employee]) 229 | val probe4 = TestProbe() 230 | benefits = system.actorOf(Props(new Benefits("localhost", freePort)), name = "benefits") 231 | probe4 watch benefits 232 | employeeProcessor = system.actorOf(Props(new EmployeeProcessor(ref, benefits.path)), name = "employee-processor") 233 | employeeService = new EmployeeService(ref, employeeProcessor) 234 | awaitCond(ref.single.get.values.size == 1, duration, 100 milliseconds, "Employees did not recover.") 235 | probe4.expectNoMsg(500 milliseconds) 236 | } 237 | } 238 | } 239 | 240 | object EmployeeSpec { 241 | 242 | def config(port: Int) = ConfigFactory.parseString( 243 | s""" 244 | |akka.persistence.journal.plugin = "casbah-journal" 245 | |akka.persistence.snapshot-store.plugin = "casbah-snapshot-store" 246 | |akka.persistence.journal.max-deletion-batch-size = 3 247 | |akka.persistence.publish-plugin-commands = on 248 | |akka.persistence.publish-confirmations = on 249 | |casbah-journal.mongo-journal-url = "mongodb://localhost:$port/store.messages" 250 | |casbah-journal.mongo-journal-write-concern = "journaled" 251 | |casbah-journal.mongo-journal-write-concern-timeout = 10000 252 | |casbah-snapshot-store.mongo-snapshot-url = "mongodb://localhost:$port/store.snapshots" 253 | |casbah-snapshot-store.mongo-snapshot-write-concern = "journaled" 254 | |casbah-snapshot-store.mongo-snapshot-write-concern-timeout = 10000 255 | """.stripMargin) 256 | 257 | lazy val freePort = Network.getFreeServerPort 258 | } 259 | -------------------------------------------------------------------------------- /mongo-cqrs-es-app/README.md: -------------------------------------------------------------------------------- 1 | # CQRS/Event Sourcing Smaple Application 2 | 3 | ## Technology Stack 4 | 5 | * Akka-Persistence 6 | * Uses [Mongo Journal](https://github.com/ironfish/akka-persistence-mongo/) plugin for Akka Persistence implementation. 7 | * Uses [`PersistentActor`](http://doc.akka.io/docs/akka/current/scala/persistence.html#Event_sourcing) for processing CQRS command-side commands, journaling events, recovery and snapshots. 8 | * Uses [`PersistentView`](http://doc.akka.io/docs/akka/current/scala/persistence.html#Views) for projecting eventually consistent CQRS query-side data. 9 | * Uses [Scalaz](https://github.com/scalaz/scalaz) for non-breaking error validation of commands. 10 | * Uses [Salat](https://github.com/novus/salat) for case class serialization atop the mongo casbah driver on the query-side aggregation. 11 | * Uses [Embedded Mongo](https://github.com/flapdoodle-oss/de.flapdoodle.embed.mongo) for test persistence store. 12 | * Uses [ScalaTest](http://www.scalatest.org/) as test toolkit. 13 | 14 | ## Description 15 | 16 | This sample project demonstrates one way to implement an [Akka Persistence](http://doc.akka.io/docs/akka/current/scala/persistence.html) based CQRS Event Sourcing application. It contains a fictitious domain model that represents an employee management lifecycle where the issuing of domain commands, if valid, generate one or more domain events which are then journaled (persisted). 17 | 18 | This application does not have a UI component and is not intended to be a complete HR system. Rather it is designed to demonstrate the use of the above technologies in a simple use case. 19 | 20 | ## Important Terminology 21 | 22 | ### Event Sourcing 23 | 24 | Event sourcing provides a means by which one can capture the real intent of our users. In an Event sourcing system, all data operations are viewed as a sequence of events that are recorded to an append-only store. This pattern can simplify tasks in complex domains by avoiding the requirement to synchronize the data model and the business domain. It can improve performance, scalability, and responsiveness; provide consistency for transactional data, and maintain full audit trails and history that may enable compensating actions. 25 | 26 | Event Sourcing is a very powerful (and old) construct which allows for the recovery of the current state at any point in time from origin to now. Typically an event sourcing system will maintain a current state model in-memory for fast access. Such is the case with this example. 27 | 28 | #### Commands 29 | 30 | Commands are behavioral constructs that request to change the state of an entity. They are imperative by nature and as such represent a request that one can reject. A single Command can also request to change state in aggregate (similar to batching) from which, if valid, can generate many domain events. The construction of Commands follows the present tense `VerbNoun` format, for example: 31 | 32 | ```scala 33 | HireEmployee 34 | ChangeEmployeeStreet 35 | ``` 36 | 37 | #### Events 38 | 39 | Events are a key component of Event Sourcing, thus the name. Events are Indicative in nature and serve as a sign or indication that something has happened. As such, they are immutable and cannot be rejected. They follow a past tense `NounVerb` format, for example: 40 | 41 | ```scala 42 | EmployeeHired 43 | EmployeeStreetChanged 44 | ``` 45 | 46 | Events should be atomic in nature, and represent for form of state that has transpired against a domain aggregate. 47 | 48 | 49 | ### CQRS 50 | 51 | Command Query Responsibility Segregation is an architectural pattern created by Greg Young, based on Bertrand Meyer’s Command and Query Separation principle. Wikipedia defines this principle as: 52 | 53 | > It states that every method should either be a command that performs an action, or a query that returns data to the caller, but not both. In other words, asking a question should not change the answer. More formally, methods should return a value only if they are referentially transparent and hence possess no side effects. (Wikipedia) 54 | 55 | CQRS is similar to CQS in that it uses the same definition for Commands and Queries, but fundamentally believes that Commands and Queries should distinct objects in the domain. One of the key advantages to CQRS is that because the Commands (writes) are separate from the Queries (reads) it allows for distinct optimization of each of these concerns. 56 | 57 | This example implements CQRS in the following way: 58 | 59 | * The Command side contains two Scala files, `Employee.scala` & `EmployeeProtocol.scala`. 60 | * The Query side contains two Scala files, `Benefits.scala` & `BenefitsProtocol.scala`. 61 | 62 | The command side will process command requests and upon validation, generates and journals the appropriate events. These events are made _eventually consistent_ with the query side by way of an Akka-Persistence `View`. 63 | 64 | ## Workflow 65 | 66 | ### Command Side 67 | 68 | In this example, the Command side implements a `1-n`, single actor per aggregate type. Aggregates are case classes with the associated `PersistentActor` named `EmployeeProcessor`. The primary aggregate is the `Employee` entity which can be in one of the three following states: 69 | 70 | * `ActiveEmployee` - the employee is currently active. 71 | * `InactiveEmployee` - the employee is temporarily deactivated (i.e., leave of absence) 72 | * `TerminatedEmployee` - the employee has been terminated. 73 | 74 | The `EmployeeProcessor` is responsible for managing all the lifecycles for all employees, which entails the following responsibilities: 75 | 76 | * Process commands, upon validation, generate events. 77 | * Journaling valid events. 78 | * Updating the in-memory model for the current state of a given aggregate. This update may result in the change of the `Employee` type. 79 | 80 | ### Query Side 81 | 82 | The Query side implements a single aggregate the `BenefitDates` aggregate. Modification to the `BenefitDates` aggregate occurs whenever certain events are made _eventually consistent_ from the Command side. Eventual consistency is achieved by using a `PersistentView` named `BenefitsView`, which receives, transforms and persists the events. The events from the Command side that trigger this behavior are: 83 | 84 | * `EmployeeHired` - the hiring of an employee 85 | * `EmployeeDeactivated` - whenever an employee id deactivated (i.e., leave of absence). 86 | * `EmployeeActivated` - the activation of a de-active employee. 87 | * `EmployeeTerminated` - the termination of an active employee. 88 | * `EmployeeRehired` - the re-hire of a terminated employee. 89 | 90 | ## DDD Implementation 91 | 92 | There has been a fair amount of discussion around how to design a distributed domain model using Akka. See the following links for information around a design pattern that seems to be materializing: 93 | 94 | * [Creating one instance of an Akka actor per entity](https://groups.google.com/forum/#!topic/akka-user/BRh3YNjP0kY) 95 | * [Cluster sharding for akka-persistence](https://groups.google.com/forum/#!msg/akka-dev/ohdT-Et4ZoY/6cB52mnpkAkJ) 96 | * [Using Scala and Akka with Domain-Driven Design](https://vaughnvernon.co/?p=770) 97 | 98 | The general approach identified in the links above is one of a single actor per aggregate, `1-1`. While, in the long run, this is a better approach (especially in regards to clustering), I chose not to implement this design for this example. 99 | 100 | ## Status 101 | 102 | - ~~Initial commit with base functionality~~ 103 | - ~~Add State changes~~ 104 | - ~~Deactive~~ 105 | - ~~Activate~~ 106 | - ~~Terminate~~ 107 | - ~~Rehire~~ 108 | - ~~Add Run Payroll~~ 109 | - ~~Implement base tests~~ 110 | - ~~Implement CQRS query side w/ views~~ 111 | - ~~Implement CQRS tests~~ 112 | - ~~Implement get~~ 113 | - Implement `AtLeastOnceDelivery` 114 | - Implement `AtLeastOnceDelivery` confirmation test 115 | 116 | ## Author / Maintainer 117 | 118 | - [Duncan DeVore (@ironfish)](https://github.com/ddevore/) 119 | 120 | ## Contributors 121 | 122 | - [Sean Walsh (@SeanWalshEsq)](https://github.com/sean-walsh/) 123 | - [Al Iacovella](https://github.com/aiacovella/) 124 | -------------------------------------------------------------------------------- /mongo-cqrs-es-app/build.sbt: -------------------------------------------------------------------------------- 1 | organization := Common.Organization 2 | 3 | name := Common.NameMongoCqrsEsApp 4 | 5 | scalaVersion := Common.ScalaVersion 6 | 7 | crossScalaVersions := Common.CrossScalaVersions 8 | 9 | version := "0.3.1-SNAPSHOT" 10 | 11 | parallelExecution in Test := Common.ParallelExecutionInTest 12 | 13 | scalacOptions ++= Common.ScalaCOptions 14 | 15 | publishLocal := {} 16 | 17 | publish := {} 18 | 19 | publishArtifact := false 20 | 21 | resolvers += "Sonatype OSS Releases" at "https://oss.sonatype.org/content/repositories/releases" 22 | 23 | libraryDependencies ++= Seq( 24 | "com.typesafe.akka" %% "akka-persistence-experimental" % Common.AkkaVersion % "compile", 25 | "com.github.ironfish" %% "akka-persistence-mongo-casbah" % Common.PluginVersion % "compile", 26 | "org.scalaz" %% "scalaz-core" % Common.ScalazVersion % "compile", 27 | "com.novus" %% "salat" % Common.SalatVersion % "compile", 28 | "org.scala-stm" %% "scala-stm" % Common.ScalaStmVersion % "compile", 29 | "com.typesafe.akka" %% "akka-slf4j" % Common.AkkaVersion % "compile", 30 | "com.typesafe.akka" %% "akka-testkit" % Common.AkkaVersion % "test", 31 | "de.flapdoodle.embed" % "de.flapdoodle.embed.mongo" % Common.EmbeddedMongoVersion % "test", 32 | "org.scalatest" %% "scalatest" % Common.ScalatestVersion % "test" 33 | ) 34 | -------------------------------------------------------------------------------- /mongo-cqrs-es-app/src/main/scala/com/github/ironfish/akka/persistence/cqrses/sample/Benefits.scala: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright (C) 2013-2014 Duncan DeVore. 3 | */ 4 | package com.github.ironfish.akka.persistence.cqrses.sample 5 | 6 | import akka.persistence._ 7 | 8 | import com.mongodb.casbah.Imports._ 9 | import com.novus.salat._ 10 | import com.novus.salat.global._ 11 | 12 | sealed trait Benefits { 13 | def employeeId: String 14 | } 15 | 16 | case class BenefitDates( 17 | employeeId: String, 18 | startDate: Long, 19 | deactivateDates: List[Long], 20 | termDates: List[Long], 21 | rehireDates: List[Long]) extends Benefits 22 | 23 | class BenefitsView extends PersistentView { 24 | import EmployeeProtocol._ 25 | implicit val concern = WriteConcern.JournalSafe 26 | 27 | def config = context.system.settings.config.getConfig("benefits-view") 28 | 29 | private val uri = MongoClientURI(config.getString("mongo-url")) 30 | private val client = MongoClient(uri) 31 | private val db = client(uri.database.get) 32 | private val coll = db(uri.collection.get) 33 | 34 | override def persistenceId = "employee-persistence" 35 | override def viewId = "benefits-view" 36 | 37 | def receive = { 38 | case payload if isPersistent => 39 | // Handle journal events 40 | payload match { 41 | case evt: EmployeeHired => 42 | val eb = BenefitDates(evt.id, evt.startDate, Nil, Nil, Nil) 43 | val dbo = grater[BenefitDates].asDBObject(eb) 44 | coll.insert(dbo) 45 | 46 | case evt: EmployeeDeactivated => 47 | val dbo = coll.findOne(MongoDBObject("employeeId" -> evt.id)).get 48 | val eb = grater[BenefitDates].asObject(dbo) 49 | val up = eb.copy(deactivateDates = eb.deactivateDates :+ evt.deactivateDate) 50 | coll.update(MongoDBObject("employeeId" -> evt.id), grater[BenefitDates].asDBObject(up)) 51 | 52 | case evt: EmployeeActivated => 53 | val dbo = coll.findOne(MongoDBObject("employeeId" -> evt.id)).get 54 | val eb = grater[BenefitDates].asObject(dbo) 55 | val up = eb.copy(startDate = evt.activateDate) 56 | coll.update(MongoDBObject("employeeId" -> evt.id), grater[BenefitDates].asDBObject(up)) 57 | 58 | case evt: EmployeeTerminated => 59 | val dbo = coll.findOne(MongoDBObject("employeeId" -> evt.id)).get 60 | val eb = grater[BenefitDates].asObject(dbo) 61 | val up = eb.copy(termDates = eb.termDates :+ evt.termDate) 62 | coll.update(MongoDBObject("employeeId" -> evt.id), grater[BenefitDates].asDBObject(up)) 63 | 64 | case evt: EmployeeRehired => 65 | val dbo = coll.findOne(MongoDBObject("employeeId" -> evt.id)).get 66 | val eb = grater[BenefitDates].asObject(dbo) 67 | val up = eb.copy(rehireDates = eb.rehireDates :+ evt.rehireDate) 68 | coll.update(MongoDBObject("employeeId" -> evt.id), grater[BenefitDates].asDBObject(up)) 69 | 70 | case _ => // ignore 71 | } 72 | case payload => // Handle user events 73 | 74 | } 75 | 76 | override def postStop(): Unit = { 77 | client.close() 78 | super.postStop() 79 | } 80 | } 81 | -------------------------------------------------------------------------------- /mongo-cqrs-es-app/src/main/scala/com/github/ironfish/akka/persistence/cqrses/sample/BenefitsProtocol.scala: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright (C) 2013-2014 Duncan DeVore. 3 | */ 4 | package com.github.ironfish.akka.persistence.cqrses.sample 5 | 6 | object BenefitsProtocol { 7 | 8 | /** 9 | * These are the sealed commands of every action that may be performed by an employee. Commands can be rejected. 10 | */ 11 | sealed trait BenefitsMessage { 12 | def date: Long 13 | } 14 | 15 | final case class BenefitsHired(date: Long, data: String) extends BenefitsMessage 16 | final case class BenefitsDeactivated(date: Long, data: String) extends BenefitsMessage 17 | final case class BenefitsActivated(date: Long, data: String) extends BenefitsMessage 18 | final case class BenefitsTerminated(date: Long, data: String) extends BenefitsMessage 19 | final case class BenefitsRehired(date: Long, data: String) extends BenefitsMessage 20 | } 21 | -------------------------------------------------------------------------------- /mongo-cqrs-es-app/src/main/scala/com/github/ironfish/akka/persistence/cqrses/sample/Employee.scala: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright (C) 2013-2014 Duncan DeVore. 3 | */ 4 | package com.github.ironfish.akka.persistence.cqrses.sample 5 | 6 | import akka.persistence.{PersistentActor, SnapshotOffer} 7 | 8 | import org.joda.time.{DateTimeZone, DateTime} 9 | 10 | import scalaz._ 11 | import Scalaz._ 12 | 13 | /** 14 | * An employee domain aggregate trait to be extended by ActiveEmployee, InactiveEmployee, etc. 15 | */ 16 | sealed trait Employee { 17 | def id: String 18 | def version: Long 19 | def lastName: String 20 | def firstName: String 21 | def address: Address 22 | def startDate: Long 23 | def dept: String 24 | def title: String 25 | def salary: BigDecimal 26 | def salaryOwed: BigDecimal 27 | } 28 | 29 | /** 30 | * Companion object for employee abstraction that handles version validation. 31 | */ 32 | object Employee { 33 | import EmployeeProtocol._ 34 | 35 | def requireVersion[A <: Employee](e: A, cmd: EmployeeCommand): DomainValidation[A] = { 36 | if (cmd.expectedVersion == e.version) e.successNel 37 | else s"$e version does not match $cmd version".failureNel 38 | } 39 | } 40 | 41 | /** 42 | * Validations trait for employee abstractions. 43 | */ 44 | trait EmployeeValidations { 45 | case object IdRequired extends ValidationKey 46 | case object LastNameRequired extends ValidationKey 47 | case object FirstNameRequired extends ValidationKey 48 | case object DepartmentRequired extends ValidationKey 49 | case object TitleRequired extends ValidationKey 50 | case object TerminationReasonRequired extends ValidationKey 51 | 52 | def checkStartDate(d: Long): Validation[String, Long] = { 53 | val dt = new DateTime(d, DateTimeZone.UTC) 54 | if (dt.getMillis != dt.withTimeAtStartOfDay.getMillis) s"start date $d must be start of day boundary".failure else d.success 55 | } 56 | 57 | def checkSalary(s: BigDecimal): Validation[String, BigDecimal] = 58 | if (s < 0 || s > BigDecimal(1000000)) s"salary $s must be between 0 and 1000000.00".failure else s.success 59 | 60 | def checkDeactivateDate(dd: Long, sd: Long): Validation[String, Long] = 61 | if (dd <= sd) s"deactivate date $dd must be greater than start date $sd".failure else dd.success 62 | 63 | def checkActivateDate(sd: Long, dd: Long): Validation[String, Long] = 64 | if (sd <= dd) s"activate date $sd must be greater than deactivate date $dd".failure else sd.success 65 | 66 | def checkTerminationDate(td: Long, sd: Long): Validation[String, Long] = 67 | if (td <= sd) s"termination date $td must be greater than start date $sd".failure else td.success 68 | 69 | def checkRehireDate(rd: Long, td: Long): Validation[String, Long] = 70 | if (rd <= td) s"rehire date $td must be greater than termination date $td".failure else rd.success 71 | 72 | def checkPay(p: BigDecimal, so: BigDecimal): Validation[String, BigDecimal] = 73 | if(so - p < BigDecimal(0)) s"payment $p cannot be greater than salary owed $so".failure else p.success 74 | } 75 | 76 | /** 77 | * Case class that represents the state of an active employee. 78 | */ 79 | case class ActiveEmployee ( 80 | id: String, 81 | version: Long, 82 | lastName: String, 83 | firstName: String, 84 | address: Address, 85 | startDate: Long, 86 | dept: String, 87 | title: String, 88 | salary: BigDecimal, 89 | salaryOwed: BigDecimal) extends Employee with EmployeeValidations { 90 | import CommonValidations._ 91 | 92 | def withLastName(lastName: String): DomainValidation[ActiveEmployee] = 93 | checkString(lastName, LastNameRequired) fold (f => f.failureNel, s => copy(version = version + 1, lastName = s).success) 94 | 95 | def withFirstName(firstName: String): DomainValidation[ActiveEmployee] = 96 | checkString(firstName, FirstNameRequired) fold (f => f.failureNel, s => copy(version = version + 1, firstName = s).success) 97 | 98 | def withAddress(street: String, city: String, stateOrProvince: String, country: String, 99 | postalCode: String): DomainValidation[ActiveEmployee] = 100 | Address.validate(street, city, stateOrProvince, country, postalCode) fold (f => f.failure, s => copy(version = version + 1, 101 | address = s).success) 102 | 103 | def withStartDate(startDate: Long): DomainValidation[ActiveEmployee] = 104 | checkStartDate(startDate) fold (f => f.failureNel, s => copy(version = version + 1, startDate = s).success) 105 | 106 | def withDept(dept: String): DomainValidation[ActiveEmployee] = 107 | checkString(dept, DepartmentRequired) fold (f => f.failureNel, s => copy(version = version + 1, dept = s).success) 108 | 109 | def withTitle(title: String): DomainValidation[ActiveEmployee] = 110 | checkString(dept, TitleRequired) fold (f => f.failureNel, s => copy(version = version + 1, title = s).success) 111 | 112 | def withSalary(salary: BigDecimal): DomainValidation[ActiveEmployee] = 113 | checkSalary(salary) fold (f => f.failureNel, s => copy(version = version + 1, salary = s).success) 114 | 115 | def deactivate(deactivateDate: Long): DomainValidation[InactiveEmployee] = 116 | checkDeactivateDate(deactivateDate, this.startDate) fold ( 117 | f => f.failureNel, 118 | s => InactiveEmployee(this.id, this.version + 1, this.lastName, this.firstName, this.address, this.startDate, this.dept, 119 | this.title, this.salary, this.salaryOwed, s).success) 120 | 121 | def terminate(termDate: Long, termReason: String): DomainValidation[TerminatedEmployee] = 122 | (checkTerminationDate(termDate, this.startDate).toValidationNel |@| 123 | checkString(termReason, TerminationReasonRequired).toValidationNel) { (td, tr) => 124 | TerminatedEmployee(this.id, this.version + 1, this.lastName, this.firstName, this.address, this.startDate, this.dept, 125 | this.title, this.salary, this.salaryOwed, td, tr) 126 | } 127 | 128 | def pay(p: BigDecimal): DomainValidation[ActiveEmployee] = 129 | checkPay(p, this.salaryOwed) fold (f => f.failureNel, s => copy(version = version + 1, salaryOwed = salaryOwed - p).success) 130 | } 131 | 132 | /** 133 | * Companion object to ActiveEmployee for hiring. 134 | */ 135 | object ActiveEmployee extends EmployeeValidations { 136 | import EmployeeProtocol._ 137 | import CommonValidations._ 138 | 139 | def hire(cmd: HireEmployee): DomainValidation[ActiveEmployee] = 140 | (checkString(cmd.id, IdRequired).toValidationNel |@| 141 | 0L.successNel |@| 142 | checkString(cmd.lastName, LastNameRequired).toValidationNel |@| 143 | checkString(cmd.firstName, FirstNameRequired).toValidationNel |@| 144 | Address.validate(cmd.street, cmd.city, cmd.stateOrProvince, cmd.country, cmd.postalCode) |@| 145 | checkStartDate(cmd.startDate).toValidationNel |@| 146 | checkString(cmd.dept, DepartmentRequired).toValidationNel |@| 147 | checkString(cmd.title, TitleRequired).toValidationNel |@| 148 | checkSalary(cmd.salary).toValidationNel) { (id, v, ln, fn, a, sd, d, t, s) => 149 | ActiveEmployee(id, v, ln, fn, a, sd, d, t, s, s) } 150 | } 151 | 152 | /** 153 | * Case class that represents the state of an inactive employee. 154 | */ 155 | case class InactiveEmployee ( 156 | id: String, 157 | version: Long, 158 | lastName: String, 159 | firstName: String, 160 | address: Address, 161 | startDate: Long, 162 | dept: String, 163 | title: String, 164 | salary: BigDecimal, 165 | salaryOwed: BigDecimal, 166 | deactivateDate: Long) extends Employee with EmployeeValidations { 167 | 168 | def activate(startDate: Long): DomainValidation[ActiveEmployee] = 169 | (checkStartDate(startDate).toValidationNel |@| 170 | checkActivateDate(startDate, this.deactivateDate).toValidationNel) {(s1, s2) => 171 | ActiveEmployee(this.id, version = this.version + 1, this.lastName, this.firstName, this.address, s2, this.dept, this.title, 172 | this.salary, this.salaryOwed) 173 | } 174 | } 175 | 176 | /** 177 | * Case class that represents the state of a terminated employee. 178 | */ 179 | case class TerminatedEmployee ( 180 | id: String, 181 | version: Long, 182 | lastName: String, 183 | firstName: String, 184 | address: Address, 185 | startDate: Long, 186 | dept: String, 187 | title: String, 188 | salary: BigDecimal, 189 | salaryOwed: BigDecimal, 190 | termDate: Long, 191 | termReason: String) extends Employee with EmployeeValidations { 192 | 193 | def rehire(rehireDate: Long): DomainValidation[ActiveEmployee] = 194 | checkRehireDate(rehireDate, this.termDate) fold ( 195 | f => f.failureNel, 196 | s => ActiveEmployee(this.id, version = this.version + 1, this.lastName, this.firstName, this.address, rehireDate, 197 | this.dept, this.title, this.salary, this.salaryOwed).success) 198 | } 199 | 200 | /** 201 | * Case class the contains the in-memory current state of all [[Employee]] aggregates. 202 | * @param employees Map[String, Employee] that contains the current state of all [[Employee]] aggregates. 203 | */ 204 | final case class EmployeeState(employees: Map[String, Employee] = Map.empty) { 205 | def update(e: Employee) = copy(employees = employees + (e.id -> e)) 206 | def get(id: String) = employees.get(id) 207 | def getActive(id: String) = get(id) map (_.asInstanceOf[ActiveEmployee]) 208 | def getActiveAll = employees map (_._2.asInstanceOf[ActiveEmployee]) 209 | def getInactive(id: String) = get(id) map (_.asInstanceOf[InactiveEmployee]) 210 | def getTerminated(id: String) = get(id) map (_.asInstanceOf[TerminatedEmployee]) 211 | } 212 | 213 | /** 214 | * The EmployeeProcessor is responsible for maintaining state changes for all [[Employee]] aggregates. This particular 215 | * processor uses Akka-Persistence's [[PersistentActor]]. It receives Commands and if valid will persist the generated events, 216 | * afterwhich it will updated the current state of the [[Employee]] being processed. 217 | */ 218 | class EmployeeProcessor extends PersistentActor { 219 | import EmployeeProtocol._ 220 | 221 | override def persistenceId = "employee-persistence" 222 | 223 | var state = EmployeeState() 224 | 225 | def updateState(emp: Employee): Unit = 226 | state = state.update(emp) 227 | 228 | /** 229 | * These are the events that are recovered during journal recovery. They cannot fail and must be processed to recreate the current 230 | * state of the aggregate. 231 | */ 232 | val receiveRecover: Receive = { 233 | case evt: EmployeeHired => 234 | updateState(ActiveEmployee(evt.id, evt.version, evt.lastName, evt.firstName, Address(evt.street, 235 | evt.city, evt.stateOrProvince, evt.country, evt.postalCode), evt.startDate, evt.dept, evt.title, evt.salary, evt.salary)) 236 | case evt: EmployeeLastNameChanged => 237 | updateState(state.getActive(evt.id).get.copy(version = evt.version, lastName = evt.lastName)) 238 | case evt: EmployeeFirstNameChanged => 239 | updateState(state.getActive(evt.id).get.copy(version = evt.version, firstName = evt.firstName)) 240 | case evt: EmployeeAddressChanged => 241 | updateState(state.getActive(evt.id).get.copy(version = evt.version, address = Address(evt.street, evt.city, 242 | evt.stateOrProvince, evt.country, evt.postalCode))) 243 | case evt: EmployeeStartDateChanged => 244 | updateState(state.getActive(evt.id).get.copy(version = evt.version, startDate = evt.startDate)) 245 | case evt: EmployeeDeptChanged => 246 | updateState(state.getActive(evt.id).get.copy(version = evt.version, dept = evt.dept)) 247 | case evt: EmployeeTitleChanged => 248 | updateState(state.getActive(evt.id).get.copy(version = evt.version, title = evt.title)) 249 | case evt: EmployeeSalaryChanged => 250 | updateState(state.getActive(evt.id).get.copy(version = evt.version, salary = evt.salary)) 251 | case evt: EmployeeDeactivated => 252 | updateState(state.getActive(evt.id).map(e => InactiveEmployee(e.id, evt.version, e.lastName, e.firstName, e.address, 253 | e.startDate, e.dept, e.title, e.salary, e.salaryOwed, evt.deactivateDate)).get) 254 | case evt: EmployeeActivated => 255 | updateState(state.getInactive(evt.id).map(e => ActiveEmployee(e.id, evt.version, e.lastName, e.firstName, e.address, 256 | evt.activateDate, e.dept, e.title, e.salary, e.salaryOwed)).get) 257 | case evt: EmployeeTerminated => 258 | updateState(state.getActive(evt.id).map(e => TerminatedEmployee(e.id, evt.version, e.lastName, e.firstName, e.address, 259 | e.startDate, e.dept, e.title, e.salary, e.salaryOwed, evt.termDate, evt.termReason)).get) 260 | case evt: EmployeeRehired => 261 | updateState(state.getTerminated(evt.id).map(e => ActiveEmployee(e.id, evt.version, e.lastName, e.firstName, e.address, 262 | evt.rehireDate, e.dept, e.title, e.salary, e.salaryOwed)).get) 263 | case evt: EmployeePaid => 264 | updateState(state.getActive(evt.id).map(e => e.copy(version = evt.version, salaryOwed = e.salaryOwed - evt.amount)).get) 265 | case SnapshotOffer(_, snapshot: EmployeeState) => state = snapshot 266 | } 267 | 268 | /** 269 | * These are the commands that are requested. As command they can fail sending response back to the user. Each command will 270 | * generate one or more events to be journaled. 271 | */ 272 | val receiveCommand: Receive = { 273 | case cmd: HireEmployee => hire(cmd) fold ( 274 | f => sender ! ErrorMessage(s"error $f occurred on $cmd"), 275 | s => persist(EmployeeHired(s.id, s.version, s.lastName, s.firstName, s.address.street, s.address.city, 276 | s.address.stateOrProvince, s.address.postalCode, s.address.country, s.startDate, s.dept, s.title, s.salary)) { event => 277 | updateState(s) 278 | context.system.eventStream.publish(event) 279 | }) 280 | case cmd: ChangeEmployeeLastName => changeLastName(cmd) fold ( 281 | f => sender ! ErrorMessage(s"error $f occurred on $cmd"), 282 | s => persist(EmployeeLastNameChanged(s.id, s.version, s.lastName)) { event => 283 | updateState(s) 284 | context.system.eventStream.publish(event) 285 | }) 286 | case cmd: ChangeEmployeeFirstName => changeFirstName(cmd) fold ( 287 | f => sender ! ErrorMessage(s"error $f occurred on $cmd"), 288 | s => persist(EmployeeFirstNameChanged(s.id, s.version, s.firstName)) { event => 289 | updateState(s) 290 | context.system.eventStream.publish(event) 291 | }) 292 | case cmd: ChangeEmployeeAddress => changeAddress(cmd) fold ( 293 | f => sender ! ErrorMessage(s"error $f occurred on $cmd"), 294 | s => persist(EmployeeAddressChanged(s.id, s.version, s.address.street, s.address.city, s.address.stateOrProvince, 295 | s.address.country, s.address.postalCode)) { event => 296 | updateState(s) 297 | context.system.eventStream.publish(event) 298 | }) 299 | case cmd: ChangeEmployeeStartDate => changeStartDate(cmd) fold ( 300 | f => sender ! ErrorMessage(s"error $f occurred on $cmd"), 301 | s => persist(EmployeeStartDateChanged(s.id, s.version, s.startDate)) { event => 302 | updateState(s) 303 | context.system.eventStream.publish(event) 304 | }) 305 | case cmd: ChangeEmployeeDept => changeDept(cmd) fold ( 306 | f => sender ! ErrorMessage(s"error $f occurred on $cmd"), 307 | s => persist(EmployeeDeptChanged(s.id, s.version, s.dept)) { event => 308 | updateState(s) 309 | context.system.eventStream.publish(event) 310 | }) 311 | case cmd: ChangeEmployeeTitle => changeTitle(cmd) fold ( 312 | f => sender ! ErrorMessage(s"error $f occurred on $cmd"), 313 | s => persist(EmployeeTitleChanged(s.id, s.version, s.title)) { event => 314 | updateState(s) 315 | context.system.eventStream.publish(event) 316 | }) 317 | case cmd: ChangeEmployeeSalary => changeSalary(cmd) fold ( 318 | f => sender ! ErrorMessage(s"error $f occurred on $cmd"), 319 | s => persist(EmployeeSalaryChanged(s.id, s.version, s.salary)) { event => 320 | updateState(s) 321 | context.system.eventStream.publish(event) 322 | }) 323 | case cmd: DeactivateEmployee => deactivate(cmd) fold ( 324 | f => sender ! ErrorMessage(s"error $f occurred on $cmd"), 325 | s => persist(EmployeeDeactivated(s.id, s.version, s.deactivateDate)) { event => 326 | updateState(s) 327 | context.system.eventStream.publish(event) 328 | }) 329 | case cmd: ActivateEmployee => activate(cmd) fold ( 330 | f => sender ! ErrorMessage(s"error $f occurred on $cmd"), 331 | s => persist(EmployeeActivated(s.id, s.version, s.startDate)) { event => 332 | updateState(s) 333 | context.system.eventStream.publish(event) 334 | }) 335 | case cmd: TerminateEmployee => terminate(cmd) fold ( 336 | f => sender ! ErrorMessage(s"error $f occurred on $cmd"), 337 | s => persist(EmployeeTerminated(s.id, s.version, s.termDate, s.termReason)) { event => 338 | updateState(s) 339 | context.system.eventStream.publish(event) 340 | }) 341 | case cmd: RehireEmployee => rehire(cmd) fold ( 342 | f => sender ! ErrorMessage(s"error $f occurred on $cmd"), 343 | s => persist(EmployeeRehired(s.id, s.version, s.startDate)) { event => 344 | updateState(s) 345 | context.system.eventStream.publish(event) 346 | }) 347 | case RunPayroll => state.getActiveAll map { e => 348 | val cmd = PayEmployee(e.id, e.version, BigDecimal(5000)) 349 | pay(cmd) fold( 350 | f => sender ! ErrorMessage(s"error $f occurred on $cmd"), 351 | s => persist(EmployeePaid(s.id, s.version, cmd.amount)) { event => 352 | updateState(s) 353 | context.system.eventStream.publish(event) 354 | })} 355 | case cmd: GetEmployee => 356 | sender ! state.get(cmd.id) 357 | case SnapshotEmployees => saveSnapshot(state) 358 | case "print" => println("STATE: " + state) 359 | } 360 | 361 | def hire(cmd: HireEmployee): DomainValidation[ActiveEmployee] = 362 | state.get(cmd.id) match { 363 | case Some(emp) => s"employee for $cmd already exists".failureNel 364 | case None => ActiveEmployee.hire(cmd) 365 | } 366 | 367 | def changeLastName(cmd: ChangeEmployeeLastName): DomainValidation[ActiveEmployee] = 368 | updateActive(cmd) { e => e.withLastName(cmd.lastName) } 369 | 370 | def changeFirstName(cmd: ChangeEmployeeFirstName): DomainValidation[ActiveEmployee] = 371 | updateActive(cmd) { e => e.withFirstName(cmd.firstName) } 372 | 373 | def changeAddress(cmd: ChangeEmployeeAddress): DomainValidation[ActiveEmployee] = 374 | updateActive(cmd) { e => e.withAddress(cmd.street, cmd.city, cmd.stateOrProvince, cmd.country, cmd.postalCode) } 375 | 376 | def changeStartDate(cmd: ChangeEmployeeStartDate): DomainValidation[ActiveEmployee] = 377 | updateActive(cmd) { e => e.withStartDate(cmd.startDate) } 378 | 379 | def changeDept(cmd: ChangeEmployeeDept): DomainValidation[ActiveEmployee] = 380 | updateActive(cmd) { e => e.withDept(cmd.dept) } 381 | 382 | def changeTitle(cmd: ChangeEmployeeTitle): DomainValidation[ActiveEmployee] = 383 | updateActive(cmd) { e => e.withTitle(cmd.title) } 384 | 385 | def changeSalary(cmd: ChangeEmployeeSalary): DomainValidation[ActiveEmployee] = 386 | updateActive(cmd) { e => e.withSalary(cmd.salary) } 387 | 388 | def deactivate(cmd: DeactivateEmployee): DomainValidation[InactiveEmployee] = 389 | updateActive(cmd) { e => e.deactivate(cmd.deactivateDate) } 390 | 391 | def activate(cmd: ActivateEmployee): DomainValidation[ActiveEmployee] = 392 | updateInactive(cmd) { e => e.activate(cmd.activateDate) } 393 | 394 | def terminate(cmd: TerminateEmployee): DomainValidation[TerminatedEmployee] = 395 | updateActive(cmd) { e => e.terminate(cmd.termDate, cmd.termReason) } 396 | 397 | def rehire(cmd: RehireEmployee): DomainValidation[ActiveEmployee] = 398 | updateTerminated(cmd) { e => e.rehire(cmd.rehireDate) } 399 | 400 | def pay(cmd: PayEmployee): DomainValidation[ActiveEmployee] = 401 | updateActive(cmd) { e => e.pay(cmd.amount) } 402 | 403 | def updateEmployee[A <: Employee](cmd: EmployeeCommand)(fn: Employee => DomainValidation[A]): DomainValidation[A] = 404 | state.get(cmd.id) match { 405 | case Some(emp) => Employee.requireVersion(emp, cmd) fold (f => f.failure, s => fn(s)) 406 | case None => s"employee for $cmd does not exist".failureNel 407 | } 408 | 409 | def updateActive[A <: Employee](cmd: EmployeeCommand)(fn: ActiveEmployee => DomainValidation[A]): DomainValidation[A] = 410 | updateEmployee(cmd) { 411 | case emp: ActiveEmployee => fn(emp) 412 | case emp => s"$emp for $cmd is not active".failureNel 413 | } 414 | 415 | def updateInactive[A <: Employee](cmd: EmployeeCommand)(fn: InactiveEmployee => DomainValidation[A]): DomainValidation[A] = 416 | updateEmployee(cmd) { 417 | case emp: InactiveEmployee => fn(emp) 418 | case emp => s"$emp for $cmd is not inactive".failureNel 419 | } 420 | 421 | def updateTerminated[A <: Employee](cmd: EmployeeCommand)(fn: TerminatedEmployee => DomainValidation[A]): DomainValidation[A] = 422 | updateEmployee(cmd) { 423 | case emp: TerminatedEmployee => fn(emp) 424 | case emp => s"$emp for $cmd is not terminated".failureNel 425 | } 426 | } 427 | -------------------------------------------------------------------------------- /mongo-cqrs-es-app/src/main/scala/com/github/ironfish/akka/persistence/cqrses/sample/EmployeeProtocol.scala: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright (C) 2013-2014 Duncan DeVore. 3 | */ 4 | package com.github.ironfish.akka.persistence.cqrses.sample 5 | 6 | /** 7 | * This object contains all the commands and events as well as other messages that the employee aggregate may process. 8 | */ 9 | object EmployeeProtocol { 10 | 11 | /** 12 | * These are the sealed commands of every action that may be performed by an employee. Commands can be rejected. 13 | */ 14 | sealed trait EmployeeCommand { 15 | def id: String 16 | def expectedVersion: Long 17 | } 18 | 19 | final case class HireEmployee(id: String, expectedVersion: Long = -1L, lastName: String, firstName: String, street: String, 20 | city: String, stateOrProvince: String, postalCode: String, country: String, startDate: Long, dept: String, title: String, 21 | salary: BigDecimal) extends EmployeeCommand 22 | final case class ChangeEmployeeLastName(id: String, expectedVersion: Long, lastName: String) extends EmployeeCommand 23 | final case class ChangeEmployeeFirstName(id: String, expectedVersion: Long, firstName: String) extends EmployeeCommand 24 | final case class ChangeEmployeeAddress(id: String, expectedVersion: Long, street: String, city: String, stateOrProvince: String, 25 | country: String, postalCode: String) extends EmployeeCommand 26 | final case class ChangeEmployeeStartDate(id: String, expectedVersion: Long, startDate: Long) extends EmployeeCommand 27 | final case class ChangeEmployeeDept(id: String, expectedVersion: Long, dept: String) extends EmployeeCommand 28 | final case class ChangeEmployeeTitle(id: String, expectedVersion: Long, title: String) extends EmployeeCommand 29 | final case class ChangeEmployeeSalary(id: String, expectedVersion: Long, salary: BigDecimal) extends EmployeeCommand 30 | final case class DeactivateEmployee(id: String, expectedVersion: Long, deactivateDate: Long) extends EmployeeCommand 31 | final case class ActivateEmployee(id: String, expectedVersion: Long, activateDate: Long) extends EmployeeCommand 32 | final case class TerminateEmployee(id: String, expectedVersion: Long, termDate: Long, termReason: String) extends EmployeeCommand 33 | final case class RehireEmployee(id: String, expectedVersion: Long, rehireDate: Long) extends EmployeeCommand 34 | final case class PayEmployee(id: String, expectedVersion: Long, amount: BigDecimal) extends EmployeeCommand 35 | 36 | /** 37 | * These are the resulting events from commands performed by an employee if the commands were not rejected. They will be journaled 38 | * in the event store and cannot be rejected. 39 | */ 40 | sealed trait EmployeeEvent { 41 | def id: String 42 | def version: Long 43 | } 44 | 45 | final case class EmployeeHired(id: String, version: Long, lastName: String, firstName: String, street: String, city: String, 46 | stateOrProvince: String, postalCode: String, country: String, startDate: Long, dept: String, title: String, 47 | salary: BigDecimal) extends EmployeeEvent 48 | final case class EmployeeLastNameChanged(id: String, version: Long, lastName: String) extends EmployeeEvent 49 | final case class EmployeeFirstNameChanged(id: String, version: Long, firstName: String) extends EmployeeEvent 50 | final case class EmployeeAddressChanged(id: String, version: Long, street: String, city: String, stateOrProvince: String, 51 | country: String, postalCode: String) extends EmployeeEvent 52 | final case class EmployeeStartDateChanged(id: String, version: Long, startDate: Long) extends EmployeeEvent 53 | final case class EmployeeDeptChanged(id: String, version: Long, dept: String) extends EmployeeEvent 54 | final case class EmployeeTitleChanged(id: String, version: Long, title: String) extends EmployeeEvent 55 | final case class EmployeeSalaryChanged(id: String, version: Long, salary: BigDecimal) extends EmployeeEvent 56 | final case class EmployeeDeactivated(id: String, version: Long, deactivateDate: Long) extends EmployeeEvent 57 | final case class EmployeeActivated(id: String, version: Long, activateDate: Long) extends EmployeeEvent 58 | final case class EmployeeTerminated(id: String, version: Long, termDate: Long, termReason: String) 59 | final case class EmployeeRehired(id: String, version: Long, rehireDate: Long) extends EmployeeEvent 60 | final case class EmployeePaid(id: String, version: Long, amount: BigDecimal) extends EmployeeEvent 61 | 62 | final case class ErrorMessage(data: String) 63 | final case class GetEmployee(id: String) 64 | 65 | case object RunPayroll 66 | case object SnapshotEmployees 67 | } 68 | -------------------------------------------------------------------------------- /mongo-cqrs-es-app/src/main/scala/com/github/ironfish/akka/persistence/cqrses/sample/package.scala: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright (C) 2013-2014 Duncan DeVore. 3 | */ 4 | package com.github.ironfish.akka.persistence.cqrses 5 | 6 | 7 | import scalaz._ 8 | import Scalaz._ 9 | 10 | package object sample { 11 | type DomainValidation[+α] = Validation[NonEmptyList[String], α] 12 | 13 | /** 14 | * Trait for validation errors 15 | */ 16 | trait ValidationKey { 17 | def failNel = this.toString.failureNel 18 | def nel = NonEmptyList(this.toString) 19 | def failure = this.toString.failure 20 | } 21 | 22 | object CommonValidations { 23 | /** 24 | * Validates that a string is not null and non empty 25 | * 26 | * @param s String to be validated 27 | * @param err ValidationKey 28 | * @return Validation 29 | */ 30 | def checkString(s: String, err: ValidationKey): Validation[String, String] = 31 | if (s == null || s.isEmpty) err.failure else s.success 32 | 33 | /** 34 | * Validates that a date is a non zero Long value. 35 | * 36 | * @param d Long to be validated 37 | * @param err ValidationKey 38 | * @return Validation 39 | */ 40 | def checkDate(d: Long, err: ValidationKey): Validation[String, Long] = 41 | if (d <= 0) err.failure else d.success 42 | } 43 | 44 | sealed case class Address ( 45 | street: String, 46 | city: String, 47 | stateOrProvince: String, 48 | country: String, 49 | postalCode: String 50 | ) 51 | 52 | object Address { 53 | import CommonValidations._ 54 | 55 | case object StreetRequired extends ValidationKey 56 | case object CityRequired extends ValidationKey 57 | case object StateOrProvinceRequired extends ValidationKey 58 | case object CountryRequired extends ValidationKey 59 | case object PostalCodeRequired extends ValidationKey 60 | 61 | def validate(street: String, city: String, stateOrProvince: String, country: String, postalCode: String): 62 | ValidationNel[String, Address] = 63 | (checkString(street, StreetRequired).toValidationNel |@| 64 | checkString(city, CityRequired).toValidationNel |@| 65 | checkString(stateOrProvince, StateOrProvinceRequired).toValidationNel |@| 66 | checkString(country, CountryRequired).toValidationNel |@| 67 | checkString(postalCode, PostalCodeRequired).toValidationNel) { 68 | Address(_, _, _, _, _) 69 | } 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /mongo-cqrs-es-app/src/test/scala/com/github/ironfish/akka/persistence/cqrses/sample/BenefitsSpec.scala: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright (C) 2013-2014 Duncan DeVore. 3 | */ 4 | package com.github.ironfish.akka.persistence.cqrses.sample 5 | 6 | import akka.testkit.{ImplicitSender, TestKit} 7 | import akka.actor.{ActorRef, Props, ActorSystem} 8 | 9 | import com.mongodb.casbah.Imports._ 10 | import com.novus.salat._ 11 | import com.novus.salat.global._ 12 | import com.typesafe.config.ConfigFactory 13 | 14 | import concurrent.duration._ 15 | 16 | import de.flapdoodle.embed.process.runtime.Network 17 | import de.flapdoodle.embed.process.io.directories.PlatformTempDir 18 | import de.flapdoodle.embed.process.extract.UUIDTempNaming 19 | import de.flapdoodle.embed.mongo.{MongodStarter, Command} 20 | import de.flapdoodle.embed.mongo.distribution.Version 21 | import de.flapdoodle.embed.process.config.io.ProcessOutput 22 | import de.flapdoodle.embed.process.io.{NullProcessor, Processors} 23 | import de.flapdoodle.embed.process.config.IRuntimeConfig 24 | import de.flapdoodle.embed.mongo.config._ 25 | 26 | import org.scalatest.{BeforeAndAfterEach, BeforeAndAfterAll, MustMatchers, WordSpecLike} 27 | 28 | object BenefitsSpec { 29 | 30 | def config(port: Int) = ConfigFactory.parseString( 31 | s""" 32 | |akka.persistence.journal.plugin = "casbah-journal" 33 | |akka.persistence.snapshot-store.plugin = "casbah-snapshot-store" 34 | |akka.persistence.journal.max-deletion-batch-size = 3 35 | |akka.persistence.publish-plugin-commands = on 36 | |akka.persistence.publish-confirmations = on 37 | |akka.persistence.view.auto-update-interval = 1s 38 | |casbah-journal.mongo-journal-url = "mongodb://localhost:$port/store2.messages" 39 | |casbah-journal.mongo-journal-write-concern = "acknowledged" 40 | |casbah-journal.mongo-journal-write-concern-timeout = 10000 41 | |casbah-snapshot-store.mongo-snapshot-url = "mongodb://localhost:$port/store2.snapshots" 42 | |casbah-snapshot-store.mongo-snapshot-write-concern = "acknowledged" 43 | |casbah-snapshot-store.mongo-snapshot-write-concern-timeout = 10000 44 | |benefits-view.mongo-url = "mongodb://localhost:$port/hr2.benefits" 45 | """.stripMargin) 46 | 47 | lazy val freePort = Network.getFreeServerPort 48 | } 49 | 50 | class BenefitsSpec extends TestKit(ActorSystem("test-benefits", BenefitsSpec.config(BenefitsSpec.freePort))) 51 | with ImplicitSender 52 | with WordSpecLike 53 | with MustMatchers 54 | with BeforeAndAfterAll 55 | with BeforeAndAfterEach { 56 | 57 | import BenefitsSpec._ 58 | import EmployeeProtocol._ 59 | 60 | lazy val host = "localhost" 61 | lazy val port = freePort 62 | lazy val localHostIPV6 = Network.localhostIsIPv6() 63 | 64 | val artifactStorePath = new PlatformTempDir() 65 | val executableNaming = new UUIDTempNaming() 66 | val command = Command.MongoD 67 | val version = Version.Main.PRODUCTION 68 | 69 | // Used to filter out console output messages. 70 | val processOutput = new ProcessOutput( 71 | Processors.named("[mongod>]", new NullProcessor), 72 | Processors.named("[MONGOD>]", new NullProcessor), 73 | Processors.named("[console>]", new NullProcessor)) 74 | 75 | val runtimeConfig: IRuntimeConfig = 76 | new RuntimeConfigBuilder() 77 | .defaults(command) 78 | .processOutput(processOutput) 79 | .artifactStore(new ArtifactStoreBuilder() 80 | .defaults(command) 81 | .download(new DownloadConfigBuilder() 82 | .defaultsForCommand(command) 83 | .artifactStorePath(artifactStorePath)) 84 | .executableNaming(executableNaming)) 85 | .build() 86 | 87 | val mongodConfig = 88 | new MongodConfigBuilder() 89 | .version(version) 90 | .net(new Net(port, localHostIPV6)) 91 | .cmdOptions(new MongoCmdOptionsBuilder() 92 | .syncDelay(1) 93 | .useNoPrealloc(false) 94 | .useSmallFiles(false) 95 | .useNoJournal(false) 96 | .enableTextSearch(true) 97 | .build()) 98 | .build() 99 | 100 | lazy val mongodStarter = MongodStarter.getInstance(runtimeConfig) 101 | lazy val mongod = mongodStarter.prepare(mongodConfig) 102 | lazy val mongodExe = mongod.start() 103 | 104 | val duration = 10.seconds 105 | var employeeProcessor: ActorRef = _ 106 | var benefitsView: ActorRef = _ 107 | 108 | override def beforeAll() = { 109 | mongodExe 110 | employeeProcessor = system.actorOf(Props[EmployeeProcessor]) 111 | benefitsView = system.actorOf(Props[BenefitsView]) 112 | } 113 | 114 | override def afterAll() = { 115 | system.shutdown() 116 | system.awaitTermination(duration) 117 | client.close() 118 | mongod.stop() 119 | mongodExe.stop() 120 | } 121 | 122 | val IdJonSmith = "121-33-4546" 123 | val StartDateMarch1st2014Midnight = 1393632000000L 124 | val DeactivateDateMay1st2014Midnight = 1398902400000L 125 | val ActivateDateMay2nd2014Midnight = 1398988800000L 126 | val TerminateDateMay3rd2014Midnight = 1399075200000L 127 | val RehireDateMay4th2014Midnight = 1399161600000L 128 | 129 | lazy val uri = MongoClientURI(BenefitsSpec.config(freePort).getString("benefits-view.mongo-url")) 130 | lazy val client = MongoClient(uri) 131 | lazy val db = client(uri.database.get) 132 | lazy val coll = db(uri.collection.get) 133 | 134 | "The Application" must { 135 | "when persisted EmployeeHired view persists associated BenefitDates" in { 136 | employeeProcessor ! HireEmployee(IdJonSmith, -1l, "smith", "jon", "123 Big Road", "Perkiomenville", "PA", "18074", "USA", 137 | StartDateMarch1st2014Midnight, "Technology", "The Total Package", BigDecimal(300000)) 138 | val Expected = Some(BenefitDates(IdJonSmith, StartDateMarch1st2014Midnight, Nil, Nil, Nil)) 139 | awaitCond({ 140 | val dbo = coll.findOne(MongoDBObject("employeeId" -> IdJonSmith)) 141 | if (!dbo.isDefined) false 142 | else if (Some(grater[BenefitDates].asObject(dbo.get)) == Expected) true 143 | else false 144 | }, duration, 500 milliseconds, "BenefitDates read side failure.") 145 | } 146 | "when persisted EmployeeDeactivated view persists associated BenefitDates" in { 147 | employeeProcessor ! DeactivateEmployee(IdJonSmith, 0L, DeactivateDateMay1st2014Midnight) 148 | val Expected = Some(BenefitDates(IdJonSmith, StartDateMarch1st2014Midnight, List(DeactivateDateMay1st2014Midnight), Nil, Nil)) 149 | awaitCond({ 150 | val dbo = coll.findOne(MongoDBObject("employeeId" -> IdJonSmith)) 151 | if (!dbo.isDefined) false 152 | else if (Some(grater[BenefitDates].asObject(dbo.get)) == Expected) true 153 | else false 154 | }, duration, 500 milliseconds, "BenefitDates read side failure.") 155 | } 156 | "when persisted EmployeeActivated view persists associated BenefitDates" in { 157 | employeeProcessor ! ActivateEmployee(IdJonSmith, 1L, ActivateDateMay2nd2014Midnight) 158 | val Expected = Some(BenefitDates(IdJonSmith, ActivateDateMay2nd2014Midnight, List(DeactivateDateMay1st2014Midnight), Nil, Nil)) 159 | awaitCond({ 160 | val dbo = coll.findOne(MongoDBObject("employeeId" -> IdJonSmith)) 161 | if (!dbo.isDefined) false 162 | else if (Some(grater[BenefitDates].asObject(dbo.get)) == Expected) true 163 | else false 164 | }, duration, 500 milliseconds, "BenefitDates read side failure.") 165 | } 166 | "when persisted EmployeeTerminated view persists associated BenefitDates" in { 167 | employeeProcessor ! TerminateEmployee(IdJonSmith, 2L, TerminateDateMay3rd2014Midnight, "Tired") 168 | val Expected = Some(BenefitDates(IdJonSmith, ActivateDateMay2nd2014Midnight, List(DeactivateDateMay1st2014Midnight), 169 | List(TerminateDateMay3rd2014Midnight), Nil)) 170 | awaitCond({ 171 | val dbo = coll.findOne(MongoDBObject("employeeId" -> IdJonSmith)) 172 | if (!dbo.isDefined) false 173 | else if (Some(grater[BenefitDates].asObject(dbo.get)) == Expected) true 174 | else false 175 | }, duration, 500 milliseconds, "BenefitDates read side failure.") 176 | } 177 | "when persisted EmployeeRehired view persists associated BenefitDates" in { 178 | employeeProcessor ! RehireEmployee(IdJonSmith, 3L, RehireDateMay4th2014Midnight) 179 | val Expected = Some(BenefitDates(IdJonSmith, ActivateDateMay2nd2014Midnight, List(DeactivateDateMay1st2014Midnight), 180 | List(TerminateDateMay3rd2014Midnight), List(RehireDateMay4th2014Midnight))) 181 | awaitCond({ 182 | val dbo = coll.findOne(MongoDBObject("employeeId" -> IdJonSmith)) 183 | if (!dbo.isDefined) false 184 | else if (Some(grater[BenefitDates].asObject(dbo.get)) == Expected) true 185 | else false 186 | }, duration, 500 milliseconds, "BenefitDates read side failure.") 187 | } 188 | } 189 | } 190 | -------------------------------------------------------------------------------- /mongo-cqrs-es-app/src/test/scala/com/github/ironfish/akka/persistence/cqrses/sample/EmployeeSpec.scala: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright (C) 2013-2014 Duncan DeVore. 3 | */ 4 | package com.github.ironfish.akka.persistence.cqrses.sample 5 | 6 | import akka.testkit.{TestProbe, ImplicitSender, TestKit} 7 | import akka.actor.{ActorRef, Props, ActorSystem} 8 | 9 | import com.typesafe.config.ConfigFactory 10 | 11 | import concurrent.duration._ 12 | 13 | import de.flapdoodle.embed.process.runtime.Network 14 | import de.flapdoodle.embed.process.io.directories.PlatformTempDir 15 | import de.flapdoodle.embed.process.extract.UUIDTempNaming 16 | import de.flapdoodle.embed.mongo.{MongodStarter, Command} 17 | import de.flapdoodle.embed.mongo.distribution.Version 18 | import de.flapdoodle.embed.process.config.io.ProcessOutput 19 | import de.flapdoodle.embed.process.io.{NullProcessor, Processors} 20 | import de.flapdoodle.embed.process.config.IRuntimeConfig 21 | import de.flapdoodle.embed.mongo.config._ 22 | 23 | import org.scalatest.{BeforeAndAfterEach, BeforeAndAfterAll, MustMatchers, WordSpecLike} 24 | 25 | object EmployeeSpec { 26 | 27 | def config(port: Int) = ConfigFactory.parseString( 28 | s""" 29 | |akka.persistence.journal.plugin = "casbah-journal" 30 | |akka.persistence.snapshot-store.plugin = "casbah-snapshot-store" 31 | |akka.persistence.journal.max-deletion-batch-size = 3 32 | |akka.persistence.publish-plugin-commands = on 33 | |akka.persistence.publish-confirmations = on 34 | |akka.persistence.view.auto-update-interval = 1s 35 | |casbah-journal.mongo-journal-url = "mongodb://localhost:$port/store.messages" 36 | |casbah-journal.mongo-journal-write-concern = "journaled" 37 | |casbah-journal.mongo-journal-write-concern-timeout = 10000 38 | |casbah-snapshot-store.mongo-snapshot-url = "mongodb://localhost:$port/store.snapshots" 39 | |casbah-snapshot-store.mongo-snapshot-write-concern = "journaled" 40 | |casbah-snapshot-store.mongo-snapshot-write-concern-timeout = 10000 41 | |benefits-view.mongo-url = "mongodb://localhost:$port/hr.benefits" 42 | """.stripMargin) 43 | 44 | lazy val freePort = Network.getFreeServerPort 45 | } 46 | 47 | class EmployeeSpec extends TestKit(ActorSystem("test", EmployeeSpec.config(EmployeeSpec.freePort))) 48 | with ImplicitSender 49 | with WordSpecLike 50 | with MustMatchers 51 | with BeforeAndAfterAll 52 | with BeforeAndAfterEach { 53 | 54 | import EmployeeSpec._ 55 | import EmployeeProtocol._ 56 | 57 | lazy val host = "localhost" 58 | lazy val port = freePort 59 | lazy val localHostIPV6 = Network.localhostIsIPv6() 60 | 61 | val artifactStorePath = new PlatformTempDir() 62 | val executableNaming = new UUIDTempNaming() 63 | val command = Command.MongoD 64 | val version = Version.Main.PRODUCTION 65 | 66 | // Used to filter out console output messages. 67 | val processOutput = new ProcessOutput( 68 | Processors.named("[mongod>]", new NullProcessor), 69 | Processors.named("[MONGOD>]", new NullProcessor), 70 | Processors.named("[console>]", new NullProcessor)) 71 | 72 | val runtimeConfig: IRuntimeConfig = 73 | new RuntimeConfigBuilder() 74 | .defaults(command) 75 | .processOutput(processOutput) 76 | .artifactStore(new ArtifactStoreBuilder() 77 | .defaults(command) 78 | .download(new DownloadConfigBuilder() 79 | .defaultsForCommand(command) 80 | .artifactStorePath(artifactStorePath)) 81 | .executableNaming(executableNaming)) 82 | .build() 83 | 84 | val mongodConfig = 85 | new MongodConfigBuilder() 86 | .version(version) 87 | .net(new Net(port, localHostIPV6)) 88 | .cmdOptions(new MongoCmdOptionsBuilder() 89 | .syncDelay(1) 90 | .useNoPrealloc(false) 91 | .useSmallFiles(false) 92 | .useNoJournal(false) 93 | .enableTextSearch(true) 94 | .build()) 95 | .build() 96 | 97 | lazy val mongodStarter = MongodStarter.getInstance(runtimeConfig) 98 | lazy val mongod = mongodStarter.prepare(mongodConfig) 99 | lazy val mongodExe = mongod.start() 100 | 101 | val duration = 10.seconds 102 | var employeeProcessor: ActorRef = _ 103 | var benefitsView: ActorRef = _ 104 | 105 | override def beforeAll() = { 106 | mongodExe 107 | employeeProcessor = system.actorOf(Props[EmployeeProcessor]) 108 | benefitsView = system.actorOf(Props[BenefitsView]) 109 | } 110 | 111 | override def afterAll() = { 112 | system.shutdown() 113 | system.awaitTermination(duration) 114 | mongod.stop() 115 | mongodExe.stop() 116 | } 117 | 118 | val IdJonSmith = "121-33-4546" 119 | val StartDateMarch1st2014Midnight = 1393632000000L 120 | val DeactivateDateMay1st2014Midnight = 1398902400000L 121 | val ActivateDateMay2nd2014Midnight = 1398988800000L 122 | val TerminateDateMay3rd2014Midnight = 1399075200000L 123 | val RehireDateMay4th2014Midnight = 1399161600000L 124 | 125 | "The Application" must { 126 | "when issued a validated HireEmployee command, generate a persisted EmployeeHired event" in { 127 | val probe = TestProbe() 128 | system.eventStream.subscribe(probe.ref, classOf[EmployeeHired]) 129 | val msg = HireEmployee(IdJonSmith, -1l, "smith", "jon", "123 Big Road", "Perkiomenville", "PA", "18074", "USA", 130 | StartDateMarch1st2014Midnight, "Technology", "The Total Package", BigDecimal(300000)) 131 | employeeProcessor ! msg 132 | probe.expectMsgType[EmployeeHired] 133 | } 134 | "when issued a validated ChangeEmployeeLastName command, generate a persisted EmployeeLastNameChanged event" in { 135 | val probe = TestProbe() 136 | system.eventStream.subscribe(probe.ref, classOf[EmployeeLastNameChanged]) 137 | val msg = ChangeEmployeeLastName(IdJonSmith, 0, "Smith") 138 | employeeProcessor ! msg 139 | probe.expectMsgType[EmployeeLastNameChanged] 140 | } 141 | "when issued a validated ChangeEmployeeFirstName command, generate a persisted EmployeeFirstNameChanged event" in { 142 | val probe = TestProbe() 143 | system.eventStream.subscribe(probe.ref, classOf[EmployeeFirstNameChanged]) 144 | val msg = ChangeEmployeeFirstName(IdJonSmith, 1, "Jon") 145 | employeeProcessor ! msg 146 | probe.expectMsgType[EmployeeFirstNameChanged] 147 | } 148 | "when issued a validated ChangeEmployeeAddress command, generate a persisted EmployeeAddressChanged event" in { 149 | val probe = TestProbe() 150 | system.eventStream.subscribe(probe.ref, classOf[EmployeeAddressChanged]) 151 | val msg = ChangeEmployeeAddress(IdJonSmith, 2, "4839 E. Trindle Road.", "Mechanicsburg", "PA", "USA", "17055") 152 | employeeProcessor ! msg 153 | probe.expectMsgType[EmployeeAddressChanged] 154 | } 155 | "when issued a validated ChangeEmployeeStartDate command, generate a persisted EmployeeStartDateChanged event" in { 156 | val probe = TestProbe() 157 | system.eventStream.subscribe(probe.ref, classOf[EmployeeStartDateChanged]) 158 | val msg = ChangeEmployeeStartDate(IdJonSmith, 3, 1393977600000L) 159 | employeeProcessor ! msg 160 | probe.expectMsgType[EmployeeStartDateChanged] 161 | } 162 | "when issued a validated ChangeEmployeeDept command, generate a persisted EmployeeDeptChanged event" in { 163 | val probe = TestProbe() 164 | system.eventStream.subscribe(probe.ref, classOf[EmployeeDeptChanged]) 165 | val msg = ChangeEmployeeDept(IdJonSmith, 4, "Computer Science") 166 | employeeProcessor ! msg 167 | probe.expectMsgType[EmployeeDeptChanged] 168 | } 169 | "when issued a validated ChangeEmployeeTitle command, generate a persisted EmployeeTitleChanged event" in { 170 | val probe = TestProbe() 171 | system.eventStream.subscribe(probe.ref, classOf[EmployeeTitleChanged]) 172 | val msg = ChangeEmployeeTitle(IdJonSmith, 5, "World Traveller") 173 | employeeProcessor ! msg 174 | probe.expectMsgType[EmployeeTitleChanged] 175 | } 176 | "when issued a validated ChangeEmployeeSalary command, generate a persisted EmployeeSalaryChanged event" in { 177 | val probe = TestProbe() 178 | system.eventStream.subscribe(probe.ref, classOf[EmployeeSalaryChanged]) 179 | val msg = ChangeEmployeeSalary(IdJonSmith, 6, BigDecimal(650000)) 180 | employeeProcessor ! msg 181 | probe.expectMsgType[EmployeeSalaryChanged] 182 | } 183 | "when issued a validated DeactivateEmployee command, generate a persisted EmployeeDeactivated event" in { 184 | val probe = TestProbe() 185 | system.eventStream.subscribe(probe.ref, classOf[EmployeeDeactivated]) 186 | val msg = DeactivateEmployee(IdJonSmith, 7, DeactivateDateMay1st2014Midnight) 187 | employeeProcessor ! msg 188 | probe.expectMsgType[EmployeeDeactivated] 189 | } 190 | "when issued a validated ActivateEmployee command, generate a persisted EmployeeActivated event" in { 191 | val probe = TestProbe() 192 | system.eventStream.subscribe(probe.ref, classOf[EmployeeActivated]) 193 | val msg = ActivateEmployee(IdJonSmith, 8, ActivateDateMay2nd2014Midnight) 194 | employeeProcessor ! msg 195 | probe.expectMsgType[EmployeeActivated] 196 | } 197 | "when issued a validated TerminateEmployee command, generate a persisted EmployeeTerminated event" in { 198 | val probe = TestProbe() 199 | system.eventStream.subscribe(probe.ref, classOf[EmployeeTerminated]) 200 | val msg = TerminateEmployee(IdJonSmith, 9, TerminateDateMay3rd2014Midnight, "Tired") 201 | employeeProcessor ! msg 202 | probe.expectMsgType[EmployeeTerminated] 203 | } 204 | "when issued a validated RehireEmployee command, generate a persisted EmployeeRehired event" in { 205 | val probe = TestProbe() 206 | system.eventStream.subscribe(probe.ref, classOf[EmployeeRehired]) 207 | val msg = RehireEmployee(IdJonSmith, 10, RehireDateMay4th2014Midnight) 208 | employeeProcessor ! msg 209 | probe.expectMsgType[EmployeeRehired] 210 | } 211 | "when issued a RunPayroll request for two employees, two EmployeePaid events are persisted" in { 212 | val probe = TestProbe() 213 | system.eventStream.subscribe(probe.ref, classOf[EmployeePaid]) 214 | val msg = HireEmployee("2", -1l, "Sean", "Walsh", "321 Large Ave.", "Rumson", "NJ", "07760", "USA", 1393632000000L, 215 | "Technology", "The Brain", BigDecimal(300000)) 216 | employeeProcessor ! msg 217 | employeeProcessor ! RunPayroll 218 | probe.expectMsg(EmployeePaid(IdJonSmith, 12, BigDecimal(5000))) 219 | probe.expectMsg(EmployeePaid("2", 1, BigDecimal(5000))) 220 | } 221 | "when issued a invalid HireEmployee command, an ErrorMessage is returned" in { 222 | val probe = TestProbe() 223 | system.eventStream.subscribe(probe.ref, classOf[EmployeeHired]) 224 | val msg = HireEmployee("3", -1l, "David", "Johnson", "888 Small Street", "Rumson", "NJ", "07760", "USA", 1399150441L, 225 | "IT", "Intern", BigDecimal(30000)) 226 | employeeProcessor ! msg 227 | expectMsgType[ErrorMessage] 228 | probe.expectNoMsg() 229 | } 230 | "when issued a ChangeEmployeeLastName for a non-existent employee, an ErrorMessage is returned" in { 231 | val probe = TestProbe() 232 | system.eventStream.subscribe(probe.ref, classOf[ChangeEmployeeLastName]) 233 | val msg = ChangeEmployeeLastName("3", 0, "Johnson") 234 | employeeProcessor ! msg 235 | expectMsgType[ErrorMessage] 236 | probe.expectNoMsg() 237 | } 238 | "when issued a ChangeEmployeeLastName with the incorrect version, an ErrorMessage is returned" in { 239 | val probe = TestProbe() 240 | system.eventStream.subscribe(probe.ref, classOf[ChangeEmployeeLastName]) 241 | val msg = ChangeEmployeeLastName("2", 0, "Walshy") 242 | employeeProcessor ! msg 243 | expectMsgType[ErrorMessage] 244 | probe.expectNoMsg() 245 | } 246 | "when issued a GetEmployee command respond with the appropriate employee" in { 247 | employeeProcessor ! GetEmployee("2") 248 | expectMsg(Some(ActiveEmployee("2",1,"Sean","Walsh",Address("321 Large Ave.","Rumson","NJ","USA","07760"),1393632000000L, 249 | "Technology","The Brain",300000,295000))) 250 | } 251 | } 252 | } 253 | -------------------------------------------------------------------------------- /project/Common.scala: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright (C) 2013-2014 Duncan DeVore. 3 | */ 4 | import sbt._ 5 | import Keys._ 6 | 7 | object Common { 8 | def Organization = "com.github.ironfish" 9 | def NameMongoCqrsCsApp = "mongo-cqrs-cs-app" 10 | def NameMongoCqrsEsApp = "mongo-cqrs-es-app" 11 | def AkkaVersion = "2.3.6" 12 | def CrossScalaVersions = Seq("2.10.4", "2.11.4") 13 | def EmbeddedMongoVersion = "1.46.1" 14 | def PluginVersion = "0.7.5" 15 | def SalatVersion = "1.9.9" 16 | def ScalaStmVersion = "0.7" 17 | def ScalaVersion = "2.11.4" 18 | def ScalatestVersion = "2.2.2" 19 | def ScalazVersion = "7.1.0" 20 | def ParallelExecutionInTest = false 21 | def ScalaCOptions = Seq( "-deprecation", "-unchecked", "-feature", "-language:postfixOps" ) 22 | def TestCompile = "test->test;compile->compile" 23 | } 24 | -------------------------------------------------------------------------------- /project/build.properties: -------------------------------------------------------------------------------- 1 | sbt.version=0.13.6 2 | -------------------------------------------------------------------------------- /project/plugins.sbt: -------------------------------------------------------------------------------- 1 | addSbtPlugin("com.orrsella" % "sbt-sublime" % "1.0.9") 2 | --------------------------------------------------------------------------------