Skip to content

Designing Model state

A Model represents something your users recognize and change: a project, order or booking. Choose its lifecycle first. Then group the values that belong to it. You do not need to choose documents, snapshots or Graph projections to make these domain decisions.

A Project has an identity and editable details. Start with only the name the product needs:

@Model
@With
public record Project(@EntityId ProjectId projectId, ProjectDetails details) {}
@With
public record ProjectDetails(@NotBlank String name) {}
public final class ProjectId extends Id<Project> {
public ProjectId(String id) { super(id); }
}

ProjectDetails is an ordinary immutable value owned by Project. It needs no entity annotations. Java’s @With is from Lombok; Kotlin uses copy. Public Java types go in separate files.

Creation supplies the details; a rename supplies only the new name:

public record CreateProject(@NotNull ProjectId projectId, @NotNull @Valid ProjectDetails details) {
@Apply
Project apply() { return new Project(projectId, details); }
}
public record RenameProject(@NotNull ProjectId projectId, @NotBlank String name) {
@Apply
Project apply(Project project) {
return project.withDetails(project.details().withName(name));
}
}

Send CreateProject(id, details) and then RenameProject(id, "Release") through Fluxzero. Automatic command handling loads the Project and stores the returned state. No forwarding handler is needed.

The rename copies the existing details. If you later add a description, renaming still preserves it. Command shape expresses the user’s action; it need not copy the shape of the stored Model.

@NotBlank rejects an empty name. @Valid asks validation to inspect the nested details. A constraint inside ProjectDetails alone does not enable that cascade. In Kotlin, use @field: for field constraints; required constructor values are non-null. Serialized invalid values can fail deserialization before validation.

Add @AssertLegal for rules that depend on current state or the authenticated user. For example, if Project also has an ownerId, a rename command can enforce ownership:

@AssertLegal
void assertOwner(Project project, Sender sender) {
if (!project.ownerId().equals(sender.userId())) {
throw new UnauthorizedException("Not allowed to rename project");
}
}

Here Sender is your authenticated User implementation, and creation assigns ownerId from that sender. Do not trust an owner identity supplied by an untrusted caller. The minimal Model above has no authorization policy; add the one your application needs before exposing writes.

For a rule combining new and retained fields, check the candidate state in @AssertLegal. The SDK does not validate every returned Model automatically. See payload validation.

Group values by domain meaning and shared validation, not by Java type or field count. Keep a details value even when it starts with only a name; do not add speculative fields to make the group larger.

Kind of fieldWhere it belongsExample
Descriptive business dataA cohesive details valueProjectDetails(name, description)
Desired policyA focused settings valueNotificationSettings
IdentityOn the Model, with a typed ID@EntityId ProjectId projectId
RelationshipsAn explicit typed referenceownerId or @Parent workspaceId
Current statusA field, or a state value grouping one invariantarchived, status, completedAt

A requested delivery date is business input; a last retry time is execution progress. Their meaning determines placement. A details object is not a bag for every field left over after the ID.

Choose the owner before the lifecycle boundary

Section titled “Choose the owner before the lifecycle boundary”

Keep independently created, changed or deleted domain state in its own Model, connected with @Parent where appropriate. A plain details value has no independent lifecycle: replacing it changes its owning Model. Use embedded members only when their lifecycle deliberately belongs to the containing root.

Distinguish domain facts from integration execution. A payment capture or refund obligation belongs in a Model; provider attempt IDs, idempotency keys and retry progress usually belong in a stateful workflow. Compose and update related Models show larger combinations.

Spring discovers Model command behavior within its configured scan. Outside Spring, register the owning types explicitly. Register writes in the application responsible for them: separate consumers can each execute the same command. Sharing a Model for reads does not require registering its write behavior everywhere.

See the registration walkthrough.

The grouping above is a modeling convention. Moving an already stored name to details.name changes serialized shape and query paths. Plan upcasting or migration instead of silently changing existing history. Migration tests help preserve values across that transition.

Continue with persistence and queries when you need to choose how this state is stored and read.


© 2026 Fluxzero