Skip to content

Building your first app

In this tutorial, we’ll build a simple to-do app that shows the core ideas of Fluxzero — one step at a time. We’ll start from a blank slate and add features like creating projects, managing tasks, querying data, and scheduling actions.

Each feature comes with a test, so you’ll see how Fluxzero works in practice, not just theory.

Ask your coding agent to implement each section. The tutorial also doubles as a tour of the real code behind the product.


To-do apps are simple and familiar, yet they cover all the essentials of a Fluxzero backend, like commands, queries and entities.

To familiarize yourself with core concepts in Fluxzero, first check them out, if you haven’t done so.


Have your coding agent create a new project called ‘Todo’ using the Basic Starter for Java or Kotlin (see Installation for how). We’ll use the following package layout:

io.fluxzero.todo
└── project
├── command // Commands like CreateProject, AssignTask
├── query // Queries like ListProjects
├── model // Entities, value objects, ID types
└── handler classes

Your coding agent can work in the project directly. Whenever you want to inspect or edit the code, IntelliJ IDEA provides a good Java and Kotlin experience.


Before we add code, let’s decide what kind of to-do app we want.

Our app should support multiple projects, each containing its own tasks. This lets users organize tasks into categories like Work, Groceries, or Side project.

That’s why we’ll model Project as our root entity. It gives us a clear entry point for commands and queries, and acts as a container for related tasks.

Later, we’ll add Tasks as nested entities inside a Project.


We’ll begin with a command that represents the intent to create a new Project. A command can be a simple record:

src/main/java/io/fluxzero/todo/project/command/CreateProject.java
public record CreateProject(ProjectId projectId,
ProjectDetails details) {
}

Most commands contain identifiers of the entities they target and some data.

Here we use a strongly typed ProjectId to identify our target entity. Strong IDs like ProjectId are preferred over raw strings or UUIDs. This id class extends from Id<T>:

src/main/java/io/fluxzero/todo/project/model/ProjectId.java
public final class ProjectId extends Id<Project> {
public ProjectId(String id) {
super(id);
}
}

This id class references its entity class Project. Let’s create that now and get back to it in a bit:

src/main/java/io/fluxzero/todo/project/model/Project.java
@Aggregate
public record Project(@EntityId ProjectId id,
ProjectDetails details,
UserId ownerId) {
}

Here:

  • the @Aggregate annotation tells Fluxzero that Project is the base point of a group of related entities. For instance our Project will later be given a list of Task entities.
  • the @EntityId marks the field that uniquely identifies each Project.

You can add any number of business rules to a command. These rules generally fall into three categories:

  • Constraint validations — required fields, min/max lengths, etc.
  • User access control — who is allowed to execute the command.
  • Invariants — what must be true before the command can succeed.

Let’s start by adding basic constraints using annotations:

public record CreateProject(@NotNull ProjectId projectId,
@NotNull @Valid ProjectDetails details) {
}

We’ve added:

  • @NotNull to ensure both fields are present.
  • @Valid to cascade validation into the ProjectDetails value object.

Let’s define ProjectDetails next:

src/main/java/io/fluxzero/todo/project/model/ProjectDetails.java
public record ProjectDetails(@NotBlank String name,
@Size(max = 1000) String description) {
}

This makes sure every Project has a name and an optional description, up to 1000 characters.


To limit who can create projects we can require users to have the role of MANAGER:

@RequiresRole(Role.MANAGER)
public record CreateProject(@NotNull ProjectId projectId,
@NotNull @Valid ProjectDetails details) {
}

You can define any roles. For more info on user and role based access see user access control.


Ok, we’ve created the command, but are not doing anything with it in our application. Let’s create a handler:

src/main/java/io/fluxzero/todo/project/ProjectCommandHandler.java
@Component
public class ProjectCommandHandler {
@HandleCommand
void handle(CreateProject command) {
// handler logic here
}
}

What this method tells Fluxzero is that this handler is interested in commands of type CreateProject. These commands may have been published in the same app or any other service connected through Fluxzero.

You can inject all kinds of parameters into handler methods, like the command sender, metadata, or full command message. For more info on handlers see message handlers.

Our CreateProject command targets a specific Project entity (as opposed to say a command to send an email). For these types of commands it is most elegant to defer all business behavior to the command itself.

We can do that by loading the targeted entity and applying the command:

@Component
public class ProjectCommandHandler {
@HandleCommand
void handle(CreateProject command) {
Fluxzero.loadEntity(command.projectId())
.assertAndApply(command);
}
}

This loads the current state of the Project entity as Entity<Project> and applies the command to the entity. This works even if the entity does not yet exist.


