Pattern language for mapping commands to domain model

This commit is contained in:
Kenny Bastani
2016-12-28 14:00:55 -05:00
parent 1418ee2bbf
commit 55cf52132a
21 changed files with 608 additions and 156 deletions

View File

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

View File

@@ -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{" +

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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