Cleaning up
This commit is contained in:
17
account/README.md
Normal file
17
account/README.md
Normal file
@@ -0,0 +1,17 @@
|
||||
# Account Microservice
|
||||
|
||||
This is the parent project that contains modules of a microservice deployment for the _Account_ domain context. The two modules contained in this project are separated into separate deployment artifacts, one for synchronous HTTP-based interactions and one for asynchronous AMQP-based messaging.
|
||||
|
||||
Each microservice in this reference architecture breaks down into three different independently deployable components.
|
||||
|
||||

|
||||
|
||||
The diagram above details the system architecture of the bounded context for _Accounts_, which includes deployable units for each [Backing Service](https://12factor.net/backing-services), [Microservice](https://en.wikipedia.org/wiki/Microservices), and [AWS Lambda Function](https://en.wikipedia.org/wiki/AWS_Lambda).
|
||||
|
||||
## Account Web
|
||||
|
||||
The `account-web` module is a web application that produces a REST API that can be used by consumers to interact with and manage domain objects in the `Account` context. _Domain Events_ can be triggered directly over HTTP, and will also be produced in the response to actions that alter the state of the `Account` object. This web service also provides built-in hypermedia support for looking up the event logs on an aggregate domain object.
|
||||
|
||||
## Account Worker
|
||||
|
||||
The `account-worker` module is a event stream processing application that listens for `Account` domain events as AMQP messages. The domain events that are generated by the `account-web` application are processed in this module. The worker is responsible for durable transaction processing for work flows that are required to coordinate asynchronously with applications residing in other domain contexts. The worker is also responsible for automatically remediating state changes in any distributed transactions that encounter a partial failure. The most important goal of the worker module is to keep the state of the system consistent through automated means — to guarantee eventual consistency.
|
||||
309
account/account-web/README.md
Normal file
309
account/account-web/README.md
Normal file
@@ -0,0 +1,309 @@
|
||||
# Account Microservice: Web Role
|
||||
|
||||
The `account-web` module is a web application that produces a REST API that can be used by consumers to interact with and manage domain objects in the `Account` context. _Domain Events_ can be triggered directly over HTTP, and will also be produced in the response to actions that alter the state of the `Account` object. This web service also provides built-in hypermedia support for looking up the event logs on an aggregate domain object.
|
||||
|
||||
## Usage
|
||||
|
||||
The `account-web` application provides a hypermedia-driven REST API for managing accounts.
|
||||
|
||||
To create a new account, we can send an HTTP POST request to `/v1/accounts`.
|
||||
|
||||
```json
|
||||
{
|
||||
"userId": 1,
|
||||
"accountNumber": "123456",
|
||||
"defaultAccount": true
|
||||
}
|
||||
```
|
||||
|
||||
If the request was successful, a hypermedia response will be returned back.
|
||||
|
||||
```json
|
||||
{
|
||||
"createdAt": 1481351048967,
|
||||
"lastModified": 1481351048967,
|
||||
"userId": 1,
|
||||
"accountNumber": "123456",
|
||||
"defaultAccount": true,
|
||||
"status": "ACCOUNT_CREATED",
|
||||
"_links": {
|
||||
"self": {
|
||||
"href": "http://localhost:54656/v1/accounts/1"
|
||||
},
|
||||
"events": {
|
||||
"href": "http://localhost:54656/v1/accounts/1/events"
|
||||
},
|
||||
"commands": {
|
||||
"href": "http://localhost:54656/v1/accounts/1/commands"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
The snippet above is the response that was returned after creating the new account. We can see the field `status` has a value of `ACCOUNT_CREATED`. Notice the embedded links in the response, which provide context for the resource. In this case, we have two links of interest: `events` and `commands`. Let's take a look at both of these resources, starting with `commands`.
|
||||
|
||||
The `commands` resource provides us with an additional set of hypermedia links for each command that can be applied to the `Account` resource.
|
||||
|
||||
_GET_ `/v1/accounts/1/commands`
|
||||
|
||||
```json
|
||||
{
|
||||
"_links": {
|
||||
"confirm": {
|
||||
"href": "http://localhost:54656/v1/accounts/1/commands/confirm"
|
||||
},
|
||||
"activate": {
|
||||
"href": "http://localhost:54656/v1/accounts/1/commands/activate"
|
||||
},
|
||||
"suspend": {
|
||||
"href": "http://localhost:54656/v1/accounts/1/commands/suspend"
|
||||
},
|
||||
"archive": {
|
||||
"href": "http://localhost:54656/v1/accounts/1/commands/archive"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
In an event-driven architecture, the state of an object can only change in response to an event. The `account-worker` module will be monitoring for events and can execute commands against the resource that is the subject of an event.
|
||||
|
||||
Let's return back to the parent `Account` resource for these commands.
|
||||
|
||||
_GET_ `/v1/accounts/1`
|
||||
|
||||
```json
|
||||
{
|
||||
"createdAt": 1481351048967,
|
||||
"lastModified": 1481351049385,
|
||||
"userId": 1,
|
||||
"accountNumber": "123456",
|
||||
"defaultAccount": true,
|
||||
"status": "ACCOUNT_ACTIVE",
|
||||
"_links": {
|
||||
"self": {
|
||||
"href": "http://localhost:54656/v1/accounts/1"
|
||||
},
|
||||
"events": {
|
||||
"href": "http://localhost:54656/v1/accounts/1/events"
|
||||
},
|
||||
"commands": {
|
||||
"href": "http://localhost:54656/v1/accounts/1/commands"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Sending a new _GET_ request to the parent `Account` resource returns back a different the object with a different `status` value. The status value is now set to `ACCOUNT_ACTIVE`, which previously was `ACCOUNT_CREATED`. The cause for this is because we are also running the `account-worker` in parallel, which is reacting to domain events triggered by `account-web`.
|
||||
|
||||
During an account creation workflow, a domain event will be triggered and sent to the account stream, which the `account-worker` module is listening to. To understand exactly what has happened to this `Account` resource, we can trace the events that led to its current state. Sending a GET request to the attached hypermedia link named `events` returns back an event log.
|
||||
|
||||
_GET_ `/v1/accounts/1/events`
|
||||
|
||||
```json
|
||||
{
|
||||
"_embedded": {
|
||||
"accountEventList": [
|
||||
{
|
||||
"createdAt": 1481351049002,
|
||||
"lastModified": 1481351049002,
|
||||
"type": "ACCOUNT_CREATED",
|
||||
"_links": {
|
||||
"self": {
|
||||
"href": "http://localhost:54656/v1/events/1"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"createdAt": 1481351049318,
|
||||
"lastModified": 1481351049318,
|
||||
"type": "ACCOUNT_CONFIRMED",
|
||||
"_links": {
|
||||
"self": {
|
||||
"href": "http://localhost:54656/v1/events/2"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"createdAt": 1481351049387,
|
||||
"lastModified": 1481351049387,
|
||||
"type": "ACCOUNT_ACTIVATED",
|
||||
"_links": {
|
||||
"self": {
|
||||
"href": "http://localhost:54656/v1/events/3"
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
"_links": {
|
||||
"self": {
|
||||
"href": "http://localhost:54656/v1/accounts/1/events"
|
||||
},
|
||||
"account": {
|
||||
"href": "http://localhost:54656/v1/accounts/1"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
The response returned is an ordered collection of account events. The log describes the events that caused the `status` of the account resource to be set to the `ACCOUNT_ACTIVE` value. But how did the `account-worker` module drive the state changes of this account resource?
|
||||
|
||||
Source: _EventService.java_
|
||||
|
||||
```java
|
||||
public AccountEvent createEvent(AccountEvent event) {
|
||||
// Save new event
|
||||
event = addEvent(event);
|
||||
Assert.notNull(event, "The event could not be appended to the account");
|
||||
|
||||
// Append the account event to the stream
|
||||
accountStreamSource.output()
|
||||
.send(MessageBuilder
|
||||
.withPayload(getAccountEventResource(event))
|
||||
.build());
|
||||
|
||||
return event;
|
||||
}
|
||||
```
|
||||
|
||||
In the snippet above we see the creation of a new `AccountEvent` in the `EventService` class. Notice how the payload that is sent to the account stream is being constructed. The `getAccountEventResource` method will prepare the `AccountEvent` object as a hypermedia resource. This means that when the `account-worker` processes the event message that it will be able to use the embedded links to understand the full context of the event. The exact representation of the `AccountEvent` resource that will be sent is shown in the snippet below.
|
||||
|
||||
The body of the _AccountEvent_ message for creating an account:
|
||||
|
||||
```json
|
||||
{
|
||||
"createdAt": 1481353397395,
|
||||
"lastModified": 1481353397395,
|
||||
"type": "ACCOUNT_CREATED",
|
||||
"_links": {
|
||||
"self": {
|
||||
"href": "http://localhost:54932/v1/events/1"
|
||||
},
|
||||
"account": {
|
||||
"href": "http://localhost:54932/v1/accounts/1"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Here we see the `AccountEvent` resource that was sent to the account stream during the create account workflow. Notice how the body of the event message contains no fields describing the new `Account` resource. The full context for the event is only available as a graph of hypermedia links attached to the `AccountEvent` resource. Because of this, the event processor does not need to know anything about the location of resources. With this approach, we can drive the behavior and the state of the application using hypermedia.
|
||||
|
||||
The snippet below is an example of how the `account-worker` is able to traverse the graph of hypermedia links of an `AccountEvent` resource.
|
||||
|
||||
```java
|
||||
// Traverse the hypermedia link for the attached account
|
||||
Traverson traverson = new Traverson(
|
||||
new URI(accountEvent.getLink("account").getHref()),
|
||||
MediaTypes.HAL_JSON
|
||||
);
|
||||
|
||||
// Traverse the hypermedia link to retrieve the event log for the account
|
||||
AccountEvents events = traverson.follow("events")
|
||||
.toEntity(AccountEvents.class)
|
||||
.getBody();
|
||||
```
|
||||
|
||||
Here we use a `Traverson` as a REST client instead of a `RestTemplate` to easily follow related hypermedia links. `Traverson` wraps a standard `RestTemplate` with support that makes it simple to process related hypermedia links. In this kind of architecture, caching is absolutely essential, since traversing hypermedia links is a sequential traversal.
|
||||
|
||||
The event processor has no knowledge about the structure of a REST API outside of the hypermedia links it discovers. Because of this, we may be required to dispatch more HTTP _GET_ requests than we normally would.
|
||||
|
||||
Now that we understand how the event processor can use hypermedia to discover the context of a domain event, let's now take a look at how it drives the state of domain resources.
|
||||
|
||||
```java
|
||||
// Create a traverson for the root account
|
||||
Traverson traverson = new Traverson(
|
||||
new URI(event.getLink("account").getHref()),
|
||||
MediaTypes.HAL_JSON
|
||||
);
|
||||
|
||||
// Get the account resource attached to the event
|
||||
Account account = traverson.follow("self")
|
||||
.toEntity(Account.class)
|
||||
.getBody();
|
||||
...
|
||||
```
|
||||
|
||||
Here we see how the `account-worker` drives the state of an `Account` resource by traversing hypermedia links attached to an `AccountEvent` resource it is processing. Here we start by traversing to the `Account` resource that is the subject of the current `AccountEvent`. The result of that request is below.
|
||||
|
||||
```json
|
||||
{
|
||||
"createdAt": 1481353397350,
|
||||
"lastModified": 1481353397809,
|
||||
"userId": 1,
|
||||
"accountNumber": "123456",
|
||||
"defaultAccount": true,
|
||||
"status": "ACCOUNT_PENDING",
|
||||
"_links": {
|
||||
"self": {
|
||||
"href": "http://localhost:54932/v1/accounts/1"
|
||||
},
|
||||
"events": {
|
||||
"href": "http://localhost:54932/v1/accounts/1/events"
|
||||
},
|
||||
"commands": {
|
||||
"href": "http://localhost:54932/v1/accounts/1/commands"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
In this workflow we are wanting to transition the account from a pending state to a confirmed state. Since the `Account` resource has a hypermedia link relation for `commands`, we can use that link to find the list of possible operations that can be performed on an `Account` resource. Following the `commands` link returns the following result.
|
||||
|
||||
```json
|
||||
{
|
||||
"_links": {
|
||||
"confirm": {
|
||||
"href": "http://localhost:54932/v1/accounts/1/commands/confirm"
|
||||
},
|
||||
"activate": {
|
||||
"href": "http://localhost:54932/v1/accounts/1/commands/activate"
|
||||
},
|
||||
"suspend": {
|
||||
"href": "http://localhost:54932/v1/accounts/1/commands/suspend"
|
||||
},
|
||||
"archive": {
|
||||
"href": "http://localhost:54932/v1/accounts/1/commands/archive"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Now that we have the list of commands that can be applied to the resource, we can choose which one we need to transition the state of the `Account` from `ACCOUNT_PENDING` to `ACCOUNT_CONFIRMED`. To do this, all we need to do is to follow the `confirm` link, using the `Traverson` client, which is shown below.
|
||||
|
||||
```java
|
||||
// Traverse to the confirm account command
|
||||
account = traverson.follow("commands")
|
||||
.follow("confirm")
|
||||
.toEntity(Account.class)
|
||||
.getBody();
|
||||
|
||||
log.info("Account confirmed: " + account);
|
||||
```
|
||||
|
||||
The response returned for the `confirm` command returns the updated state of the `Account` resource, shown below.
|
||||
|
||||
```json
|
||||
{
|
||||
"createdAt": 1481353397350,
|
||||
"lastModified": 1481355618050,
|
||||
"userId": 1,
|
||||
"accountNumber": "123456",
|
||||
"defaultAccount": true,
|
||||
"status": "ACCOUNT_CONFIRMED",
|
||||
"_links": {
|
||||
"self": {
|
||||
"href": "http://localhost:54932/v1/accounts/1"
|
||||
},
|
||||
"events": {
|
||||
"href": "http://localhost:54932/v1/accounts/1/events"
|
||||
},
|
||||
"commands": {
|
||||
"href": "http://localhost:54932/v1/accounts/1/commands"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
As the `Account` resource transitions from `ACCOUNT_PENDING` to `ACCOUNT_CONFIRMED`, yet another event is triggered by the `account-web` application and dispatched to the account stream.
|
||||
|
||||
By using hypermedia to drive the state of domain resources, an subscriber of a domain event only needs to know the structure of how domain resources are connected by hypermedia relationships. This means that any prior knowledge of the URL structure of a REST API is not required by subscribers processing an event stream.
|
||||
11
account/account-web/manifest.yml
Normal file
11
account/account-web/manifest.yml
Normal file
@@ -0,0 +1,11 @@
|
||||
name: account-web
|
||||
memory: 1024M
|
||||
instances: 1
|
||||
path: ./target/account-web-0.0.1-SNAPSHOT.jar
|
||||
buildpack: java_buildpack
|
||||
services:
|
||||
- rabbit-events
|
||||
- redis-cache
|
||||
disk_quota: 1024M
|
||||
host: account-event-web
|
||||
domain: cfapps.io
|
||||
71
account/account-web/pom.xml
Normal file
71
account/account-web/pom.xml
Normal file
@@ -0,0 +1,71 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
|
||||
<modelVersion>4.0.0</modelVersion>
|
||||
|
||||
<artifactId>account-web</artifactId>
|
||||
<version>0.0.1-SNAPSHOT</version>
|
||||
<packaging>jar</packaging>
|
||||
|
||||
<name>account-web</name>
|
||||
|
||||
<parent>
|
||||
<groupId>org.kbastani</groupId>
|
||||
<artifactId>account</artifactId>
|
||||
<version>1.0-SNAPSHOT</version>
|
||||
<relativePath>../</relativePath>
|
||||
</parent>
|
||||
|
||||
<properties>
|
||||
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
|
||||
<project.reporting.outputEncoding>UTF-8</project.reporting.outputEncoding>
|
||||
<java.version>1.8</java.version>
|
||||
</properties>
|
||||
|
||||
<dependencies>
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-web</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-data-jpa</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-redis</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-actuator</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-hateoas</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.springframework.cloud</groupId>
|
||||
<artifactId>spring-cloud-starter-stream-rabbit</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-integration</artifactId>
|
||||
</dependency>
|
||||
|
||||
<dependency>
|
||||
<groupId>com.h2database</groupId>
|
||||
<artifactId>h2</artifactId>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
|
||||
<build>
|
||||
<plugins>
|
||||
<plugin>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-maven-plugin</artifactId>
|
||||
</plugin>
|
||||
</plugins>
|
||||
</build>
|
||||
|
||||
|
||||
</project>
|
||||
@@ -0,0 +1,14 @@
|
||||
package demo;
|
||||
|
||||
import org.springframework.boot.SpringApplication;
|
||||
import org.springframework.boot.autoconfigure.SpringBootApplication;
|
||||
import org.springframework.hateoas.config.EnableHypermediaSupport;
|
||||
|
||||
@SpringBootApplication
|
||||
@EnableHypermediaSupport(type = {EnableHypermediaSupport.HypermediaType.HAL})
|
||||
public class AccountServiceApplication {
|
||||
|
||||
public static void main(String[] args) {
|
||||
SpringApplication.run(AccountServiceApplication.class, args);
|
||||
}
|
||||
}
|
||||
108
account/account-web/src/main/java/demo/account/Account.java
Normal file
108
account/account-web/src/main/java/demo/account/Account.java
Normal file
@@ -0,0 +1,108 @@
|
||||
package demo.account;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonIgnore;
|
||||
import demo.domain.BaseEntity;
|
||||
import demo.event.AccountEvent;
|
||||
|
||||
import javax.persistence.*;
|
||||
import java.util.HashSet;
|
||||
import java.util.Set;
|
||||
|
||||
/**
|
||||
* The {@link Account} domain object contains information related to
|
||||
* a user's account. The status of an account is event sourced using
|
||||
* events logged to the {@link AccountEvent} collection attached to
|
||||
* this resource.
|
||||
*
|
||||
* @author kbastani
|
||||
*/
|
||||
@Entity
|
||||
public class Account extends BaseEntity {
|
||||
|
||||
@Id
|
||||
@GeneratedValue
|
||||
private Long id;
|
||||
|
||||
private String firstName;
|
||||
private String lastName;
|
||||
private String email;
|
||||
|
||||
@OneToMany(cascade = CascadeType.ALL, fetch = FetchType.LAZY)
|
||||
private Set<AccountEvent> events = new HashSet<>();
|
||||
|
||||
@Enumerated(value = EnumType.STRING)
|
||||
private AccountStatus status;
|
||||
|
||||
public Account() {
|
||||
status = AccountStatus.ACCOUNT_CREATED;
|
||||
}
|
||||
|
||||
public Account(String firstName, String lastName, String email) {
|
||||
this();
|
||||
this.firstName = firstName;
|
||||
this.lastName = lastName;
|
||||
this.email = email;
|
||||
}
|
||||
|
||||
@JsonIgnore
|
||||
public Long getAccountId() {
|
||||
return id;
|
||||
}
|
||||
|
||||
public void setAccountId(Long id) {
|
||||
this.id = id;
|
||||
}
|
||||
|
||||
public String getFirstName() {
|
||||
return firstName;
|
||||
}
|
||||
|
||||
public void setFirstName(String firstName) {
|
||||
this.firstName = firstName;
|
||||
}
|
||||
|
||||
public String getLastName() {
|
||||
return lastName;
|
||||
}
|
||||
|
||||
public void setLastName(String lastName) {
|
||||
this.lastName = lastName;
|
||||
}
|
||||
|
||||
public String getEmail() {
|
||||
return email;
|
||||
}
|
||||
|
||||
public void setEmail(String email) {
|
||||
this.email = email;
|
||||
}
|
||||
|
||||
@JsonIgnore
|
||||
public Set<AccountEvent> getEvents() {
|
||||
return events;
|
||||
}
|
||||
|
||||
public void setEvents(Set<AccountEvent> events) {
|
||||
this.events = events;
|
||||
}
|
||||
|
||||
public AccountStatus getStatus() {
|
||||
return status;
|
||||
}
|
||||
|
||||
public void setStatus(AccountStatus status) {
|
||||
this.status = status;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String toString() {
|
||||
return "Account{" +
|
||||
"id=" + id +
|
||||
", firstName='" + firstName + '\'' +
|
||||
", lastName='" + lastName + '\'' +
|
||||
", email='" + email + '\'' +
|
||||
", events=" + events +
|
||||
", status=" + status +
|
||||
"} " + super.toString();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
package demo.account;
|
||||
|
||||
/**
|
||||
* The {@link AccountCommand} represents an action that can be performed to an
|
||||
* {@link Account} aggregate. Commands initiate an action that can mutate the state of
|
||||
* an account entity as it transitions between {@link AccountStatus} values.
|
||||
*
|
||||
* @author kbastani
|
||||
*/
|
||||
public enum AccountCommand {
|
||||
CONFIRM_ACCOUNT,
|
||||
ACTIVATE_ACCOUNT,
|
||||
SUSPEND_ACCOUNT,
|
||||
ARCHIVE_ACCOUNT
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
package demo.account;
|
||||
|
||||
import org.springframework.hateoas.ResourceSupport;
|
||||
|
||||
/**
|
||||
* A hypermedia resource that describes the collection of commands that
|
||||
* can be applied to a {@link Account} aggregate.
|
||||
*
|
||||
* @author kbastani
|
||||
*/
|
||||
public class AccountCommandsResource extends ResourceSupport {
|
||||
}
|
||||
@@ -0,0 +1,273 @@
|
||||
package demo.account;
|
||||
|
||||
import demo.event.AccountEvent;
|
||||
import demo.event.AccountEvents;
|
||||
import demo.event.EventController;
|
||||
import demo.event.EventService;
|
||||
import org.springframework.hateoas.LinkBuilder;
|
||||
import org.springframework.hateoas.Resource;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.util.Assert;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
import java.util.Optional;
|
||||
|
||||
import static org.springframework.hateoas.mvc.ControllerLinkBuilder.linkTo;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/v1")
|
||||
public class AccountController {
|
||||
|
||||
private final AccountService accountService;
|
||||
private final EventService eventService;
|
||||
|
||||
public AccountController(AccountService accountService, EventService eventService) {
|
||||
this.accountService = accountService;
|
||||
this.eventService = eventService;
|
||||
}
|
||||
|
||||
@PostMapping(path = "/accounts")
|
||||
public ResponseEntity createAccount(@RequestBody Account account) {
|
||||
return Optional.ofNullable(createAccountResource(account))
|
||||
.map(e -> new ResponseEntity<>(e, HttpStatus.CREATED))
|
||||
.orElseThrow(() -> new RuntimeException("Account creation failed"));
|
||||
}
|
||||
|
||||
@PutMapping(path = "/accounts/{id}")
|
||||
public ResponseEntity updateAccount(@RequestBody Account account, @PathVariable Long id) {
|
||||
return Optional.ofNullable(updateAccountResource(id, account))
|
||||
.map(e -> new ResponseEntity<>(e, HttpStatus.OK))
|
||||
.orElseThrow(() -> new RuntimeException("Account update failed"));
|
||||
}
|
||||
|
||||
@GetMapping(path = "/accounts/{id}")
|
||||
public ResponseEntity getAccount(@PathVariable Long id) {
|
||||
return Optional.ofNullable(getAccountResource(id))
|
||||
.map(e -> new ResponseEntity<>(e, HttpStatus.OK))
|
||||
.orElse(new ResponseEntity<>(HttpStatus.NOT_FOUND));
|
||||
}
|
||||
|
||||
@DeleteMapping(path = "/accounts/{id}")
|
||||
public ResponseEntity deleteAccount(@PathVariable Long id) {
|
||||
return Optional.ofNullable(accountService.deleteAccount(id))
|
||||
.map(e -> new ResponseEntity<>(HttpStatus.NO_CONTENT))
|
||||
.orElseThrow(() -> new RuntimeException("Account deletion failed"));
|
||||
}
|
||||
|
||||
@GetMapping(path = "/accounts/{id}/events")
|
||||
public ResponseEntity getAccountEvents(@PathVariable Long id) {
|
||||
return Optional.of(getAccountEventResources(id))
|
||||
.map(e -> new ResponseEntity<>(e, HttpStatus.OK))
|
||||
.orElseThrow(() -> new RuntimeException("Could not get account events"));
|
||||
}
|
||||
|
||||
@PostMapping(path = "/accounts/{id}/events")
|
||||
public ResponseEntity createAccount(@PathVariable Long id, @RequestBody AccountEvent event) {
|
||||
return Optional.ofNullable(appendEventResource(id, event))
|
||||
.map(e -> new ResponseEntity<>(e, HttpStatus.CREATED))
|
||||
.orElseThrow(() -> new RuntimeException("Append account event failed"));
|
||||
}
|
||||
|
||||
@GetMapping(path = "/accounts/{id}/commands")
|
||||
public ResponseEntity getAccountCommands(@PathVariable Long id) {
|
||||
return Optional.ofNullable(getCommandsResource(id))
|
||||
.map(e -> new ResponseEntity<>(e, HttpStatus.OK))
|
||||
.orElseThrow(() -> new RuntimeException("The account could not be found"));
|
||||
}
|
||||
|
||||
@GetMapping(path = "/accounts/{id}/commands/confirm")
|
||||
public ResponseEntity confirmAccount(@PathVariable Long id) {
|
||||
return Optional.ofNullable(getAccountResource(
|
||||
accountService.applyCommand(id, AccountCommand.CONFIRM_ACCOUNT)))
|
||||
.map(e -> new ResponseEntity<>(e, HttpStatus.OK))
|
||||
.orElseThrow(() -> new RuntimeException("The command could not be applied"));
|
||||
}
|
||||
|
||||
@GetMapping(path = "/accounts/{id}/commands/activate")
|
||||
public ResponseEntity activateAccount(@PathVariable Long id) {
|
||||
return Optional.ofNullable(getAccountResource(
|
||||
accountService.applyCommand(id, AccountCommand.ACTIVATE_ACCOUNT)))
|
||||
.map(e -> new ResponseEntity<>(e, HttpStatus.OK))
|
||||
.orElseThrow(() -> new RuntimeException("The command could not be applied"));
|
||||
}
|
||||
|
||||
@GetMapping(path = "/accounts/{id}/commands/suspend")
|
||||
public ResponseEntity suspendAccount(@PathVariable Long id) {
|
||||
return Optional.ofNullable(getAccountResource(
|
||||
accountService.applyCommand(id, AccountCommand.SUSPEND_ACCOUNT)))
|
||||
.map(e -> new ResponseEntity<>(e, HttpStatus.OK))
|
||||
.orElseThrow(() -> new RuntimeException("The command could not be applied"));
|
||||
}
|
||||
|
||||
@GetMapping(path = "/accounts/{id}/commands/archive")
|
||||
public ResponseEntity archiveAccount(@PathVariable Long id) {
|
||||
return Optional.ofNullable(getAccountResource(
|
||||
accountService.applyCommand(id, AccountCommand.ARCHIVE_ACCOUNT)))
|
||||
.map(e -> new ResponseEntity<>(e, HttpStatus.OK))
|
||||
.orElseThrow(() -> new RuntimeException("The command could not be applied"));
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves a hypermedia resource for {@link Account} with the specified identifier.
|
||||
*
|
||||
* @param id is the unique identifier for looking up the {@link Account} entity
|
||||
* @return a hypermedia resource for the fetched {@link Account}
|
||||
*/
|
||||
private Resource<Account> getAccountResource(Long id) {
|
||||
Resource<Account> accountResource = null;
|
||||
|
||||
// Get the account for the provided id
|
||||
Account account = accountService.getAccount(id);
|
||||
|
||||
// If the account exists, wrap the hypermedia response
|
||||
if (account != null)
|
||||
accountResource = getAccountResource(account);
|
||||
|
||||
|
||||
return accountResource;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a new {@link Account} entity and persists the result to the repository.
|
||||
*
|
||||
* @param account is the {@link Account} model used to create a new account
|
||||
* @return a hypermedia resource for the newly created {@link Account}
|
||||
*/
|
||||
private Resource<Account> createAccountResource(Account account) {
|
||||
Assert.notNull(account, "Account body must not be null");
|
||||
Assert.notNull(account.getEmail(), "Email is required");
|
||||
Assert.notNull(account.getFirstName(), "First name is required");
|
||||
Assert.notNull(account.getLastName(), "Last name is required");
|
||||
|
||||
// Create the new account
|
||||
account = accountService.registerAccount(account);
|
||||
|
||||
return getAccountResource(account);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update a {@link Account} entity for the provided identifier.
|
||||
*
|
||||
* @param id is the unique identifier for the {@link Account} update
|
||||
* @param account is the entity representation containing any updated {@link Account} fields
|
||||
* @return a hypermedia resource for the updated {@link Account}
|
||||
*/
|
||||
private Resource<Account> updateAccountResource(Long id, Account account) {
|
||||
return getAccountResource(accountService.updateAccount(id, account));
|
||||
}
|
||||
|
||||
/**
|
||||
* Appends an {@link AccountEvent} domain event to the event log of the {@link Account}
|
||||
* aggregate with the specified accountId.
|
||||
*
|
||||
* @param accountId is the unique identifier for the {@link Account}
|
||||
* @param event is the {@link AccountEvent} that attempts to alter the state of the {@link Account}
|
||||
* @return a hypermedia resource for the newly appended {@link AccountEvent}
|
||||
*/
|
||||
private Resource<AccountEvent> appendEventResource(Long accountId, AccountEvent event) {
|
||||
Resource<AccountEvent> eventResource = null;
|
||||
|
||||
event = accountService.appendEvent(accountId, event);
|
||||
|
||||
if (event != null) {
|
||||
eventResource = new Resource<>(event,
|
||||
linkTo(EventController.class)
|
||||
.slash("events")
|
||||
.slash(event.getEventId())
|
||||
.withSelfRel(),
|
||||
linkTo(AccountController.class)
|
||||
.slash("accounts")
|
||||
.slash(accountId)
|
||||
.withRel("account")
|
||||
);
|
||||
}
|
||||
|
||||
return eventResource;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the {@link AccountCommand} hypermedia resource that lists the available commands that can be applied
|
||||
* to an {@link Account} entity.
|
||||
*
|
||||
* @param id is the {@link Account} identifier to provide command links for
|
||||
* @return an {@link AccountCommandsResource} with a collection of embedded command links
|
||||
*/
|
||||
private AccountCommandsResource getCommandsResource(Long id) {
|
||||
// Get the account resource for the identifier
|
||||
Resource<Account> accountResource = getAccountResource(id);
|
||||
|
||||
// Create a new account commands hypermedia resource
|
||||
AccountCommandsResource commandResource = new AccountCommandsResource();
|
||||
|
||||
// Add account command hypermedia links
|
||||
if (accountResource != null) {
|
||||
commandResource.add(
|
||||
getCommandLinkBuilder(id)
|
||||
.slash("confirm")
|
||||
.withRel("confirm"),
|
||||
getCommandLinkBuilder(id)
|
||||
.slash("activate")
|
||||
.withRel("activate"),
|
||||
getCommandLinkBuilder(id)
|
||||
.slash("suspend")
|
||||
.withRel("suspend"),
|
||||
getCommandLinkBuilder(id)
|
||||
.slash("archive")
|
||||
.withRel("archive")
|
||||
);
|
||||
}
|
||||
|
||||
return commandResource;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get {@link AccountEvents} for the supplied {@link Account} identifier.
|
||||
*
|
||||
* @param id is the unique identifier of the {@link Account}
|
||||
* @return a list of {@link AccountEvent} wrapped in a hypermedia {@link AccountEvents} resource
|
||||
*/
|
||||
private AccountEvents getAccountEventResources(Long id) {
|
||||
return new AccountEvents(id, eventService.getAccountEvents(id));
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a {@link LinkBuilder} for generating the {@link AccountCommandsResource}.
|
||||
*
|
||||
* @param id is the unique identifier for a {@link Account}
|
||||
* @return a {@link LinkBuilder} for the {@link AccountCommandsResource}
|
||||
*/
|
||||
private LinkBuilder getCommandLinkBuilder(Long id) {
|
||||
return linkTo(AccountController.class)
|
||||
.slash("accounts")
|
||||
.slash(id)
|
||||
.slash("commands");
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a hypermedia enriched {@link Account} entity.
|
||||
*
|
||||
* @param account is the {@link Account} to enrich with hypermedia links
|
||||
* @return is a hypermedia enriched resource for the supplied {@link Account} entity
|
||||
*/
|
||||
private Resource<Account> getAccountResource(Account account) {
|
||||
Resource<Account> accountResource;
|
||||
|
||||
// Prepare hypermedia response
|
||||
accountResource = new Resource<>(account,
|
||||
linkTo(AccountController.class)
|
||||
.slash("accounts")
|
||||
.slash(account.getAccountId())
|
||||
.withSelfRel(),
|
||||
linkTo(AccountController.class)
|
||||
.slash("accounts")
|
||||
.slash(account.getAccountId())
|
||||
.slash("events")
|
||||
.withRel("events"),
|
||||
getCommandLinkBuilder(account.getAccountId())
|
||||
.withRel("commands")
|
||||
);
|
||||
|
||||
return accountResource;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
package demo.account;
|
||||
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
import org.springframework.data.repository.query.Param;
|
||||
|
||||
public interface AccountRepository extends JpaRepository<Account, Long> {
|
||||
|
||||
Account findAccountByEmail(@Param("email") String email);
|
||||
}
|
||||
@@ -0,0 +1,225 @@
|
||||
package demo.account;
|
||||
|
||||
import demo.event.AccountEvent;
|
||||
import demo.event.AccountEventType;
|
||||
import demo.event.EventService;
|
||||
import demo.event.ConsistencyModel;
|
||||
import org.springframework.cache.CacheManager;
|
||||
import org.springframework.cache.annotation.CacheConfig;
|
||||
import org.springframework.cache.annotation.CacheEvict;
|
||||
import org.springframework.cache.annotation.CachePut;
|
||||
import org.springframework.cache.annotation.Cacheable;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.util.Assert;
|
||||
|
||||
import java.util.Arrays;
|
||||
import java.util.Objects;
|
||||
|
||||
import static demo.account.AccountStatus.*;
|
||||
|
||||
/**
|
||||
* The {@link AccountService} provides transactional support for managing {@link Account}
|
||||
* entities. This service also provides event sourcing support for {@link AccountEvent}.
|
||||
* Events can be appended to an {@link Account}, which contains a append-only log of
|
||||
* actions that can be used to support remediation for distributed transactions that encountered
|
||||
* a partial failure.
|
||||
*
|
||||
* @author kbastani
|
||||
*/
|
||||
@Service
|
||||
@CacheConfig(cacheNames = {"accounts"})
|
||||
public class AccountService {
|
||||
|
||||
private final AccountRepository accountRepository;
|
||||
private final EventService eventService;
|
||||
private final CacheManager cacheManager;
|
||||
|
||||
public AccountService(AccountRepository accountRepository, EventService eventService, CacheManager cacheManager) {
|
||||
this.accountRepository = accountRepository;
|
||||
this.eventService = eventService;
|
||||
this.cacheManager = cacheManager;
|
||||
}
|
||||
|
||||
@CacheEvict(cacheNames = "accounts", key = "#account.getAccountId().toString()")
|
||||
public Account registerAccount(Account account) {
|
||||
|
||||
account = createAccount(account);
|
||||
|
||||
cacheManager.getCache("accounts")
|
||||
.evict(account.getAccountId());
|
||||
|
||||
// Trigger the account creation event
|
||||
AccountEvent event = appendEvent(account.getAccountId(),
|
||||
new AccountEvent(AccountEventType.ACCOUNT_CREATED));
|
||||
|
||||
// Attach account identifier
|
||||
event.getAccount().setAccountId(account.getAccountId());
|
||||
|
||||
// Return the result
|
||||
return event.getAccount();
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new {@link Account} entity.
|
||||
*
|
||||
* @param account is the {@link Account} to create
|
||||
* @return the newly created {@link Account}
|
||||
*/
|
||||
@CacheEvict(cacheNames = "accounts", key = "#account.getAccountId().toString()")
|
||||
public Account createAccount(Account account) {
|
||||
|
||||
// Assert for uniqueness constraint
|
||||
Assert.isNull(accountRepository.findAccountByEmail(account.getEmail()),
|
||||
"An account with the supplied email already exists");
|
||||
|
||||
// Save the account to the repository
|
||||
account = accountRepository.saveAndFlush(account);
|
||||
|
||||
return account;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get an {@link Account} entity for the supplied identifier.
|
||||
*
|
||||
* @param id is the unique identifier of a {@link Account} entity
|
||||
* @return an {@link Account} entity
|
||||
*/
|
||||
@Cacheable(cacheNames = "accounts", key = "#id.toString()")
|
||||
public Account getAccount(Long id) {
|
||||
return accountRepository.findOne(id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update an {@link Account} entity with the supplied identifier.
|
||||
*
|
||||
* @param id is the unique identifier of the {@link Account} entity
|
||||
* @param account is the {@link Account} containing updated fields
|
||||
* @return the updated {@link Account} entity
|
||||
*/
|
||||
@CachePut(cacheNames = "accounts", key = "#id.toString()")
|
||||
public Account updateAccount(Long id, Account account) {
|
||||
Assert.notNull(id, "Account id must be present in the resource URL");
|
||||
Assert.notNull(account, "Account request body cannot be null");
|
||||
|
||||
if (account.getAccountId() != null) {
|
||||
Assert.isTrue(Objects.equals(id, account.getAccountId()),
|
||||
"The account id in the request body must match the resource URL");
|
||||
} else {
|
||||
account.setAccountId(id);
|
||||
}
|
||||
|
||||
Assert.state(accountRepository.exists(id),
|
||||
"The account with the supplied id does not exist");
|
||||
|
||||
Account currentAccount = accountRepository.findOne(id);
|
||||
currentAccount.setEmail(account.getEmail());
|
||||
currentAccount.setFirstName(account.getFirstName());
|
||||
currentAccount.setLastName(account.getLastName());
|
||||
currentAccount.setStatus(account.getStatus());
|
||||
|
||||
return accountRepository.save(currentAccount);
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete the {@link Account} with the supplied identifier.
|
||||
*
|
||||
* @param id is the unique identifier for the {@link Account}
|
||||
*/
|
||||
@CacheEvict(cacheNames = "accounts", key = "#id.toString()")
|
||||
public Boolean deleteAccount(Long id) {
|
||||
Assert.state(accountRepository.exists(id),
|
||||
"The account with the supplied id does not exist");
|
||||
this.accountRepository.delete(id);
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Append a new {@link AccountEvent} to the {@link Account} reference for the supplied identifier.
|
||||
*
|
||||
* @param accountId is the unique identifier for the {@link Account}
|
||||
* @param event is the {@link AccountEvent} to append to the {@link Account} entity
|
||||
* @return the newly appended {@link AccountEvent}
|
||||
*/
|
||||
public AccountEvent appendEvent(Long accountId, AccountEvent event) {
|
||||
return appendEvent(accountId, event, ConsistencyModel.ACID);
|
||||
}
|
||||
|
||||
/**
|
||||
* Append a new {@link AccountEvent} to the {@link Account} reference for the supplied identifier.
|
||||
*
|
||||
* @param accountId is the unique identifier for the {@link Account}
|
||||
* @param event is the {@link AccountEvent} to append to the {@link Account} entity
|
||||
* @return the newly appended {@link AccountEvent}
|
||||
*/
|
||||
public AccountEvent appendEvent(Long accountId, AccountEvent event, ConsistencyModel consistencyModel) {
|
||||
Account account = getAccount(accountId);
|
||||
Assert.notNull(account, "The account with the supplied id does not exist");
|
||||
event.setAccount(account);
|
||||
event = eventService.createEvent(accountId, event);
|
||||
account.getEvents().add(event);
|
||||
accountRepository.saveAndFlush(account);
|
||||
eventService.raiseEvent(event, consistencyModel);
|
||||
return event;
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply an {@link AccountCommand} to the {@link Account} with a specified identifier.
|
||||
*
|
||||
* @param id is the unique identifier of the {@link Account}
|
||||
* @param accountCommand is the command to apply to the {@link Account}
|
||||
* @return a hypermedia resource containing the updated {@link Account}
|
||||
*/
|
||||
@CachePut(cacheNames = "accounts", key = "#id.toString()")
|
||||
public Account applyCommand(Long id, AccountCommand accountCommand) {
|
||||
Account account = getAccount(id);
|
||||
|
||||
Assert.notNull(account, "The account for the supplied id could not be found");
|
||||
|
||||
AccountStatus status = account.getStatus();
|
||||
|
||||
switch (accountCommand) {
|
||||
case CONFIRM_ACCOUNT:
|
||||
Assert.isTrue(status == ACCOUNT_PENDING, "The account has already been confirmed");
|
||||
|
||||
// Confirm the account
|
||||
Account updateAccount = account;
|
||||
updateAccount.setStatus(ACCOUNT_CONFIRMED);
|
||||
this.updateAccount(id, updateAccount);
|
||||
this.appendEvent(id, new AccountEvent(AccountEventType.ACCOUNT_CONFIRMED))
|
||||
.getAccount();
|
||||
break;
|
||||
case ACTIVATE_ACCOUNT:
|
||||
Assert.isTrue(status != ACCOUNT_ACTIVE, "The account is already active");
|
||||
Assert.isTrue(Arrays.asList(ACCOUNT_CONFIRMED, ACCOUNT_SUSPENDED, ACCOUNT_ARCHIVED)
|
||||
.contains(status), "The account cannot be activated");
|
||||
|
||||
// Activate the account
|
||||
account.setStatus(ACCOUNT_ACTIVE);
|
||||
this.updateAccount(id, account);
|
||||
this.appendEvent(id, new AccountEvent(AccountEventType.ACCOUNT_ACTIVATED))
|
||||
.getAccount();
|
||||
break;
|
||||
case SUSPEND_ACCOUNT:
|
||||
Assert.isTrue(status == ACCOUNT_ACTIVE, "An inactive account cannot be suspended");
|
||||
|
||||
// Suspend the account
|
||||
account.setStatus(ACCOUNT_SUSPENDED);
|
||||
account = this.updateAccount(id, account);
|
||||
this.appendEvent(id, new AccountEvent(AccountEventType.ACCOUNT_SUSPENDED));
|
||||
break;
|
||||
case ARCHIVE_ACCOUNT:
|
||||
Assert.isTrue(status == ACCOUNT_ACTIVE, "An inactive account cannot be archived");
|
||||
|
||||
// Archive the account
|
||||
account.setStatus(ACCOUNT_ARCHIVED);
|
||||
account = this.updateAccount(id, account);
|
||||
this.appendEvent(id, new AccountEvent(AccountEventType.ACCOUNT_ARCHIVED));
|
||||
break;
|
||||
default:
|
||||
Assert.notNull(accountCommand,
|
||||
"The provided command cannot be applied to this account in its current state");
|
||||
}
|
||||
|
||||
return account;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
package demo.account;
|
||||
|
||||
/**
|
||||
* The {@link AccountStatus} describes the state of an {@link Account}.
|
||||
* The aggregate state of a {@link Account} is sourced from attached domain
|
||||
* events in the form of {@link demo.event.AccountEvent}.
|
||||
*
|
||||
* @author kbastani
|
||||
*/
|
||||
public enum AccountStatus {
|
||||
ACCOUNT_CREATED,
|
||||
ACCOUNT_PENDING,
|
||||
ACCOUNT_CONFIRMED,
|
||||
ACCOUNT_ACTIVE,
|
||||
ACCOUNT_SUSPENDED,
|
||||
ACCOUNT_ARCHIVED
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
package demo.config;
|
||||
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.cache.CacheManager;
|
||||
import org.springframework.cache.annotation.EnableCaching;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.data.redis.cache.RedisCacheManager;
|
||||
import org.springframework.data.redis.connection.RedisConnectionFactory;
|
||||
import org.springframework.data.redis.connection.jedis.JedisConnectionFactory;
|
||||
import org.springframework.data.redis.core.RedisTemplate;
|
||||
|
||||
import java.util.Arrays;
|
||||
|
||||
@Configuration
|
||||
@EnableCaching
|
||||
public class CacheConfig {
|
||||
|
||||
@Bean
|
||||
public JedisConnectionFactory redisConnectionFactory(
|
||||
@Value("${spring.redis.port}") Integer redisPort,
|
||||
@Value("${spring.redis.host}") String redisHost) {
|
||||
JedisConnectionFactory redisConnectionFactory = new JedisConnectionFactory();
|
||||
|
||||
redisConnectionFactory.setHostName(redisHost);
|
||||
redisConnectionFactory.setPort(redisPort);
|
||||
|
||||
return redisConnectionFactory;
|
||||
}
|
||||
|
||||
@Bean
|
||||
public RedisTemplate redisTemplate(RedisConnectionFactory cf) {
|
||||
RedisTemplate redisTemplate = new RedisTemplate();
|
||||
redisTemplate.setConnectionFactory(cf);
|
||||
return redisTemplate;
|
||||
}
|
||||
|
||||
@Bean
|
||||
public CacheManager cacheManager(RedisTemplate redisTemplate) {
|
||||
RedisCacheManager cacheManager = new RedisCacheManager(redisTemplate);
|
||||
cacheManager.setDefaultExpiration(50000);
|
||||
cacheManager.setCacheNames(Arrays.asList("accounts", "events"));
|
||||
cacheManager.setUsePrefix(true);
|
||||
return cacheManager;
|
||||
}
|
||||
}
|
||||
13
account/account-web/src/main/java/demo/config/JpaConfig.java
Normal file
13
account/account-web/src/main/java/demo/config/JpaConfig.java
Normal file
@@ -0,0 +1,13 @@
|
||||
package demo.config;
|
||||
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.data.jpa.repository.config.EnableJpaAuditing;
|
||||
|
||||
/**
|
||||
* Enable JPA auditing on an empty configuration class to disable auditing on
|
||||
*
|
||||
*/
|
||||
@Configuration
|
||||
@EnableJpaAuditing
|
||||
public class JpaConfig {
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
package demo.config;
|
||||
|
||||
import org.springframework.cloud.stream.annotation.EnableBinding;
|
||||
import org.springframework.cloud.stream.messaging.Source;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
|
||||
@Configuration
|
||||
@EnableBinding(Source.class)
|
||||
public class StreamConfig {
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
package demo.config;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.http.converter.HttpMessageConverter;
|
||||
import org.springframework.http.converter.json.MappingJackson2HttpMessageConverter;
|
||||
import org.springframework.web.client.RestTemplate;
|
||||
import org.springframework.web.servlet.config.annotation.WebMvcConfigurerAdapter;
|
||||
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
|
||||
@Configuration
|
||||
public class WebMvcConfig extends WebMvcConfigurerAdapter {
|
||||
|
||||
private ObjectMapper objectMapper;
|
||||
|
||||
public WebMvcConfig(ObjectMapper objectMapper) {
|
||||
this.objectMapper = objectMapper;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void configureMessageConverters(List<HttpMessageConverter<?>> converters) {
|
||||
final MappingJackson2HttpMessageConverter converter = new MappingJackson2HttpMessageConverter();
|
||||
converter.setObjectMapper(objectMapper);
|
||||
converters.add(converter);
|
||||
}
|
||||
|
||||
@Bean
|
||||
protected RestTemplate restTemplate(ObjectMapper objectMapper) {
|
||||
MappingJackson2HttpMessageConverter converter = new MappingJackson2HttpMessageConverter();
|
||||
converter.setObjectMapper(objectMapper);
|
||||
return new RestTemplate(Collections.singletonList(converter));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
package demo.domain;
|
||||
|
||||
import org.springframework.data.annotation.CreatedDate;
|
||||
import org.springframework.data.annotation.LastModifiedDate;
|
||||
import org.springframework.data.jpa.domain.support.AuditingEntityListener;
|
||||
import org.springframework.hateoas.ResourceSupport;
|
||||
|
||||
import javax.persistence.EntityListeners;
|
||||
import javax.persistence.MappedSuperclass;
|
||||
import java.io.Serializable;
|
||||
|
||||
@MappedSuperclass
|
||||
@EntityListeners(AuditingEntityListener.class)
|
||||
public class BaseEntity extends ResourceSupport implements Serializable {
|
||||
|
||||
@CreatedDate
|
||||
private Long createdAt;
|
||||
|
||||
@LastModifiedDate
|
||||
private Long lastModified;
|
||||
|
||||
public BaseEntity() {
|
||||
}
|
||||
|
||||
public Long getCreatedAt() {
|
||||
return createdAt;
|
||||
}
|
||||
|
||||
public void setCreatedAt(Long createdAt) {
|
||||
this.createdAt = createdAt;
|
||||
}
|
||||
|
||||
public Long getLastModified() {
|
||||
return lastModified;
|
||||
}
|
||||
|
||||
public void setLastModified(Long lastModified) {
|
||||
this.lastModified = lastModified;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String toString() {
|
||||
return "BaseEntity{" +
|
||||
"createdAt=" + createdAt +
|
||||
", lastModified=" + lastModified +
|
||||
'}';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
package demo.event;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonIgnore;
|
||||
import demo.account.Account;
|
||||
import demo.domain.BaseEntity;
|
||||
|
||||
import javax.persistence.*;
|
||||
|
||||
/**
|
||||
* The domain event {@link AccountEvent} tracks the type and state of events as
|
||||
* applied to the {@link Account} domain object. This event resource can be used
|
||||
* to event source the aggregate state of {@link Account}.
|
||||
* <p>
|
||||
* This event resource also provides a transaction log that can be used to append
|
||||
* actions to the event.
|
||||
*
|
||||
* @author kbastani
|
||||
*/
|
||||
@Entity
|
||||
public class AccountEvent extends BaseEntity {
|
||||
|
||||
@Id
|
||||
@GeneratedValue
|
||||
private Long id;
|
||||
|
||||
@Enumerated(EnumType.STRING)
|
||||
private AccountEventType type;
|
||||
|
||||
@OneToOne(cascade = CascadeType.ALL, fetch = FetchType.LAZY)
|
||||
@JsonIgnore
|
||||
private Account account;
|
||||
|
||||
public AccountEvent() {
|
||||
}
|
||||
|
||||
public AccountEvent(AccountEventType type) {
|
||||
this.type = type;
|
||||
}
|
||||
|
||||
@JsonIgnore
|
||||
public Long getEventId() {
|
||||
return id;
|
||||
}
|
||||
|
||||
public void setEventId(Long id) {
|
||||
this.id = id;
|
||||
}
|
||||
|
||||
public AccountEventType getType() {
|
||||
return type;
|
||||
}
|
||||
|
||||
public void setType(AccountEventType type) {
|
||||
this.type = type;
|
||||
}
|
||||
|
||||
public Account getAccount() {
|
||||
return account;
|
||||
}
|
||||
|
||||
public void setAccount(Account account) {
|
||||
this.account = account;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String toString() {
|
||||
return "AccountEvent{" +
|
||||
"id=" + id +
|
||||
", type=" + type +
|
||||
", account=" + account +
|
||||
"} " + super.toString();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
package demo.event;
|
||||
|
||||
import demo.account.Account;
|
||||
import demo.account.AccountStatus;
|
||||
|
||||
/**
|
||||
* The {@link AccountEventType} represents a collection of possible events that describe
|
||||
* state transitions of {@link AccountStatus} on the {@link Account} aggregate.
|
||||
*
|
||||
* @author kbastani
|
||||
*/
|
||||
public enum AccountEventType {
|
||||
ACCOUNT_CREATED,
|
||||
ACCOUNT_CONFIRMED,
|
||||
ACCOUNT_ACTIVATED,
|
||||
ACCOUNT_SUSPENDED,
|
||||
ACCOUNT_ARCHIVED
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
package demo.event;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonIgnore;
|
||||
import demo.account.Account;
|
||||
import demo.account.AccountController;
|
||||
import org.springframework.hateoas.Link;
|
||||
import org.springframework.hateoas.LinkBuilder;
|
||||
import org.springframework.hateoas.Resources;
|
||||
|
||||
import java.io.Serializable;
|
||||
import java.util.List;
|
||||
|
||||
import static org.springframework.hateoas.mvc.ControllerLinkBuilder.linkTo;
|
||||
|
||||
/**
|
||||
* The {@link AccountEvents} is a hypermedia collection of {@link AccountEvent} resources.
|
||||
*
|
||||
* @author kbastani
|
||||
*/
|
||||
public class AccountEvents extends Resources<AccountEvent> implements Serializable {
|
||||
|
||||
private Long accountId;
|
||||
|
||||
/**
|
||||
* Create a new {@link AccountEvents} hypermedia resources collection for an {@link Account}.
|
||||
*
|
||||
* @param accountId is the unique identifier for the {@link Account}
|
||||
* @param content is the collection of {@link AccountEvents} attached to the {@link Account}
|
||||
*/
|
||||
public AccountEvents(Long accountId, List<AccountEvent> content) {
|
||||
this(content);
|
||||
this.accountId = accountId;
|
||||
|
||||
// Add hypermedia links to resources parent
|
||||
add(linkTo(AccountController.class)
|
||||
.slash("accounts")
|
||||
.slash(accountId)
|
||||
.slash("events")
|
||||
.withSelfRel(),
|
||||
linkTo(AccountController.class)
|
||||
.slash("accounts")
|
||||
.slash(accountId)
|
||||
.withRel("account"));
|
||||
|
||||
LinkBuilder linkBuilder = linkTo(EventController.class);
|
||||
|
||||
// Add hypermedia links to each item of the collection
|
||||
content.stream().parallel().forEach(event -> event.add(
|
||||
linkBuilder.slash("events")
|
||||
.slash(event.getEventId())
|
||||
.withSelfRel()
|
||||
));
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a {@link Resources} instance with the given content and {@link Link}s (optional).
|
||||
*
|
||||
* @param content must not be {@literal null}.
|
||||
* @param links the links to be added to the {@link Resources}.
|
||||
*/
|
||||
private AccountEvents(Iterable<AccountEvent> content, Link... links) {
|
||||
super(content, links);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the {@link Account} identifier that the {@link AccountEvents} apply to.
|
||||
*
|
||||
* @return the account identifier
|
||||
*/
|
||||
@JsonIgnore
|
||||
public Long getAccountId() {
|
||||
return accountId;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
package demo.event;
|
||||
|
||||
public enum ConsistencyModel {
|
||||
BASE,
|
||||
ACID
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
package demo.event;
|
||||
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
import java.util.Optional;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/v1")
|
||||
public class EventController {
|
||||
|
||||
private final EventService eventService;
|
||||
|
||||
public EventController(EventService eventService) {
|
||||
this.eventService = eventService;
|
||||
}
|
||||
|
||||
@PostMapping(path = "/events/{id}")
|
||||
public ResponseEntity createEvent(@RequestBody AccountEvent event, @PathVariable Long id) {
|
||||
return Optional.ofNullable(eventService.createEvent(id, event, ConsistencyModel.ACID))
|
||||
.map(e -> new ResponseEntity<>(e, HttpStatus.CREATED))
|
||||
.orElseThrow(() -> new IllegalArgumentException("Event creation failed"));
|
||||
}
|
||||
|
||||
@PutMapping(path = "/events/{id}")
|
||||
public ResponseEntity updateEvent(@RequestBody AccountEvent event, @PathVariable Long id) {
|
||||
return Optional.ofNullable(eventService.updateEvent(id, event))
|
||||
.map(e -> new ResponseEntity<>(e, HttpStatus.OK))
|
||||
.orElseThrow(() -> new IllegalArgumentException("Event update failed"));
|
||||
}
|
||||
|
||||
@GetMapping(path = "/events/{id}")
|
||||
public ResponseEntity getEvent(@PathVariable Long id) {
|
||||
return Optional.ofNullable(eventService.getEvent(id))
|
||||
.map(e -> new ResponseEntity<>(e, HttpStatus.OK))
|
||||
.orElse(new ResponseEntity<>(HttpStatus.NOT_FOUND));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
package demo.event;
|
||||
|
||||
import org.springframework.data.domain.Page;
|
||||
import org.springframework.data.domain.Pageable;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
import org.springframework.data.repository.query.Param;
|
||||
|
||||
public interface EventRepository extends JpaRepository<AccountEvent, Long> {
|
||||
Page<AccountEvent> findAccountEventsByAccountId(@Param("accountId") Long accountId, Pageable pageable);
|
||||
}
|
||||
214
account/account-web/src/main/java/demo/event/EventService.java
Normal file
214
account/account-web/src/main/java/demo/event/EventService.java
Normal file
@@ -0,0 +1,214 @@
|
||||
package demo.event;
|
||||
|
||||
import demo.account.Account;
|
||||
import demo.account.AccountController;
|
||||
import org.apache.log4j.Logger;
|
||||
import org.springframework.cache.annotation.CacheConfig;
|
||||
import org.springframework.cache.annotation.CacheEvict;
|
||||
import org.springframework.cache.annotation.Cacheable;
|
||||
import org.springframework.cloud.stream.messaging.Source;
|
||||
import org.springframework.data.domain.PageRequest;
|
||||
import org.springframework.hateoas.MediaTypes;
|
||||
import org.springframework.hateoas.Resource;
|
||||
import org.springframework.http.RequestEntity;
|
||||
import org.springframework.integration.support.MessageBuilder;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.util.Assert;
|
||||
import org.springframework.web.client.RestTemplate;
|
||||
|
||||
import java.net.URI;
|
||||
import java.util.Arrays;
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
|
||||
import static org.springframework.hateoas.mvc.ControllerLinkBuilder.linkTo;
|
||||
|
||||
/**
|
||||
* The {@link EventService} provides transactional service methods for {@link AccountEvent}
|
||||
* entities of the Account Service. Account domain events are generated with a {@link AccountEventType},
|
||||
* and action logs are appended to the {@link AccountEvent}.
|
||||
*
|
||||
* @author kbastani
|
||||
*/
|
||||
@Service
|
||||
@CacheConfig(cacheNames = {"events"})
|
||||
public class EventService {
|
||||
|
||||
private final Logger log = Logger.getLogger(EventService.class);
|
||||
|
||||
private final EventRepository eventRepository;
|
||||
private final Source accountStreamSource;
|
||||
private final RestTemplate restTemplate;
|
||||
|
||||
public EventService(EventRepository eventRepository, Source accountStreamSource, RestTemplate restTemplate) {
|
||||
this.eventRepository = eventRepository;
|
||||
this.accountStreamSource = accountStreamSource;
|
||||
this.restTemplate = restTemplate;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new {@link AccountEvent} and append it to the event log of the referenced {@link Account}.
|
||||
* After the {@link AccountEvent} has been persisted, send the event to the account stream. Events can
|
||||
* be raised as a blocking or non-blocking operation depending on the {@link ConsistencyModel}.
|
||||
*
|
||||
* @param accountId is the unique identifier for the {@link Account}
|
||||
* @param event is the {@link AccountEvent} to create
|
||||
* @param consistencyModel is the desired consistency model for the response
|
||||
* @return an {@link AccountEvent} that has been appended to the {@link Account}'s event log
|
||||
*/
|
||||
public AccountEvent createEvent(Long accountId, AccountEvent event, ConsistencyModel consistencyModel) {
|
||||
event = createEvent(accountId, event);
|
||||
return raiseEvent(event, consistencyModel);
|
||||
}
|
||||
|
||||
/**
|
||||
* Raise an {@link AccountEvent} that attempts to transition the state of an {@link Account}.
|
||||
*
|
||||
* @param event is an {@link AccountEvent} that will be raised
|
||||
* @param consistencyModel is the consistency model for this request
|
||||
* @return an {@link AccountEvent} that has been appended to the {@link Account}'s event log
|
||||
*/
|
||||
public AccountEvent raiseEvent(AccountEvent event, ConsistencyModel consistencyModel) {
|
||||
switch (consistencyModel) {
|
||||
case BASE:
|
||||
asyncRaiseEvent(event);
|
||||
break;
|
||||
case ACID:
|
||||
event = raiseEvent(event);
|
||||
break;
|
||||
}
|
||||
|
||||
return event;
|
||||
}
|
||||
|
||||
/**
|
||||
* Raise an asynchronous {@link AccountEvent} by sending an AMQP message to the account stream. Any
|
||||
* state changes will be applied to the {@link Account} outside of the current HTTP request context.
|
||||
* <p>
|
||||
* Use this operation when a workflow can be processed asynchronously outside of the current HTTP
|
||||
* request context.
|
||||
*
|
||||
* @param event is an {@link AccountEvent} that will be raised
|
||||
*/
|
||||
private void asyncRaiseEvent(AccountEvent event) {
|
||||
// Append the account event to the stream
|
||||
accountStreamSource.output()
|
||||
.send(MessageBuilder
|
||||
.withPayload(getAccountEventResource(event))
|
||||
.build());
|
||||
}
|
||||
|
||||
/**
|
||||
* Raise a synchronous {@link AccountEvent} by sending a HTTP request to the account stream. The response
|
||||
* is a blocking operation, which ensures that the result of a multi-step workflow will not return until
|
||||
* the transaction reaches a consistent state.
|
||||
* <p>
|
||||
* Use this operation when the result of a workflow must be returned within the current HTTP request context.
|
||||
*
|
||||
* @param event is an {@link AccountEvent} that will be raised
|
||||
* @return an {@link AccountEvent} which contains the consistent state of an {@link Account}
|
||||
*/
|
||||
private AccountEvent raiseEvent(AccountEvent event) {
|
||||
try {
|
||||
// Create a new request entity
|
||||
RequestEntity<Resource<AccountEvent>> requestEntity = RequestEntity.post(
|
||||
URI.create("http://localhost:8081/v1/events"))
|
||||
.contentType(MediaTypes.HAL_JSON)
|
||||
.body(getAccountEventResource(event), Resource.class);
|
||||
|
||||
// Update the account entity's status
|
||||
Account result = restTemplate.exchange(requestEntity, Account.class)
|
||||
.getBody();
|
||||
|
||||
log.info(result);
|
||||
event.setAccount(result);
|
||||
} catch (Exception ex) {
|
||||
log.error(ex);
|
||||
}
|
||||
|
||||
return event;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Create a new {@link AccountEvent} and publish it to the account stream.
|
||||
*
|
||||
* @param event is the {@link AccountEvent} to publish to the account stream
|
||||
* @return a hypermedia {@link AccountEvent} resource
|
||||
*/
|
||||
@CacheEvict(cacheNames = "events", key = "#id.toString()")
|
||||
public AccountEvent createEvent(Long id, AccountEvent event) {
|
||||
// Save new event
|
||||
event = addEvent(event);
|
||||
Assert.notNull(event, "The event could not be appended to the account");
|
||||
|
||||
return event;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get an {@link AccountEvent} with the supplied identifier.
|
||||
*
|
||||
* @param id is the unique identifier for the {@link AccountEvent}
|
||||
* @return an {@link AccountEvent}
|
||||
*/
|
||||
public Resource<AccountEvent> getEvent(Long id) {
|
||||
return getAccountEventResource(eventRepository.findOne(id));
|
||||
}
|
||||
|
||||
/**
|
||||
* Update an {@link AccountEvent} with the supplied identifier.
|
||||
*
|
||||
* @param id is the unique identifier for the {@link AccountEvent}
|
||||
* @param event is the {@link AccountEvent} to update
|
||||
* @return the updated {@link AccountEvent}
|
||||
*/
|
||||
@CacheEvict(cacheNames = "events", key = "#event.getAccount().getAccountId().toString()")
|
||||
public AccountEvent updateEvent(Long id, AccountEvent event) {
|
||||
Assert.notNull(id);
|
||||
Assert.isTrue(event.getId() == null || Objects.equals(id, event.getId()));
|
||||
|
||||
return eventRepository.save(event);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get {@link AccountEvents} for the supplied {@link Account} identifier.
|
||||
*
|
||||
* @param id is the unique identifier of the {@link Account}
|
||||
* @return a list of {@link AccountEvent} wrapped in a hypermedia {@link AccountEvents} resource
|
||||
*/
|
||||
@Cacheable(cacheNames = "events", key = "#id.toString()")
|
||||
public List<AccountEvent> getAccountEvents(Long id) {
|
||||
return eventRepository.findAccountEventsByAccountId(id,
|
||||
new PageRequest(0, Integer.MAX_VALUE)).getContent();
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets a hypermedia resource for a {@link AccountEvent} entity.
|
||||
*
|
||||
* @param event is the {@link AccountEvent} to enrich with hypermedia
|
||||
* @return a hypermedia resource for the supplied {@link AccountEvent} entity
|
||||
*/
|
||||
private Resource<AccountEvent> getAccountEventResource(AccountEvent event) {
|
||||
return new Resource<AccountEvent>(event, Arrays.asList(
|
||||
linkTo(AccountController.class)
|
||||
.slash("events")
|
||||
.slash(event.getEventId())
|
||||
.withSelfRel(),
|
||||
linkTo(AccountController.class)
|
||||
.slash("accounts")
|
||||
.slash(event.getAccount().getAccountId())
|
||||
.withRel("account")));
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a {@link AccountEvent} to an {@link Account} entity.
|
||||
*
|
||||
* @param event is the {@link AccountEvent} to append to an {@link Account} entity
|
||||
* @return the newly appended {@link AccountEvent} entity
|
||||
*/
|
||||
@CacheEvict(cacheNames = "events", key = "#event.getAccount().getAccountId().toString()")
|
||||
private AccountEvent addEvent(AccountEvent event) {
|
||||
event = eventRepository.saveAndFlush(event);
|
||||
return event;
|
||||
}
|
||||
}
|
||||
17
account/account-web/src/main/resources/application.yml
Normal file
17
account/account-web/src/main/resources/application.yml
Normal file
@@ -0,0 +1,17 @@
|
||||
spring:
|
||||
profiles:
|
||||
active: development
|
||||
---
|
||||
spring:
|
||||
profiles: development
|
||||
cloud:
|
||||
stream:
|
||||
bindings:
|
||||
output:
|
||||
destination: account
|
||||
contentType: 'application/json'
|
||||
redis:
|
||||
host: localhost
|
||||
port: 6379
|
||||
server:
|
||||
port: 8080
|
||||
4
account/account-web/src/main/resources/bootstrap.yml
Normal file
4
account/account-web/src/main/resources/bootstrap.yml
Normal file
@@ -0,0 +1,4 @@
|
||||
spring:
|
||||
application:
|
||||
name: account-web
|
||||
---
|
||||
@@ -0,0 +1,43 @@
|
||||
package demo.account;
|
||||
|
||||
import demo.event.EventService;
|
||||
import org.junit.Test;
|
||||
import org.junit.runner.RunWith;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest;
|
||||
import org.springframework.boot.test.mock.mockito.MockBean;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.test.context.junit4.SpringRunner;
|
||||
import org.springframework.test.web.servlet.MockMvc;
|
||||
|
||||
import static org.mockito.BDDMockito.given;
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
|
||||
|
||||
@RunWith(SpringRunner.class)
|
||||
@WebMvcTest(AccountController.class)
|
||||
public class AccountControllerTest {
|
||||
|
||||
@Autowired
|
||||
private MockMvc mvc;
|
||||
|
||||
@MockBean
|
||||
private AccountService accountService;
|
||||
|
||||
@MockBean
|
||||
private EventService eventService;
|
||||
|
||||
@Test
|
||||
public void getUserAccountResourceShouldReturnAccount() throws Exception {
|
||||
String content = "{\"firstName\": \"Jane\", \"lastName\": \"Doe\", \"email\": \"jane.doe@example.com\"}";
|
||||
|
||||
Account account = new Account("Jane", "Doe", "jane.doe@example.com");
|
||||
|
||||
given(this.accountService.getAccount(1L))
|
||||
.willReturn(account);
|
||||
|
||||
this.mvc.perform(get("/v1/accounts/1").accept(MediaType.APPLICATION_JSON))
|
||||
.andExpect(status().isOk()).andExpect(content().json(content));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,192 @@
|
||||
package demo.account;
|
||||
|
||||
import demo.event.AccountEvent;
|
||||
import demo.event.AccountEventType;
|
||||
import demo.event.EventService;
|
||||
import org.junit.Before;
|
||||
import org.junit.Test;
|
||||
import org.junit.runner.RunWith;
|
||||
import org.springframework.boot.test.mock.mockito.MockBean;
|
||||
import org.springframework.cache.CacheManager;
|
||||
import org.springframework.test.context.junit4.SpringRunner;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.mockito.BDDMockito.given;
|
||||
|
||||
@RunWith(SpringRunner.class)
|
||||
public class AccountServiceTests {
|
||||
|
||||
@MockBean
|
||||
private EventService eventService;
|
||||
|
||||
@MockBean
|
||||
private AccountRepository accountRepository;
|
||||
|
||||
@MockBean
|
||||
private CacheManager cacheManager;
|
||||
|
||||
private AccountService accountService;
|
||||
|
||||
@Before
|
||||
public void before() {
|
||||
accountService = new AccountService(accountRepository, eventService, cacheManager);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void getAccountReturnsAccount() throws Exception {
|
||||
Account expected = new Account("Jane", "Doe", "jane.doe@example.com");
|
||||
|
||||
given(this.accountRepository.findOne(1L)).willReturn(expected);
|
||||
|
||||
Account actual = accountService.getAccount(1L);
|
||||
|
||||
assertThat(actual).isNotNull();
|
||||
assertThat(actual.getEmail()).isEqualTo("jane.doe@example.com");
|
||||
assertThat(actual.getFirstName()).isEqualTo("Jane");
|
||||
assertThat(actual.getLastName()).isEqualTo("Doe");
|
||||
}
|
||||
|
||||
@Test
|
||||
public void createAccountReturnsAccount() throws Exception {
|
||||
Account account = new Account("Jane", "Doe", "jane.doe@example.com");
|
||||
account.setAccountId(1L);
|
||||
|
||||
given(this.accountRepository.findOne(1L)).willReturn(account);
|
||||
given(this.accountRepository.saveAndFlush(account)).willReturn(account);
|
||||
|
||||
Account actual = accountService.createAccount(account);
|
||||
|
||||
assertThat(actual).isNotNull();
|
||||
assertThat(actual.getStatus()).isEqualTo(AccountStatus.ACCOUNT_CREATED);
|
||||
assertThat(actual.getEmail()).isEqualTo("jane.doe@example.com");
|
||||
assertThat(actual.getFirstName()).isEqualTo("Jane");
|
||||
assertThat(actual.getLastName()).isEqualTo("Doe");
|
||||
}
|
||||
|
||||
@Test
|
||||
public void applyCommandSuspendsAccount() throws Exception {
|
||||
Account account = new Account("Jane", "Doe", "jane.doe@example.com");
|
||||
account.setStatus(AccountStatus.ACCOUNT_ACTIVE);
|
||||
|
||||
AccountEvent accountEvent = new AccountEvent(AccountEventType.ACCOUNT_SUSPENDED);
|
||||
accountEvent.setAccount(account);
|
||||
accountEvent.setEventId(1L);
|
||||
|
||||
given(this.accountRepository.findOne(1L)).willReturn(account);
|
||||
given(this.accountRepository.exists(1L)).willReturn(true);
|
||||
given(this.accountRepository.save(account)).willReturn(account);
|
||||
given(this.eventService.createEvent(1L, new AccountEvent(AccountEventType.ACCOUNT_SUSPENDED)))
|
||||
.willReturn(accountEvent);
|
||||
|
||||
Account actual = accountService.applyCommand(1L, AccountCommand.SUSPEND_ACCOUNT);
|
||||
|
||||
assertThat(actual).isNotNull();
|
||||
assertThat(actual.getStatus()).isEqualTo(AccountStatus.ACCOUNT_SUSPENDED);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void applyCommandUnsuspendsAccount() throws Exception {
|
||||
Account account = new Account("Jane", "Doe", "jane.doe@example.com");
|
||||
account.setStatus(AccountStatus.ACCOUNT_SUSPENDED);
|
||||
|
||||
AccountEvent accountEvent = new AccountEvent(AccountEventType.ACCOUNT_ACTIVATED);
|
||||
accountEvent.setAccount(account);
|
||||
accountEvent.setEventId(1L);
|
||||
|
||||
given(this.accountRepository.findOne(1L)).willReturn(account);
|
||||
given(this.accountRepository.exists(1L)).willReturn(true);
|
||||
given(this.accountRepository.save(account)).willReturn(account);
|
||||
given(this.eventService.createEvent(1L, new AccountEvent(AccountEventType.ACCOUNT_ACTIVATED)))
|
||||
.willReturn(accountEvent);
|
||||
|
||||
Account actual = accountService.applyCommand(1L, AccountCommand.ACTIVATE_ACCOUNT);
|
||||
|
||||
assertThat(actual).isNotNull();
|
||||
assertThat(actual.getStatus()).isEqualTo(AccountStatus.ACCOUNT_ACTIVE);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void applyCommandArchivesAccount() throws Exception {
|
||||
Account account = new Account("Jane", "Doe", "jane.doe@example.com");
|
||||
account.setStatus(AccountStatus.ACCOUNT_ACTIVE);
|
||||
|
||||
AccountEvent accountEvent = new AccountEvent(AccountEventType.ACCOUNT_ARCHIVED);
|
||||
accountEvent.setAccount(account);
|
||||
accountEvent.setEventId(1L);
|
||||
|
||||
given(this.accountRepository.findOne(1L)).willReturn(account);
|
||||
given(this.accountRepository.exists(1L)).willReturn(true);
|
||||
given(this.accountRepository.save(account)).willReturn(account);
|
||||
given(this.eventService.createEvent(1L, new AccountEvent(AccountEventType.ACCOUNT_ARCHIVED)))
|
||||
.willReturn(accountEvent);
|
||||
|
||||
Account actual = accountService.applyCommand(1L, AccountCommand.ARCHIVE_ACCOUNT);
|
||||
|
||||
assertThat(actual).isNotNull();
|
||||
assertThat(actual.getStatus()).isEqualTo(AccountStatus.ACCOUNT_ARCHIVED);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void applyCommandUnarchivesAccount() throws Exception {
|
||||
Account account = new Account("Jane", "Doe", "jane.doe@example.com");
|
||||
account.setStatus(AccountStatus.ACCOUNT_ARCHIVED);
|
||||
|
||||
AccountEvent accountEvent = new AccountEvent(AccountEventType.ACCOUNT_ACTIVATED);
|
||||
accountEvent.setAccount(account);
|
||||
accountEvent.setEventId(1L);
|
||||
|
||||
given(this.accountRepository.findOne(1L)).willReturn(account);
|
||||
given(this.accountRepository.exists(1L)).willReturn(true);
|
||||
given(this.accountRepository.save(account)).willReturn(account);
|
||||
given(this.eventService.createEvent(1L, new AccountEvent(AccountEventType.ACCOUNT_ACTIVATED)))
|
||||
.willReturn(accountEvent);
|
||||
|
||||
Account actual = accountService.applyCommand(1L, AccountCommand.ACTIVATE_ACCOUNT);
|
||||
|
||||
assertThat(actual).isNotNull();
|
||||
assertThat(actual.getStatus()).isEqualTo(AccountStatus.ACCOUNT_ACTIVE);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void applyCommandConfirmsAccount() throws Exception {
|
||||
Account account = new Account("Jane", "Doe", "jane.doe@example.com");
|
||||
account.setStatus(AccountStatus.ACCOUNT_PENDING);
|
||||
|
||||
AccountEvent accountEvent = new AccountEvent(AccountEventType.ACCOUNT_CONFIRMED);
|
||||
accountEvent.setAccount(account);
|
||||
accountEvent.setEventId(1L);
|
||||
|
||||
given(this.accountRepository.findOne(1L)).willReturn(account);
|
||||
given(this.accountRepository.exists(1L)).willReturn(true);
|
||||
given(this.accountRepository.save(account)).willReturn(account);
|
||||
given(this.eventService.createEvent(1L, new AccountEvent(AccountEventType.ACCOUNT_CONFIRMED)))
|
||||
.willReturn(accountEvent);
|
||||
|
||||
Account actual = accountService.applyCommand(1L, AccountCommand.CONFIRM_ACCOUNT);
|
||||
|
||||
assertThat(actual).isNotNull();
|
||||
assertThat(actual.getStatus()).isEqualTo(AccountStatus.ACCOUNT_CONFIRMED);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void applyCommandActivatesAccount() throws Exception {
|
||||
Account account = new Account("Jane", "Doe", "jane.doe@example.com");
|
||||
account.setStatus(AccountStatus.ACCOUNT_CONFIRMED);
|
||||
|
||||
|
||||
AccountEvent accountEvent = new AccountEvent(AccountEventType.ACCOUNT_ACTIVATED);
|
||||
accountEvent.setAccount(account);
|
||||
accountEvent.setEventId(1L);
|
||||
|
||||
given(this.accountRepository.findOne(1L)).willReturn(account);
|
||||
given(this.accountRepository.exists(1L)).willReturn(true);
|
||||
given(this.accountRepository.save(account)).willReturn(account);
|
||||
given(this.eventService.createEvent(1L, new AccountEvent(AccountEventType.ACCOUNT_ACTIVATED)))
|
||||
.willReturn(accountEvent);
|
||||
|
||||
Account actual = accountService.applyCommand(1L, AccountCommand.ACTIVATE_ACCOUNT);
|
||||
|
||||
assertThat(actual).isNotNull();
|
||||
assertThat(actual.getStatus()).isEqualTo(AccountStatus.ACCOUNT_ACTIVE);
|
||||
}
|
||||
}
|
||||
1
account/account-web/src/test/resources/data-h2.sql
Normal file
1
account/account-web/src/test/resources/data-h2.sql
Normal file
@@ -0,0 +1 @@
|
||||
INSERT INTO ACCOUNT(ID, FIRST_NAME, LAST_NAME, EMAIL) values (1, 'John', 'Doe', 'john.doe@example.com');
|
||||
99
account/account-worker/README.md
Normal file
99
account/account-worker/README.md
Normal file
@@ -0,0 +1,99 @@
|
||||
# Account Microservice: Worker
|
||||
|
||||
The `account-worker` application is a event stream processing application that listens for `Account` domain events as AMQP messages. The domain events that are generated by the `account-web` application are processed in this module.
|
||||
|
||||
The worker is responsible for durable transaction processing for work flows that are required to coordinate asynchronously with applications residing in other domain contexts.
|
||||
|
||||
The worker is also responsible for automatically remediating state changes in a distributed transactions that encountered a partial failure. The most important goal of the worker module is to keep the state of the system consistent through automated means — to guarantee eventual consistency.
|
||||
|
||||
# Usage
|
||||
|
||||
The `account-worker` is a _Spring Cloud Stream_ application that drives the state of the `Account` domain resource. The application is completely stateless because it uses hypermedia to drive the state of the application. At the heart of the `account-worker` is a configurable state machine that describes how domain events trigger state transitions on an `Account` resource.
|
||||
|
||||
The code snippet below describes a single state machine transition.
|
||||
|
||||
```java
|
||||
// Describe state machine transitions for accounts
|
||||
transitions.withExternal()
|
||||
.source(AccountStatus.ACCOUNT_CREATED)
|
||||
.target(AccountStatus.ACCOUNT_PENDING)
|
||||
.event(AccountEventType.ACCOUNT_CREATED)
|
||||
.action(createAccount())
|
||||
```
|
||||
|
||||
The `/src/main/java/demo/config/StateMachineConfig.java` class configures a state machine using the _Spring Statemachine_ project. The snippet above describes the first transition of a state machine for the `Account` resource. Here we see that the source state is `ACCOUNT_CREATED` and the target state is `ACCOUNT_PENDING`. We also see that the state transition is triggered by an `ACCOUNT_CREATED` event. Finally, we see that an action method named `createAccount` is mapped to this state transition.
|
||||
|
||||
Each time an `AccountEvent` is received by the stream listener in the `AccountEventStream` class, a state machine is replicated by applying the ordered history of previous account events—we call this history the _Event Log_. Since each `AccountEvent` provides hypermedia links for retrieving the context of the attached `Account` resource, we can traverse to an account's event log and use a technique called _Event Sourcing_ to aggregate the current state of the `Account`.
|
||||
|
||||
### Functions
|
||||
|
||||
As we saw earlier in the configuration of state machine transitions, an action can be mapped to a function. In the `StateMachineConfig` class we'll find multiple bean definitions that correspond to transition actions. For example, earlier we saw the method triggered for a transition triggered by an `ACCOUNT_CREATED` event that mapped to an action named `createAccount`. Let's see the definition of that method.
|
||||
|
||||
```java
|
||||
@Bean
|
||||
public Action<AccountStatus, AccountEventType> createAccount() {
|
||||
return context -> applyEvent(context,
|
||||
new CreateAccountFunction(context));
|
||||
}
|
||||
```
|
||||
|
||||
The `createAccount` method returns an executable action that passes the state context to a method named `applyEvent`. The `applyEvent` method is a step function that replicates the current state of an `Account` resource.
|
||||
|
||||
Since a state machine is replicated in-memory each time an `AccountEvent` is processed, we'll need to ensure that actions are not executed against the same resource multiple times during replication. The `applyEvent` method will only execute the supplied function—in this case `CreateAccountFunction`—if the state machine is finished replicating.
|
||||
|
||||
When the state machine is finished replicating, it will attempt to apply the `AccountEvent` for this context to an action mapped function. We can find each of the function classes for state transitions inside the `/src/main/java/demo/function` package.
|
||||
|
||||
```bash
|
||||
.
|
||||
├── /src/main/java/demo/function
|
||||
├── AccountFunction.java
|
||||
├── ActivateAccountFunction.java
|
||||
├── ArchiveAccountFunction.java
|
||||
├── ConfirmAccountFunction.java
|
||||
├── CreateAccountFunction.java
|
||||
├── SuspendAccountFunction.java
|
||||
├── UnarchiveAccountFunction.java
|
||||
└── UnsuspendAccountFunction.java
|
||||
```
|
||||
|
||||
The `AccountFunction` abstract class is extended by each of the other classes inside of the `function` package. Since we're using hypermedia to drive the state of the application, each function is immutable and stateless. In this reference application we can either define the task inside an `AccountFunction` class, or we can use a `Consumer<T>`, which is a Java 8 lambda expression, to apply an `AccountEvent` to an `Account` resource.
|
||||
|
||||
Let's go back to the `StateMachineConfig` class and look at an example of an action mapped function that uses a lambda expression.
|
||||
|
||||
```java
|
||||
@Bean
|
||||
public Action<AccountStatus, AccountEventType> confirmAccount() {
|
||||
return context -> {
|
||||
// Map the account action to a Java 8 lambda function
|
||||
ConfirmAccountFunction accountFunction;
|
||||
|
||||
accountFunction = new ConfirmAccountFunction(context, event -> {
|
||||
// Get the account resource for the event
|
||||
Traverson traverson = new Traverson(
|
||||
URI.create(event.getLink("account").getHref()),
|
||||
MediaTypes.HAL_JSON
|
||||
);
|
||||
|
||||
// Follow the command resource to activate the account
|
||||
Account account = traverson.follow("commands")
|
||||
.follow("activate")
|
||||
.toEntity(Account.class)
|
||||
.getBody();
|
||||
});
|
||||
|
||||
applyEvent(context, accountFunction);
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
The snippet above shows the definition of the `confirmAccount` action. Here we see a stateless function that uses a `Traverson` client to follow hypermedia links of the `AccountEvent` resource in a workflow that activates the `Account`. Since the embedded hypermedia links provide the full context of an `AccountEvent` resource, we can implement this function from anywhere—_even as a serverless function_!
|
||||
|
||||
#### Serverless Functions
|
||||
|
||||
A _serverless_ function is a unit of cloud deployment in a PaaS (Platform-as-a-Service) that is composed of a stateless function. Serverless was first popularized by _Amazon Web Services_ as a part of their _AWS Lambda_ compute platform.
|
||||
|
||||
Serverless—which is also referred to as FaaS (Function-as-a-Service)—allows you to deploy code as functions without needing to setup or manage application servers or containers.
|
||||
|
||||
With a serverless function, a cloud platform will take care of when and where a function is scheduled and executed. A cloud platform is also opinionated about the compute resources required to execute and/or scale a function.
|
||||
|
||||
In this reference architecture we have two units of deployment per microservice, a web and worker application. Each of the workloads for the deployments are designed to operate in an immutable Linux container. Since the state machine actions that are mapped to the `AccountFunction` classes in the `account-worker` application are both immutable and stateless, we can choose to instead map each of these actions to a serverless function.
|
||||
10
account/account-worker/manifest.yml
Normal file
10
account/account-worker/manifest.yml
Normal file
@@ -0,0 +1,10 @@
|
||||
name: account-worker
|
||||
memory: 1024M
|
||||
instances: 1
|
||||
path: ./target/account-worker-0.0.1-SNAPSHOT.jar
|
||||
buildpack: java_buildpack
|
||||
services:
|
||||
- rabbit-events
|
||||
disk_quota: 1024M
|
||||
host: account-event-worker
|
||||
domain: cfapps.io
|
||||
95
account/account-worker/pom.xml
Normal file
95
account/account-worker/pom.xml
Normal file
@@ -0,0 +1,95 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
|
||||
<modelVersion>4.0.0</modelVersion>
|
||||
|
||||
<artifactId>account-worker</artifactId>
|
||||
<version>0.0.1-SNAPSHOT</version>
|
||||
<packaging>jar</packaging>
|
||||
|
||||
<name>account-worker</name>
|
||||
|
||||
<parent>
|
||||
<groupId>org.kbastani</groupId>
|
||||
<artifactId>account</artifactId>
|
||||
<version>1.0-SNAPSHOT</version>
|
||||
<relativePath>../</relativePath>
|
||||
</parent>
|
||||
|
||||
<properties>
|
||||
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
|
||||
<project.reporting.outputEncoding>UTF-8</project.reporting.outputEncoding>
|
||||
<java.version>1.8</java.version>
|
||||
</properties>
|
||||
|
||||
<dependencies>
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-web</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-actuator</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-hateoas</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.springframework.cloud</groupId>
|
||||
<artifactId>spring-cloud-starter-stream-rabbit</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-integration</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.springframework.statemachine</groupId>
|
||||
<artifactId>spring-statemachine-core</artifactId>
|
||||
<version>${spring-statemachine-core.version}</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.kbastani</groupId>
|
||||
<artifactId>spring-boot-starter-aws-lambda</artifactId>
|
||||
<version>${spring-boot-starter-aws-lambda.version}</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>com.amazonaws</groupId>
|
||||
<artifactId>aws-java-sdk-sts</artifactId>
|
||||
<version>${aws-java-sdk-sts.version}</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>com.amazonaws</groupId>
|
||||
<artifactId>aws-java-sdk-lambda</artifactId>
|
||||
</dependency>
|
||||
|
||||
<dependency>
|
||||
<groupId>com.jayway.jsonpath</groupId>
|
||||
<artifactId>json-path</artifactId>
|
||||
<version>${json-path.version}</version>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
|
||||
<dependencyManagement>
|
||||
<dependencies>
|
||||
<dependency>
|
||||
<groupId>com.amazonaws</groupId>
|
||||
<artifactId>aws-java-sdk-bom</artifactId>
|
||||
<version>${aws-java-sdk-sts.version}</version>
|
||||
<type>pom</type>
|
||||
<scope>import</scope>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
</dependencyManagement>
|
||||
|
||||
<build>
|
||||
<plugins>
|
||||
<plugin>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-maven-plugin</artifactId>
|
||||
</plugin>
|
||||
</plugins>
|
||||
</build>
|
||||
|
||||
|
||||
</project>
|
||||
@@ -0,0 +1,16 @@
|
||||
package demo;
|
||||
|
||||
import org.springframework.boot.SpringApplication;
|
||||
import org.springframework.boot.autoconfigure.SpringBootApplication;
|
||||
import org.springframework.hateoas.config.EnableHypermediaSupport;
|
||||
import org.springframework.hateoas.config.EnableHypermediaSupport.HypermediaType;
|
||||
|
||||
@SpringBootApplication
|
||||
@EnableHypermediaSupport(type = {HypermediaType.HAL})
|
||||
public class AccountStreamModuleApplication {
|
||||
public static void main(String[] args) {
|
||||
SpringApplication.run(AccountStreamModuleApplication.class, args);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
package demo.account;
|
||||
|
||||
import demo.domain.BaseEntity;
|
||||
import demo.event.AccountEvent;
|
||||
|
||||
/**
|
||||
* The {@link Account} domain object contains information related to
|
||||
* a user's account. The status of an account is event sourced using
|
||||
* events logged to the {@link AccountEvent} collection attached to
|
||||
* this resource.
|
||||
*
|
||||
* @author kbastani
|
||||
*/
|
||||
public class Account extends BaseEntity {
|
||||
private String firstName;
|
||||
private String lastName;
|
||||
private String email;
|
||||
private AccountStatus status;
|
||||
|
||||
public Account() {
|
||||
}
|
||||
|
||||
public String getFirstName() {
|
||||
return firstName;
|
||||
}
|
||||
|
||||
public void setFirstName(String firstName) {
|
||||
this.firstName = firstName;
|
||||
}
|
||||
|
||||
public String getLastName() {
|
||||
return lastName;
|
||||
}
|
||||
|
||||
public void setLastName(String lastName) {
|
||||
this.lastName = lastName;
|
||||
}
|
||||
|
||||
public String getEmail() {
|
||||
return email;
|
||||
}
|
||||
|
||||
public void setEmail(String email) {
|
||||
this.email = email;
|
||||
}
|
||||
|
||||
public AccountStatus getStatus() {
|
||||
return status;
|
||||
}
|
||||
|
||||
public void setStatus(AccountStatus status) {
|
||||
this.status = status;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
package demo.account;
|
||||
|
||||
/**
|
||||
* The {@link AccountStatus} describes the state of an {@link Account}.
|
||||
* The aggregate state of a {@link Account} is sourced from attached domain
|
||||
* events in the form of {@link demo.event.AccountEvent}.
|
||||
*
|
||||
* @author kbastani
|
||||
*/
|
||||
public enum AccountStatus {
|
||||
ACCOUNT_CREATED,
|
||||
ACCOUNT_PENDING,
|
||||
ACCOUNT_CONFIRMED,
|
||||
ACCOUNT_ACTIVE,
|
||||
ACCOUNT_SUSPENDED,
|
||||
ACCOUNT_ARCHIVED
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
package demo.config;
|
||||
|
||||
import amazon.aws.AWSLambdaConfigurerAdapter;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import demo.function.LambdaFunctions;
|
||||
import demo.util.LambdaUtil;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.context.annotation.Profile;
|
||||
|
||||
@Configuration
|
||||
@Profile("cloud")
|
||||
public class AwsLambdaConfig {
|
||||
|
||||
@Bean
|
||||
public LambdaFunctions lambdaInvoker(AWSLambdaConfigurerAdapter configurerAdapter) {
|
||||
return configurerAdapter
|
||||
.getFunctionInstance(LambdaFunctions.class);
|
||||
}
|
||||
|
||||
@Bean
|
||||
public LambdaUtil lambdaUtil(ObjectMapper objectMapper) {
|
||||
return new LambdaUtil(objectMapper);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,314 @@
|
||||
package demo.config;
|
||||
|
||||
import demo.account.Account;
|
||||
import demo.account.AccountStatus;
|
||||
import demo.event.AccountEvent;
|
||||
import demo.event.AccountEventType;
|
||||
import demo.function.*;
|
||||
import org.apache.log4j.Logger;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.hateoas.MediaTypes;
|
||||
import org.springframework.hateoas.client.Traverson;
|
||||
import org.springframework.statemachine.StateContext;
|
||||
import org.springframework.statemachine.StateMachine;
|
||||
import org.springframework.statemachine.action.Action;
|
||||
import org.springframework.statemachine.config.EnableStateMachineFactory;
|
||||
import org.springframework.statemachine.config.EnumStateMachineConfigurerAdapter;
|
||||
import org.springframework.statemachine.config.builders.StateMachineStateConfigurer;
|
||||
import org.springframework.statemachine.config.builders.StateMachineTransitionConfigurer;
|
||||
|
||||
import java.net.URI;
|
||||
import java.util.EnumSet;
|
||||
|
||||
/**
|
||||
* A configuration adapter for describing a {@link StateMachine} factory that maps actions to functional
|
||||
* expressions. Actions are executed during transitions between a source state and a target state.
|
||||
* <p>
|
||||
* A state machine provides a robust declarative language for describing the state of an {@link Account}
|
||||
* resource given a sequence of ordered {@link demo.event.AccountEvents}. When an event is received
|
||||
* in {@link demo.event.AccountEventStream}, an in-memory state machine is fully replicated given the
|
||||
* {@link demo.event.AccountEvents} attached to an {@link Account} resource.
|
||||
*
|
||||
* @author kbastani
|
||||
*/
|
||||
@Configuration
|
||||
@EnableStateMachineFactory
|
||||
public class StateMachineConfig extends EnumStateMachineConfigurerAdapter<AccountStatus, AccountEventType> {
|
||||
|
||||
final private Logger log = Logger.getLogger(StateMachineConfig.class);
|
||||
|
||||
/**
|
||||
* Configures the initial conditions of a new in-memory {@link StateMachine} for {@link Account}.
|
||||
*
|
||||
* @param states is the {@link StateMachineStateConfigurer} used to describe the initial condition
|
||||
*/
|
||||
@Override
|
||||
public void configure(StateMachineStateConfigurer<AccountStatus, AccountEventType> states) {
|
||||
try {
|
||||
// Describe the initial condition of the account state machine
|
||||
states.withStates()
|
||||
.initial(AccountStatus.ACCOUNT_CREATED)
|
||||
.states(EnumSet.allOf(AccountStatus.class));
|
||||
} catch (Exception e) {
|
||||
throw new RuntimeException("State machine configuration failed", e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Configures the {@link StateMachine} that describes how {@link AccountEventType} drives the state
|
||||
* of an {@link Account}. Events are applied as transitions from a source {@link AccountStatus} to
|
||||
* a target {@link AccountStatus}. An {@link Action} is attached to each transition, which maps to a
|
||||
* function that is executed in the context of an {@link AccountEvent}.
|
||||
*
|
||||
* @param transitions is the {@link StateMachineTransitionConfigurer} used to describe state transitions
|
||||
*/
|
||||
@Override
|
||||
public void configure(StateMachineTransitionConfigurer<AccountStatus, AccountEventType> transitions) {
|
||||
try {
|
||||
// Describe state machine transitions for accounts
|
||||
transitions.withExternal()
|
||||
.source(AccountStatus.ACCOUNT_CREATED)
|
||||
.target(AccountStatus.ACCOUNT_PENDING)
|
||||
.event(AccountEventType.ACCOUNT_CREATED)
|
||||
.action(createAccount())
|
||||
.and()
|
||||
.withExternal()
|
||||
.source(AccountStatus.ACCOUNT_PENDING)
|
||||
.target(AccountStatus.ACCOUNT_CONFIRMED)
|
||||
.event(AccountEventType.ACCOUNT_CONFIRMED)
|
||||
.action(confirmAccount())
|
||||
.and()
|
||||
.withExternal()
|
||||
.source(AccountStatus.ACCOUNT_CONFIRMED)
|
||||
.target(AccountStatus.ACCOUNT_ACTIVE)
|
||||
.event(AccountEventType.ACCOUNT_ACTIVATED)
|
||||
.action(activateAccount())
|
||||
.and()
|
||||
.withExternal()
|
||||
.source(AccountStatus.ACCOUNT_ACTIVE)
|
||||
.target(AccountStatus.ACCOUNT_ARCHIVED)
|
||||
.event(AccountEventType.ACCOUNT_ARCHIVED)
|
||||
.action(archiveAccount())
|
||||
.and()
|
||||
.withExternal()
|
||||
.source(AccountStatus.ACCOUNT_ACTIVE)
|
||||
.target(AccountStatus.ACCOUNT_SUSPENDED)
|
||||
.event(AccountEventType.ACCOUNT_SUSPENDED)
|
||||
.action(suspendAccount())
|
||||
.and()
|
||||
.withExternal()
|
||||
.source(AccountStatus.ACCOUNT_ARCHIVED)
|
||||
.target(AccountStatus.ACCOUNT_ACTIVE)
|
||||
.event(AccountEventType.ACCOUNT_ACTIVATED)
|
||||
.action(unarchiveAccount())
|
||||
.and()
|
||||
.withExternal()
|
||||
.source(AccountStatus.ACCOUNT_SUSPENDED)
|
||||
.target(AccountStatus.ACCOUNT_ACTIVE)
|
||||
.event(AccountEventType.ACCOUNT_ACTIVATED)
|
||||
.action(unsuspendAccount());
|
||||
} catch (Exception e) {
|
||||
throw new RuntimeException("Could not configure state machine transitions", e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Functions are mapped to actions that are triggered during the replication of a state machine. Functions
|
||||
* should only be executed after the state machine has completed replication. This method checks the state
|
||||
* context of the machine for an {@link AccountEvent}, which signals that the state machine is finished
|
||||
* replication.
|
||||
* <p>
|
||||
* The {@link AccountFunction} argument is only applied if an {@link AccountEvent} is provided as a
|
||||
* message header in the {@link StateContext}.
|
||||
*
|
||||
* @param context is the state machine context that may include an {@link AccountEvent}
|
||||
* @param accountFunction is the account function to apply after the state machine has completed replication
|
||||
* @return an {@link AccountEvent} only if this event has not yet been processed, otherwise returns null
|
||||
*/
|
||||
private AccountEvent applyEvent(StateContext<AccountStatus, AccountEventType> context,
|
||||
AccountFunction accountFunction) {
|
||||
AccountEvent accountEvent = null;
|
||||
|
||||
// Log out the progress of the state machine replication
|
||||
log.info("Replicate event: " + context.getMessage().getPayload());
|
||||
|
||||
// The machine is finished replicating when an AccountEvent is found in the message header
|
||||
if (context.getMessageHeader("event") != null) {
|
||||
accountEvent = (AccountEvent) context.getMessageHeader("event");
|
||||
log.info("State machine replicated: " + accountEvent.getType());
|
||||
|
||||
// Apply the provided function to the AccountEvent
|
||||
accountFunction.apply(accountEvent);
|
||||
}
|
||||
|
||||
return accountEvent;
|
||||
}
|
||||
|
||||
/**
|
||||
* The action that is triggered in response to an account transitioning from ACCOUNT_CREATED
|
||||
* to ACCOUNT_PENDING.
|
||||
* <p>
|
||||
* The body of this method shows an example of how to map an {@link AccountFunction} to a function
|
||||
* defined in a class method of {@link CreateAccount}.
|
||||
*
|
||||
* @return an implementation of {@link Action} that includes a function to execute
|
||||
*/
|
||||
@Bean
|
||||
public Action<AccountStatus, AccountEventType> createAccount() {
|
||||
return context -> applyEvent(context, new CreateAccount(context));
|
||||
}
|
||||
|
||||
/**
|
||||
* The action that is triggered in response to an account transitioning from ACCOUNT_PENDING
|
||||
* to ACCOUNT_CONFIRMED.
|
||||
* <p>
|
||||
* The body of this method shows an example of how to use a {@link java.util.function.Consumer} Java 8
|
||||
* lambda function instead of the class method definition that was shown in {@link CreateAccount}.
|
||||
*
|
||||
* @return an implementation of {@link Action} that includes a function to execute
|
||||
*/
|
||||
@Bean
|
||||
public Action<AccountStatus, AccountEventType> confirmAccount() {
|
||||
return context -> {
|
||||
// Map the account action to a Java 8 lambda function
|
||||
ConfirmAccount accountFunction;
|
||||
|
||||
accountFunction = new ConfirmAccount(context, event -> {
|
||||
// Get the account resource for the event
|
||||
Traverson traverson = new Traverson(
|
||||
URI.create(event.getLink("account").getHref()),
|
||||
MediaTypes.HAL_JSON
|
||||
);
|
||||
|
||||
// Follow the command resource to activate the account
|
||||
Account account = traverson.follow("commands")
|
||||
.follow("activate")
|
||||
.toEntity(Account.class)
|
||||
.getBody();
|
||||
|
||||
log.info(event.getType() + ": " + event.getLink("account").getHref());
|
||||
|
||||
return account;
|
||||
});
|
||||
|
||||
applyEvent(context, accountFunction);
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The action that is triggered in response to an account transitioning from ACCOUNT_CONFIRMED
|
||||
* to ACCOUNT_ACTIVE.
|
||||
*
|
||||
* @return an implementation of {@link Action} that includes a function to execute
|
||||
*/
|
||||
@Bean
|
||||
public Action<AccountStatus, AccountEventType> activateAccount() {
|
||||
return context -> applyEvent(context,
|
||||
new ActivateAccount(context, event -> {
|
||||
log.info(event.getType() + ": " + event.getLink("account").getHref());
|
||||
// Get the account resource for the event
|
||||
Traverson traverson = new Traverson(
|
||||
URI.create(event.getLink("account").getHref()),
|
||||
MediaTypes.HAL_JSON
|
||||
);
|
||||
|
||||
return traverson.follow("self")
|
||||
.toEntity(Account.class)
|
||||
.getBody();
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* The action that is triggered in response to an account transitioning from ACCOUNT_ACTIVE
|
||||
* to ACCOUNT_ARCHIVED.
|
||||
*
|
||||
* @return an implementation of {@link Action} that includes a function to execute
|
||||
*/
|
||||
@Bean
|
||||
public Action<AccountStatus, AccountEventType> archiveAccount() {
|
||||
return context -> applyEvent(context,
|
||||
new ArchiveAccount(context, event -> {
|
||||
log.info(event.getType() + ": " + event.getLink("account").getHref());
|
||||
// Get the account resource for the event
|
||||
Traverson traverson = new Traverson(
|
||||
URI.create(event.getLink("account").getHref()),
|
||||
MediaTypes.HAL_JSON
|
||||
);
|
||||
|
||||
return traverson.follow("self")
|
||||
.toEntity(Account.class)
|
||||
.getBody();
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* The action that is triggered in response to an account transitioning from ACCOUNT_ACTIVE
|
||||
* to ACCOUNT_SUSPENDED.
|
||||
*
|
||||
* @return an implementation of {@link Action} that includes a function to execute
|
||||
*/
|
||||
@Bean
|
||||
public Action<AccountStatus, AccountEventType> suspendAccount() {
|
||||
return context -> applyEvent(context,
|
||||
new SuspendAccount(context, event -> {
|
||||
log.info(event.getType() + ": " + event.getLink("account").getHref());
|
||||
// Get the account resource for the event
|
||||
Traverson traverson = new Traverson(
|
||||
URI.create(event.getLink("account").getHref()),
|
||||
MediaTypes.HAL_JSON
|
||||
);
|
||||
|
||||
return traverson.follow("self")
|
||||
.toEntity(Account.class)
|
||||
.getBody();
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* The action that is triggered in response to an account transitioning from ACCOUNT_ARCHIVED
|
||||
* to ACCOUNT_ACTIVE.
|
||||
*
|
||||
* @return an implementation of {@link Action} that includes a function to execute
|
||||
*/
|
||||
@Bean
|
||||
public Action<AccountStatus, AccountEventType> unarchiveAccount() {
|
||||
return context -> applyEvent(context,
|
||||
new UnarchiveAccount(context, event -> {
|
||||
log.info(event.getType() + ": " + event.getLink("account").getHref());
|
||||
// Get the account resource for the event
|
||||
Traverson traverson = new Traverson(
|
||||
URI.create(event.getLink("account").getHref()),
|
||||
MediaTypes.HAL_JSON
|
||||
);
|
||||
|
||||
return traverson.follow("self")
|
||||
.toEntity(Account.class)
|
||||
.getBody();
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* The action that is triggered in response to an account transitioning from ACCOUNT_SUSPENDED
|
||||
* to ACCOUNT_ACTIVE.
|
||||
*
|
||||
* @return an implementation of {@link Action} that includes a function to execute
|
||||
*/
|
||||
@Bean
|
||||
public Action<AccountStatus, AccountEventType> unsuspendAccount() {
|
||||
return context -> applyEvent(context,
|
||||
new UnsuspendAccount(context, event -> {
|
||||
log.info(event.getType() + ": " + event.getLink("account").getHref());
|
||||
// Get the account resource for the event
|
||||
Traverson traverson = new Traverson(
|
||||
URI.create(event.getLink("account").getHref()),
|
||||
MediaTypes.HAL_JSON
|
||||
);
|
||||
|
||||
return traverson.follow("self")
|
||||
.toEntity(Account.class)
|
||||
.getBody();
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
package demo.domain;
|
||||
|
||||
import org.springframework.hateoas.ResourceSupport;
|
||||
|
||||
public class BaseEntity extends ResourceSupport {
|
||||
|
||||
private Long createdAt;
|
||||
private Long lastModified;
|
||||
|
||||
public BaseEntity() {
|
||||
}
|
||||
|
||||
public Long getCreatedAt() {
|
||||
return createdAt;
|
||||
}
|
||||
|
||||
public void setCreatedAt(Long createdAt) {
|
||||
this.createdAt = createdAt;
|
||||
}
|
||||
|
||||
public Long getLastModified() {
|
||||
return lastModified;
|
||||
}
|
||||
|
||||
public void setLastModified(Long lastModified) {
|
||||
this.lastModified = lastModified;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String toString() {
|
||||
return "BaseEntity{" +
|
||||
"createdAt=" + createdAt +
|
||||
", lastModified=" + lastModified +
|
||||
"} " + super.toString();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
package demo.event;
|
||||
|
||||
import demo.account.Account;
|
||||
import demo.domain.BaseEntity;
|
||||
|
||||
/**
|
||||
* The domain event {@link AccountEvent} tracks the type and state of events as
|
||||
* applied to the {@link Account} domain object. This event resource can be used
|
||||
* to event source the aggregate state of {@link Account}.
|
||||
* <p>
|
||||
* This event resource also provides a transaction log that can be used to append
|
||||
* actions to the event.
|
||||
*
|
||||
* @author kbastani
|
||||
*/
|
||||
public class AccountEvent extends BaseEntity {
|
||||
|
||||
private AccountEventType type;
|
||||
|
||||
public AccountEvent() {
|
||||
}
|
||||
|
||||
public AccountEvent(AccountEventType type) {
|
||||
this.type = type;
|
||||
}
|
||||
|
||||
public AccountEventType getType() {
|
||||
return type;
|
||||
}
|
||||
|
||||
public void setType(AccountEventType type) {
|
||||
this.type = type;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String toString() {
|
||||
return "AccountEvent{" +
|
||||
"type=" + type +
|
||||
"} " + super.toString();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
package demo.event;
|
||||
|
||||
import demo.account.Account;
|
||||
import org.springframework.boot.autoconfigure.EnableAutoConfiguration;
|
||||
import org.springframework.cloud.stream.annotation.EnableBinding;
|
||||
import org.springframework.cloud.stream.annotation.StreamListener;
|
||||
import org.springframework.cloud.stream.messaging.Sink;
|
||||
import org.springframework.context.annotation.Profile;
|
||||
import org.springframework.statemachine.StateMachine;
|
||||
|
||||
/**
|
||||
* The {@link AccountEventStream} monitors for a variety of {@link AccountEvent} domain
|
||||
* events for an {@link Account}.
|
||||
*
|
||||
* @author kbastani
|
||||
*/
|
||||
@EnableAutoConfiguration
|
||||
@EnableBinding(Sink.class)
|
||||
@Profile({ "cloud", "development" })
|
||||
public class AccountEventStream {
|
||||
|
||||
private EventService eventService;
|
||||
|
||||
public AccountEventStream(EventService eventService) {
|
||||
this.eventService = eventService;
|
||||
}
|
||||
|
||||
/**
|
||||
* Listens to a stream of incoming {@link AccountEvent} messages. For each
|
||||
* new message received, replicate an in-memory {@link StateMachine} that
|
||||
* reproduces the current state of the {@link Account} resource that is the
|
||||
* subject of the {@link AccountEvent}.
|
||||
*
|
||||
* @param accountEvent is the {@link Account} domain event to process
|
||||
*/
|
||||
@StreamListener(Sink.INPUT)
|
||||
public void streamListerner(AccountEvent accountEvent) {
|
||||
eventService.apply(accountEvent);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
package demo.event;
|
||||
|
||||
import demo.account.Account;
|
||||
import demo.account.AccountStatus;
|
||||
|
||||
/**
|
||||
* The {@link AccountEventType} represents a collection of possible events that describe
|
||||
* state transitions of {@link AccountStatus} on the {@link Account} aggregate.
|
||||
*
|
||||
* @author kbastani
|
||||
*/
|
||||
public enum AccountEventType {
|
||||
ACCOUNT_CREATED,
|
||||
ACCOUNT_CONFIRMED,
|
||||
ACCOUNT_ACTIVATED,
|
||||
ACCOUNT_SUSPENDED,
|
||||
ACCOUNT_ARCHIVED
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
package demo.event;
|
||||
|
||||
import org.springframework.hateoas.Resources;
|
||||
|
||||
/**
|
||||
* The {@link AccountEvents} is a hypermedia collection of {@link AccountEvent} resources.
|
||||
*
|
||||
* @author kbastani
|
||||
*/
|
||||
public class AccountEvents extends Resources<AccountEvent> {
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
package demo.event;
|
||||
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestBody;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
import java.util.Optional;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/v1")
|
||||
public class EventController {
|
||||
|
||||
private EventService eventService;
|
||||
|
||||
public EventController(EventService eventService) {
|
||||
this.eventService = eventService;
|
||||
}
|
||||
|
||||
@PostMapping(path = "/events")
|
||||
public ResponseEntity handleEvent(@RequestBody AccountEvent event) {
|
||||
return Optional.ofNullable(eventService.apply(event))
|
||||
.map(e -> new ResponseEntity<>(e, HttpStatus.CREATED))
|
||||
.orElseThrow(() -> new RuntimeException("Apply event failed"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
package demo.event;
|
||||
|
||||
import demo.account.Account;
|
||||
import demo.account.AccountStatus;
|
||||
import demo.state.StateMachineService;
|
||||
import org.apache.log4j.Logger;
|
||||
import org.springframework.hateoas.MediaTypes;
|
||||
import org.springframework.hateoas.client.Traverson;
|
||||
import org.springframework.messaging.MessageHeaders;
|
||||
import org.springframework.messaging.support.MessageBuilder;
|
||||
import org.springframework.statemachine.StateMachine;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.net.URI;
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
|
||||
@Service
|
||||
public class EventService {
|
||||
|
||||
final private Logger log = Logger.getLogger(EventService.class);
|
||||
final private StateMachineService stateMachineService;
|
||||
|
||||
public EventService(StateMachineService stateMachineService) {
|
||||
this.stateMachineService = stateMachineService;
|
||||
}
|
||||
|
||||
public Account apply(AccountEvent accountEvent) {
|
||||
|
||||
Account result;
|
||||
|
||||
log.info("Account event received: " + accountEvent.getLink("self").getHref());
|
||||
|
||||
// Generate a state machine for computing the state of the account resource
|
||||
StateMachine<AccountStatus, AccountEventType> stateMachine =
|
||||
stateMachineService.getStateMachine();
|
||||
|
||||
// Follow the hypermedia link to fetch the attached account
|
||||
Traverson traverson = new Traverson(
|
||||
URI.create(accountEvent.getLink("account").getHref()),
|
||||
MediaTypes.HAL_JSON
|
||||
);
|
||||
|
||||
// Get the event log for the attached account resource
|
||||
AccountEvents events = traverson.follow("events")
|
||||
.toEntity(AccountEvents.class)
|
||||
.getBody();
|
||||
|
||||
// Prepare account event message headers
|
||||
Map<String, Object> headerMap = new HashMap<>();
|
||||
headerMap.put("event", accountEvent);
|
||||
|
||||
// Replicate the current state of the account resource
|
||||
events.getContent()
|
||||
.stream()
|
||||
.sorted((a1, a2) -> a1.getCreatedAt().compareTo(a2.getCreatedAt()))
|
||||
.forEach(e -> {
|
||||
MessageHeaders headers = new MessageHeaders(null);
|
||||
|
||||
// Check to see if this is the current event
|
||||
if (e.getLink("self").equals(accountEvent.getLink("self"))) {
|
||||
headers = new MessageHeaders(headerMap);
|
||||
}
|
||||
|
||||
// Send the event to the state machine
|
||||
stateMachine.sendEvent(MessageBuilder.createMessage(e.getType(), headers));
|
||||
});
|
||||
|
||||
|
||||
// Get result
|
||||
Map<Object, Object> context = stateMachine.getExtendedState()
|
||||
.getVariables();
|
||||
|
||||
// Get the account result
|
||||
result = (Account) context.getOrDefault("account", null);
|
||||
|
||||
// Destroy the state machine
|
||||
stateMachine.stop();
|
||||
|
||||
return result;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
package demo.function;
|
||||
|
||||
import demo.account.Account;
|
||||
import demo.account.AccountStatus;
|
||||
import demo.event.AccountEvent;
|
||||
import demo.event.AccountEventType;
|
||||
import org.apache.log4j.Logger;
|
||||
import org.springframework.statemachine.StateContext;
|
||||
|
||||
import java.util.function.Function;
|
||||
|
||||
/**
|
||||
* The {@link AccountFunction} is an abstraction used to map actions that are triggered by
|
||||
* state transitions on a {@link demo.account.Account} resource on to a function. Mapped functions
|
||||
* can take multiple forms and reside either remotely or locally on the classpath of this application.
|
||||
*
|
||||
* @author kbastani
|
||||
*/
|
||||
public abstract class AccountFunction {
|
||||
|
||||
final private Logger log = Logger.getLogger(AccountFunction.class);
|
||||
final protected StateContext<AccountStatus, AccountEventType> context;
|
||||
final protected Function<AccountEvent, Account> lambda;
|
||||
|
||||
/**
|
||||
* Create a new instance of a class that extends {@link AccountFunction}, supplying
|
||||
* a state context and a lambda function used to apply {@link AccountEvent} to a provided
|
||||
* action.
|
||||
*
|
||||
* @param context is the {@link StateContext} for a replicated state machine
|
||||
* @param lambda is the lambda function describing an action that consumes an {@link AccountEvent}
|
||||
*/
|
||||
public AccountFunction(StateContext<AccountStatus, AccountEventType> context,
|
||||
Function<AccountEvent, Account> lambda) {
|
||||
this.context = context;
|
||||
this.lambda = lambda;
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply an {@link AccountEvent} to the lambda function that was provided through the
|
||||
* constructor of this {@link AccountFunction}.
|
||||
*
|
||||
* @param event is the {@link AccountEvent} to apply to the lambda function
|
||||
*/
|
||||
public Account apply(AccountEvent event) {
|
||||
// Execute the lambda function
|
||||
Account result = lambda.apply(event);
|
||||
context.getExtendedState().getVariables().put("account", result);
|
||||
return result;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
package demo.function;
|
||||
|
||||
import demo.account.Account;
|
||||
import demo.account.AccountStatus;
|
||||
import demo.event.AccountEvent;
|
||||
import demo.event.AccountEventType;
|
||||
import org.apache.log4j.Logger;
|
||||
import org.springframework.statemachine.StateContext;
|
||||
|
||||
import java.util.function.Function;
|
||||
|
||||
/**
|
||||
* The {@link AccountFunction} is an abstraction used to map actions that are triggered by
|
||||
* state transitions on a {@link demo.account.Account} resource on to a function. Mapped functions
|
||||
* can take multiple forms and reside either remotely or locally on the classpath of this application.
|
||||
*
|
||||
* @author kbastani
|
||||
*/
|
||||
public class ActivateAccount extends AccountFunction {
|
||||
|
||||
final private Logger log = Logger.getLogger(ActivateAccount.class);
|
||||
|
||||
public ActivateAccount(StateContext<AccountStatus, AccountEventType> context, Function<AccountEvent, Account> lambda) {
|
||||
super(context, lambda);
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply an {@link AccountEvent} to the lambda function that was provided through the
|
||||
* constructor of this {@link AccountFunction}.
|
||||
*
|
||||
* @param event is the {@link AccountEvent} to apply to the lambda function
|
||||
*/
|
||||
@Override
|
||||
public Account apply(AccountEvent event) {
|
||||
log.info("Executing workflow for an activated account...");
|
||||
return super.apply(event);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
package demo.function;
|
||||
|
||||
import demo.account.Account;
|
||||
import demo.account.AccountStatus;
|
||||
import demo.event.AccountEvent;
|
||||
import demo.event.AccountEventType;
|
||||
import org.apache.log4j.Logger;
|
||||
import org.springframework.statemachine.StateContext;
|
||||
|
||||
import java.util.function.Function;
|
||||
|
||||
/**
|
||||
* The {@link AccountFunction} is an abstraction used to map actions that are triggered by
|
||||
* state transitions on a {@link demo.account.Account} resource on to a function. Mapped functions
|
||||
* can take multiple forms and reside either remotely or locally on the classpath of this application.
|
||||
*
|
||||
* @author kbastani
|
||||
*/
|
||||
public class ArchiveAccount extends AccountFunction {
|
||||
|
||||
final private Logger log = Logger.getLogger(ArchiveAccount.class);
|
||||
|
||||
public ArchiveAccount(StateContext<AccountStatus, AccountEventType> context, Function<AccountEvent, Account> lambda) {
|
||||
super(context, lambda);
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply an {@link AccountEvent} to the lambda function that was provided through the
|
||||
* constructor of this {@link AccountFunction}.
|
||||
*
|
||||
* @param event is the {@link AccountEvent} to apply to the lambda function
|
||||
*/
|
||||
@Override
|
||||
public Account apply(AccountEvent event) {
|
||||
log.info("Executing workflow for an archived account...");
|
||||
return super.apply(event);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
package demo.function;
|
||||
|
||||
import demo.account.Account;
|
||||
import demo.account.AccountStatus;
|
||||
import demo.event.AccountEvent;
|
||||
import demo.event.AccountEventType;
|
||||
import org.apache.log4j.Logger;
|
||||
import org.springframework.statemachine.StateContext;
|
||||
|
||||
import java.util.function.Function;
|
||||
|
||||
/**
|
||||
* The {@link AccountFunction} is an abstraction used to map actions that are triggered by
|
||||
* state transitions on a {@link demo.account.Account} resource on to a function. Mapped functions
|
||||
* can take multiple forms and reside either remotely or locally on the classpath of this application.
|
||||
*
|
||||
* @author kbastani
|
||||
*/
|
||||
public class ConfirmAccount extends AccountFunction {
|
||||
|
||||
final private Logger log = Logger.getLogger(ConfirmAccount.class);
|
||||
|
||||
public ConfirmAccount(StateContext<AccountStatus, AccountEventType> context, Function<AccountEvent, Account> lambda) {
|
||||
super(context, lambda);
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply an {@link AccountEvent} to the lambda function that was provided through the
|
||||
* constructor of this {@link AccountFunction}.
|
||||
*
|
||||
* @param event is the {@link AccountEvent} to apply to the lambda function
|
||||
*/
|
||||
@Override
|
||||
public Account apply(AccountEvent event) {
|
||||
log.info("Executing workflow for a confirmed account...");
|
||||
return super.apply(event);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
package demo.function;
|
||||
|
||||
import demo.account.Account;
|
||||
import demo.account.AccountStatus;
|
||||
import demo.event.AccountEvent;
|
||||
import demo.event.AccountEventType;
|
||||
import org.apache.log4j.Logger;
|
||||
import org.springframework.hateoas.MediaTypes;
|
||||
import org.springframework.hateoas.client.Traverson;
|
||||
import org.springframework.http.RequestEntity;
|
||||
import org.springframework.statemachine.StateContext;
|
||||
import org.springframework.web.client.RestTemplate;
|
||||
|
||||
import java.net.URI;
|
||||
import java.util.function.Function;
|
||||
|
||||
/**
|
||||
* The {@link AccountFunction} is an abstraction used to map actions that are triggered by
|
||||
* state transitions on a {@link demo.account.Account} resource on to a function. Mapped functions
|
||||
* can take multiple forms and reside either remotely or locally on the classpath of this application.
|
||||
*
|
||||
* @author kbastani
|
||||
*/
|
||||
public class CreateAccount extends AccountFunction {
|
||||
|
||||
final private Logger log = Logger.getLogger(CreateAccount.class);
|
||||
|
||||
public CreateAccount(StateContext<AccountStatus, AccountEventType> context) {
|
||||
this(context, null);
|
||||
}
|
||||
|
||||
public CreateAccount(StateContext<AccountStatus, AccountEventType> context,
|
||||
Function<AccountEvent, Account> function) {
|
||||
super(context, function);
|
||||
}
|
||||
|
||||
/**
|
||||
* Applies the {@link AccountEvent} to the {@link Account} aggregate.
|
||||
*
|
||||
* @param event is the {@link AccountEvent} for this context
|
||||
*/
|
||||
@Override
|
||||
public Account apply(AccountEvent event) {
|
||||
|
||||
Account account;
|
||||
|
||||
log.info("Executing workflow for a created account...");
|
||||
|
||||
// Create a traverson for the root account
|
||||
Traverson traverson = new Traverson(
|
||||
URI.create(event.getLink("account").getHref()),
|
||||
MediaTypes.HAL_JSON
|
||||
);
|
||||
|
||||
// Get the account resource attached to the event
|
||||
account = traverson.follow("self")
|
||||
.toEntity(Account.class)
|
||||
.getBody();
|
||||
|
||||
// Set the account to a pending state
|
||||
account = setAccountPendingStatus(event, account);
|
||||
|
||||
// The account can only be confirmed if it is in a pending state
|
||||
if (account.getStatus() == AccountStatus.ACCOUNT_PENDING) {
|
||||
// Traverse to the confirm account command
|
||||
account = traverson.follow("commands")
|
||||
.follow("confirm")
|
||||
.toEntity(Account.class)
|
||||
.getBody();
|
||||
|
||||
log.info(event.getType() + ": " +
|
||||
event.getLink("account").getHref());
|
||||
}
|
||||
|
||||
context.getExtendedState().getVariables().put("account", account);
|
||||
|
||||
return account;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the {@link Account} resource to a pending state.
|
||||
*
|
||||
* @param event is the {@link AccountEvent} for this context
|
||||
* @param account is the {@link Account} attached to the {@link AccountEvent} resource
|
||||
* @return an {@link Account} with its updated state set to pending
|
||||
*/
|
||||
private Account setAccountPendingStatus(AccountEvent event, Account account) {
|
||||
// Set the account status to pending
|
||||
account.setStatus(AccountStatus.ACCOUNT_PENDING);
|
||||
RestTemplate restTemplate = new RestTemplate();
|
||||
|
||||
// Create a new request entity
|
||||
RequestEntity<Account> requestEntity = RequestEntity.put(
|
||||
URI.create(event.getLink("account").getHref()))
|
||||
.contentType(MediaTypes.HAL_JSON)
|
||||
.body(account);
|
||||
|
||||
// Update the account entity's status
|
||||
account = restTemplate.exchange(requestEntity, Account.class).getBody();
|
||||
|
||||
return account;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
package demo.function;
|
||||
|
||||
import com.amazonaws.services.lambda.invoke.LambdaFunction;
|
||||
import com.amazonaws.services.lambda.model.LogType;
|
||||
import demo.account.Account;
|
||||
|
||||
import java.util.Map;
|
||||
|
||||
public interface LambdaFunctions {
|
||||
|
||||
@LambdaFunction(functionName="account-created-accountCreated-13P0EDGLDE399", logType = LogType.Tail)
|
||||
Account accountCreated(Map event);
|
||||
|
||||
@LambdaFunction(functionName="accountConfirmed", logType = LogType.Tail)
|
||||
Account accountConfirmed(Map event);
|
||||
|
||||
@LambdaFunction(functionName="account-activated-accountActivated-1P0I6FTFCMHKH", logType = LogType.Tail)
|
||||
Account accountActivated(Map event);
|
||||
|
||||
@LambdaFunction(functionName="accountSuspended", logType = LogType.Tail)
|
||||
Account accountSuspended(Map event);
|
||||
|
||||
@LambdaFunction(functionName="accountArchived", logType = LogType.Tail)
|
||||
Account accountArchived(Map event);
|
||||
|
||||
@LambdaFunction(functionName="accountUnsuspended", logType = LogType.Tail)
|
||||
Account accountUnsuspended(Map event);
|
||||
|
||||
@LambdaFunction(functionName="accountUnarchived", logType = LogType.Tail)
|
||||
Account accountUnarchived(Map event);
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
package demo.function;
|
||||
|
||||
import demo.account.Account;
|
||||
import demo.account.AccountStatus;
|
||||
import demo.event.AccountEvent;
|
||||
import demo.event.AccountEventType;
|
||||
import org.apache.log4j.Logger;
|
||||
import org.springframework.statemachine.StateContext;
|
||||
|
||||
import java.util.function.Function;
|
||||
|
||||
/**
|
||||
* The {@link AccountFunction} is an abstraction used to map actions that are triggered by
|
||||
* state transitions on a {@link demo.account.Account} resource on to a function. Mapped functions
|
||||
* can take multiple forms and reside either remotely or locally on the classpath of this application.
|
||||
*
|
||||
* @author kbastani
|
||||
*/
|
||||
public class SuspendAccount extends AccountFunction {
|
||||
|
||||
final private Logger log = Logger.getLogger(SuspendAccount.class);
|
||||
|
||||
public SuspendAccount(StateContext<AccountStatus, AccountEventType> context, Function<AccountEvent, Account> lambda) {
|
||||
super(context, lambda);
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply an {@link AccountEvent} to the lambda function that was provided through the
|
||||
* constructor of this {@link AccountFunction}.
|
||||
*
|
||||
* @param event is the {@link AccountEvent} to apply to the lambda function
|
||||
*/
|
||||
@Override
|
||||
public Account apply(AccountEvent event) {
|
||||
log.info("Executing workflow for a suspended account...");
|
||||
return super.apply(event);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
package demo.function;
|
||||
|
||||
import demo.account.Account;
|
||||
import demo.account.AccountStatus;
|
||||
import demo.event.AccountEvent;
|
||||
import demo.event.AccountEventType;
|
||||
import org.apache.log4j.Logger;
|
||||
import org.springframework.statemachine.StateContext;
|
||||
|
||||
import java.util.function.Function;
|
||||
|
||||
/**
|
||||
* The {@link AccountFunction} is an abstraction used to map actions that are triggered by
|
||||
* state transitions on a {@link demo.account.Account} resource on to a function. Mapped functions
|
||||
* can take multiple forms and reside either remotely or locally on the classpath of this application.
|
||||
*
|
||||
* @author kbastani
|
||||
*/
|
||||
public class UnarchiveAccount extends AccountFunction {
|
||||
|
||||
final private Logger log = Logger.getLogger(UnarchiveAccount.class);
|
||||
|
||||
public UnarchiveAccount(StateContext<AccountStatus, AccountEventType> context, Function<AccountEvent, Account> lambda) {
|
||||
super(context, lambda);
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply an {@link AccountEvent} to the lambda function that was provided through the
|
||||
* constructor of this {@link AccountFunction}.
|
||||
*
|
||||
* @param event is the {@link AccountEvent} to apply to the lambda function
|
||||
*/
|
||||
@Override
|
||||
public Account apply(AccountEvent event) {
|
||||
log.info("Executing workflow for an unarchived account...");
|
||||
return super.apply(event);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
package demo.function;
|
||||
|
||||
import demo.account.Account;
|
||||
import demo.account.AccountStatus;
|
||||
import demo.event.AccountEvent;
|
||||
import demo.event.AccountEventType;
|
||||
import org.apache.log4j.Logger;
|
||||
import org.springframework.statemachine.StateContext;
|
||||
|
||||
import java.util.function.Function;
|
||||
|
||||
/**
|
||||
* The {@link AccountFunction} is an abstraction used to map actions that are triggered by
|
||||
* state transitions on a {@link demo.account.Account} resource on to a function. Mapped functions
|
||||
* can take multiple forms and reside either remotely or locally on the classpath of this application.
|
||||
*
|
||||
* @author kbastani
|
||||
*/
|
||||
public class UnsuspendAccount extends AccountFunction {
|
||||
|
||||
final private Logger log = Logger.getLogger(UnsuspendAccount.class);
|
||||
|
||||
public UnsuspendAccount(StateContext<AccountStatus, AccountEventType> context, Function<AccountEvent, Account> lambda) {
|
||||
super(context, lambda);
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply an {@link AccountEvent} to the lambda function that was provided through the
|
||||
* constructor of this {@link AccountFunction}.
|
||||
*
|
||||
* @param event is the {@link AccountEvent} to apply to the lambda function
|
||||
*/
|
||||
@Override
|
||||
public Account apply(AccountEvent event) {
|
||||
log.info("Executing workflow for a unsuspended account...");
|
||||
return super.apply(event);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
package demo.state;
|
||||
|
||||
import demo.account.AccountStatus;
|
||||
import demo.event.AccountEventType;
|
||||
import org.springframework.statemachine.StateMachine;
|
||||
import org.springframework.statemachine.config.StateMachineFactory;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.util.UUID;
|
||||
|
||||
/**
|
||||
* The {@link StateMachineService} provides factory access to get new state machines for
|
||||
* replicating the state of an {@link demo.account.Account} from {@link demo.event.AccountEvents}.
|
||||
*
|
||||
* @author kbastani
|
||||
*/
|
||||
@Service
|
||||
public class StateMachineService {
|
||||
|
||||
private final StateMachineFactory<AccountStatus, AccountEventType> factory;
|
||||
|
||||
public StateMachineService(StateMachineFactory<AccountStatus, AccountEventType> factory) {
|
||||
this.factory = factory;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new state machine that is initially configured and ready for replicating
|
||||
* the state of an {@link demo.account.Account} from a sequence of {@link demo.event.AccountEvent}.
|
||||
*
|
||||
* @return a new instance of {@link StateMachine}
|
||||
*/
|
||||
public StateMachine<AccountStatus, AccountEventType> getStateMachine() {
|
||||
// Create a new state machine in its initial state
|
||||
StateMachine<AccountStatus, AccountEventType> stateMachine =
|
||||
factory.getStateMachine(UUID.randomUUID().toString());
|
||||
|
||||
// Start the new state machine
|
||||
stateMachine.start();
|
||||
|
||||
return stateMachine;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
package demo.util;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.util.HashMap;
|
||||
|
||||
@Component
|
||||
public class LambdaUtil {
|
||||
|
||||
private ObjectMapper objectMapper;
|
||||
|
||||
public LambdaUtil(ObjectMapper objectMapper) {
|
||||
this.objectMapper = objectMapper;
|
||||
}
|
||||
|
||||
public HashMap objectToMap(Object object) {
|
||||
HashMap result = null;
|
||||
|
||||
try {
|
||||
result = objectMapper.readValue(objectMapper.writeValueAsString(object), HashMap.class);
|
||||
} catch (IOException e) {
|
||||
e.printStackTrace();
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
}
|
||||
24
account/account-worker/src/main/resources/application.yml
Normal file
24
account/account-worker/src/main/resources/application.yml
Normal file
@@ -0,0 +1,24 @@
|
||||
spring:
|
||||
profiles:
|
||||
active: development
|
||||
---
|
||||
spring:
|
||||
profiles: development
|
||||
cloud:
|
||||
stream:
|
||||
bindings:
|
||||
input:
|
||||
destination: account
|
||||
group: account-group
|
||||
contentType: 'application/json'
|
||||
consumer:
|
||||
durableSubscription: true
|
||||
server:
|
||||
port: 8081
|
||||
amazon:
|
||||
aws:
|
||||
access-key-id: replace
|
||||
access-key-secret: replace
|
||||
---
|
||||
spring:
|
||||
profiles: test
|
||||
4
account/account-worker/src/main/resources/bootstrap.yml
Normal file
4
account/account-worker/src/main/resources/bootstrap.yml
Normal file
@@ -0,0 +1,4 @@
|
||||
spring:
|
||||
application:
|
||||
name: account-worker
|
||||
---
|
||||
@@ -0,0 +1,18 @@
|
||||
package demo;
|
||||
|
||||
import org.junit.Test;
|
||||
import org.junit.runner.RunWith;
|
||||
import org.springframework.boot.test.context.SpringBootTest;
|
||||
import org.springframework.test.context.ActiveProfiles;
|
||||
import org.springframework.test.context.junit4.SpringRunner;
|
||||
|
||||
@RunWith(SpringRunner.class)
|
||||
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.MOCK)
|
||||
@ActiveProfiles("test")
|
||||
public class AccountStreamModuleApplicationTests {
|
||||
|
||||
@Test
|
||||
public void contextLoads() {
|
||||
}
|
||||
|
||||
}
|
||||
1
account/functions/account-activated-function
Submodule
1
account/functions/account-activated-function
Submodule
Submodule account/functions/account-activated-function added at 0732443191
1
account/functions/account-archived-function
Submodule
1
account/functions/account-archived-function
Submodule
Submodule account/functions/account-archived-function added at bc84d25a14
1
account/functions/account-confirmed-function
Submodule
1
account/functions/account-confirmed-function
Submodule
Submodule account/functions/account-confirmed-function added at 9750acd1d5
1
account/functions/account-created-function
Submodule
1
account/functions/account-created-function
Submodule
Submodule account/functions/account-created-function added at ed636207ce
1
account/functions/account-suspended-function
Submodule
1
account/functions/account-suspended-function
Submodule
Submodule account/functions/account-suspended-function added at e1fee403ac
1
account/functions/account-unarchived-function
Submodule
1
account/functions/account-unarchived-function
Submodule
Submodule account/functions/account-unarchived-function added at 0144a13967
1
account/functions/account-unsuspended-function
Submodule
1
account/functions/account-unsuspended-function
Submodule
Submodule account/functions/account-unsuspended-function added at 8a0c8e3a4f
24
account/pom.xml
Normal file
24
account/pom.xml
Normal file
@@ -0,0 +1,24 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project xmlns="http://maven.apache.org/POM/4.0.0"
|
||||
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
|
||||
<modelVersion>4.0.0</modelVersion>
|
||||
|
||||
<artifactId>account</artifactId>
|
||||
<version>1.0-SNAPSHOT</version>
|
||||
<packaging>pom</packaging>
|
||||
|
||||
<name>account</name>
|
||||
|
||||
<parent>
|
||||
<groupId>org.kbastani</groupId>
|
||||
<artifactId>event-stream-processing-microservices</artifactId>
|
||||
<version>1.0-SNAPSHOT</version>
|
||||
<relativePath>../</relativePath>
|
||||
</parent>
|
||||
|
||||
<modules>
|
||||
<module>account-web</module>
|
||||
<module>account-worker</module>
|
||||
</modules>
|
||||
</project>
|
||||
Reference in New Issue
Block a user