In the last example, the command payload (CreateProject) is applied to the entity. If that succeeds, the same payload is wrapped in a new message which gets published as event.

Want to understand why we recommend reusing the same payload in both command and event? See Applying entity updates.


Now let’s have the command create a new Project when it gets applied:

@RequiresRole(Role.MANAGER)
public record CreateProject(@NotNull ProjectId projectId,
@NotNull @Valid ProjectDetails details) {
@Apply
Project create() {
return new Project(projectId, details, null);
}
}

@Apply methods are used to modify the state of an entity. In our case the Project doesn’t exist yet so we simply create a new one.

Like with handlers you can inject context into @Apply methods. In fact, let’s inject the user sending in the command and make it the owner of the Project:

@RequiresRole(Role.MANAGER)
public record CreateProject(@NotNull ProjectId projectId,
@NotNull @Valid ProjectDetails details) {
@Apply
Project create(Sender sender) {
return new Project(projectId, details, sender.userId());
}
}

Most commands contain checks against the current state of the entity. You can add those checks by annotating methods with @AssertLegal.

For our CreateProject command we want to ensure that no Project exists having the same id:

@RequiresRole(Role.MANAGER)
public record CreateProject(@NotNull ProjectId projectId,
@NotNull @Valid ProjectDetails details) {
@AssertLegal
void assertNew(Project project) {
if (project != null) {
throw new IllegalCommandException("Project already exists");
}
}
@Apply
Project create(Sender sender) {
return new Project(projectId, details, sender.userId());
}
}

Here we added an assertion that injects the current state of the Project entity. If the Project already exists an exception is thrown.

Actually, this method can even be simpler:

@AssertLegal
void assertNew(Project project) {
throw new IllegalCommandException("Project already exists");
}

This also works, because Fluxzero will only invoke this method if the Project != null. To invoke the method even when a parameter may be null, add @Nullable to the parameter (or ? in Kotlin).


Okay, that concludes our command and handler. Let’s now have a closer look at the Project entity we created earlier:

@Aggregate
public record Project(@EntityId ProjectId id,
ProjectDetails details,
UserId ownerId) {
}

To configure the way a Project is to be persisted you can use the @Aggregate annotation. For instance, to make Projects available for search, simply enable it:

@Aggregate(searchable = true)
public record Project(@EntityId ProjectId id,
ProjectDetails details,
UserId ownerId) {
}

By default, Fluxzero enables event-sourcing for aggregates (to disable set eventSourced = false). When an event-sourced entity is loaded and applied to, the following happens:

  1. Rehydrates the entity from events or snapshots.
  2. Runs @AssertLegal methods to validate business rules.
  3. Calls the @Apply method to produce the next state.
  4. Persists an event to Project’s event log containing the applied update.
  5. Publishes the same event to the global event log.
  6. Stores the entity in the document store (if searchable = true).

This ensures that business rules are enforced before anything is persisted, and that your event log reflects what actually happened.


Fluxzero makes it easy to write behavior tests as you go. Here’s our first one:

src/test/java/io/fluxzero/todo/project/command/CreateProjectTest.java
class CreateProjectTest {
TestFixture fixture = TestFixture.create(new ProjectCommandHandler());
@Test
void creatingProjectSucceeds() {
var projectId = new ProjectId("p1");
var details = new ProjectDetails("My first project", "Tutorial starter");
var createProject = new CreateProject(projectId, details);
fixture.whenCommand(createProject)
.expectEvents(createProject);
}
}

This test checks that when we send a CreateProject command, the same payload is applied and published as an event.


Writing tests like this can be quite cumbersome. It is often preferable to load test inputs and outputs from external JSON files:

@Test
void creatingProjectSucceeds() {
fixture.whenCommand("/project/create-project.json")
.expectEvents("/project/create-project.json");
}
src/test/resources/project/create-project.json
{
"@class": "CreateProject",
"projectId": "p1",
"details": {
"name": "My first project",
"description": "Tutorial starter"
}
}

Adding "@class": "CreateProject" is needed for deserialization of the JSON.

At this point you should be able to run your very first test and see it pass.

Run your test suite with:

Terminal window
./mvnw test
# or
./gradlew test

You should see CreateProjectTest succeed — confirming that:

  • Your command and entity classes compile correctly,
  • Fluxzero loads your Project aggregate,
  • The CreateProject command is applied and published as an event.

If the test fails, check the following:

  • Handler registration: Did you register ProjectCommandHandler with the TestFixture?
  • Test resources: Is your JSON test file in src/test/resources/project/ with the correct @class field?
  • Imports: Make sure you’re importing the correct classes (not Spring or Jakarta equivalents).

