├── .gitignore ├── README.md ├── pom.xml └── src └── main ├── java └── nl │ └── avthart │ └── todo │ └── app │ ├── Application.java │ ├── configuration │ ├── WebSecurityConfiguration.java │ └── WebSocketConfiguration.java │ ├── domain │ └── task │ │ ├── Task.java │ │ ├── TaskAlreadyCompletedException.java │ │ ├── commands │ │ ├── CompleteTaskCommand.java │ │ ├── CreateTaskCommand.java │ │ ├── ModifyTaskTitleCommand.java │ │ ├── StarTaskCommand.java │ │ └── UnstarTaskCommand.java │ │ └── events │ │ ├── TaskCompletedEvent.java │ │ ├── TaskCreatedEvent.java │ │ ├── TaskEvent.java │ │ ├── TaskStarredEvent.java │ │ ├── TaskTitleModifiedEvent.java │ │ └── TaskUnstarredEvent.java │ ├── notify │ └── task │ │ ├── TaskEventNotification.java │ │ └── TaskEventNotifyingEventHandler.java │ ├── query │ └── task │ │ ├── TaskEntry.java │ │ ├── TaskEntryRepository.java │ │ └── TaskEntryUpdatingEventHandler.java │ └── rest │ └── task │ ├── TaskController.java │ └── requests │ ├── CreateTaskRequest.java │ └── ModifyTitleRequest.java ├── resources └── application.yml └── webapp ├── css └── toaster.css ├── img └── bg-wood.jpg ├── index.html ├── js ├── app.js └── toaster.js └── login.html /.gitignore: -------------------------------------------------------------------------------- 1 | todo-application/.classpath 2 | todo-application/.project 3 | todo-application/target 4 | .DS_Store 5 | todo-application/.settings 6 | todo-application/.springBeans 7 | todo-application/data 8 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Spring Boot Axon Sample 2 | 3 | ## Introduction 4 | 5 | This is a sample application to demonstrate Spring Boot (1.5.x) and Axon Framework (3.x). 6 | 7 | The Todo application makes use of the following design patterns: 8 | - Domain Driven Design 9 | - CQRS 10 | - Event Sourcing 11 | - Task based User Interface 12 | 13 | ## Building 14 | 15 | > mvn package 16 | 17 | ## Running 18 | 19 | > mvn spring-boot:run 20 | 21 | Browse to http://localhost:8080/index.html 22 | 23 | ## Implementation 24 | 25 | Implementation notes: 26 | - The event store is backed by a JPA Event Store implementation which comes with Axon 27 | - The query model is backed by a Spring Data JPA Repository 28 | - The user interface is updated asynchronously via stompjs over websockets using Spring Websockets support 29 | 30 | ## Roadmap 31 | 32 | - Add unit and integration tests 33 | - Replace JPA EventStore with AxonDB 34 | - Convert AngularJS to Angular, ReactJS or other 35 | 36 | ## Documentation 37 | 38 | * Axon Framework - http://www.axonframework.org/ 39 | * Spring Boot - http://projects.spring.io/spring-boot/ 40 | * Spring Framework - http://projects.spring.io/spring-framework/ 41 | * Spring Data JPA - https://projects.spring.io/spring-data-jpa/ 42 | -------------------------------------------------------------------------------- /pom.xml: -------------------------------------------------------------------------------- 1 | 3 | 4.0.0 4 | nl.avthart.samples 5 | todo-application 6 | 1.0.0-SNAPSHOT 7 | Todo application 8 | Todo Sample Application using Spring Boot and Axon Framework 9 | 10 | 11 | org.springframework.boot 12 | spring-boot-starter-parent 13 | 1.5.10.RELEASE 14 | 15 | 16 | 17 | UTF-8 18 | UTF-8 19 | 1.8 20 | 21 | 22 | 23 | 24 | org.springframework.boot 25 | spring-boot-starter-web 26 | 27 | 28 | org.springframework.boot 29 | spring-boot-starter-web 30 | 31 | 32 | org.springframework.boot 33 | spring-boot-starter-websocket 34 | 35 | 36 | org.springframework.boot 37 | spring-boot-starter-security 38 | 39 | 40 | org.springframework.boot 41 | spring-boot-starter-actuator 42 | 43 | 44 | org.springframework.boot 45 | spring-boot-starter-data-jpa 46 | 47 | 48 | org.axonframework 49 | axon-spring-boot-starter 50 | 3.1 51 | 52 | 53 | org.projectlombok 54 | lombok 55 | provided 56 | 57 | 58 | com.h2database 59 | h2 60 | runtime 61 | 62 | 63 | 64 | 65 | 66 | org.springframework.boot 67 | spring-boot-maven-plugin 68 | 69 | 70 | 71 | -------------------------------------------------------------------------------- /src/main/java/nl/avthart/todo/app/Application.java: -------------------------------------------------------------------------------- 1 | package nl.avthart.todo.app; 2 | 3 | import org.springframework.boot.SpringApplication; 4 | import org.springframework.boot.autoconfigure.EnableAutoConfiguration; 5 | import org.springframework.boot.autoconfigure.SpringBootApplication; 6 | import org.springframework.context.annotation.ComponentScan; 7 | import org.springframework.context.annotation.Configuration; 8 | 9 | /** 10 | * Todo App using Axon and Spring Boot 11 | * 12 | * @author albert 13 | */ 14 | @SpringBootApplication 15 | public class Application { 16 | 17 | public static void main(String[] args) { 18 | SpringApplication.run(Application.class, args); 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /src/main/java/nl/avthart/todo/app/configuration/WebSecurityConfiguration.java: -------------------------------------------------------------------------------- 1 | package nl.avthart.todo.app.configuration; 2 | 3 | import org.springframework.context.annotation.Configuration; 4 | import org.springframework.security.config.annotation.authentication.builders.AuthenticationManagerBuilder; 5 | import org.springframework.security.config.annotation.web.builders.HttpSecurity; 6 | import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; 7 | import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter; 8 | 9 | @Configuration 10 | @EnableWebSecurity 11 | public class WebSecurityConfiguration extends WebSecurityConfigurerAdapter { 12 | 13 | @Override 14 | protected void configure(HttpSecurity http) throws Exception { 15 | http 16 | .authorizeRequests() 17 | .antMatchers("/css/**", "/js/**", "/img/**").permitAll() 18 | .anyRequest().authenticated(); 19 | http 20 | .csrf() 21 | .disable() 22 | .formLogin() 23 | .defaultSuccessUrl("/index.html") 24 | .loginPage("/login.html") 25 | .failureUrl("/login.html?error") 26 | .permitAll() 27 | .and() 28 | .logout() 29 | .logoutSuccessUrl("/login.html?logout") 30 | .permitAll(); 31 | } 32 | 33 | 34 | @Override 35 | protected void configure(AuthenticationManagerBuilder auth) throws Exception { 36 | auth 37 | .inMemoryAuthentication() 38 | .withUser("albert").password("1234").roles("USER").and() 39 | .withUser("foo").password("bar").roles("USER"); 40 | } 41 | 42 | } 43 | -------------------------------------------------------------------------------- /src/main/java/nl/avthart/todo/app/configuration/WebSocketConfiguration.java: -------------------------------------------------------------------------------- 1 | package nl.avthart.todo.app.configuration; 2 | 3 | import java.util.List; 4 | 5 | import org.springframework.context.annotation.Configuration; 6 | import org.springframework.messaging.converter.MessageConverter; 7 | import org.springframework.messaging.handler.invocation.HandlerMethodArgumentResolver; 8 | import org.springframework.messaging.handler.invocation.HandlerMethodReturnValueHandler; 9 | import org.springframework.messaging.simp.config.ChannelRegistration; 10 | import org.springframework.messaging.simp.config.MessageBrokerRegistry; 11 | import org.springframework.web.socket.config.annotation.EnableWebSocketMessageBroker; 12 | import org.springframework.web.socket.config.annotation.StompEndpointRegistry; 13 | import org.springframework.web.socket.config.annotation.WebSocketMessageBrokerConfigurer; 14 | import org.springframework.web.socket.config.annotation.WebSocketTransportRegistration; 15 | 16 | @Configuration 17 | @EnableWebSocketMessageBroker 18 | public class WebSocketConfiguration implements WebSocketMessageBrokerConfigurer { 19 | 20 | @Override 21 | public void configureMessageBroker(MessageBrokerRegistry config) { 22 | config.enableSimpleBroker("/topic", "/queue"); 23 | config.setApplicationDestinationPrefixes("/app"); 24 | } 25 | 26 | @Override 27 | public boolean configureMessageConverters(List converters) { 28 | return true; 29 | } 30 | 31 | @Override 32 | public void registerStompEndpoints(StompEndpointRegistry registry) { 33 | registry.addEndpoint("/tasks").withSockJS(); 34 | } 35 | 36 | @Override 37 | public void configureWebSocketTransport(WebSocketTransportRegistration webSocketTransportRegistration) { 38 | 39 | } 40 | 41 | @Override 42 | public void configureClientInboundChannel(ChannelRegistration channelRegistration) { 43 | 44 | } 45 | 46 | @Override 47 | public void configureClientOutboundChannel(ChannelRegistration channelRegistration) { 48 | 49 | } 50 | 51 | @Override 52 | public void addArgumentResolvers(List list) { 53 | 54 | } 55 | 56 | @Override 57 | public void addReturnValueHandlers(List list) { 58 | 59 | } 60 | 61 | 62 | } 63 | -------------------------------------------------------------------------------- /src/main/java/nl/avthart/todo/app/domain/task/Task.java: -------------------------------------------------------------------------------- 1 | package nl.avthart.todo.app.domain.task; 2 | 3 | import javax.validation.constraints.NotNull; 4 | 5 | import nl.avthart.todo.app.domain.task.commands.CompleteTaskCommand; 6 | import nl.avthart.todo.app.domain.task.commands.CreateTaskCommand; 7 | import nl.avthart.todo.app.domain.task.commands.ModifyTaskTitleCommand; 8 | import nl.avthart.todo.app.domain.task.commands.StarTaskCommand; 9 | import nl.avthart.todo.app.domain.task.commands.UnstarTaskCommand; 10 | import nl.avthart.todo.app.domain.task.events.TaskCompletedEvent; 11 | import nl.avthart.todo.app.domain.task.events.TaskCreatedEvent; 12 | import nl.avthart.todo.app.domain.task.events.TaskStarredEvent; 13 | import nl.avthart.todo.app.domain.task.events.TaskTitleModifiedEvent; 14 | import nl.avthart.todo.app.domain.task.events.TaskUnstarredEvent; 15 | import org.axonframework.commandhandling.CommandHandler; 16 | import org.axonframework.commandhandling.model.AggregateIdentifier; 17 | import org.axonframework.eventsourcing.EventSourcingHandler; 18 | import org.axonframework.spring.stereotype.Aggregate; 19 | 20 | import static org.axonframework.commandhandling.model.AggregateLifecycle.apply; 21 | 22 | /** 23 | * Task 24 | * @author albert 25 | */ 26 | @Aggregate 27 | public class Task { 28 | 29 | /** 30 | * The constant serialVersionUID 31 | */ 32 | private static final long serialVersionUID = -5977984483620451665L; 33 | 34 | @AggregateIdentifier 35 | private String id; 36 | 37 | @NotNull 38 | private boolean completed; 39 | 40 | /** 41 | * Creates a new Task. 42 | * 43 | * @param command create Task 44 | */ 45 | @CommandHandler 46 | public Task(CreateTaskCommand command) { 47 | apply(new TaskCreatedEvent(command.getId(), command.getUsername(), command.getTitle())); 48 | } 49 | 50 | Task() { 51 | } 52 | 53 | /** 54 | * Completes a Task. 55 | * 56 | * @param command complete Task 57 | */ 58 | @CommandHandler 59 | void on(CompleteTaskCommand command) { 60 | apply(new TaskCompletedEvent(command.getId())); 61 | } 62 | 63 | /** 64 | * Stars a Task. 65 | * 66 | * @param command star Task 67 | */ 68 | @CommandHandler 69 | void on(StarTaskCommand command) { 70 | apply(new TaskStarredEvent(command.getId())); 71 | } 72 | 73 | /** 74 | * Unstars a Task. 75 | * 76 | * @param command unstar Task 77 | */ 78 | @CommandHandler 79 | void on(UnstarTaskCommand command) { 80 | apply(new TaskUnstarredEvent(command.getId())); 81 | } 82 | 83 | /** 84 | * Modifies a Task title. 85 | * 86 | * @param command modify Task title 87 | */ 88 | @CommandHandler 89 | void on(ModifyTaskTitleCommand command) { 90 | assertNotCompleted(); 91 | apply(new TaskTitleModifiedEvent(command.getId(), command.getTitle())); 92 | } 93 | 94 | @EventSourcingHandler 95 | void on(TaskCreatedEvent event) { 96 | this.id = event.getId(); 97 | } 98 | 99 | @EventSourcingHandler 100 | void on(TaskCompletedEvent event) { 101 | this.completed = true; 102 | } 103 | 104 | private void assertNotCompleted() { 105 | if (completed) { 106 | throw new TaskAlreadyCompletedException("Task [ identifier = " + id + " ] is completed."); 107 | } 108 | } 109 | } 110 | -------------------------------------------------------------------------------- /src/main/java/nl/avthart/todo/app/domain/task/TaskAlreadyCompletedException.java: -------------------------------------------------------------------------------- 1 | package nl.avthart.todo.app.domain.task; 2 | 3 | /** 4 | * @author albert 5 | */ 6 | public class TaskAlreadyCompletedException extends RuntimeException { 7 | 8 | private static final long serialVersionUID = 1518440584190922771L; 9 | 10 | public TaskAlreadyCompletedException(String message) { 11 | super(message); 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /src/main/java/nl/avthart/todo/app/domain/task/commands/CompleteTaskCommand.java: -------------------------------------------------------------------------------- 1 | package nl.avthart.todo.app.domain.task.commands; 2 | 3 | import lombok.Value; 4 | 5 | import org.axonframework.commandhandling.TargetAggregateIdentifier; 6 | 7 | /** 8 | * @author albert 9 | */ 10 | @Value 11 | public class CompleteTaskCommand { 12 | 13 | @TargetAggregateIdentifier 14 | private final String id; 15 | } -------------------------------------------------------------------------------- /src/main/java/nl/avthart/todo/app/domain/task/commands/CreateTaskCommand.java: -------------------------------------------------------------------------------- 1 | package nl.avthart.todo.app.domain.task.commands; 2 | 3 | import javax.validation.constraints.NotNull; 4 | 5 | import lombok.Value; 6 | 7 | /** 8 | * @author albert 9 | */ 10 | @Value 11 | public class CreateTaskCommand { 12 | 13 | @NotNull 14 | private final String id; 15 | 16 | @NotNull 17 | private final String username; 18 | 19 | @NotNull 20 | private final String title; 21 | } 22 | -------------------------------------------------------------------------------- /src/main/java/nl/avthart/todo/app/domain/task/commands/ModifyTaskTitleCommand.java: -------------------------------------------------------------------------------- 1 | package nl.avthart.todo.app.domain.task.commands; 2 | 3 | import javax.validation.constraints.NotNull; 4 | 5 | import lombok.Value; 6 | 7 | import org.axonframework.commandhandling.TargetAggregateIdentifier; 8 | 9 | /** 10 | * @author albert 11 | */ 12 | @Value 13 | public class ModifyTaskTitleCommand { 14 | 15 | @TargetAggregateIdentifier 16 | private final String id; 17 | 18 | @NotNull 19 | private final String title; 20 | } 21 | 22 | -------------------------------------------------------------------------------- /src/main/java/nl/avthart/todo/app/domain/task/commands/StarTaskCommand.java: -------------------------------------------------------------------------------- 1 | package nl.avthart.todo.app.domain.task.commands; 2 | 3 | import lombok.Value; 4 | import org.axonframework.commandhandling.TargetAggregateIdentifier; 5 | 6 | /** 7 | * @author albert 8 | */ 9 | @Value 10 | public class StarTaskCommand { 11 | 12 | @TargetAggregateIdentifier 13 | private final String id; 14 | } 15 | -------------------------------------------------------------------------------- /src/main/java/nl/avthart/todo/app/domain/task/commands/UnstarTaskCommand.java: -------------------------------------------------------------------------------- 1 | package nl.avthart.todo.app.domain.task.commands; 2 | 3 | import lombok.Value; 4 | import org.axonframework.commandhandling.TargetAggregateIdentifier; 5 | 6 | /** 7 | * @author albert 8 | */ 9 | @Value 10 | public class UnstarTaskCommand { 11 | 12 | @TargetAggregateIdentifier 13 | private final String id; 14 | } -------------------------------------------------------------------------------- /src/main/java/nl/avthart/todo/app/domain/task/events/TaskCompletedEvent.java: -------------------------------------------------------------------------------- 1 | package nl.avthart.todo.app.domain.task.events; 2 | 3 | import lombok.Value; 4 | 5 | /** 6 | * @author albert 7 | */ 8 | @Value 9 | public class TaskCompletedEvent implements TaskEvent { 10 | 11 | private final String id; 12 | } 13 | -------------------------------------------------------------------------------- /src/main/java/nl/avthart/todo/app/domain/task/events/TaskCreatedEvent.java: -------------------------------------------------------------------------------- 1 | package nl.avthart.todo.app.domain.task.events; 2 | 3 | import lombok.Value; 4 | 5 | /** 6 | * @author albert 7 | */ 8 | @Value 9 | public class TaskCreatedEvent implements TaskEvent { 10 | 11 | private final String id; 12 | 13 | private final String username; 14 | 15 | private final String title; 16 | } 17 | -------------------------------------------------------------------------------- /src/main/java/nl/avthart/todo/app/domain/task/events/TaskEvent.java: -------------------------------------------------------------------------------- 1 | package nl.avthart.todo.app.domain.task.events; 2 | 3 | public interface TaskEvent { 4 | 5 | String getId(); 6 | 7 | } 8 | -------------------------------------------------------------------------------- /src/main/java/nl/avthart/todo/app/domain/task/events/TaskStarredEvent.java: -------------------------------------------------------------------------------- 1 | package nl.avthart.todo.app.domain.task.events; 2 | 3 | import lombok.Value; 4 | 5 | /** 6 | * @author albert 7 | */ 8 | @Value 9 | public class TaskStarredEvent implements TaskEvent { 10 | 11 | private final String id; 12 | } 13 | -------------------------------------------------------------------------------- /src/main/java/nl/avthart/todo/app/domain/task/events/TaskTitleModifiedEvent.java: -------------------------------------------------------------------------------- 1 | package nl.avthart.todo.app.domain.task.events; 2 | 3 | import lombok.Value; 4 | 5 | /** 6 | * @author albert 7 | */ 8 | @Value 9 | public class TaskTitleModifiedEvent implements TaskEvent { 10 | 11 | private final String id; 12 | 13 | private final String title; 14 | } 15 | -------------------------------------------------------------------------------- /src/main/java/nl/avthart/todo/app/domain/task/events/TaskUnstarredEvent.java: -------------------------------------------------------------------------------- 1 | package nl.avthart.todo.app.domain.task.events; 2 | 3 | import lombok.Value; 4 | 5 | /** 6 | * @author albert 7 | */ 8 | @Value 9 | public class TaskUnstarredEvent implements TaskEvent { 10 | 11 | private final String id; 12 | } 13 | -------------------------------------------------------------------------------- /src/main/java/nl/avthart/todo/app/notify/task/TaskEventNotification.java: -------------------------------------------------------------------------------- 1 | package nl.avthart.todo.app.notify.task; 2 | 3 | import lombok.Value; 4 | import nl.avthart.todo.app.domain.task.events.TaskEvent; 5 | 6 | @Value 7 | public class TaskEventNotification { 8 | 9 | private String type; 10 | 11 | private TaskEvent data; 12 | } 13 | -------------------------------------------------------------------------------- /src/main/java/nl/avthart/todo/app/notify/task/TaskEventNotifyingEventHandler.java: -------------------------------------------------------------------------------- 1 | package nl.avthart.todo.app.notify.task; 2 | 3 | import nl.avthart.todo.app.domain.task.events.TaskCompletedEvent; 4 | import nl.avthart.todo.app.domain.task.events.TaskCreatedEvent; 5 | import nl.avthart.todo.app.domain.task.events.TaskEvent; 6 | import nl.avthart.todo.app.domain.task.events.TaskStarredEvent; 7 | import nl.avthart.todo.app.domain.task.events.TaskUnstarredEvent; 8 | import nl.avthart.todo.app.domain.task.events.TaskTitleModifiedEvent; 9 | import nl.avthart.todo.app.query.task.TaskEntry; 10 | import nl.avthart.todo.app.query.task.TaskEntryRepository; 11 | 12 | import org.axonframework.eventhandling.EventHandler; 13 | import org.springframework.beans.factory.annotation.Autowired; 14 | import org.springframework.messaging.simp.SimpMessageSendingOperations; 15 | import org.springframework.stereotype.Component; 16 | 17 | /** 18 | * @author albert 19 | */ 20 | @Component 21 | public class TaskEventNotifyingEventHandler { 22 | 23 | private final SimpMessageSendingOperations messagingTemplate; 24 | 25 | private final TaskEntryRepository taskEntryRepository; 26 | 27 | @Autowired 28 | public TaskEventNotifyingEventHandler(SimpMessageSendingOperations messagingTemplate, TaskEntryRepository taskEntryRepository) { 29 | this.messagingTemplate = messagingTemplate; 30 | this.taskEntryRepository = taskEntryRepository; 31 | } 32 | 33 | @EventHandler 34 | void on(TaskCreatedEvent event) { 35 | publish(event.getUsername(), event); 36 | } 37 | 38 | @EventHandler 39 | void on(TaskCompletedEvent event) { 40 | TaskEntry task = taskEntryRepository.findOne(event.getId()); 41 | publish(task.getUsername(), event); 42 | } 43 | 44 | @EventHandler 45 | void on(TaskTitleModifiedEvent event) { 46 | TaskEntry task = taskEntryRepository.findOne(event.getId()); 47 | publish(task.getUsername(), event); 48 | } 49 | 50 | @EventHandler 51 | void on (TaskStarredEvent event) { 52 | TaskEntry task = taskEntryRepository.findOne(event.getId()); 53 | publish(task.getUsername(), event); 54 | } 55 | 56 | @EventHandler 57 | void on (TaskUnstarredEvent event) { 58 | TaskEntry task = taskEntryRepository.findOne(event.getId()); 59 | publish(task.getUsername(), event); 60 | } 61 | 62 | private void publish(String username, TaskEvent event) { 63 | String type = event.getClass().getSimpleName(); 64 | this.messagingTemplate.convertAndSendToUser(username, "/queue/task-updates", new TaskEventNotification(type, event)); 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /src/main/java/nl/avthart/todo/app/query/task/TaskEntry.java: -------------------------------------------------------------------------------- 1 | package nl.avthart.todo.app.query.task; 2 | 3 | import lombok.AllArgsConstructor; 4 | import lombok.EqualsAndHashCode; 5 | import lombok.Getter; 6 | import lombok.NoArgsConstructor; 7 | import lombok.Setter; 8 | import lombok.ToString; 9 | 10 | 11 | import javax.persistence.Entity; 12 | import javax.persistence.Id; 13 | 14 | /** 15 | * @author albert 16 | */ 17 | @Entity 18 | @NoArgsConstructor 19 | @AllArgsConstructor 20 | @Getter 21 | @ToString 22 | @EqualsAndHashCode(of = { "id" }) 23 | public class TaskEntry { 24 | 25 | @Id 26 | private String id; 27 | 28 | private String username; 29 | 30 | @Setter 31 | private String title; 32 | 33 | @Setter 34 | private boolean completed; 35 | 36 | @Setter 37 | private boolean starred; 38 | } -------------------------------------------------------------------------------- /src/main/java/nl/avthart/todo/app/query/task/TaskEntryRepository.java: -------------------------------------------------------------------------------- 1 | package nl.avthart.todo.app.query.task; 2 | 3 | import org.springframework.data.domain.Page; 4 | import org.springframework.data.domain.Pageable; 5 | import org.springframework.data.repository.CrudRepository; 6 | 7 | /** 8 | * @author albert 9 | */ 10 | public interface TaskEntryRepository extends CrudRepository { 11 | Page findByUsernameAndCompleted(String username, boolean completed, Pageable pageable); 12 | } 13 | -------------------------------------------------------------------------------- /src/main/java/nl/avthart/todo/app/query/task/TaskEntryUpdatingEventHandler.java: -------------------------------------------------------------------------------- 1 | package nl.avthart.todo.app.query.task; 2 | 3 | import nl.avthart.todo.app.domain.task.events.TaskCompletedEvent; 4 | import nl.avthart.todo.app.domain.task.events.TaskCreatedEvent; 5 | import nl.avthart.todo.app.domain.task.events.TaskStarredEvent; 6 | import nl.avthart.todo.app.domain.task.events.TaskUnstarredEvent; 7 | import nl.avthart.todo.app.domain.task.events.TaskTitleModifiedEvent; 8 | 9 | import org.axonframework.eventhandling.EventHandler; 10 | import org.springframework.beans.factory.annotation.Autowired; 11 | import org.springframework.stereotype.Component; 12 | 13 | /** 14 | * @author albert 15 | */ 16 | @Component 17 | public class TaskEntryUpdatingEventHandler { 18 | 19 | private final TaskEntryRepository taskEntryRepository; 20 | 21 | @Autowired 22 | public TaskEntryUpdatingEventHandler(TaskEntryRepository taskEntryRepository) { 23 | this.taskEntryRepository = taskEntryRepository; 24 | } 25 | 26 | @EventHandler 27 | void on(TaskCreatedEvent event) { 28 | TaskEntry task = new TaskEntry(event.getId(), event.getUsername(), event.getTitle(), false, false); 29 | taskEntryRepository.save(task); 30 | } 31 | 32 | @EventHandler 33 | void on(TaskCompletedEvent event) { 34 | TaskEntry task = taskEntryRepository.findOne(event.getId()); 35 | task.setCompleted(true); 36 | 37 | taskEntryRepository.save(task); 38 | } 39 | 40 | @EventHandler 41 | void on(TaskTitleModifiedEvent event) { 42 | TaskEntry task = taskEntryRepository.findOne(event.getId()); 43 | task.setTitle(event.getTitle()); 44 | 45 | taskEntryRepository.save(task); 46 | } 47 | 48 | @EventHandler 49 | void on (TaskStarredEvent event) { 50 | TaskEntry task = taskEntryRepository.findOne(event.getId()); 51 | task.setStarred(true); 52 | 53 | taskEntryRepository.save(task); 54 | } 55 | 56 | @EventHandler 57 | void on (TaskUnstarredEvent event) { 58 | TaskEntry task = taskEntryRepository.findOne(event.getId()); 59 | task.setStarred(false); 60 | 61 | taskEntryRepository.save(task); 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /src/main/java/nl/avthart/todo/app/rest/task/TaskController.java: -------------------------------------------------------------------------------- 1 | package nl.avthart.todo.app.rest.task; 2 | 3 | import java.security.Principal; 4 | 5 | import javax.validation.Valid; 6 | 7 | import lombok.RequiredArgsConstructor; 8 | import nl.avthart.todo.app.domain.task.commands.CompleteTaskCommand; 9 | import nl.avthart.todo.app.domain.task.commands.CreateTaskCommand; 10 | import nl.avthart.todo.app.domain.task.commands.ModifyTaskTitleCommand; 11 | import nl.avthart.todo.app.domain.task.commands.StarTaskCommand; 12 | import nl.avthart.todo.app.query.task.TaskEntry; 13 | import nl.avthart.todo.app.query.task.TaskEntryRepository; 14 | import nl.avthart.todo.app.rest.task.requests.CreateTaskRequest; 15 | import nl.avthart.todo.app.rest.task.requests.ModifyTitleRequest; 16 | 17 | import org.axonframework.commandhandling.gateway.CommandGateway; 18 | import org.axonframework.common.IdentifierFactory; 19 | import org.springframework.beans.factory.annotation.Autowired; 20 | import org.springframework.data.domain.Page; 21 | import org.springframework.data.domain.Pageable; 22 | import org.springframework.http.HttpStatus; 23 | import org.springframework.messaging.simp.SimpMessageSendingOperations; 24 | import org.springframework.web.bind.annotation.ExceptionHandler; 25 | import org.springframework.web.bind.annotation.PathVariable; 26 | import org.springframework.web.bind.annotation.RequestBody; 27 | import org.springframework.web.bind.annotation.RequestMapping; 28 | import org.springframework.web.bind.annotation.RequestMethod; 29 | import org.springframework.web.bind.annotation.RequestParam; 30 | import org.springframework.web.bind.annotation.ResponseBody; 31 | import org.springframework.web.bind.annotation.ResponseStatus; 32 | import org.springframework.web.bind.annotation.RestController; 33 | 34 | /** 35 | * @author albert 36 | */ 37 | @RestController 38 | @RequiredArgsConstructor 39 | public class TaskController { 40 | 41 | private final IdentifierFactory identifierFactory = IdentifierFactory.getInstance(); 42 | 43 | private final TaskEntryRepository taskEntryRepository; 44 | 45 | private final SimpMessageSendingOperations messagingTemplate; 46 | 47 | private final CommandGateway commandGateway; 48 | 49 | @RequestMapping(value = "/api/tasks", method = RequestMethod.GET) 50 | public @ResponseBody 51 | Page findAll(Principal principal, @RequestParam(required = false, defaultValue = "false") boolean completed, Pageable pageable) { 52 | return taskEntryRepository.findByUsernameAndCompleted(principal.getName(), completed, pageable); 53 | } 54 | 55 | @RequestMapping(value = "/api/tasks", method = RequestMethod.POST) 56 | @ResponseStatus(value = HttpStatus.NO_CONTENT) 57 | public void createTask(Principal principal, @RequestBody @Valid CreateTaskRequest request) { 58 | commandGateway.send(new CreateTaskCommand(identifierFactory.generateIdentifier(), principal.getName(), request.getTitle())); 59 | } 60 | 61 | @RequestMapping(value = "/api/tasks/{identifier}/title", method = RequestMethod.POST) 62 | @ResponseStatus(value = HttpStatus.NO_CONTENT) 63 | public void createTask(@PathVariable String identifier, @RequestBody @Valid ModifyTitleRequest request) { 64 | commandGateway.send(new ModifyTaskTitleCommand(identifier, request.getTitle())); 65 | } 66 | 67 | @RequestMapping(value = "/api/tasks/{identifier}/complete", method = RequestMethod.POST) 68 | @ResponseStatus(value = HttpStatus.NO_CONTENT) 69 | public void createTask(@PathVariable String identifier) { 70 | commandGateway.send(new CompleteTaskCommand(identifier)); 71 | } 72 | 73 | @RequestMapping(value = "/api/tasks/{identifier}/star", method = RequestMethod.POST) 74 | @ResponseStatus(value = HttpStatus.NO_CONTENT) 75 | public void starTask(@PathVariable String identifier) { 76 | commandGateway.send(new StarTaskCommand(identifier)); 77 | } 78 | 79 | @RequestMapping(value = "/api/tasks/{identifier}/unstar", method = RequestMethod.POST) 80 | @ResponseStatus(value = HttpStatus.NO_CONTENT) 81 | public void unstarTask(@PathVariable String identifier) { 82 | throw new RuntimeException("Could not unstar task..."); 83 | //commandGateway.sendAndWait(new UnstarTaskCommand(identifier)); 84 | } 85 | 86 | @ExceptionHandler 87 | public void handleException(Principal principal, Throwable exception) { 88 | messagingTemplate.convertAndSendToUser(principal.getName(), "/queue/errors", exception.getMessage()); 89 | } 90 | 91 | } 92 | -------------------------------------------------------------------------------- /src/main/java/nl/avthart/todo/app/rest/task/requests/CreateTaskRequest.java: -------------------------------------------------------------------------------- 1 | package nl.avthart.todo.app.rest.task.requests; 2 | 3 | import javax.validation.constraints.NotNull; 4 | 5 | /** 6 | * @author albert 7 | */ 8 | public class CreateTaskRequest { 9 | 10 | @NotNull 11 | private String title; 12 | 13 | public void setTitle(String title) { 14 | this.title = title; 15 | } 16 | 17 | public String getTitle() { 18 | return title; 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /src/main/java/nl/avthart/todo/app/rest/task/requests/ModifyTitleRequest.java: -------------------------------------------------------------------------------- 1 | package nl.avthart.todo.app.rest.task.requests; 2 | 3 | import javax.validation.constraints.NotNull; 4 | 5 | /** 6 | * @author albert 7 | */ 8 | public class ModifyTitleRequest { 9 | 10 | @NotNull 11 | private String title; 12 | 13 | public void setTitle(String title) { 14 | this.title = title; 15 | } 16 | 17 | public String getTitle() { 18 | return title; 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /src/main/resources/application.yml: -------------------------------------------------------------------------------- 1 | spring: 2 | datasource: 3 | url: jdbc:h2:file:./target/h2 4 | username: sa 5 | password: 6 | driver-class-name: org.h2.Driver 7 | jpa: 8 | generate-ddl: true -------------------------------------------------------------------------------- /src/main/webapp/css/toaster.css: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/avthart/spring-boot-axon-sample/b79cd81200b4f43bdade566ee1ba0e470a9eacc8/src/main/webapp/css/toaster.css -------------------------------------------------------------------------------- /src/main/webapp/img/bg-wood.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/avthart/spring-boot-axon-sample/b79cd81200b4f43bdade566ee1ba0e470a9eacc8/src/main/webapp/img/bg-wood.jpg -------------------------------------------------------------------------------- /src/main/webapp/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | Todo App 4 | 5 | 6 | 7 | 8 | 9 | 19 | 20 | 21 | 22 | 38 |
39 |
40 |
41 |
42 |
43 |

