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 tasks, organizing them into projects, querying data, and scheduling actions.

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

This is a Developer Guide for readers who want to work in the code. You can follow it yourself or with your agent. For the builder workflow without code, start with Local development.


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

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


Create a new project called ‘Todo’, with root package io.fluxzero.todo, using the Basic Starter for Java or Kotlin (see Installation for how).

The file comments below use io.fluxzero.todo.project with command, query, model and notifications packages so the examples are easy to follow. You can organize your own application differently.

The examples use the Basic Starter’s Sender, UserId, Role, and RequiresRole types. Keep its authentication setup and type registration; they supply user context and short JSON type names for both Java and Kotlin. Import those application types where needed. The access-control guide explains how to connect user identity and roles in your own app.

Start with the thing someone wants to do: remember a task. A task needs a name, an assignee and a completion status. We can create that useful piece first. Then we’ll introduce projects to organize tasks, followed by search, web endpoints and deadline notifications.

Give each task its own identity:

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

A typed ID keeps a Task ID distinct from a Project ID. It serializes as a string. Now define the information we want to remember:

public record TaskDetails(@NotBlank String name) {
}
@Model
public record Task(@EntityId TaskId taskId,
@With TaskDetails details,
@With UserId assignee,
@With boolean completed) {
}

@Model tells Fluxzero to persist the Task. @EntityId identifies each instance. Models are immutable: changes return a new value. The Java examples use Lombok’s @With to generate those copies; Kotlin data classes provide copy.

A command represents the intent to create a task. It contains the task’s ID and details, and an @Apply method that creates its state:

public record CreateTask(@NotNull TaskId taskId,
@NotNull @Valid TaskDetails details) {
@Apply
Task create(Sender sender) {
return new Task(taskId, details, sender.userId(), false);
}
}

There are two pieces of business behavior here:

  • @NotNull and @Valid check the input, including the task’s nonblank name.
  • @Apply creates the Task and assigns it to the sender. The sender comes from authenticated message context, not a user ID supplied by the client.

Fluxzero already rejects duplicate creation. Send the command and it handles the change automatically:

Fluxzero.sendCommandAndWait(new CreateTask(
new TaskId("t1"), new TaskDetails("Submit tutorial")));

Fluxzero validates the command, runs its assertions, applies it and stores the accepted change. The same command payload is published as an event, so other parts of the app can react. We don’t need a separate command handler for this state change. With Spring, keep these types inside the configured application scan; without Spring, register your model command types with the application. In tests, command discovery happens automatically.

Before adding more commands, verify that creation stores the expected task name:

@Test
void createsTask() {
var taskId = new TaskId("t1");
TestFixture.create()
.whenCommand(new CreateTask(taskId, new TaskDetails("Submit tutorial")))
.expectSuccessfulResult()
.expectThat(f -> assertEquals("Submit tutorial", Fluxzero.loadModel(taskId).get().details().name()));
}

This uses the Basic Starter’s test user setup described above. We will introduce reusable JSON inputs once there are several actions to test. For now, one command and one observable result are enough.

A task is useful on its own. Let’s let its assignee change it, finish it, or cancel it.

We’ll add three commands:

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

We’ll put the shared authorization rule on TaskUpdate. In this app, the assignee or an administrator may change a task.

public interface TaskUpdate {
@NotNull TaskId taskId();
@AssertLegal
default void assertAuthorized(Task task, Sender sender) {
if (!sender.isAuthorizedFor(task.assignee())) {
throw new UnauthorizedException("Unauthorized for action");
}
}
}

The @AssertLegal method checks our business rule. An update’s @Apply method takes an existing Task, so Fluxzero already rejects a missing task. We don’t need to write that existence check ourselves.

This command assigns a new user to the task:

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

Completing a task is an update too. The shared interface checks authorization; this method expresses only the change:

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

To cancel a task, simply return null from your @Apply method:

src/main/java/io/fluxzero/todo/project/command/CancelTask.java
public record CancelTask(@NotNull TaskId taskId) implements TaskUpdate {
@AssertLegal
void assertNotCompleted(Task task) {
if (task.completed()) {
throw new IllegalCommandException("Task has already completed");
}
}
@Apply
Task delete(Task task) {
return null;
}
}

TestFixture runs commands and handlers locally and lets us describe a given → when → then flow. You can pass ordinary Java or Kotlin objects to it. JSON files are an optional convenience: reusable inputs and expected results keep the test focused on the behavior we want to check.