Once this test passes, you’ve verified that your Fluxzero app is wired up correctly. From here, you can confidently move on to extending your model with updates, queries, and endpoints.


In our command we added a check that the Project should not exist yet. Let’s test that business rule:

@Test
void creatingProjectTwiceFails() {
fixture.givenCommands("/project/create-project.json")
.whenCommand("/project/create-project.json")
.expectExceptionalResult(IllegalCommandException.class);
}

It would be even better if we’d check for the expected error. We recommend introducing a ProjectErrors class that can be used by the command and in tests:

src/main/java/io/fluxzero/todo/project/ProjectErrors.java
public interface ProjectErrors {
FunctionalException
alreadyExists = new IllegalCommandException("Project already exists"),
notFound = new IllegalCommandException("Project not found"),
unauthorized = new UnauthorizedException("Unauthorized for action"),
taskNotFound = new IllegalCommandException("Task not found"),
taskCompleted = new IllegalCommandException("Task has already completed");
}

For convenience, we’ve already added some errors needed later on.

We can now improve both our command and test:

@RequiresRole(Role.MANAGER)
public record CreateProject(@NotNull ProjectId projectId,
@NotNull @Valid ProjectDetails details) {
@AssertLegal
void assertNew(Project project) {
throw ProjectErrors.alreadyExists;
}
@Apply
Project create(Sender sender) {
return new Project(projectId, details, sender.userId());
}
}
@Test
void creatingProjectTwiceFails() {
fixture.givenCommands("/project/create-project.json")
.whenCommand("/project/create-project.json")
.expectExceptionalResult(ProjectErrors.alreadyExists);
}

Let’s move on to updating an existing Project. This command can be used to rename the Project or update its description.

Start by introducing a new command:

src/main/java/io/fluxzero/todo/project/command/ProjectErrors.java
public record UpdateProject(@NotNull ProjectId projectId,
@NotNull @Valid ProjectDetails details) {
@AssertLegal
void assertExists(@Nullable Project project) {
if (project == null) {
throw ProjectErrors.notFound;
}
}
@AssertLegal
void assertAuthorized(Project project, Sender sender) {
if (!sender.isAuthorizedFor(project.ownerId())) {
throw ProjectErrors.unauthorized;
}
}
@Apply
Project apply(Project project) {
return project.withDetails(details);
}
}

This command checks that the Project exists, verifies that the user is authorized (i.e., Project owner or admin), and applies an update to the Project.

We’ll make a small change to the Project entity, adding @With to its details:

@Aggregate(searchable = true)
public record Project(@EntityId ProjectId id,
@With ProjectDetails details,
UserId ownerId) {
}

Currently, the command handler only handles CreateProject. Instead of adding a new method for UpdateProject, we’ll extract a shared interface that both commands can implement:

src/main/java/io/fluxzero/todo/project/command/ProjectCommand.java
public interface ProjectCommand {
@NotNull
ProjectId projectId();
}

And implement this interface:

@RequiresRole(Role.MANAGER)
public record CreateProject(ProjectId projectId,
@NotNull @Valid ProjectDetails details)
implements ProjectCommand { ... }
public record UpdateProject(ProjectId projectId,
@NotNull @Valid ProjectDetails details)
implements ProjectCommand { ... }

Now update the handler:

@Component
public class ProjectCommandHandler {
@HandleCommand
void handle(ProjectCommand command) {
Fluxzero.loadEntity(command.projectId())
.assertAndApply(command);
}
}

This single command handler method will now be able to handle all current and future Project commands.


Let’s ensure that our UpdateProject command succeeds:

src/test/java/io/fluxzero/todo/project/command/UpdateProjectTest.java
class UpdateProjectTest {
TestFixture fixture = TestFixture.create(new ProjectCommandHandler());
@Test
void renamingProjectSucceeds() {
fixture.givenCommands("/project/create-project.json")
.whenCommand("/project/rename-project.json")
.expectEvents("/project/rename-project.json");
}
}
src/test/resources/project/rename-project.json
{
"@class": "UpdateProject",
"projectId": "p1",
"details": {
"name": "Renamed project",
"description": "Tutorial starter"
}
}

And add some tests for failure scenarios too:

@Test
void renamingNonExistentProjectFails() {
fixture.whenCommand("/project/rename-project.json")
.expectExceptionalResult(ProjectErrors.notFound);
}
@Test
void renamingProjectByNonOwnerFails() {
fixture.givenCommands("/project/create-project.json")
.whenCommandByUser("somebodyElse", "/project/rename-project.json")
.expectExceptionalResult(ProjectErrors.unauthorized);
}

