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.
One Project, from creation to rename
Section titled “One Project, from creation to rename”A Project has an identity and editable details. Start with only the name the product needs:
@Model@Withpublic record Project(@EntityId ProjectId projectId, ProjectDetails details) {}
@Withpublic record ProjectDetails(@NotBlank String name) {}
public final class ProjectId extends Id<Project> { public ProjectId(String id) { super(id); }}@Modeldata class Project(@EntityId val projectId: ProjectId, val details: ProjectDetails)
data class ProjectDetails(@field:NotBlank val name: String)
class ProjectId(id: String) : Id<Project>(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.
Express the change as an action
Section titled “Express the change as an action”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)); }}data class CreateProject(val projectId: ProjectId, @field:Valid val details: ProjectDetails) { @Apply fun apply() = Project(projectId, details)}
data class RenameProject(val projectId: ProjectId, @field:NotBlank val name: String) { @Apply fun apply(project: Project) = project.copy(details = project.details.copy(name = 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.
Validate input, then check business rules
Section titled “Validate input, then check business rules”@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:
@AssertLegalvoid assertOwner(Project project, Sender sender) { if (!project.ownerId().equals(sender.userId())) { throw new UnauthorizedException("Not allowed to rename project"); }}@AssertLegalfun assertOwner(project: Project, sender: Sender) { if (project.ownerId != sender.userId()) { throw 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.
Choose details, configuration and state
Section titled “Choose details, configuration and state”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 field | Where it belongs | Example |
|---|---|---|
| Descriptive business data | A cohesive details value | ProjectDetails(name, description) |
| Desired policy | A focused settings value | NotificationSettings |
| Identity | On the Model, with a typed ID | @EntityId ProjectId projectId |
| Relationships | An explicit typed reference | ownerId or @Parent workspaceId |
| Current status | A field, or a state value grouping one invariant | archived, 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.
Register the command owner
Section titled “Register the command owner”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.
Evolving an existing Model
Section titled “Evolving an existing Model”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