Skip to content

Updating entities

Fluxzero models domain state with entities that evolve by applying controlled updates. A group of related entities is called an aggregate (like an order and its line items). An aggregate is treated as a single unit for data changes.

Entities in an aggregate share a common root. The root is used as the entry point when updates are applied, ensuring that the aggregate’s consistency rules are preserved.

To define the root of an aggregate, annotate it with @Aggregate:

@Aggregate
@Builder(toBuilder = true)
public record UserAccount(@EntityId UserId userId,
UserProfile profile,
boolean accountClosed) {
}

This class models an aggregate with fields like profile and accountClosed.

An aggregate is a root entity that groups its state together with any nested child entities (declared via @Member). Like all entities, it defines its identity through a unique field annotated with @EntityId.

You’ll usually load an aggregate with Fluxzero.loadAggregate(...).

class UserId extends Id<UserAccount> {
public UserId(String value) {
super(value, "user-");
}
}
@Aggregate
public record UserAccount(@EntityId UserId userId) {
}
Entity<UserAccount> user = Fluxzero.loadAggregate(new UserId("1234"));

Once an aggregate is loaded it can be updated. Here’s a basic example of a command handler applying a CreateUser update:

public class UserCommandHandler {
@HandleCommand
void handle(CreateUser update) {
Fluxzero.loadAggregate(update.getUserId()).assertAndApply(update);
}
}

This loads the UserAccount entity by ID and applies the CreateUser payload after validation.

Here’s an example of two commands that update users (modelled as UserAccount) — one to create a user and another to update their profile:

public record CreateUser(UserId userId,
UserProfile profile) {
@AssertLegal
void assertNotExists(UserAccount current) {
throw new IllegalCommandException("Account already exists");
}
@Apply
UserAccount apply() {
return new UserAccount(userId, profile, false);
}
}
public record UpdateProfile(UserId userId,
UserProfile profile) {
@AssertLegal
void assertExists(@Nullable UserAccount current) {
if (current == null) {
throw new IllegalCommandException("Account not found");
}
}
@AssertLegal
void assertAccountNotClosed(UserAccount current) {
if (current.isAccountClosed()) {
throw new IllegalCommandException("Account is closed");
}
}
@Apply
UserAccount apply(UserAccount current) {
return current.toBuilder().profile(profile).build();
}
}

Use @InterceptApply to modify or suppress updates before validation and application.

@InterceptApply
Object ignoreNoChange(UserAccount current) {
if (current.getProfile().equals(profile)) {
return null; // no-op
}
return this;
}
@InterceptApply
UpdateProfile downgradeCommand(CreateUser command, UserAccount current) {
return new UpdateProfile(command.getUserId(), command.getProfile());
}
@InterceptApply
List<CreateTask> expandBulk(BulkCreateTasks bulk) {
return bulk.getTasks();
}

Update lifecycle steps:

  1. Intercept using @InterceptApply
  2. Assert preconditions using @AssertLegal
  3. Apply state using @Apply

Interception determines which payloads reach the assertion phase:

Interceptor outcomeAssertions and application
Retain the payloadIts matching immediate @AssertLegal methods run before @Apply
Suppress the payloadNeither its assertions nor its apply methods run
Replace the payloadOnly the replacement’s matching assertions and apply methods run
Split the payloadEach part’s immediate assertions and apply run in order; later parts see earlier changes

An assertion declared only for the original payload is therefore intentionally skipped after suppression or replacement. Put an invariant that must survive a rewrite on the effective replacement, or in shared or entity-side assertion logic that also matches it. @AssertLegal(afterHandler = true) keeps its documented deferred timing.

Return valueEffect
null or voidSuppress update
thisNo change
New update objectRewrite the update
Collection / Stream / OptionalEmit multiple updates

AnnotationPurposePhase
@InterceptApplyRewrite, suppress, or expand updatesPre-check
@AssertLegalValidate preconditionsValidation
@ApplyApply state transformationExecution

While it’s possible to implement domain logic in entities, it’s usually best to keep validation and transformation logic inside update classes (typically commands).

Advantages of update-based logic:

  • Each update owns its behavior
  • Entities remain focused on holding state
  • Features are easier to isolate and remove
  • Tests are simpler and more targeted

It’s possible to put validation and logic inside the aggregate itself — but this often leads to bloat:

@Aggregate
@Builder(toBuilder = true)
public record UserAccount(@EntityId UserId userId,
UserProfile profile,
boolean accountClosed) {
@AssertLegal
static void assertNotExists(CreateUser update, @Nullable UserAccount user) {
if (user != null) {
throw new IllegalCommandException("Account already exists");
}
}
@Apply
static UserAccount create(CreateUser update) {
return new UserAccount(update.getUserId(), update.getProfile(), false);
}
@AssertLegal
static void assertExists(UpdateProfile update, @Nullable UserAccount user) {
if (user == null) {
throw new IllegalCommandException("Account does not exist");
}
}
@AssertLegal
void assertAccountNotClosed(UpdateProfile update) {
if (accountClosed) {
throw new IllegalCommandException("Account is closed");
}
}
@Apply
UserAccount update(UpdateProfile update) {
return toBuilder().profile(update.getProfile()).build();
}
}

Fluxzero supports mixing both styles:

  • Use @AssertLegal on the update (command)
  • Use @Apply on the entity
  • Or vice versa

That said, keeping logic in the update tends to result in simpler, more testable, and easier-to-maintain code.


© 2026 Fluxzero