org.elder.sourcerer.DefaultCommand Maven / Gradle / Ivy
Go to download
Show more of this group Show more artifacts with this name
Show all versions of sourcerer-core Show documentation
Show all versions of sourcerer-core Show documentation
An opinionated framework for implementing an CQRS architecture using event sourcing
The newest version!
package org.elder.sourcerer;
import com.google.common.base.Preconditions;
import com.google.common.collect.ImmutableList;
import org.elder.sourcerer.exceptions.AtomicWriteException;
import org.elder.sourcerer.exceptions.ConflictingExpectedVersionsException;
import org.elder.sourcerer.exceptions.InvalidCommandException;
import org.elder.sourcerer.exceptions.UnexpectedVersionException;
import org.elder.sourcerer.utils.RetryHandler;
import org.elder.sourcerer.utils.RetryPolicy;
import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
/**
* Default Command implementation, expressed in terms of an AggregateRepository.
*/
public class DefaultCommand implements Command {
private static final Logger logger = LoggerFactory.getLogger(DefaultCommand.class);
private final AggregateRepository repository;
private final Map metadata = new HashMap<>();
private final Operation operation;
private final RetryPolicy retryPolicy;
private boolean atomic;
private boolean idempotentCreate = false;
private String aggregateId = null;
private Snapshot snapshot = null;
private TParams arguments = null;
private ExpectedVersion expectedVersion = null;
private List metadataDecorators = new ArrayList<>();
public DefaultCommand(
@NotNull final AggregateRepository repository,
@NotNull final Operation operation,
@NotNull final RetryPolicy retryPolicy
) {
Preconditions.checkNotNull(repository);
Preconditions.checkNotNull(operation);
this.repository = repository;
this.operation = operation;
this.atomic = operation.atomic();
this.retryPolicy = retryPolicy;
}
@Override
public Command setAggregateId(final String aggregateId) {
this.aggregateId = aggregateId;
return this;
}
@Override
public Command setSnapshot(final Snapshot snapshot) {
this.snapshot = snapshot;
return this;
}
@Override
public Command setArguments(final TParams arguments) {
this.arguments = arguments;
return this;
}
@Override
public Command setExpectedVersion(final ExpectedVersion version) {
this.expectedVersion = version;
return this;
}
@Override
public Command setAtomic(final boolean atomic) {
this.atomic = atomic;
return this;
}
@Override
public Command setIdempotentCreate(final boolean idempotentCreate) {
this.idempotentCreate = idempotentCreate;
this.atomic = true;
return this;
}
@Override
public Command addMetadata(final Map metadata) {
this.metadata.putAll(metadata);
return this;
}
@Override
public Command addMetadataDecorator(
final MetadataDecorator metadataDecorator) {
this.metadataDecorators.add(metadataDecorator);
return this;
}
public Command validate() {
if (aggregateId == null) {
throw new InvalidCommandException("No aggregate id specified");
}
if (operation.requiresArguments() && arguments == null) {
throw new InvalidCommandException(
"No arguments specified to command that requires arguments");
}
// Try to calculate effective expected version - will validate combinations
getEffectiveExpectedVersion(expectedVersion, operation.expectedVersion());
// TODO: Add more validation!
return this;
}
@Override
public CommandResult run() {
logger.debug("Running command on {}", aggregateId);
validate();
ExpectedVersion effectiveExpectedVersion =
getEffectiveExpectedVersion(expectedVersion, operation.expectedVersion());
logger.debug("Expected version set as {}", effectiveExpectedVersion);
RetryHandler retryHandler = new RetryHandler(retryPolicy);
while (true) {
try {
return performCommand(effectiveExpectedVersion);
} catch (AtomicWriteException awe) {
if (effectiveExpectedVersion.getType() == ExpectedVersionType.NOT_CREATED) {
// Expected aggregate to not exist, but now it does - retrying won't help.
throw awe;
}
retryHandler.failed();
logger.info(
"Failed attempt {}: Concurrent append to aggregate {}",
retryHandler.getNrFailures(),
aggregateId
);
if (retryHandler.isThresholdReached()) {
logger.warn("Reached max retries");
throw awe;
}
retryHandler.backOff();
}
}
}
@NotNull
private CommandResult performCommand(
final ExpectedVersion effectiveExpectedVersion
) {
// Read the aggregate if needed
ImmutableAggregate aggregate =
readExistingAggregate(effectiveExpectedVersion);
// Bail out early if idempotent create, and already present
if (idempotentCreate
&& aggregate != null
&& aggregate.sourceVersion() != Aggregate.VERSION_NOT_CREATED) {
logger.debug("Bailing out early as already created (and idempotent create set)");
return new CommandResult<>(
aggregateId,
aggregate.sourceVersion(),
aggregate.sourceVersion(),
ImmutableList.of());
}
// Execute the command handler
List extends TEvent> operationEvents = operation.execute(aggregate, arguments);
ImmutableList extends TEvent> events =
ImmutableList.copyOf(operationEvents.stream().iterator());
if (events.isEmpty()) {
logger.debug("Operation is no-op, bailing early");
return new CommandResult<>(
aggregateId,
aggregate != null ? aggregate.sourceVersion() : null,
aggregate != null ? aggregate.sourceVersion() : null,
events);
}
// Create/update the event stream as needed
return updateAggregate(aggregate, events);
}
@Nullable
private ImmutableAggregate readExistingAggregate(
final ExpectedVersion effectiveExpectedVersion
) {
ImmutableAggregate aggregate;
if (operation.requiresState() || atomic) {
logger.debug("Reading aggregate record from stream");
aggregate = readAndValidateAggregate(effectiveExpectedVersion);
logger.debug(
"Current state of aggregate is {}",
aggregate.sourceVersion() == Aggregate.VERSION_NOT_CREATED
? ""
: "version " + aggregate.sourceVersion());
return aggregate;
} else {
logger.debug("Aggregate state not loaded");
return null;
}
}
@NotNull
private CommandResult updateAggregate(
@Nullable final ImmutableAggregate aggregate,
@NotNull final ImmutableList extends TEvent> events
) {
ExpectedVersion updateExpectedVersion;
if (atomic) {
// Actually null safe since atomic above ...
if (aggregate.sourceVersion() != Aggregate.VERSION_NOT_CREATED) {
updateExpectedVersion = ExpectedVersion.exactly(aggregate.sourceVersion());
} else {
updateExpectedVersion = ExpectedVersion.notCreated();
}
} else if (idempotentCreate) {
updateExpectedVersion = ExpectedVersion.notCreated();
} else {
updateExpectedVersion = ExpectedVersion.any();
}
// TODO: Handle any existing condition in event store - for now we know it's existing if
// it was existing
if (updateExpectedVersion.getType() == ExpectedVersionType.ANY_EXISTING) {
updateExpectedVersion = ExpectedVersion.any();
}
logger.debug("About to persist, expected version at save: {}", updateExpectedVersion);
Map effectiveMetadata = new HashMap<>(this.metadata);
for (MetadataDecorator metadataDecorator : metadataDecorators) {
Map decoratorMetadata = metadataDecorator.getMetadata();
if (decoratorMetadata != null) {
effectiveMetadata.putAll(decoratorMetadata);
}
}
effectiveMetadata.putAll(this.metadata);
try {
int newVersion = repository.append(
aggregateId,
events,
updateExpectedVersion,
effectiveMetadata);
// It may be nice to sanity check here by using the expected version explicitly, but
// this works regardless of whether we have a specific expected version ...
// Will return -1 if we just created the stream, which is fine
int oldVersion = newVersion - events.size();
logger.debug("Save successful, new version is {}", newVersion);
return new CommandResult<>(aggregateId, oldVersion, newVersion, events);
} catch (UnexpectedVersionException ex) {
// There's one case when this is OK - idempotent creates. We want to be able to create
// a stream and not fail if the same stream is attempted to be created on replays.
if (idempotentCreate) {
logger.debug("Idempotent create enabled, ignoring existing stream");
return new CommandResult<>(
aggregateId,
ex.getCurrentVersion(),
ex.getCurrentVersion(),
ImmutableList.of());
} else if (atomic) {
throw new AtomicWriteException(ex);
}
throw ex;
}
}
private static ExpectedVersion getEffectiveExpectedVersion(
final ExpectedVersion commandExpectedVersion,
final ExpectedVersion operationExpectedVersion) {
try {
return ExpectedVersion.merge(commandExpectedVersion, operationExpectedVersion);
} catch (ConflictingExpectedVersionsException ex) {
throw new InvalidCommandException(
"Conflicting expected version constraints: " + ex.getMessage(),
ex);
}
}
@NotNull
private ImmutableAggregate readAndValidateAggregate(
final ExpectedVersion effectiveExpectedVersion) {
ImmutableAggregate aggregate = loadAggregate();
// Validate expected version early if we have state
switch (effectiveExpectedVersion.getType()) {
case ANY:
break;
case ANY_EXISTING:
if (aggregate.sourceVersion() == Aggregate.VERSION_NOT_CREATED) {
throw new UnexpectedVersionException(
aggregate.sourceVersion(),
effectiveExpectedVersion);
}
break;
case EXACTLY:
if (aggregate.sourceVersion() != effectiveExpectedVersion.getExpectedVersion()) {
throw new UnexpectedVersionException(
aggregate.sourceVersion(),
effectiveExpectedVersion);
}
break;
case NOT_CREATED:
if (aggregate.sourceVersion() != Aggregate.VERSION_NOT_CREATED
&& !idempotentCreate) {
throw new UnexpectedVersionException(
aggregate.sourceVersion(),
effectiveExpectedVersion);
}
break;
default:
throw new IllegalArgumentException(
"Unrecognized expected version type " + effectiveExpectedVersion.getType());
}
return aggregate;
}
private ImmutableAggregate loadAggregate() {
if (snapshot != null) {
return repository.loadFromSnapshot(aggregateId, snapshot);
} else {
return repository.load(aggregateId);
}
}
}
© 2015 - 2025 Weber Informatics LLC | Privacy Policy