Pattern language for mapping commands to domain model
This commit is contained in:
@@ -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