So far we’ve shown how you can create and modify state using commands. Let’s now show how easy it is to query stored projects.

Just like commands, queries are their own objects.

You can handle queries directly inside the query class. This keeps your logic close to where it’s defined:

src/main/java/io/fluxzero/todo/project/query/GetProject.java
public record GetProject(@NotNull ProjectId projectId) implements Request<Project> {
@HandleQuery
Project handle() {
return Fluxzero.search(Project.class)
.match(projectId, "id")
.fetchFirstOrNull();
}
}

Fluxzero Search makes queries like this effortless. See the search docs for details.

You can inject the Sender just like with commands, and implement filtering or authorization logic as needed. Let’s use this to make sure only authorized users get access to a Project:

src/main/java/io/fluxzero/todo/project/query/GetProject.java
public record GetProject(@NotNull ProjectId projectId) implements Request<Project> {
@HandleQuery
Project handle(Sender sender) {
return Fluxzero.search(Project.class)
.match(projectId, "id")
.match(sender.isAdmin() ? null : sender.userId(), "ownerId")
.fetchFirstOrNull();
}
}

This query shows how to fetch a Project, with logic to restrict access based on the sender. Passing null in .match() removes the filter, allowing admins to view all results.

The GetProject query implements Request<R>, where R is the expected return type. Fluxzero validates this at compile time, ensuring handlers always return the correct type. This strengthens test clarity and lets calling code safely rely on the result.

Let’s specify another query:

src/main/java/io/fluxzero/todo/project/query/GetMyProjects.java
public record GetMyProjects() implements Request<List<Project>> {
@HandleQuery
List<Project> find(Sender sender) {
return Fluxzero.search(Project.class)
.match(sender.userId(), "ownerId")
.fetch(100);
}
}

Fluxzero’s search is full-text and autocomplete-ready from day one. Here’s how to implement lookahead behavior:

src/main/java/io/fluxzero/todo/project/query/SearchProjects.java
public record SearchProjects(String term) implements Request<List<Project>> {
@HandleQuery
List<Project> search(Sender sender) {
return Fluxzero.search(Project.class)
.lookAhead(term)
.match(sender.isAdmin() ? null : sender.userId(), "ownerId")
.fetch(100);
}
}

You can use .match(), .lookAhead(), and many other options — no configuration needed.

Okay, let’s write some tests for our queries, starting with GetProject:

src/test/java/io/fluxzero/todo/project/ProjectQueryTest.java
class ProjectQueryTest {
TestFixture fixture = TestFixture.create(new ProjectCommandHandler());
@Test
void gettingProjectSucceeds() {
fixture.givenCommands("/project/create-project.json")
.whenQuery(new GetProject(new ProjectId("p1")))
.expectResult(Project.class);
}
@Test
void otherUserCannotGetProject() {
fixture.givenCommands("/project/create-project.json")
.whenQueryByUser("someRandomUser", new GetProject(new ProjectId("p1")))
.expectNoResult();
}
}

Let’s also add tests for our other queries:

@Test
void getMyProjects() {
fixture.givenCommands("/project/create-project.json")
.whenQuery(new GetMyProjects())
.expectResult(projects -> projects.size() == 1);
}
@Test
void searchProjects() {
fixture.givenCommands("/project/create-project.json")
.whenQuery(new SearchProjects("starter"))
.expectResult(projects -> projects.size() == 1)
.andThen()
.whenQuery(new SearchProjects("starting"))
.expectResult(projects -> projects.isEmpty());
}

Now that we’ve built the core domain logic of our to-do app, let’s expose it over HTTP.

Here’s how to define a handler that uses standard REST-style routes:

src/main/java/io/fluxzero/todo/project/ProjectEndpoint.java
@Component
@Path("projects")
public class ProjectEndpoint {
@HandlePost
ProjectId handle(ProjectDetails details) {
ProjectId id = Fluxzero.generateId(ProjectId.class);
Fluxzero.sendCommandAndWait(new CreateProject(id, details));
return id;
}
@HandleGet
List<Project> getProjects() {
return Fluxzero.queryAndWait(new GetMyProjects());
}
@HandleGet("{projectId}")
Project getProject(@PathParam ProjectId projectId) {
return Fluxzero.queryAndWait(new GetProject(projectId));
}
@HandlePut("{projectId}")
void updateProject(@PathParam ProjectId projectId, ProjectDetails details) {
Fluxzero.sendCommandAndWait(new UpdateProject(projectId, details));
}
}

