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

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