Pattern language for mapping commands to domain model
This commit is contained in:
@@ -57,6 +57,10 @@
|
||||
<version>1.0-SNAPSHOT</version>
|
||||
</dependency>
|
||||
|
||||
<dependency>
|
||||
<groupId>org.projectlombok</groupId>
|
||||
<artifactId>lombok</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>com.h2database</groupId>
|
||||
<artifactId>h2</artifactId>
|
||||
|
||||
@@ -3,7 +3,6 @@ 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;
|
||||
@@ -11,7 +10,9 @@ import java.io.Serializable;
|
||||
|
||||
@MappedSuperclass
|
||||
@EntityListeners(AuditingEntityListener.class)
|
||||
public class BaseEntity extends ResourceSupport implements Serializable {
|
||||
public class AbstractEntity<T extends Serializable> extends Aggregate<T> implements Serializable {
|
||||
|
||||
private T identity;
|
||||
|
||||
@CreatedDate
|
||||
private Long createdAt;
|
||||
@@ -19,7 +20,7 @@ public class BaseEntity extends ResourceSupport implements Serializable {
|
||||
@LastModifiedDate
|
||||
private Long lastModified;
|
||||
|
||||
public BaseEntity() {
|
||||
public AbstractEntity() {
|
||||
}
|
||||
|
||||
public Long getCreatedAt() {
|
||||
@@ -38,6 +39,15 @@ public class BaseEntity extends ResourceSupport implements Serializable {
|
||||
this.lastModified = lastModified;
|
||||
}
|
||||
|
||||
@Override
|
||||
public T getIdentity() {
|
||||
return identity;
|
||||
}
|
||||
|
||||
public void setIdentity(T id) {
|
||||
this.identity = id;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String toString() {
|
||||
return "BaseEntity{" +
|
||||
@@ -1,8 +1,13 @@
|
||||
package demo.payment;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonIgnore;
|
||||
import demo.domain.BaseEntity;
|
||||
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||
import demo.domain.AbstractEntity;
|
||||
import demo.domain.Command;
|
||||
import demo.event.PaymentEvent;
|
||||
import demo.payment.action.ConnectOrder;
|
||||
import demo.payment.action.ProcessPayment;
|
||||
import demo.payment.controller.PaymentController;
|
||||
import org.springframework.hateoas.Link;
|
||||
|
||||
import javax.persistence.*;
|
||||
@@ -18,7 +23,7 @@ import static org.springframework.hateoas.mvc.ControllerLinkBuilder.linkTo;
|
||||
* @author Kenny Bastani
|
||||
*/
|
||||
@Entity
|
||||
public class Payment extends BaseEntity {
|
||||
public class Payment extends AbstractEntity<Long> {
|
||||
|
||||
@Id
|
||||
@GeneratedValue
|
||||
@@ -41,16 +46,18 @@ public class Payment extends BaseEntity {
|
||||
}
|
||||
|
||||
public Payment(Double amount, PaymentMethod paymentMethod) {
|
||||
this();
|
||||
this.amount = amount;
|
||||
this.paymentMethod = paymentMethod;
|
||||
}
|
||||
|
||||
public Long getPaymentId() {
|
||||
return id;
|
||||
@JsonProperty("paymentId")
|
||||
@Override
|
||||
public Long getIdentity() {
|
||||
return this.id;
|
||||
}
|
||||
|
||||
public void setPaymentId(Long id) {
|
||||
@Override
|
||||
public void setIdentity(Long id) {
|
||||
this.id = id;
|
||||
}
|
||||
|
||||
@@ -96,6 +103,24 @@ public class Payment extends BaseEntity {
|
||||
this.orderId = orderId;
|
||||
}
|
||||
|
||||
@Command(method = "connectOrder", controller = PaymentController.class)
|
||||
public Payment connectOrder(Long orderId) {
|
||||
getAction(ConnectOrder.class)
|
||||
.getConsumer()
|
||||
.accept(this, orderId);
|
||||
|
||||
return this;
|
||||
}
|
||||
|
||||
@Command(method = "processPayment", controller = PaymentController.class)
|
||||
public Payment processPayment() {
|
||||
getAction(ProcessPayment.class)
|
||||
.getConsumer()
|
||||
.accept(this);
|
||||
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the {@link Link} with a rel of {@link Link#REL_SELF}.
|
||||
*/
|
||||
@@ -103,16 +128,9 @@ public class Payment extends BaseEntity {
|
||||
public Link getId() {
|
||||
return linkTo(PaymentController.class)
|
||||
.slash("payments")
|
||||
.slash(getPaymentId())
|
||||
.slash(getIdentity())
|
||||
.withSelfRel();
|
||||
}
|
||||
|
||||
@Override
|
||||
public String toString() {
|
||||
return "Payment{" +
|
||||
"id=" + id +
|
||||
", events=" + events +
|
||||
", status=" + status +
|
||||
"} " + super.toString();
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -1,13 +0,0 @@
|
||||
package demo.payment;
|
||||
|
||||
/**
|
||||
* The {@link PaymentCommand} represents an action that can be performed to an {@link Payment} aggregate. Commands
|
||||
* initiate an action that can mutate the state of an payment entity as it transitions between {@link PaymentStatus}
|
||||
* values.
|
||||
*
|
||||
* @author Kenneth Bastani
|
||||
*/
|
||||
public enum PaymentCommand {
|
||||
CONNECT_ORDER,
|
||||
PROCESS_PAYMENT
|
||||
}
|
||||
@@ -1,11 +0,0 @@
|
||||
package demo.payment;
|
||||
|
||||
import org.springframework.hateoas.ResourceSupport;
|
||||
|
||||
/**
|
||||
* A hypermedia resource that describes the collection of commands that can be applied to a {@link Payment} aggregate.
|
||||
*
|
||||
* @author Kenny Bastani
|
||||
*/
|
||||
public class PaymentCommands extends ResourceSupport {
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
package demo.payment;
|
||||
|
||||
import demo.domain.Provider;
|
||||
import demo.domain.Service;
|
||||
|
||||
@org.springframework.stereotype.Service
|
||||
public class PaymentProvider extends Provider<Payment> {
|
||||
|
||||
private final PaymentService paymentService;
|
||||
|
||||
public PaymentProvider(PaymentService paymentService) {
|
||||
this.paymentService = paymentService;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected Service<? extends Payment> getDefaultService() {
|
||||
return paymentService;
|
||||
}
|
||||
}
|
||||
@@ -1,10 +1,10 @@
|
||||
package demo.payment;
|
||||
|
||||
import demo.domain.Service;
|
||||
import demo.event.EventService;
|
||||
import demo.event.PaymentEvent;
|
||||
import demo.event.PaymentEventType;
|
||||
import demo.util.ConsistencyModel;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.util.Assert;
|
||||
|
||||
import java.util.Objects;
|
||||
@@ -17,8 +17,8 @@ import java.util.Objects;
|
||||
*
|
||||
* @author Kenny Bastani
|
||||
*/
|
||||
@Service
|
||||
public class PaymentService {
|
||||
@org.springframework.stereotype.Service
|
||||
public class PaymentService extends Service<Payment> {
|
||||
|
||||
private final PaymentRepository paymentRepository;
|
||||
private final EventService<PaymentEvent, Long> eventService;
|
||||
@@ -33,11 +33,11 @@ public class PaymentService {
|
||||
payment = createPayment(payment);
|
||||
|
||||
// Trigger the payment creation event
|
||||
PaymentEvent event = appendEvent(payment.getPaymentId(),
|
||||
PaymentEvent event = appendEvent(payment.getIdentity(),
|
||||
new PaymentEvent(PaymentEventType.PAYMENT_CREATED));
|
||||
|
||||
// Attach payment identifier
|
||||
event.getEntity().setPaymentId(payment.getPaymentId());
|
||||
event.getEntity().setIdentity(payment.getIdentity());
|
||||
|
||||
// Return the result
|
||||
return event.getEntity();
|
||||
@@ -78,11 +78,11 @@ public class PaymentService {
|
||||
Assert.notNull(id, "Payment id must be present in the resource URL");
|
||||
Assert.notNull(payment, "Payment request body cannot be null");
|
||||
|
||||
if (payment.getPaymentId() != null) {
|
||||
Assert.isTrue(Objects.equals(id, payment.getPaymentId()),
|
||||
if (payment.getIdentity() != null) {
|
||||
Assert.isTrue(Objects.equals(id, payment.getIdentity()),
|
||||
"The payment id in the request body must match the resource URL");
|
||||
} else {
|
||||
payment.setPaymentId(id);
|
||||
payment.setIdentity(id);
|
||||
}
|
||||
|
||||
Assert.state(paymentRepository.exists(id),
|
||||
@@ -150,22 +150,4 @@ public class PaymentService {
|
||||
|
||||
return event;
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply an {@link PaymentCommand} to the {@link Payment} with a specified identifier.
|
||||
*
|
||||
* @param id is the unique identifier of the {@link Payment}
|
||||
* @param paymentCommand is the command to apply to the {@link Payment}
|
||||
* @return a hypermedia resource containing the updated {@link Payment}
|
||||
*/
|
||||
public Payment applyCommand(Long id, PaymentCommand paymentCommand) {
|
||||
Payment payment = getPayment(id);
|
||||
Assert.notNull(payment, "The payment for the supplied id could not be found");
|
||||
|
||||
PaymentStatus status = payment.getStatus();
|
||||
|
||||
// TODO: Implement
|
||||
|
||||
return payment;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
package demo.payment.action;
|
||||
|
||||
import demo.domain.Action;
|
||||
import demo.payment.Payment;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.util.function.BiConsumer;
|
||||
|
||||
@Service
|
||||
public class ConnectOrder extends Action<Payment> {
|
||||
public BiConsumer<Payment, Long> getConsumer() {
|
||||
return Payment::setOrderId;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
package demo.payment.action;
|
||||
|
||||
import demo.domain.Action;
|
||||
import demo.payment.Payment;
|
||||
import demo.payment.PaymentStatus;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.util.function.Consumer;
|
||||
|
||||
@Service
|
||||
public class ProcessPayment extends Action<Payment> {
|
||||
public Consumer<Payment> getConsumer() {
|
||||
return payment -> payment.setStatus(PaymentStatus.PAYMENT_PROCESSED);
|
||||
}
|
||||
}
|
||||
@@ -1,14 +1,21 @@
|
||||
package demo.payment;
|
||||
package demo.payment.controller;
|
||||
|
||||
import demo.event.*;
|
||||
import demo.event.EventController;
|
||||
import demo.event.EventService;
|
||||
import demo.event.Events;
|
||||
import demo.event.PaymentEvent;
|
||||
import demo.payment.Payment;
|
||||
import demo.payment.PaymentService;
|
||||
import org.springframework.hateoas.ExposesResourceFor;
|
||||
import org.springframework.hateoas.LinkBuilder;
|
||||
import org.springframework.hateoas.Resource;
|
||||
import org.springframework.hateoas.ResourceSupport;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.util.Assert;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
import java.lang.reflect.Method;
|
||||
import java.util.Optional;
|
||||
|
||||
import static org.springframework.hateoas.mvc.ControllerLinkBuilder.linkTo;
|
||||
@@ -31,68 +38,68 @@ public class PaymentController {
|
||||
this.eventService = eventService;
|
||||
}
|
||||
|
||||
@PostMapping(path = "/payments")
|
||||
@RequestMapping(path = "/payments", method = RequestMethod.POST)
|
||||
public ResponseEntity createPayment(@RequestBody Payment payment) {
|
||||
return Optional.ofNullable(createPaymentResource(payment))
|
||||
.map(e -> new ResponseEntity<>(e, HttpStatus.CREATED))
|
||||
.orElseThrow(() -> new RuntimeException("Payment creation failed"));
|
||||
}
|
||||
|
||||
@PutMapping(path = "/payments/{id}")
|
||||
@RequestMapping(path = "/payments/{id}", method = RequestMethod.PUT)
|
||||
public ResponseEntity updatePayment(@RequestBody Payment payment, @PathVariable Long id) {
|
||||
return Optional.ofNullable(updatePaymentResource(id, payment))
|
||||
.map(e -> new ResponseEntity<>(e, HttpStatus.OK))
|
||||
.orElseThrow(() -> new RuntimeException("Payment update failed"));
|
||||
}
|
||||
|
||||
@GetMapping(path = "/payments/{id}")
|
||||
@RequestMapping(path = "/payments/{id}", method = RequestMethod.GET)
|
||||
public ResponseEntity getPayment(@PathVariable Long id) {
|
||||
return Optional.ofNullable(getPaymentResource(id))
|
||||
.map(e -> new ResponseEntity<>(e, HttpStatus.OK))
|
||||
.orElse(new ResponseEntity<>(HttpStatus.NOT_FOUND));
|
||||
}
|
||||
|
||||
@DeleteMapping(path = "/payments/{id}")
|
||||
@RequestMapping(path = "/payments/{id}", method = RequestMethod.DELETE)
|
||||
public ResponseEntity deletePayment(@PathVariable Long id) {
|
||||
return Optional.ofNullable(paymentService.deletePayment(id))
|
||||
.map(e -> new ResponseEntity<>(HttpStatus.NO_CONTENT))
|
||||
.orElseThrow(() -> new RuntimeException("Payment deletion failed"));
|
||||
}
|
||||
|
||||
@GetMapping(path = "/payments/{id}/events")
|
||||
@RequestMapping(path = "/payments/{id}/events", method = RequestMethod.GET)
|
||||
public ResponseEntity getPaymentEvents(@PathVariable Long id) {
|
||||
return Optional.of(getPaymentEventResources(id))
|
||||
return Optional.ofNullable(getPaymentEventResources(id))
|
||||
.map(e -> new ResponseEntity<>(e, HttpStatus.OK))
|
||||
.orElseThrow(() -> new RuntimeException("Could not get payment events"));
|
||||
}
|
||||
|
||||
@PostMapping(path = "/payments/{id}/events")
|
||||
@RequestMapping(path = "/payments/{id}/events", method = RequestMethod.POST)
|
||||
public ResponseEntity createPayment(@PathVariable Long id, @RequestBody PaymentEvent event) {
|
||||
return Optional.ofNullable(appendEventResource(id, event))
|
||||
.map(e -> new ResponseEntity<>(e, HttpStatus.CREATED))
|
||||
.orElseThrow(() -> new RuntimeException("Append payment event failed"));
|
||||
}
|
||||
|
||||
@GetMapping(path = "/payments/{id}/commands")
|
||||
public ResponseEntity getPaymentCommands(@PathVariable Long id) {
|
||||
return Optional.ofNullable(getCommandsResource(id))
|
||||
@RequestMapping(path = "/payments/{id}/commands")
|
||||
public ResponseEntity getCommands(@PathVariable Long id) {
|
||||
return Optional.ofNullable(getCommandsResources(id))
|
||||
.map(e -> new ResponseEntity<>(e, HttpStatus.OK))
|
||||
.orElseThrow(() -> new RuntimeException("The payment could not be found"));
|
||||
}
|
||||
|
||||
@GetMapping(path = "/payments/{id}/commands/connectOrder")
|
||||
public ResponseEntity connectOrder(@PathVariable Long id) {
|
||||
return Optional.ofNullable(getPaymentResource(
|
||||
paymentService.applyCommand(id, PaymentCommand.CONNECT_ORDER)))
|
||||
.map(e -> new ResponseEntity<>(e, HttpStatus.OK))
|
||||
@RequestMapping(path = "/payments/{id}/commands/connectOrder")
|
||||
public ResponseEntity connectOrder(@PathVariable Long id, @RequestParam(value = "orderId") Long orderId) {
|
||||
return Optional.of(paymentService.getPayment(id)
|
||||
.connectOrder(orderId))
|
||||
.map(e -> new ResponseEntity<>(getPaymentResource(e), HttpStatus.OK))
|
||||
.orElseThrow(() -> new RuntimeException("The command could not be applied"));
|
||||
}
|
||||
|
||||
@GetMapping(path = "/payments/{id}/commands/processPayment")
|
||||
@RequestMapping(path = "/payments/{id}/commands/processPayment")
|
||||
public ResponseEntity processPayment(@PathVariable Long id) {
|
||||
return Optional.ofNullable(getPaymentResource(
|
||||
paymentService.applyCommand(id, PaymentCommand.PROCESS_PAYMENT)))
|
||||
.map(e -> new ResponseEntity<>(e, HttpStatus.OK))
|
||||
return Optional.of(paymentService.getPayment(id)
|
||||
.processPayment())
|
||||
.map(e -> new ResponseEntity<>(getPaymentResource(e), HttpStatus.OK))
|
||||
.orElseThrow(() -> new RuntimeException("The command could not be applied"));
|
||||
}
|
||||
|
||||
@@ -103,16 +110,10 @@ public class PaymentController {
|
||||
* @return a hypermedia resource for the fetched {@link Payment}
|
||||
*/
|
||||
private Resource<Payment> getPaymentResource(Long id) {
|
||||
Resource<Payment> paymentResource = null;
|
||||
|
||||
// Get the payment for the provided id
|
||||
Payment payment = paymentService.getPayment(id);
|
||||
|
||||
// If the payment exists, wrap the hypermedia response
|
||||
if (payment != null)
|
||||
paymentResource = getPaymentResource(payment);
|
||||
|
||||
return paymentResource;
|
||||
return getPaymentResource(payment);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -170,50 +171,20 @@ public class PaymentController {
|
||||
return eventResource;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the {@link PaymentCommand} hypermedia resource that lists the available commands that can be applied to a
|
||||
* {@link Payment} entity.
|
||||
*
|
||||
* @param id is the {@link Payment} identifier to provide command links for
|
||||
* @return an {@link PaymentCommands} with a collection of embedded command links
|
||||
*/
|
||||
private PaymentCommands getCommandsResource(Long id) {
|
||||
// Get the payment resource for the identifier
|
||||
Resource<Payment> paymentResource = getPaymentResource(id);
|
||||
|
||||
// Create a new payment commands hypermedia resource
|
||||
PaymentCommands commandResource = new PaymentCommands();
|
||||
|
||||
// Add payment command hypermedia links
|
||||
if (paymentResource != null) {
|
||||
commandResource.add(
|
||||
getCommandLinkBuilder(id)
|
||||
.slash("connectOrder")
|
||||
.withRel("connectOrder"),
|
||||
getCommandLinkBuilder(id)
|
||||
.slash("processPayment")
|
||||
.withRel("processPayment")
|
||||
);
|
||||
}
|
||||
|
||||
return commandResource;
|
||||
}
|
||||
|
||||
private Events getPaymentEventResources(Long id) {
|
||||
return eventService.find(id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a {@link LinkBuilder} for generating the {@link PaymentCommands}.
|
||||
*
|
||||
* @param id is the unique identifier for a {@link Payment}
|
||||
* @return a {@link LinkBuilder} for the {@link PaymentCommands}
|
||||
*/
|
||||
private LinkBuilder getCommandLinkBuilder(Long id) {
|
||||
return linkTo(PaymentController.class)
|
||||
.slash("payments")
|
||||
.slash(id)
|
||||
.slash("commands");
|
||||
private LinkBuilder linkBuilder(String name, Long id) {
|
||||
Method method;
|
||||
|
||||
try {
|
||||
method = PaymentController.class.getMethod(name, Long.class);
|
||||
} catch (NoSuchMethodException e) {
|
||||
throw new RuntimeException(e);
|
||||
}
|
||||
|
||||
return linkTo(PaymentController.class, method, id);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -223,23 +194,20 @@ public class PaymentController {
|
||||
* @return is a hypermedia enriched resource for the supplied {@link Payment} entity
|
||||
*/
|
||||
private Resource<Payment> getPaymentResource(Payment payment) {
|
||||
Resource<Payment> paymentResource;
|
||||
Assert.notNull(payment, "Payment must not be null");
|
||||
|
||||
// Prepare hypermedia response
|
||||
paymentResource = new Resource<>(payment,
|
||||
linkTo(PaymentController.class)
|
||||
.slash("payments")
|
||||
.slash(payment.getPaymentId())
|
||||
.withSelfRel(),
|
||||
linkTo(PaymentController.class)
|
||||
.slash("payments")
|
||||
.slash(payment.getPaymentId())
|
||||
.slash("events")
|
||||
.withRel("events"),
|
||||
getCommandLinkBuilder(payment.getPaymentId())
|
||||
.withRel("commands")
|
||||
);
|
||||
// Add command link
|
||||
payment.add(linkBuilder("getCommands", payment.getIdentity()).withRel("commands"));
|
||||
|
||||
return paymentResource;
|
||||
// Add get events link
|
||||
payment.add(linkBuilder("getPaymentEvents", payment.getIdentity()).withRel("events"));
|
||||
|
||||
return new Resource<>(payment);
|
||||
}
|
||||
|
||||
private ResourceSupport getCommandsResources(Long id) {
|
||||
Payment payment = new Payment();
|
||||
payment.setIdentity(id);
|
||||
return new Resource<>(payment.getCommands());
|
||||
}
|
||||
}
|
||||
@@ -23,7 +23,7 @@ public class EventServiceTests {
|
||||
Payment payment = new Payment(11.0, PaymentMethod.CREDIT_CARD);
|
||||
payment = paymentRepository.saveAndFlush(payment);
|
||||
eventService.save(new PaymentEvent(PaymentEventType.PAYMENT_CREATED, payment));
|
||||
Events events = eventService.find(payment.getPaymentId());
|
||||
Events events = eventService.find(payment.getIdentity());
|
||||
Assert.notNull(events);
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,10 @@
|
||||
package demo.payment;
|
||||
|
||||
import demo.event.EventService;
|
||||
import demo.event.Events;
|
||||
import demo.event.PaymentEvent;
|
||||
import demo.event.PaymentEventType;
|
||||
import demo.payment.controller.PaymentController;
|
||||
import org.junit.Test;
|
||||
import org.junit.runner.RunWith;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
@@ -11,6 +14,8 @@ import org.springframework.http.MediaType;
|
||||
import org.springframework.test.context.junit4.SpringRunner;
|
||||
import org.springframework.test.web.servlet.MockMvc;
|
||||
|
||||
import java.util.Collections;
|
||||
|
||||
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;
|
||||
@@ -35,10 +40,13 @@ public class PaymentControllerTest {
|
||||
|
||||
Payment payment = new Payment(42.0, PaymentMethod.CREDIT_CARD);
|
||||
|
||||
given(this.paymentService.getPayment(1L))
|
||||
.willReturn(payment);
|
||||
given(this.paymentService.getPayment(1L)).willReturn(payment);
|
||||
given(this.eventService.find(1L)).willReturn(new Events<>(1L, Collections
|
||||
.singletonList(new PaymentEvent(PaymentEventType
|
||||
.PAYMENT_CREATED))));
|
||||
|
||||
this.mvc.perform(get("/v1/payments/1").accept(MediaType.APPLICATION_JSON))
|
||||
.andExpect(status().isOk()).andExpect(content().json(content));
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(content().json(content));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -47,5 +47,10 @@
|
||||
<groupId>org.springframework.data</groupId>
|
||||
<artifactId>spring-data-commons</artifactId>
|
||||
</dependency>
|
||||
|
||||
<dependency>
|
||||
<groupId>org.projectlombok</groupId>
|
||||
<artifactId>lombok</artifactId>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
</project>
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
package demo.domain;
|
||||
|
||||
import org.springframework.beans.BeansException;
|
||||
import org.springframework.context.ApplicationContext;
|
||||
import org.springframework.context.ApplicationContextAware;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
/**
|
||||
* An {@link Action} is a reference of a method. A function contains an address to the location of a method. A function
|
||||
* may contain meta-data that describes the inputs and outputs of a method. An action invokes a method annotated with
|
||||
* {@link Command}.
|
||||
*
|
||||
* @author Kenny Bastani
|
||||
*/
|
||||
@Component
|
||||
public abstract class Action<A extends Aggregate> implements ApplicationContextAware {
|
||||
private ApplicationContext applicationContext;
|
||||
|
||||
@Override
|
||||
public void setApplicationContext(ApplicationContext applicationContext) throws BeansException {
|
||||
this.applicationContext = applicationContext;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
package demo.domain;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonIgnore;
|
||||
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||
import org.springframework.context.ApplicationContext;
|
||||
import org.springframework.core.ResolvableType;
|
||||
import org.springframework.hateoas.*;
|
||||
import org.springframework.util.Assert;
|
||||
import org.springframework.util.ReflectionUtils;
|
||||
import org.springframework.web.bind.annotation.RequestParam;
|
||||
|
||||
import java.io.Serializable;
|
||||
import java.util.Arrays;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
import static org.springframework.hateoas.mvc.ControllerLinkBuilder.linkTo;
|
||||
|
||||
/**
|
||||
* An {@link Aggregate} is an entity that contains references to one or more other {@link Value} objects. Aggregates
|
||||
* may contain a collection of references to a {@link Command}. All command references on an aggregate should be
|
||||
* explicitly typed.
|
||||
*
|
||||
* @author Kenny Bastani
|
||||
*/
|
||||
public abstract class Aggregate<ID extends Serializable> extends ResourceSupport implements Value<Link> {
|
||||
|
||||
@JsonProperty("id")
|
||||
abstract ID getIdentity();
|
||||
|
||||
private final ApplicationContext applicationContext = Optional.ofNullable(Provider.getApplicationContext())
|
||||
.orElse(null);
|
||||
|
||||
/**
|
||||
* Retrieves an {@link Action} for this {@link Provider}
|
||||
*
|
||||
* @return the action for this provider
|
||||
* @throws IllegalArgumentException if the application context is unavailable or the provider does not exist
|
||||
*/
|
||||
@SuppressWarnings("unchecked")
|
||||
protected <T extends Action<A>, A extends Aggregate> T getAction(
|
||||
Class<T> actionType) throws IllegalArgumentException {
|
||||
Provider provider = getProvider();
|
||||
Service service = provider.getDefaultService();
|
||||
return (T) service.getAction(actionType);
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves an instance of the {@link Provider} for this instance
|
||||
*
|
||||
* @return the provider for this instance
|
||||
* @throws IllegalArgumentException if the application context is unavailable or the provider does not exist
|
||||
*/
|
||||
@SuppressWarnings("unchecked")
|
||||
protected <T extends Provider<A>, A extends Aggregate> T getProvider() throws IllegalArgumentException {
|
||||
return getProvider((Class<T>) ResolvableType
|
||||
.forClassWithGenerics(Provider.class, ResolvableType.forInstance(this))
|
||||
.getRawClass());
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves an instance of a {@link Provider} with the supplied type
|
||||
*
|
||||
* @return an instance of the requested {@link Provider}
|
||||
* @throws IllegalArgumentException if the application context is unavailable or the provider does not exist
|
||||
*/
|
||||
protected <T extends Provider<A>, A extends Aggregate> T getProvider(
|
||||
Class<T> providerType) throws IllegalArgumentException {
|
||||
Assert.notNull(applicationContext, "The application context is unavailable");
|
||||
T provider = applicationContext.getBean(providerType);
|
||||
Assert.notNull(provider, "The requested provider is not registered in the application context");
|
||||
return provider;
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<Link> getLinks() {
|
||||
List<Link> links = super.getLinks()
|
||||
.stream()
|
||||
.collect(Collectors.toList());
|
||||
|
||||
links.add(getId());
|
||||
|
||||
return links;
|
||||
}
|
||||
|
||||
@JsonIgnore
|
||||
@SuppressWarnings("unchecked")
|
||||
public CommandResources getCommands() {
|
||||
CommandResources commandResources = new CommandResources();
|
||||
|
||||
// Get command annotations on the aggregate
|
||||
List<Command> commands = Arrays.stream(this.getClass()
|
||||
.getMethods())
|
||||
.filter(a -> a.isAnnotationPresent(Command.class))
|
||||
.map(a -> a.getAnnotation(Command.class))
|
||||
.collect(Collectors.toList());
|
||||
|
||||
// Compile the collection of command links
|
||||
List<Link> commandLinks = commands.stream()
|
||||
.map(a -> Arrays.stream(ReflectionUtils.getAllDeclaredMethods(a.controller()))
|
||||
.filter(m -> m.getName()
|
||||
.equalsIgnoreCase(a.method()))
|
||||
.findFirst()
|
||||
.orElseGet(null))
|
||||
.map(m -> {
|
||||
String uri = linkTo(m, getIdentity()).withRel(m.getName())
|
||||
.getHref();
|
||||
|
||||
return new Link(new UriTemplate(uri, new TemplateVariables(Arrays.stream(m.getParameters())
|
||||
.filter(p -> p.isAnnotationPresent(RequestParam.class))
|
||||
.map(p -> new TemplateVariable(p.getAnnotation(RequestParam.class)
|
||||
.value(), TemplateVariable.VariableType.REQUEST_PARAM))
|
||||
.toArray(TemplateVariable[]::new))), m.getName());
|
||||
})
|
||||
.collect(Collectors.toList());
|
||||
|
||||
commandResources.add(commandLinks);
|
||||
|
||||
return commandResources;
|
||||
}
|
||||
|
||||
public static class CommandResources extends ResourceSupport {
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
package demo.domain;
|
||||
|
||||
import org.springframework.beans.factory.annotation.Required;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import java.lang.annotation.*;
|
||||
|
||||
/**
|
||||
* A {@link Command} is an annotated method that contains a reference to a function in the context of an
|
||||
* {@link Aggregate}. A command maps a method reference on an {@link Aggregate} to a function invocation. Commands
|
||||
* are discoverable.
|
||||
*
|
||||
* @author Kenny Bastani
|
||||
*/
|
||||
@Target(ElementType.METHOD)
|
||||
@Retention(RetentionPolicy.RUNTIME)
|
||||
@Documented
|
||||
@Component
|
||||
public @interface Command {
|
||||
|
||||
String description() default "";
|
||||
|
||||
@Required
|
||||
String method() default "";
|
||||
|
||||
@Required
|
||||
Class controller();
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
package demo.domain;
|
||||
|
||||
/**
|
||||
* A {@link Commodity} object is a {@link Value} object that is also an {@link Aggregate} root. A commodity object
|
||||
* describes all aspects of an aggregate and is both stateless and immutable. A commodity is a locator that connects
|
||||
* relationships of a value object to a {@link Provider}.
|
||||
* <p>
|
||||
* The key difference between a commodity object and an aggregate is that a commodity object is a distributed
|
||||
* representation of an aggregate root that combines together references from multiple bounded contexts.
|
||||
* <p>
|
||||
* Commodities are used by a discovery service to create a reverse-proxy that translates the relationships of an
|
||||
* aggregate into URIs.
|
||||
*
|
||||
* @author Kenny Bastani
|
||||
*/
|
||||
public abstract class Commodity extends Aggregate {
|
||||
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
package demo.domain;
|
||||
|
||||
import org.springframework.beans.BeansException;
|
||||
import org.springframework.context.ApplicationContext;
|
||||
import org.springframework.context.ApplicationContextAware;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
/**
|
||||
* A {@link Provider} is a collection of {@link Service} references that are used to consume and/or produce
|
||||
* {@link org.springframework.hateoas.Resource}s. Providers transfer a resource into a {@link Commodity}.
|
||||
*
|
||||
* @author Kenny Bastani
|
||||
*/
|
||||
@Component
|
||||
public abstract class Provider<T extends Aggregate> implements ApplicationContextAware {
|
||||
private static ApplicationContext applicationContext;
|
||||
|
||||
public static ApplicationContext getApplicationContext() {
|
||||
return applicationContext;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void setApplicationContext(ApplicationContext applicationContext) throws BeansException {
|
||||
Provider.applicationContext = applicationContext;
|
||||
}
|
||||
|
||||
protected abstract Service<? extends T> getDefaultService();
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
package demo.domain;
|
||||
|
||||
import org.springframework.beans.BeansException;
|
||||
import org.springframework.context.ApplicationContext;
|
||||
import org.springframework.context.ApplicationContextAware;
|
||||
|
||||
/**
|
||||
* A {@link Service} is a functional unit that provides a need. Services are immutable and often stateless. Services
|
||||
* always consume or produce {@link Commodity} objects. Services are addressable and discoverable by other services.
|
||||
*
|
||||
* @author Kenny Bastani
|
||||
*/
|
||||
@org.springframework.stereotype.Service
|
||||
public abstract class Service<T extends Aggregate> implements ApplicationContextAware {
|
||||
private ApplicationContext applicationContext;
|
||||
|
||||
@Override
|
||||
public void setApplicationContext(ApplicationContext applicationContext) throws BeansException {
|
||||
this.applicationContext = applicationContext;
|
||||
}
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
public <A extends Action<T>> A getAction(Class<? extends A> clazz) {
|
||||
return applicationContext.getBean(clazz);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
package demo.domain;
|
||||
|
||||
|
||||
import org.springframework.hateoas.Identifiable;
|
||||
|
||||
import java.io.Serializable;
|
||||
|
||||
/**
|
||||
* {@link Value} objects are wrappers that contain the serializable properties that uniquely identify an entity.
|
||||
* Value objects contain a collection of relationships. Value objects contain a collection of comparison operators.
|
||||
* The default identity comparator evaluates true if the compared objects have the same identifier.
|
||||
*
|
||||
* @author Kenny Bastani
|
||||
*/
|
||||
public interface Value<ID extends Serializable> extends Identifiable<ID> {
|
||||
}
|
||||
@@ -0,0 +1,169 @@
|
||||
package demo.domain;
|
||||
|
||||
import lombok.*;
|
||||
import org.junit.After;
|
||||
import org.junit.Before;
|
||||
import org.junit.Test;
|
||||
import org.junit.runner.RunWith;
|
||||
import org.springframework.boot.test.context.SpringBootTest;
|
||||
import org.springframework.boot.test.util.EnvironmentTestUtils;
|
||||
import org.springframework.context.annotation.AnnotationConfigApplicationContext;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.hateoas.Link;
|
||||
import org.springframework.test.context.junit4.SpringRunner;
|
||||
import org.springframework.util.Assert;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
import java.util.function.Consumer;
|
||||
|
||||
import static junit.framework.TestCase.assertEquals;
|
||||
import static junit.framework.TestCase.assertNotNull;
|
||||
|
||||
@RunWith(SpringRunner.class)
|
||||
@SpringBootTest(classes = {ProviderTests.class, ProviderTests.EmptyConfiguration.class, ProviderTests.EmptyProvider.class, ProviderTests.EmptyService.class, ProviderTests.EmptyAction.class})
|
||||
public class ProviderTests {
|
||||
|
||||
private AnnotationConfigApplicationContext context;
|
||||
|
||||
@After
|
||||
public void tearDown() {
|
||||
if (this.context != null) {
|
||||
this.context.close();
|
||||
}
|
||||
}
|
||||
|
||||
@Before
|
||||
public void setup() {
|
||||
load(EmptyProvider.class);
|
||||
assertNotNull(context);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testGetProviderReturnsProvider() {
|
||||
assertNotNull(new EmptyAggregate().getProvider(EmptyProvider.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testGetServiceReturnsService() {
|
||||
EmptyProvider provider = new EmptyAggregate().getProvider(EmptyProvider.class);
|
||||
assertNotNull(provider.getEmptyService());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testGetActionReturnsAction() {
|
||||
EmptyProvider provider = new EmptyAggregate().getProvider(EmptyProvider.class);
|
||||
EmptyService service = provider.getEmptyService();
|
||||
assertNotNull(service.getAction(EmptyAction.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testProcessCommandChangesStatus() {
|
||||
EmptyAggregate aggregate = new EmptyAggregate(0L, AggregateStatus.CREATED);
|
||||
EmptyProvider provider = new EmptyAggregate().getProvider(EmptyProvider.class);
|
||||
EmptyService service = provider.getEmptyService();
|
||||
EmptyAction emptyAction = service.getAction(EmptyAction.class);
|
||||
emptyAction.getConsumer().accept(aggregate);
|
||||
assertEquals(aggregate.getStatus(), AggregateStatus.PROCESSED);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testProcessEmptyAggregateChangesStatus() {
|
||||
EmptyAggregate aggregate = new EmptyAggregate(0L, AggregateStatus.CREATED);
|
||||
aggregate.emptyAction();
|
||||
assertEquals(aggregate.getStatus(), AggregateStatus.PROCESSED);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testAggregateProducesCommandLinks() {
|
||||
EmptyAggregate aggregate = new EmptyAggregate(0L, AggregateStatus.CREATED);
|
||||
Assert.notEmpty(aggregate.getLinks());
|
||||
Assert.notEmpty(aggregate.getCommands().getLinks());
|
||||
}
|
||||
|
||||
private void load(Class<?> provider, String... environment) {
|
||||
AnnotationConfigApplicationContext applicationContext = new AnnotationConfigApplicationContext();
|
||||
EnvironmentTestUtils.addEnvironment(applicationContext, environment);
|
||||
applicationContext.register(EmptyConfiguration.class, provider, EmptyService.class, EmptyAction.class, EmptyController.class);
|
||||
applicationContext.refresh();
|
||||
this.context = applicationContext;
|
||||
}
|
||||
|
||||
|
||||
@Configuration
|
||||
public static class EmptyConfiguration {
|
||||
}
|
||||
|
||||
@AllArgsConstructor
|
||||
@NoArgsConstructor
|
||||
@Getter
|
||||
@Setter
|
||||
public static class EmptyAggregate extends Aggregate<Long> {
|
||||
@NonNull
|
||||
private Long id;
|
||||
@NonNull
|
||||
private AggregateStatus status;
|
||||
|
||||
@Command(controller = EmptyController.class, method = "emptyAction")
|
||||
public void emptyAction() {
|
||||
EmptyProvider emptyProvider = this.getProvider();
|
||||
emptyProvider.getEmptyService()
|
||||
.getAction(EmptyAction.class)
|
||||
.getConsumer()
|
||||
.accept(this);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Link getId() {
|
||||
return new Link("example.com").withSelfRel();
|
||||
}
|
||||
|
||||
@Override
|
||||
Long getIdentity() {
|
||||
return this.id;
|
||||
}
|
||||
}
|
||||
|
||||
@Getter
|
||||
@RequiredArgsConstructor
|
||||
public static class EmptyProvider extends Provider<EmptyAggregate> {
|
||||
private final EmptyService emptyService;
|
||||
|
||||
public Service<? extends EmptyAggregate> getDefaultService() {
|
||||
return emptyService;
|
||||
}
|
||||
}
|
||||
|
||||
public static class EmptyService extends Service<EmptyAggregate> {
|
||||
public EmptyAggregate getEmptyAggregate(Long id) {
|
||||
return new EmptyAggregate(id, AggregateStatus.CREATED);
|
||||
}
|
||||
}
|
||||
|
||||
public static class EmptyAction extends Action<EmptyAggregate> {
|
||||
|
||||
public Consumer<EmptyAggregate> getConsumer() {
|
||||
return a -> a.setStatus(AggregateStatus.PROCESSED);
|
||||
}
|
||||
}
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/v1")
|
||||
public static class EmptyController {
|
||||
|
||||
private EmptyProvider provider;
|
||||
|
||||
public EmptyController(EmptyProvider provider) {
|
||||
this.provider = provider;
|
||||
}
|
||||
|
||||
@RequestMapping(value = "/empty/{id}", method = RequestMethod.GET)
|
||||
public EmptyAggregate emptyAction(@PathVariable("id") Long id, @RequestParam("q") String query) {
|
||||
return provider.getEmptyService().getEmptyAggregate(id);
|
||||
}
|
||||
}
|
||||
|
||||
public enum AggregateStatus {
|
||||
CREATED,
|
||||
PROCESSED
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user