In Fluxzero, an HTTP request is just another message — like a command or query. Requests are logged by Fluxzero’s web proxy and then dispatched to handlers in your application. Each method in this class handles an HTTP message.

These annotations:

  • @HandlePost, @HandleGet, @HandlePut, etc. → Register HTTP routes on your endpoint (/projects, /projects/{id}, etc.).
  • @PathParam → Binds a path segment (like {projectId}) to a method parameter.

Your handler methods can return results or void. Fluxzero manages the flow transparently:

  1. HTTP request is logged — captured by Fluxzero’s web proxy as a WebRequest message.
  2. App consumes it — delivered like any other message.
  3. Handler runs — your method processes the request.
  4. Response returned — the result is sent back as a WebResponse to the client.

For more details, see the handling web requests guide.

Fluxzero makes it easy to test HTTP endpoint behavior — just like with commands and queries.

src/test/java/io/fluxzero/todo/project/ProjectEndpointTest.java
class ProjectEndpointTest {
TestFixture fixture = TestFixture.create(new ProjectCommandHandler(),
new ProjectEndpoint());
@Test
void createProjectViaPost() {
fixture.whenPost("/projects", "/project/create-project-request.json")
.expectResult(ProjectId.class)
.expectEvents(CreateProject.class);
}
}
src/test/resources/project/create-project-request.json
{
"name": "My first project",
"description": "Tutorial starter"
}

This test registers both ProjectCommandHandler and ProjectEndpoint in the TestFixture, allowing us to verify end-to-end behavior. Here, we send an HTTP POST and expect a CreateProject event to be published.


You can also mix commands and HTTP calls within the same test:

@Test
void renameProjectViaHttp() {
fixture.givenCommands("/project/create-project.json")
.whenPut("/projects/p1", "/project/rename-project-request.json")
.expectEvents("/project/rename-project.json");
}
src/test/resources/project/rename-project-request.json
{
"name": "Renamed project",
"description": "Tutorial starter"
}

With .andThen() you can extend a test beyond a single when...() phase. Each additional phase runs in sequence, and results from earlier steps are automatically available to later ones:

@Test
void updateProject() {
fixture.whenPost("/projects", "/project/create-project-request.json")
.andThen()
.whenPut("/projects/{projectId}", "/project/update-project-request.json")
.expectEvents(UpdateProject.class);
}

Fluxzero automatically replaces placeholders like {projectId} with results from previous steps. This makes it easy to chain realistic end-to-end flows.


Let’s also test our GET /projects endpoint:

@Test
void listProjectsViaGet() {
fixture.givenCommands("/project/create-project.json")
.whenGet("/projects")
.<List<Project>>expectResult(result -> result.size() == 1);
}

You can also test error scenarios of course:

@Test
void renamingNonExistentProjectFails() {
fixture.whenPut("/projects/p1", "/project/rename-project-request.json")
.expectExceptionalResult(ProjectErrors.notFound);
}

Let’s now extend our to-do app by adding support for tasks inside a project.

We’ll start with a command to add a task:

src/main/java/io/fluxzero/todo/project/command/AddTask.java
public record AddTask(ProjectId projectId,
@NotNull TaskId taskId,
@NotNull @Valid TaskDetails details) implements ProjectCommand {
@Apply
Task createTask(Sender sender) {
return new Task(taskId, details, sender.userId(), false);
}
}

That’s how simple we’d like this command to be. Fluxzero takes care of the details behind the scenes — for example, automatically returning a new Project instance with an updated task list when this command is applied.

Let’s also define the supporting types:

src/main/java/io/fluxzero/todo/project/model/TaskId.java
public final class TaskId extends Id<Task> {
public TaskId(String id) {
super(id);
}
}
src/main/java/io/fluxzero/todo/project/model/TaskDetails.java
public record TaskDetails(@NotBlank String name) {
}
src/main/java/io/fluxzero/todo/project/model/Task.java
public record Task(@EntityId TaskId taskId,
@With TaskDetails details,
@With UserId assignee,
@With boolean completed) {
}

To create and add a task to a project, we want the command to be routed to a new Task entity inside a Project.

Fluxzero makes this easy. It uses the @EntityId field (taskId) to recognize this is a new sub-entity of the Project.

To support this, we simply add a list of tasks to the project and mark it with @Member. This tells Fluxzero that these are nested entities:

@Aggregate(searchable = true)
public record Project(@EntityId ProjectId id,
@With ProjectDetails details,
UserId ownerId,
@Member List<Task> tasks) {
}

Now simply initialize the tasks list as empty when creating a new Project in CreateProject. This guarantees that every Project starts without tasks.