Create these files under src/test/resources:

project/create-task.json
{
"@class": "CreateTask",
"taskId": "t1",
"details": { "name": "Submit tutorial" }
}
project/assign-task.json
{
"@class": "AssignTask",
"taskId": "t1",
"newAssignee": "alex"
}
project/complete-task.json
{
"@class": "CompleteTask",
"taskId": "t1"
}

The @class field identifies the payload type. The Basic Starter registers application types with @RegisterType, so these short names work in both Java and Kotlin. Without that registration, use the fully qualified class name. If two registered classes have the same name, include enough of the package to distinguish them.

class TaskFlowTest {
TestFixture fixture = TestFixture.create();
@Test
void createsTask() {
fixture.whenCommand("/project/create-task.json")
.expectEvents("/project/create-task.json");
}
@Test
void assigneeCompletesTask() {
fixture.givenCommands("/project/create-task.json", "/project/assign-task.json")
.whenCommandByUser("alex", "/project/complete-task.json")
.expectEvents("/project/complete-task.json")
.expectThat(f -> assertTrue(Fluxzero.loadModel(new TaskId("t1")).get().completed()));
}
@Test
void completingMissingTaskFails() {
fixture.whenCommand("/project/complete-task.json")
.expectExceptionalResult(Entity.NOT_FOUND_EXCEPTION);
}
}

TestFixture.create() uses the starter’s user provider. Unqualified commands use its system sender; whenCommandByUser("alex", ...) exercises the same command as Alex. The last test uses the SDK’s standard missing-entity exception. Import Entity from io.fluxzero.sdk.modeling and assertTrue from JUnit’s Assertions.

Passing an instance works just as well: replace the JSON argument to whenCommand with new CompleteTask(new TaskId("t1")) in Java, or CompleteTask(TaskId("t1")) in Kotlin. Use whichever form makes the scenario clearest.

Ask your agent to check the test results in Devboard, or run the tests with your project’s build tool. We now have a small, usable domain: tasks can be created, assigned, completed, and cancelled. Next we’ll add a way to organize them.

As this app grows, we may want to organize related tasks into projects such as Work, Groceries, or Side project. A project has a name, an optional description, and an owner:

public final class ProjectId extends Id<Project> {
public ProjectId(String id) {
super(id);
}
}
public record ProjectDetails(@NotBlank String name,
@Size(max = 1000) String description) {
}
@Model
public record Project(@EntityId ProjectId id,
@With ProjectDetails details,
UserId ownerId) {
}

For this app, let’s decide that managers may create projects, while a project’s owner controls its contents. The Basic Starter includes a Role enum and a @RequiresRole annotation, so we can state the creation rule explicitly:

@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());
}
}

@RequiresRole(Role.MANAGER) checks the sender before applying the command. These are application-owned types from the starter; its role hierarchy also permits administrators. String roles via the SDK’s @RequiresAnyRole are available too, but enum roles keep this app’s role names checked by the compiler. See user access control for role configuration. We’ll use shared errors in commands and tests:

src/main/java/io/fluxzero/todo/project/ProjectErrors.java
public interface ProjectErrors {
FunctionalException
unauthorized = new UnauthorizedException("Unauthorized for action");
}

The Task and Project are useful independently. We’ll connect them when we add tasks to a project below.

We already have standalone tasks. Now let a task optionally belong to a project by adding a projectId field:

@Model
public record Task(@EntityId TaskId taskId,
@With @Parent(pathInParent = "tasks") ProjectId projectId,
@With TaskDetails details,
@With UserId assignee,
@With boolean completed) {
}

@Parent connects the task to its project. The Project doesn’t need a stored task list. Tasks keep their identities and can still exist without a project. Update CreateTask to pass null for the new field:

@Apply
Task create(Sender sender) {
return new Task(taskId, null, details, sender.userId(), false);
}

For creating a task directly in a project, introduce AddTask. We’ll also let owners rename a project with UpdateProject. Both commands should enforce the same ownership rule.

As with TaskUpdate, a shared interface can express the rule once: the project owner or an administrator may modify the project. Both adding a task and changing the project details will use that rule.

We’ll introduce a ProjectUpdate interface:

src/main/java/io/fluxzero/todo/project/command/ProjectUpdate.java
public interface ProjectUpdate {
@NotNull ProjectId projectId();
@AssertLegal
default void assertAuthorized(Project project, Sender sender) {
if (!sender.isAuthorizedFor(project.ownerId())) {
throw ProjectErrors.unauthorized;
}
}
}