Tasks

44 |
45 |
46 |
    47 |
  • 48 |
    49 | 50 |
    51 |
  • 52 |
  • 53 | 54 | {{task.title}} 55 | 56 | 57 |
  • 58 |
59 |
60 |

{{completedTasks.length}} completed tasks

61 |
    62 |
  • 63 | 64 | {{task.title}} 65 | 66 | 67 |
  • 68 |
69 |
70 |
71 |
72 |
73 |
74 |
75 |
76 |
77 | 78 |
79 |
80 |
81 | 95 |
96 |
97 |
98 |
99 |
100 | 101 | 102 | 103 | 104 | 105 | 106 | 107 | 108 | 109 | 110 | 111 | 112 | 113 | 114 | -------------------------------------------------------------------------------- /src/main/webapp/js/app.js: -------------------------------------------------------------------------------- 1 | angular.module('todo-app', [ 'toaster' ]); 2 | 3 | function TaskCtrl($scope, $http, $timeout, toaster) { 4 | $scope.tasks = []; 5 | $scope.completedTasks = []; 6 | 7 | var socket = new SockJS('/tasks'); 8 | stompClient = Stomp.over(socket); 9 | stompClient.connect({}, function(frame) { 10 | stompClient.subscribe('/user/queue/task-updates', function(event) { 11 | var taskEvent = JSON.parse(event.body); 12 | toaster.pop('success', taskEvent.type, taskEvent.data); 13 | if (taskEvent.type == "TaskCreatedEvent") { 14 | var task = { 'id': taskEvent.data.id, 'title': taskEvent.data.title, 'completed': false, 'starred': false }; 15 | $scope.tasks.unshift(task); 16 | } else if (taskEvent.type == "TaskCompletedEvent") { 17 | var key = getTaskKey($scope.tasks, taskEvent.data.id); 18 | var task = $scope.tasks[key]; 19 | task.completed = true; 20 | $scope.completedTasks.unshift(task); 21 | $scope.tasks.splice(key, 1); 22 | } else if (taskEvent.type == "TaskStarredEvent") { 23 | var key = getTaskKey($scope.tasks, taskEvent.data.id); 24 | $scope.tasks[key].starred = true; 25 | } else if (taskEvent.type == "TaskUnstarredEvent") { 26 | var key = getTaskKey($scope.tasks, taskEvent.data.id); 27 | $scope.tasks[key].starred = false; 28 | } else { 29 | // modify data for other events (such as modify title, etc) 30 | var key = getTaskKey($scope.tasks, taskEvent.data.id); 31 | angular.extend($scope.tasks[key], taskEvent.data); 32 | } 33 | 34 | $scope.$apply(); 35 | }); 36 | stompClient.subscribe('/user/queue/errors', function(event) { 37 | toaster.pop('error', "Error", event.body); 38 | $scope.$apply(); 39 | }); 40 | }); 41 | 42 | $scope.loadTasks = function() { 43 | $scope.taskDetails = null; 44 | $http.get('api/tasks/').then(function(response) { 45 | angular.copy(response.data.content, $scope.tasks); 46 | }); 47 | $http.get('api/tasks/?completed=true').then(function(response) { 48 | angular.copy(response.data.content, $scope.completedTasks); 49 | }); 50 | }; 51 | 52 | $scope.addTask = function() { 53 | $http.post('api/tasks/', $scope.newTask).then(function(response) { 54 | $scope.newTask = ''; 55 | }); 56 | }; 57 | 58 | $scope.starTask = function(task) { 59 | $http.post('api/tasks/' + task.id + '/star'); 60 | }; 61 | 62 | $scope.unstarTask = function(task) { 63 | $http.post('api/tasks/' + task.id + '/unstar'); 64 | }; 65 | 66 | $scope.completeTask = function(task) { 67 | $http.post('api/tasks/' + task.id + '/complete'); 68 | }; 69 | 70 | $scope.showTaskDetails = function(task) { 71 | $scope.taskDetails = angular.copy(task); 72 | }; 73 | 74 | $scope.modifyTaskTitle = function(task) { 75 | $http.post('api/tasks/' + task.id + '/title', { 76 | 'title' : $scope.taskDetails.title 77 | }).then(function(response) { 78 | $scope.taskDetails = null; 79 | }); 80 | }; 81 | 82 | $scope.loadTasks(); 83 | }; 84 | 85 | function getTaskKey(tasks, id) { 86 | var taskKey; 87 | angular.forEach(tasks, function(value, key) { 88 | if (value.id == id) { 89 | taskKey = key; 90 | } 91 | }); 92 | return taskKey; 93 | } -------------------------------------------------------------------------------- /src/main/webapp/js/toaster.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | /* 4 | * AngularJS Toaster 5 | * Version: 0.4.3 6 | * 7 | * Copyright 2013 Jiri Kavulak. 8 | * All Rights Reserved. 9 | * Use, reproduction, distribution, and modification of this code is subject to the terms and 10 | * conditions of the MIT license, available at http://www.opensource.org/licenses/mit-license.php 11 | * 12 | * Author: Jiri Kavulak 13 | * Related to project of John Papa and Hans Fjällemark 14 | */ 15 | 16 | angular.module('toaster', ['ngAnimate']) 17 | .service('toaster', ['$rootScope', function ($rootScope) { 18 | this.pop = function (type, title, body, timeout, bodyOutputType) { 19 | this.toast = { 20 | type: type, 21 | title: title, 22 | body: body, 23 | timeout: timeout, 24 | bodyOutputType: bodyOutputType 25 | }; 26 | $rootScope.$broadcast('toaster-newToast'); 27 | }; 28 | 29 | this.clear = function () { 30 | $rootScope.$broadcast('toaster-clearToasts'); 31 | }; 32 | }]) 33 | .constant('toasterConfig', { 34 | 'limit': 0, // limits max number of toasts 35 | 'tap-to-dismiss': true, 36 | 'newest-on-top': true, 37 | //'fade-in': 1000, // done in css 38 | //'on-fade-in': undefined, // not implemented 39 | //'fade-out': 1000, // done in css 40 | // 'on-fade-out': undefined, // not implemented 41 | //'extended-time-out': 1000, // not implemented 42 | 'time-out': 5000, // Set timeOut and extendedTimeout to 0 to make it sticky 43 | 'icon-classes': { 44 | error: 'toast-error', 45 | info: 'toast-info', 46 | success: 'toast-success', 47 | warning: 'toast-warning' 48 | }, 49 | 'body-output-type': '', // Options: '', 'trustedHtml', 'template' 50 | 'body-template': 'toasterBodyTmpl.html', 51 | 'icon-class': 'toast-info', 52 | 'position-class': 'toast-top-right', 53 | 'title-class': 'toast-title', 54 | 'message-class': 'toast-message' 55 | }) 56 | .directive('toasterContainer', ['$compile', '$timeout', '$sce', 'toasterConfig', 'toaster', 57 | function ($compile, $timeout, $sce, toasterConfig, toaster) { 58 | return { 59 | replace: true, 60 | restrict: 'EA', 61 | link: function (scope, elm, attrs) { 62 | 63 | var id = 0; 64 | 65 | var mergedConfig = toasterConfig; 66 | if (attrs.toasterOptions) { 67 | angular.extend(mergedConfig, scope.$eval(attrs.toasterOptions)); 68 | } 69 | 70 | scope.config = { 71 | position: mergedConfig['position-class'], 72 | title: mergedConfig['title-class'], 73 | message: mergedConfig['message-class'], 74 | tap: mergedConfig['tap-to-dismiss'] 75 | }; 76 | 77 | function addToast(toast) { 78 | toast.type = mergedConfig['icon-classes'][toast.type]; 79 | if (!toast.type) 80 | toast.type = mergedConfig['icon-class']; 81 | 82 | id++; 83 | angular.extend(toast, { id: id }); 84 | 85 | // Set the toast.bodyOutputType to the default if it isn't set 86 | toast.bodyOutputType = toast.bodyOutputType || mergedConfig['body-output-type'] 87 | switch (toast.bodyOutputType) { 88 | case 'trustedHtml': 89 | toast.html = $sce.trustAsHtml(toast.body); 90 | break; 91 | case 'template': 92 | toast.bodyTemplate = mergedConfig['body-template']; 93 | break; 94 | } 95 | 96 | var timeout = typeof (toast.timeout) == "number" ? toast.timeout : mergedConfig['time-out']; 97 | if (timeout > 0) 98 | setTimeout(toast, timeout); 99 | 100 | if (mergedConfig['newest-on-top'] === true) { 101 | scope.toasters.unshift(toast); 102 | if (mergedConfig['limit'] > 0 && scope.toasters.length > mergedConfig['limit']) { 103 | scope.toasters.pop(); 104 | } 105 | } else { 106 | scope.toasters.push(toast); 107 | if (mergedConfig['limit'] > 0 && scope.toasters.length > mergedConfig['limit']) { 108 | scope.toasters.shift(); 109 | } 110 | } 111 | } 112 | 113 | function setTimeout(toast, time) { 114 | toast.timeout = $timeout(function () { 115 | scope.removeToast(toast.id); 116 | }, time); 117 | } 118 | 119 | scope.toasters = []; 120 | scope.$on('toaster-newToast', function () { 121 | addToast(toaster.toast); 122 | }); 123 | 124 | scope.$on('toaster-clearToasts', function () { 125 | scope.toasters.splice(0, scope.toasters.length); 126 | }); 127 | }, 128 | controller: ['$scope', '$element', '$attrs', function ($scope, $element, $attrs) { 129 | 130 | $scope.stopTimer = function (toast) { 131 | if (toast.timeout) 132 | $timeout.cancel(toast.timeout); 133 | }; 134 | 135 | $scope.removeToast = function (id) { 136 | var i = 0; 137 | for (i; i < $scope.toasters.length; i++) { 138 | if ($scope.toasters[i].id === id) 139 | break; 140 | } 141 | $scope.toasters.splice(i, 1); 142 | }; 143 | 144 | $scope.remove = function (id) { 145 | if ($scope.config.tap === true) { 146 | $scope.removeToast(id); 147 | } 148 | }; 149 | }], 150 | template: 151 | '
' + 152 | '
' + 153 | '
{{toaster.title}}
' + 154 | '
' + 155 | '
' + 156 | '
' + 157 | '
{{toaster.body}}
' + 158 | '
' + 159 | '
' + 160 | '
' 161 | }; 162 | }]); 163 | -------------------------------------------------------------------------------- /src/main/webapp/login.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Sign in · Todo App 6 | 7 | 8 | 9 | 10 | 23 |
24 |
25 |
26 |
27 |
28 |

Sign in

29 |
30 |
31 |
32 |
33 | 34 |
35 |
36 | 37 |
38 |
39 | 40 |
41 |

42 | Log in as albert/1234 or foo/bar 43 |

44 |
45 |
46 |
47 |
48 |
49 |
50 | 51 | --------------------------------------------------------------------------------