@Apply
Project create(Sender sender) {
return new Project(projectId, details, sender.userId(), List.of());
}

When adding a task, we really want to reuse the same checks that already exist in UpdateProject:

  • Does the project exist?
  • Is the user allowed to modify it?

Instead of duplicating this logic in every update command, let’s move these assertions to a shared interface.

We’ll introduce a new ProjectUpdate interface that extends ProjectCommand:

src/main/java/io/fluxzero/todo/project/command/ProjectUpdate.java
public interface ProjectUpdate extends ProjectCommand {
@AssertLegal
default void assertExists(@Nullable Project project) {
if (project == null) {
throw ProjectErrors.notFound;
}
}
@AssertLegal
default void assertAuthorized(Project project, Sender sender) {
if (!sender.isAuthorizedFor(project.ownerId())) {
throw ProjectErrors.unauthorized;
}
}
}

Now, we simply implement this interface from both UpdateProject and AddTask:

public record UpdateProject(@NotNull ProjectId projectId,
@NotNull @Valid ProjectDetails details) implements ProjectUpdate {
@Apply
Project apply(Project project) {
return project.withDetails(details);
}
}
public record AddTask(ProjectId projectId,
@NotNull TaskId taskId,
@NotNull @Valid TaskDetails details) implements ProjectUpdate {
@Apply
Task createTask(Sender sender) {
return new Task(taskId, details, sender.userId(), false);
}
}

This ensures:

  • Shared business logic for all updates is centralized.
  • Each update command remains clean and focused on what it actually changes.

Now that we can add tasks to a project, let’s introduce a few more commands that operate on individual tasks.

We’ll add three commands:

  • AssignTask: assigns a task to a different user.
  • CompleteTask: marks a task as completed.
  • CancelTask: cancels an unresolved task.

Let’s start by defining a shared interface for task-related updates. We allow these to be sent by Project owner or Task assignee:

src/main/java/io/fluxzero/todo/project/command/TaskUpdate.java
public interface TaskUpdate extends ProjectUpdate {
@NotNull @EntityId TaskId taskId();
@AssertLegal
default void assertExists(@Nullable Task task) {
if (task == null) {
throw ProjectErrors.taskNotFound;
}
}
@Override
default void assertAuthorized(Project project, Sender sender) {
// no-op: overridden below for more specific check
}
@AssertLegal
default void assertAuthorized(Project project, Task task, Sender sender) {
if (!sender.isAuthorizedFor(project.ownerId())
&& !sender.isAuthorizedFor(task.assignee())) {
throw ProjectErrors.unauthorized;
}
}
}

This allows all task-related updates to implement TaskUpdate and automatically inherit:

  • Authorization logic that covers both project owner and task assignee
  • Existence checks for the task

This keeps command classes clean and consistent — you can now define new task behaviors in just a few lines.


This command assigns a new user to the task:

src/main/java/io/fluxzero/todo/project/command/AssignTask.java
public record AssignTask(ProjectId projectId,
@NotNull TaskId taskId,
@NotNull UserId newAssignee) implements TaskUpdate {
@Apply
Task assign(Task task) {
return task.withAssignee(newAssignee);
}
}

Anyone can mark a task complete if they’re the project owner or the task’s assignee:

src/main/java/io/fluxzero/todo/project/command/CompleteTask.java
public record CompleteTask(ProjectId projectId,
@NotNull TaskId taskId) implements TaskUpdate {
@Apply
Task complete(Task task) {
return task.withCompleted(true);
}
}

To cancel a task and delete it from the Project, simply return null from your @Apply method:

src/main/java/io/fluxzero/todo/project/command/CancelTask.java
public record CancelTask(ProjectId projectId,
@NotNull TaskId taskId) implements TaskUpdate {
@AssertLegal
void assertNotCompleted(Task task) {
if (task.completed()) {
throw ProjectErrors.taskCompleted;
}
}
@Apply
Task delete(Task task) {
return null;
}
}

Let’s verify that tasks can be added and then removed again using the CancelTask command:

