├── .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 | [](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 |
--------------------------------------------------------------------------------