Cleaning up

This commit is contained in:
Kenny Bastani
2016-12-20 17:30:24 -08:00
parent 878e40a2e5
commit 2af2788bc7
154 changed files with 2507 additions and 451 deletions

17
account/README.md Normal file
View 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.
![Account microservice](http://i.imgur.com/WZTR4lQ.png)
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.

View 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.

View 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

View 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>

View File

@@ -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);
}
}

View 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();
}
}

View File

@@ -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
}

View File

@@ -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 {
}

View File

@@ -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;
}
}

View File

@@ -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);
}

View File

@@ -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;
}
}

View File

@@ -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
}

View File

@@ -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;
}
}

View 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 {
}

View File

@@ -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 {
}

View File

@@ -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));
}
}

View File

@@ -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 +
'}';
}
}

View File

@@ -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();
}
}

View File

@@ -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
}

View File

@@ -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;
}
}

View File

@@ -0,0 +1,6 @@
package demo.event;
public enum ConsistencyModel {
BASE,
ACID
}

View File

@@ -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));
}
}

View File

@@ -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);
}

View 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;
}
}

View 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

View File

@@ -0,0 +1,4 @@
spring:
application:
name: account-web
---

View File

@@ -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));
}
}

View File

@@ -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);
}
}

View File

@@ -0,0 +1 @@
INSERT INTO ACCOUNT(ID, FIRST_NAME, LAST_NAME, EMAIL) values (1, 'John', 'Doe', 'john.doe@example.com');

View 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.

View 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

View 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>

View File

@@ -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);
}
}

View File

@@ -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;
}
}

View File

@@ -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
}

View File

@@ -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);
}
}

View File

@@ -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();
}));
}
}

View File

@@ -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();
}
}

View File

@@ -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();
}
}

View File

@@ -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);
}
}

View File

@@ -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
}

View File

@@ -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> {
}

View File

@@ -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"));
}
}

View File

@@ -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;
}
}

View File

@@ -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;
}
}

View File

@@ -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);
}
}

View File

@@ -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);
}
}

View File

@@ -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);
}
}

View File

@@ -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;
}
}

View File

@@ -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);
}

View File

@@ -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);
}
}

View File

@@ -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);
}
}

View File

@@ -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);
}
}

View File

@@ -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;
}
}

View File

@@ -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;
}
}

View 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

View File

@@ -0,0 +1,4 @@
spring:
application:
name: account-worker
---

View File

@@ -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() {
}
}

24
account/pom.xml Normal file
View 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>