Skip to content

Commit

Permalink
feat(scenario): simplify scenario implemenation providing an in memor…
Browse files Browse the repository at this point in the history
…y repository and additional helper methods
  • Loading branch information
tmorin committed Mar 16, 2024
1 parent 593e36e commit c121527
Show file tree
Hide file tree
Showing 7 changed files with 389 additions and 23 deletions.
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
package io.morin.faggregate.core.validation;

import io.morin.faggregate.api.Context;
import io.morin.faggregate.api.Initializer;
import java.util.*;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.CompletionStage;
import lombok.Builder;
import lombok.NonNull;
import lombok.Value;

/**
* An in-memory repository is a repository that stores the state and the events in memory.
* <p>
* The purpose of this repository is to be used for testing and prototyping.
* Especially in conjunction with the scenario execution.
* So that, the scenario can be validated by the core business logic before to be used by the side effect implementations.
* <p>
* The initialization of the state can be handled by an initializer on load.
* The <code>initializedWhenNoFound</code> flag must be set to true.
* So that, the initializer will be called when the state is not found.
*
* @param <I> the type of the aggregate identifier
* @param <S> the type of the aggregate state
*/
@Value
@Builder
public class InMemoryRepository<I, S> implements Suite.Repository<I, S> {

/**
* The map to store the states.
*/
@Builder.Default
Map<I, S> statesMap = new HashMap<>();

/**
* The map to store the events.
*/
@Builder.Default
Map<I, List<Object>> eventsMap = new HashMap<>();

/**
* The flag to initialize the state when the state is not found.
* <p>
* The default value is false.
*/
boolean initializedWhenNoFound;

/**
* The initializer is called when the state is not found and the <code>initializedWhenNoFound</code> flag is set to true.
* <p>
* The default initializer throws an UnsupportedOperationException.
*/
@Builder.Default
Initializer<I, S> initializer = context -> {
throw new UnsupportedOperationException("Not implemented");
};

@Override
public <E> CompletableFuture<Void> persist(Context<I, ?> context, S state, List<E> events) {
// Delegate the store method
return this.storeState(context.getIdentifier(), state, events).toCompletableFuture();
}

@Override
@SuppressWarnings("unchecked")
public CompletableFuture<Optional<S>> load(Context<I, ?> context) {
// Load the state
return this.loadState(context.getIdentifier())
.thenApply(d -> (Optional<S>) Optional.ofNullable(d))
.thenCompose(optional -> {
// If the state is not found and the initializedWhenNoFound flag is set to true, initialize the state
if (optional.isEmpty() && initializedWhenNoFound) {
return initializer.initialize(context).thenApply(Optional::of);
}
return CompletableFuture.completedFuture(optional);
})
.toCompletableFuture();
}

@Override
public <E> CompletableFuture<Void> destroy(Context<I, ?> context, S state, List<E> events) {
// Remove the state
statesMap.remove(context.getIdentifier());

// Remove the events
eventsMap.remove(context.getIdentifier());

// Return a completed future
return CompletableFuture.completedFuture(null);
}

@Override
@SuppressWarnings("unchecked")
public CompletionStage<Void> storeState(
@NonNull Object identifier,
@NonNull Object state,
@NonNull List<?> events
) {
// Persist the state
statesMap.put((I) identifier, (S) state);

// Persist the events
eventsMap.computeIfAbsent((I) identifier, k -> new ArrayList<>()).addAll(events);

// Return a completed future
return CompletableFuture.completedStage(null);
}

@Override
public CompletionStage<Object> loadState(@NonNull Object identifier) {
// Load the state
return CompletableFuture.completedStage(statesMap.get(identifier));
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -76,7 +76,8 @@ public CompletionStage<Void> execute() {
return Optional
// GIVEN STEP - initialize the state of the aggregate with a given state
.ofNullable(scenario.getGiven().getState())
.map(state -> before.store(scenario.getGiven().getIdentifier(), state, scenario.getGiven().getEvents()))
.map(state -> before.storeState(scenario.getGiven().getIdentifier(), state, scenario.getGiven().getEvents())
)
.orElseGet(() -> CompletableFuture.completedStage(null))
// GIVEN STEP - mutate the aggregate with given commands
.thenAccept(unused -> {
Expand All @@ -94,7 +95,7 @@ public CompletionStage<Void> execute() {
// WHEN - create the outcome based on the fetch aggregate state and the output of the command
.thenCompose(output ->
after
.load(scenario.getGiven().getIdentifier())
.loadState(scenario.getGiven().getIdentifier())
.thenApply(currentState -> new Outcome(output, currentState))
)
// THEN - assert the outcome
Expand Down Expand Up @@ -139,7 +140,7 @@ public interface Before {
* @param events a set of initial domain events
* @return a completion stage
*/
CompletionStage<Void> store(@NonNull Object identifier, @NonNull Object state, @NonNull List<?> events);
CompletionStage<Void> storeState(@NonNull Object identifier, @NonNull Object state, @NonNull List<?> events);
}

/**
Expand All @@ -153,7 +154,7 @@ public interface After {
* @param identifier the identifier of the aggregate
* @return the state of the aggregate as a completion stage
*/
CompletionStage<Object> load(@NonNull Object identifier);
CompletionStage<Object> loadState(@NonNull Object identifier);
}

/**
Expand All @@ -168,7 +169,7 @@ static class Outcome {
final Output<?> output;

/**
* The state fetched using {@link After#load(Object)}.
* The state fetched using {@link After#loadState(Object)}.
*/
final Object state;
}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
package io.morin.faggregate.core.validation;

import io.morin.faggregate.api.AggregateManager;
import io.morin.faggregate.api.*;
import java.util.List;
import java.util.Optional;
import java.util.concurrent.CompletableFuture;
import java.util.function.Supplier;
import java.util.stream.Collectors;
import lombok.*;

Expand All @@ -14,14 +15,76 @@
@Builder
public class Suite {

/**
* The repository is a set of interfaces to store, load and destroy the state of the aggregate.
*
* @param <I> the type of the identifier
* @param <S> the type of the state
*/
public interface Repository<I, S>
extends Loader<I, S>, Persister<I, S>, Destroyer<I, S>, ScenarioExecutor.Before, ScenarioExecutor.After {
/**
* Apply the in-memory repository to the aggregate manager builder.
*
* @param builder the aggregate manager builder
* @return the aggregate manager builder
*/
default AggregateManagerBuilder<I, S> applyTo(@NonNull AggregateManagerBuilder<I, S> builder) {
builder.set((Loader<I, S>) this);
builder.set((Persister<I, S>) this);
builder.set((Destroyer<I, S>) this);
return builder;
}
}

/**
* The set of scenarios.
*/
@Singular
List<Scenario> scenarios;

/**
* Execute the scenarios sequentially.
* The supplier of the in-memory repository.
* <p>
* The default value is a supplier that creates a new instance of the in-memory repository.
*/
@Builder.Default
Supplier<Repository<?, ?>> repositorySupplier = () -> InMemoryRepository.builder().build();

/**
* Execute the scenarios sequentially based on the given Aggregate Manager Builder.
* <p>
* The method build the artifact manager associating side effect implementations to an in-memory repository.
* Moreover, the <i>before</i> and <i>after</i> lambda are implemented to store and load the state of the aggregate.
* This method is useful to validate the scenarios before to be used by the side effect implementations.
* That means to perform integration test on the core business logic, i.e. the {@link io.morin.faggregate.api.Handler}
* and the {@link io.morin.faggregate.api.Mutator}.
* <p>
* The in-memory repository implements the following interfaces:
* <ul>
* <li>{@link io.morin.faggregate.api.Persister}</li>
* <li>{@link io.morin.faggregate.api.Loader}</li>
* <li>{@link io.morin.faggregate.api.Destroyer}</li>
* <li>{@link io.morin.faggregate.core.validation.ScenarioExecutor.Before#storeState(Object, Object, List)}</li>
* <li>{@link io.morin.faggregate.core.validation.ScenarioExecutor.After#loadState(Object)}</li>
* </ul>
*
* @param amBuilder the Aggregate Manager Builder
* @param <I> The type of the identifier
* @param <S> The type of the state
* @return a completion stage
*/
@SuppressWarnings("unchecked")
public <I, S> CompletableFuture<Void> execute(@NonNull AggregateManagerBuilder<I, S> amBuilder) {
val repository = (Repository<I, S>) repositorySupplier.get();
return execute(repository.applyTo(amBuilder).build(), repository, repository);
}

/**
* Execute the scenarios sequentially based on the given Aggregate Manager.
* <p>
* This method is useful to validate the side effect implementations when the scenarios don't rely on both an initial
* state and the validation of the final state.
*
* @param am the Aggregate Manager
* @param <I> The type of the identifier
Expand All @@ -33,15 +96,20 @@ public <I> CompletableFuture<Void> execute(@NonNull AggregateManager<I> am) {
}

/**
* Execute the scenarios sequentially.
* Execute the scenarios sequentially based on the given Aggregate Manager.
* <p>
* This method is useful to validate the side effect implementations when the scenarios rely on either an initial
* state or the validation of the final state.
* <p>
* The before lambda is executed before the _Given_ phase.
* Its purpose is to store the state of the aggregate.
* As long as the state of the artifact is not provided during the _Given_ phase, the before lambda is not mandatory.
* As long as the state of the artifact is not provided during the <i>Given</i> phase (i.e. {@link io.morin.faggregate.core.validation.Scenario.Given#state}),
* the before lambda is not mandatory.
* <p>
* The after lambda is executed after the _Then_ phase.
* Its purpose is to load the state of the aggregate.
* As long as the state of the artifact is not validated during the _Then_ phase, the after lambda is not mandatory.
* As long as the state of the artifact is not validated during the <i>Then</i> phase (i.e. {@link io.morin.faggregate.core.validation.Scenario.Then#state}),
* the after lambda is not mandatory.
*
* @param am the Aggregate Manager
* @param before the optional before lambda
Expand Down
Loading

0 comments on commit c121527

Please sign in to comment.