Implement this interface from UpdateProject, which changes the name or description, and AddTask, which creates a task in the project:

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(Project project, Sender sender) {
return new Task(taskId, projectId, details, sender.userId(), false);
}
}

The required Project parameter in AddTask ensures the parent exists before a task is created. The shared interface checks whether the sender may modify that project. This ensures:

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

We can now widen the task-update rule: a task’s assignee, its project’s owner, or an administrator may change it. Replace TaskUpdate with the following version. The commands still need only a Task ID; Fluxzero resolves the Project through the relationship.

public interface TaskUpdate {
@NotNull TaskId taskId();
@AssertLegal
default void assertAuthorized(Task task, @Nullable Project project, Sender sender) {
if (!sender.isAuthorizedFor(task.assignee())
&& (project == null || !sender.isAuthorizedFor(project.ownerId()))) {
throw ProjectErrors.unauthorized;
}
}
}

These checks work for both standalone tasks and tasks in a project. @Nullable (or ? in Kotlin) keeps the assertion active when no project exists. The existing task commands keep their @Apply methods unchanged.

Add a project creation resource under src/test/resources, then check creation and duplicate creation:

project/create-project.json
{
"@class": "CreateProject",
"projectId": "p1",
"details": {
"name": "My first project",
"description": "Tutorial starter"
}
}
class CreationTest {
TestFixture fixture = TestFixture.create();
@Test
void creatingTaskSucceeds() {
fixture.whenCommand("/project/create-task.json")
.expectEvents("/project/create-task.json");
}
@Test
void creatingProjectSucceeds() {
fixture.whenCommand("/project/create-project.json")
.expectEvents("/project/create-project.json");
}
@Test
void creatingProjectTwiceFails() {
fixture.givenCommands("/project/create-project.json")
.whenCommand("/project/create-project.json")
.expectExceptionalResult(Entity.ALREADY_EXISTS_EXCEPTION);
}
}

Let’s ensure that our UpdateProject command succeeds:

src/test/java/io/fluxzero/todo/project/command/UpdateProjectTest.java
class UpdateProjectTest {
TestFixture fixture = TestFixture.create();
@Test
void renamingProjectSucceeds() {
fixture.givenCommands("/project/create-project.json")
.whenCommand("/project/rename-project.json")
.expectEvents("/project/rename-project.json");
}
}
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(Entity.NOT_FOUND_EXCEPTION);
}
@Test
void renamingProjectByNonOwnerFails() {
fixture.givenCommands("/project/create-project.json")
.whenCommandByUser("somebodyElse", "/project/rename-project.json")
.expectExceptionalResult(ProjectErrors.unauthorized);
}

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();
@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")
.whenCommand("/project/cancel-task.json")
.expectEvents("/project/cancel-task.json")
.expectThat(f -> assertNull(Fluxzero.loadModel(new TaskId("t1")).get()));
}
}
project/add-task.json
{
"@class": "AddTask",
"projectId": "p1",
"taskId": "t1",
"details": {
"name": "Submit tutorial"
}
}
project/cancel-task.json
{
"@class": "CancelTask",
"taskId": "t1"
}

The first test checks that adding a task succeeds. The second test confirms that cancelling deletes the current task. assertNull, like assertTrue, comes from JUnit.


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.

To list and search projects, let Fluxzero maintain a searchable Graph for each project:

@Model(searchable = true, graphProjection = @GraphProjection(mode = GraphProjectionMode.ASYNC))
public record Project(@EntityId ProjectId id, @With ProjectDetails details, UserId ownerId) {
}

With searchable = true, Fluxzero indexes each project and its composition descendants. GraphProjection ASYNC additionally maintains the composed search document, which may lag behind current node state. We can query it with searchGraph. This view updates in the background; an identity-based load still gives you the accepted state immediately.

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.searchGraph(Project.class)
.match(projectId, "id")
.fetchFirstOrNull(Project.class);
}
}

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.searchGraph(Project.class)
.match(projectId, "id")
.match(sender.isAdmin() ? null : sender.userId(), "ownerId")
.fetchFirstOrNull(Project.class);
}
}

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. This lets calling code infer the result type when sending the query. Your handler and tests must still honor that result contract.

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.searchGraph(Project.class)
.match(sender.userId(), "ownerId")
.fetch(100, Project.class);
}
}

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.searchGraph(Project.class)
.lookAhead(term)
.match(sender.isAdmin() ? null : sender.userId(), "ownerId")
.fetch(100, Project.class);
}
}

