diff --git a/build.gradle b/build.gradle index 4bc7fef..cbd26d8 100644 --- a/build.gradle +++ b/build.gradle @@ -1,4 +1,5 @@ plugins { + id 'io.franzbecker.gradle-lombok' version '3.1.0' id 'org.springframework.boot' version '2.2.0.M4' id 'groovy' id 'idea' @@ -19,6 +20,11 @@ dependencies { implementation 'org.springframework.boot:spring-boot-starter-actuator' implementation 'org.springframework.boot:spring-boot-starter-webflux' implementation 'org.codehaus.groovy:groovy' + implementation 'io.vavr:vavr:0.10.1' + + annotationProcessor('org.projectlombok:lombok') + testCompileOnly('org.projectlombok:lombok') + testImplementation('org.springframework.boot:spring-boot-starter-test') { exclude group: 'org.junit.vintage', module: 'junit-vintage-engine' exclude group: 'junit', module: 'junit' diff --git a/src/main/groovy/com/github/daggerok/eventsourcing/user/InMemoryUserRepository.java b/src/main/groovy/com/github/daggerok/eventsourcing/user/InMemoryUserRepository.java new file mode 100644 index 0000000..d826afd --- /dev/null +++ b/src/main/groovy/com/github/daggerok/eventsourcing/user/InMemoryUserRepository.java @@ -0,0 +1,33 @@ +package com.github.daggerok.eventsourcing.user; + +import com.github.daggerok.eventsourcing.user.event.DomainEvent; + +import java.util.ArrayList; +import java.util.Collection; +import java.util.Map; +import java.util.UUID; +import java.util.concurrent.ConcurrentHashMap; +import java.util.stream.Collectors; +import java.util.stream.Stream; + +public class InMemoryUserRepository implements UserRepository { + + private final Map> eventStore = new ConcurrentHashMap<>(); + + @Override + public void save(User user) { + Collection domainEvents = eventStore.getOrDefault(user.getUserId(), new ArrayList<>()); + Collection newEvents = Stream.concat(domainEvents.stream(), user.getEventStream().stream()) + .collect(Collectors.toList()); + eventStore.put(user.getUserId(), newEvents); + user.flushEvents(); + } + + @Override + public User find(UUID userId) { + User snapshot = new User(userId); + return eventStore.containsKey(userId) + ? User.recreate(snapshot, eventStore.get(userId)) + : snapshot; + } +} diff --git a/src/main/groovy/com/github/daggerok/eventsourcing/user/User.java b/src/main/groovy/com/github/daggerok/eventsourcing/user/User.java new file mode 100644 index 0000000..4da66f9 --- /dev/null +++ b/src/main/groovy/com/github/daggerok/eventsourcing/user/User.java @@ -0,0 +1,92 @@ +package com.github.daggerok.eventsourcing.user; + +import com.github.daggerok.eventsourcing.user.event.DomainEvent; +import com.github.daggerok.eventsourcing.user.event.UserActivated; +import com.github.daggerok.eventsourcing.user.event.UserDeactivated; +import io.vavr.API; +import lombok.Getter; +import lombok.ToString; + +import java.time.ZonedDateTime; +import java.util.Collection; +import java.util.UUID; +import java.util.concurrent.CopyOnWriteArrayList; +import java.util.function.Function; + +import static io.vavr.API.$; +import static io.vavr.API.Case; +import static io.vavr.Predicates.instanceOf; + +/** + * Created user can be: + * - activated + * - deactivated + * + * Activated user can be: + * - deactivated + * Activated user cannot be: + * - activated + * + * Deactivated user can be: + * - activated + * Deactivated user cannot be: + * - deactivated + */ +@Getter +@ToString +public class User implements Function { + + private final Collection eventStream = new CopyOnWriteArrayList<>(); + private final UUID userId; + private UserStatus state; + + public User(UUID userId) { + this.userId = userId; + state = UserStatus.PENDING; + } + + public void flushEvents() { + eventStream.clear(); + } + + // cmd 1: + public void activate() { + if (state == UserStatus.ACTIVE) + throw new IllegalStateException("user is already active"); + onActivate(new UserActivated(userId, ZonedDateTime.now())); + } + // evt: 1 + private User onActivate(UserActivated event) { + eventStream.add(event); + state = UserStatus.ACTIVE; + return this; + } + + // cmd 2: + public void deactivate() { + if (state == UserStatus.SUSPENDED) + throw new IllegalStateException("user is already suspended"); + onDeactivate(new UserDeactivated(userId, ZonedDateTime.now())); + } + // evt 2: + private User onDeactivate(UserDeactivated event) { + eventStream.add(event); + state = UserStatus.SUSPENDED; + return this; + } + + /* es */ + + public static User recreate(User snapshot, Collection domainEvents) { + return io.vavr.collection.List.ofAll(domainEvents) + .foldLeft(snapshot, User::apply); + } + + @Override + public User apply(DomainEvent domainEvent) { + return API.Match(domainEvent).of( + Case($(instanceOf(UserActivated.class)), this::onActivate), + Case($(instanceOf(UserDeactivated.class)), this::onDeactivate) + ); + } +} diff --git a/src/main/groovy/com/github/daggerok/eventsourcing/user/UserRepository.java b/src/main/groovy/com/github/daggerok/eventsourcing/user/UserRepository.java new file mode 100644 index 0000000..a78eade --- /dev/null +++ b/src/main/groovy/com/github/daggerok/eventsourcing/user/UserRepository.java @@ -0,0 +1,8 @@ +package com.github.daggerok.eventsourcing.user; + +import java.util.UUID; + +public interface UserRepository { + void save(User user); + User find(UUID userId); +} diff --git a/src/main/groovy/com/github/daggerok/eventsourcing/user/UserStatus.java b/src/main/groovy/com/github/daggerok/eventsourcing/user/UserStatus.java new file mode 100644 index 0000000..efcde72 --- /dev/null +++ b/src/main/groovy/com/github/daggerok/eventsourcing/user/UserStatus.java @@ -0,0 +1,5 @@ +package com.github.daggerok.eventsourcing.user; + +public enum UserStatus { + PENDING, ACTIVE, SUSPENDED +} diff --git a/src/main/groovy/com/github/daggerok/eventsourcing/user/event/DomainEvent.java b/src/main/groovy/com/github/daggerok/eventsourcing/user/event/DomainEvent.java new file mode 100644 index 0000000..016b208 --- /dev/null +++ b/src/main/groovy/com/github/daggerok/eventsourcing/user/event/DomainEvent.java @@ -0,0 +1,9 @@ +package com.github.daggerok.eventsourcing.user.event; + +import java.time.ZonedDateTime; +import java.util.UUID; + +public interface DomainEvent { + UUID getAggregateId(); + ZonedDateTime getAt(); +} diff --git a/src/main/groovy/com/github/daggerok/eventsourcing/user/event/UserActivated.java b/src/main/groovy/com/github/daggerok/eventsourcing/user/event/UserActivated.java new file mode 100644 index 0000000..00ecf47 --- /dev/null +++ b/src/main/groovy/com/github/daggerok/eventsourcing/user/event/UserActivated.java @@ -0,0 +1,15 @@ +package com.github.daggerok.eventsourcing.user.event; + +import lombok.*; + +import java.time.ZonedDateTime; +import java.util.UUID; + +@Getter +@ToString +@AllArgsConstructor +@NoArgsConstructor(access = AccessLevel.PACKAGE) +public class UserActivated implements DomainEvent { + UUID aggregateId; + ZonedDateTime at; +} diff --git a/src/main/groovy/com/github/daggerok/eventsourcing/user/event/UserDeactivated.java b/src/main/groovy/com/github/daggerok/eventsourcing/user/event/UserDeactivated.java new file mode 100644 index 0000000..19c3517 --- /dev/null +++ b/src/main/groovy/com/github/daggerok/eventsourcing/user/event/UserDeactivated.java @@ -0,0 +1,15 @@ +package com.github.daggerok.eventsourcing.user.event; + +import lombok.*; + +import java.time.ZonedDateTime; +import java.util.UUID; + +@Getter +@ToString +@AllArgsConstructor +@NoArgsConstructor(access = AccessLevel.PACKAGE) +public class UserDeactivated implements DomainEvent { + UUID aggregateId; + ZonedDateTime at; +} diff --git a/src/test/groovy/com/github/daggerok/eventsourcing/user/UserRepositoryTest.groovy b/src/test/groovy/com/github/daggerok/eventsourcing/user/UserRepositoryTest.groovy new file mode 100644 index 0000000..5cda1a5 --- /dev/null +++ b/src/test/groovy/com/github/daggerok/eventsourcing/user/UserRepositoryTest.groovy @@ -0,0 +1,34 @@ +package com.github.daggerok.eventsourcing.user + +import spock.lang.Specification + +class UserRepositoryTest extends Specification { + + def userRepository = new InMemoryUserRepository() + + def 'save user operation should flush user eventStream'() { + given: + def user = new User(UUID.randomUUID()) + and: + user.activate() + when: + userRepository.save(user) + then: + user.eventStream.size() == 0 + } + + def 'find operation should recreate user state'() { + given: + def userId = UUID.randomUUID() + and: + def user = new User(userId) + and: + user.activate() + and: + userRepository.save(user) + when: + def recreatedUser = userRepository.find(userId) + then: + recreatedUser.state == UserStatus.ACTIVE + } +} diff --git a/src/test/groovy/com/github/daggerok/eventsourcing/user/UserTest.groovy b/src/test/groovy/com/github/daggerok/eventsourcing/user/UserTest.groovy new file mode 100644 index 0000000..6aead8e --- /dev/null +++ b/src/test/groovy/com/github/daggerok/eventsourcing/user/UserTest.groovy @@ -0,0 +1,55 @@ +package com.github.daggerok.eventsourcing.user + +import spock.lang.Specification + +class UserTest extends Specification { + + def 'created user should have pending state'() { + when: + def user = new User(UUID.randomUUID()) + then: + user.state == UserStatus.PENDING + } + + def 'active user should not be activated'() { + given: + def user = new User(UUID.randomUUID()) + and: + user.activate() + when: + user.activate() + then: + thrown(IllegalStateException) + } + + def 'created user can be activated'() { + given: + def user = new User(UUID.randomUUID()) + when: + user.activate() + then: + user.state == UserStatus.ACTIVE + } + + def 'suspended user cannot be deactivated'() { + given: + def user = new User(UUID.randomUUID()) + and: + user.deactivate() + when: + user.deactivate() + then: + thrown(IllegalStateException) + } + + def 'active user can be deactivated'() { + given: + def user = new User(UUID.randomUUID()) + and: + user.activate() + when: + user.deactivate() + then: + user.state == UserStatus.SUSPENDED + } +}