src/test/java/io/fluxzero/todo/project/command/CancelTaskTest.java
class CancelTaskTest {
TestFixture fixture = TestFixture.create(new ProjectCommandHandler());
@Test
void addTask() {
fixture.givenCommands("/project/create-project.json")
.whenCommand("/project/add-task.json")
.expectEvents("/project/add-task.json");
}
@Test
void addAndRemoveTask() {
fixture.givenCommands("/project/create-project.json", "/project/add-task.json")
.whenQuery(new GetProject(new ProjectId("p1")))
.expectResult(project -> project.tasks().size() == 1)
.andThen()
.givenCommands("/project/cancel-task.json")
.whenQuery(new GetProject(new ProjectId("p1")))
.expectResult(project -> project.tasks().isEmpty());
}
}
src/test/resources/project/add-task.json
{
"@class": "AddTask",
"projectId": "p1",
"taskId": "t1",
"details": {
"name": "Submit tutorial"
}
}
src/test/resources/project/cancel-task.json
{
"@class": "CancelTask",
"projectId": "p1",
"taskId": "t1"
}

The first test checks that adding a task succeeds. The second test confirms that after cancelling, the project no longer contains the task.


While tasks are modeled as sub-entities within a project, you may want to index them separately for fast querying or UI rendering. This is especially useful when you need:

  • A flat view of all tasks,
  • Queries like “my open tasks” or “tasks due today”,
  • To avoid loading the entire project entity just to show a task.

Here’s how you can create a simple event handler to index tasks as top-level documents:

src/main/java/io/fluxzero/todo/project/TaskIndexer.java
@Component
public class TaskIndexer {
@HandleEvent
void handle(AddTask event) {
Fluxzero.index(Fluxzero.loadEntity(event.taskId()));
}
@HandleEvent
void handle(TaskUpdate event) {
Fluxzero.index(Fluxzero.loadEntity(event.taskId()));
}
@HandleEvent
void handle(CancelTask event) {
Fluxzero.deleteDocument(event.taskId(), Task.class);
}
}

The Fluxzero.index(...) call turns the current entity state into a searchable document in the document store.

By storing the Task entity (Entity<Task>) instead of the Task value, a reference to its parent Project is automatically added as metadata, so tasks can be filtered by projectId.

You can now add a query to list all tasks assigned to the current user.

src/main/java/io/fluxzero/todo/project/query/FindMyTasks.java
public record FindMyTasks() implements Request<List<Task>> {
@HandleQuery
List<Task> find(Sender sender) {
return Fluxzero.search(Task.class)
.match(sender.userId(), "assignee")
.fetch(100);
}
}

And expose this query via a simple endpoint:

@HandleGet("tasks")
List<Task> getMyTasks() {
return Fluxzero.queryAndWait(new FindMyTasks());
}

Now let’s verify that the TaskIndexer correctly maintains a flat task view. The following tests check that after adding a task it becomes available via the /projects/tasks endpoint, and that cancelling the task removes it from the indexed results.

src/test/java/io/fluxzero/todo/project/TaskIndexerTest.java
class TaskIndexerTest {
TestFixture fixture = TestFixture.create(
new ProjectCommandHandler(),
new ProjectEndpoint(),
new TaskIndexer()
);
@Test
void queryTaskAfterAdding() {
fixture.givenCommands("/project/create-project.json",
"/project/add-task.json")
.whenGet("/projects/tasks")
.<List<Task>>expectResult(tasks -> tasks.size() == 1);
}
@Test
void queryTaskAfterCanceling() {
fixture.givenCommands("/project/create-project.json",
"/project/add-task.json",
"/project/cancel-task.json")
.whenGet("/projects/tasks")
.<List<Task>>expectResult(List::isEmpty);
}
}

To wrap up this tutorial, let’s add support for task deadlines.

When a task has a deadline, we want to send a notification if it’s not completed in time. If the task is completed before the deadline, the scheduled notification should be cancelled automatically.

First, we extend TaskDetails to include an optional deadline:

public record TaskDetails(@NotBlank String name,
Instant deadline) {
}

Now let’s create a handler that listens for Task events and is responsible for scheduling and cancelling task expiry:

src/main/java/io/fluxzero/todo/project/TaskScheduler.java
@Component
public class TaskScheduler {
@HandleEvent
void handle(AddTask event) {
if (event.details().deadline() != null) {
Fluxzero.schedule(new TaskExpiry(event.taskId()),
event.details().deadline());
}
}
@HandleEvent(allowedClasses = {CompleteTask.class, CancelTask.class})
void handle(TaskUpdate event) {
Fluxzero.cancelSchedule(new TaskExpiry(event.taskId()));
}
}

The scheduled object, TaskExpiry, looks like this:

src/main/java/io/fluxzero/todo/project/notifications/TaskExpiry.java
public record TaskExpiry(TaskId taskId) {
}

Now that we’ve scheduled TaskExpiry, let’s send a notification to a Slack channel when a tasks expires.

We’ll do this in a separate handler to keep responsibilities clear. This handler will load the task, check if it still exists, and then send a message to a Slack webhook (if configured):