You can use .match(), .lookAhead(), and many other search options on this view.

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();
@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());
}

The task list is a query over the relationship:

public record GetProjectTasks(ProjectId projectId) implements Request<List<Task>> {
@HandleQuery
List<Task> handle(Sender sender) {
Project project = Fluxzero.loadModel(projectId).get();
if (project == null || !sender.isAuthorizedFor(project.ownerId())) {
throw ProjectErrors.unauthorized;
}
return Fluxzero.searchGraph(Task.class).whereParent(projectId).fetch(100, Task.class);
}
}

The explicit tasks relationship path makes the child available to this parent-constrained search. The same Task Graph can also be searched without a project filter, as we will do below.

A project task list is useful, but we also want a flat view of everything assigned to the current user. Task already declares @Parent(pathInParent = "tasks"), so Fluxzero can search its Graph without any additional Model settings. This includes standalone tasks, whose optional project relation is still empty.

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.searchGraph(Task.class)
.match(sender.userId(), "assignee")
.fetch(100, Task.class);
}
}

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));
}
}

The leading / in @Path("/projects") makes this class path absolute. It overrides the starter’s package-level /api prefix, so the examples below use /projects. You can choose /api/projects instead if you prefer.

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 ProjectEndpoint());
@Test
void createProjectViaPost() {
fixture.whenPost("/projects", "/project/create-project-request.json")
.expectResult(ProjectId.class)
.expectEvents(CreateProject.class);
}
}
project/create-project-request.json
{
"name": "My first project",
"description": "Tutorial starter"
}

This test registers 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");
}
project/rename-project-request.json
{
"name": "Renamed project",
"description": "Tutorial starter"
}

With .andThen() you can extend a test beyond a single when...() phase:

@Test
void updateThenReadProject() {
fixture.givenCommands("/project/create-project.json")
.whenPut("/projects/p1", "/project/rename-project-request.json")
.expectEvents("/project/rename-project.json")
.andThen()
.whenGet("/projects/p1")
.expectResult(Project.class);
}

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 renamingNonExistentProjectViaHttpFails() {
fixture.whenPut("/projects/p1", "/project/rename-project-request.json")
.expectExceptionalResult(Entity.NOT_FOUND_EXCEPTION);
}

Add one more method to ProjectEndpoint to expose FindMyTasks:

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

Now let’s verify that the app 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/TaskSearchTest.java
class TaskSearchTest {
TestFixture fixture = TestFixture.create(
new ProjectEndpoint()
);
@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(CreateTask event) {
if (event.details().deadline() != null) {
Fluxzero.schedule(new TaskExpiry(event.taskId()),
"task-expiry-" + event.taskId(), event.details().deadline());
}
}
@HandleEvent
void handle(AddTask event) {
if (event.details().deadline() != null) {
Fluxzero.schedule(new TaskExpiry(event.taskId()),
"task-expiry-" + event.taskId(), event.details().deadline());
}
}
@HandleEvent(allowedClasses = {CompleteTask.class, CancelTask.class})
void handle(TaskUpdate event) {
Fluxzero.cancelSchedule("task-expiry-" + 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.loadCurrentGraph(expiry.taskId()).get();
if (task != null && !task.completed()) {
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()
);

By default, the request travels through Fluxzero’s WebRequest log and forward proxy. This keeps external calls observable alongside internal messages. You can also configure native HTTP dispatch; see sending web requests for transport and retry settings.

A remote service accepting a request is a separate effect from committing a model change. Check the returned future when later behavior depends on that outcome, and design retryable integrations with an idempotency key where the remote API supports one. A Slack webhook may be delivered again after a retry.

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 TaskScheduler(),
new SlackNotifier()
).withProperty("slack.webhook.url", slackWebhookUrl)
.atFixedTime(Instant.parse("2026-01-01T00:00:00Z"));
@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:

project/add-task-30day-timeout.json
{
"@class": "AddTask",
"projectId": "p1",
"taskId": "t1",
"details": {
"name": "Submit tutorial",
"deadline": "2026-01-31T00:00:00Z"
}
}
project/slack-notification.json
{
"@class" : "java.util.LinkedHashMap",
"text": "Task 'Submit tutorial' has expired!"
}

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

You have now built the backend of a to-do app, complete with:

  • ✅ Creating and updating projects
  • ✅ Creating tasks independently and organizing them into projects
  • ✅ 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