src/main/java/io/fluxzero/todo/project/notifications/SlackNotifier.java
@Component
@ConditionalOnProperty("slack.webhook.url")
public class SlackNotifier {
@HandleSchedule
void notifyAssignee(TaskExpiry expiry) {
Task task = Fluxzero.loadEntity(expiry.taskId()).get();
if (task != null) {
var message = String.format("Task '%s' has expired!", task.details().name());
var slackUrl = ApplicationProperties.getProperty("slack.webhook.url");
Fluxzero.sendWebRequest(
WebRequest.post(slackUrl)
.payload(Map.of("text", message))
.build()
);
}
}
}

This pattern keeps things clean:

  • TaskScheduler schedules and cancels deadlines.
  • SlackNotifier reacts to expirations.
  • You could easily add other notifiers (e.g. email, WebSocket) that also react to the TaskExpiry schedule.

This design keeps your logic clean and your system honest: expiry is only acted on when the schedule triggers — and always based on the latest task state.

In Fluxzero, outgoing web requests — like sending a Slack message — are treated just like any other message.

When you call:

Fluxzero.sendWebRequest(
WebRequest.post(slackUrl)
.payload(Map.of("text", message))
.build()
);

Fluxzero does not use a web client to send the request directly from your app. Instead, the request is:

  1. Logged to the WebRequest log, just like a regular command or event.
  2. Picked up and executed by Fluxzero’s proxy, which handles all outbound traffic.
  3. Audited and observable in the same way as incoming requests and internal messages.

This gives you several advantages:

  • Monitoring: You can track which external calls were made and when.
  • Security: You can restrict or filter external requests in production (e.g. only allow requests to specific domains).
  • Retry & Scheduling: Just like other messages, web requests can be scheduled, retried, or routed to specific environments.

This design ensures that even side-effects like webhooks or API calls are safe, observable, and testable — without giving up on declarative modeling.

Let’s add a test to ensure that the Slack message goes out when a task deadline is missed.

We’ll use Fluxzero’s time-based testing features to simulate time advancing to the deadline. We’ll also verify that a WebRequest is sent to Slack using the expected payload:

src/test/java/io/fluxzero/todo/project/notifications/SlackNotifierTest.java
class SlackNotifierTest {
String slackWebhookUrl = "http://slack.test";
TestFixture fixture = TestFixture.create(
new ProjectCommandHandler(),
new TaskScheduler(),
new SlackNotifier()
).withProperty("slack.webhook.url", slackWebhookUrl);
@Test
void notifyAssigneeWhenDeadlineIsMissed() {
fixture
.givenCommands(
"/project/create-project.json",
"/project/add-task-30day-timeout.json"
)
.whenTimeElapses(Duration.ofDays(30))
.expectWebRequests("/project/slack-notification.json");
}
@Test
void doNotNotifyIfTaskWasCompletedBeforeDeadline() {
fixture
.givenCommands(
"/project/create-project.json",
"/project/add-task-30day-timeout.json",
"/project/complete-task.json"
)
.whenTimeElapses(Duration.ofDays(30))
.expectNoWebRequests();
}
}

With the following example JSON files:

src/test/resources/project/add-task-30day-timeout.json
{
"@class": "AddTask",
"projectId": "p1",
"taskId": "t1",
"details": {
"name": "Submit tutorial",
"timeout": "P30D"
}
}
src/test/resources/project/slack-notification.json
{
"@class" : "com.fasterxml.jackson.databind.node.ObjectNode",
"text": "Task 'Submit tutorial' has expired!"
}
src/test/resources/project/complete-task.json
{
"@class": "CompleteTask",
"projectId": "p1",
"taskId": "t1"
}

This setup ensures your task expiration logic and Slack integration are fully testable using clean, reusable JSON definitions.

That’s it! Your coding agent has built your first Fluxzero app, complete with:

  • ✅ Creating and updating projects
  • ✅ Adding nested tasks
  • ✅ Defining business rules and assertions
  • ✅ Querying data with filters and full-text search
  • ✅ Exposing commands and queries as HTTP endpoints
  • ✅ Scheduling future actions
  • ✅ Sending Slack notifications
  • ✅ Writing clean, declarative tests for everything

All of this using simple message classes and a handful of handlers in a clean, domain-driven design.


You’ve now seen how Fluxzero helps you build powerful apps with simple, expressive code — but this is just the start.

Want to keep going?


Fluxzero is designed to help you move fast, without giving up on clean architecture or observability.

Let us know what you build — and if you have questions or feedback, we’d love to hear. We built this for builders like you.


© 2026 Fluxzero