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.
Why a to-do app?
Section titled “Why a to-do app?”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.
Getting started
Section titled “Getting started”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.
Designing our app
Section titled “Designing our 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.
Create a Task
Section titled “Create a Task”Give each task its own identity:
public final class TaskId extends Id<Task> { public TaskId(String id) { super(id); }}class TaskId(id: String) : Id<Task>(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) {}
@Modelpublic record Task(@EntityId TaskId taskId, @With TaskDetails details, @With UserId assignee, @With boolean completed) {}data class TaskDetails(@field:NotBlank val name: String)
@Modeldata class Task( @EntityId val taskId: TaskId, val details: TaskDetails, val assignee: UserId, val completed: Boolean)@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.
Create it with a command
Section titled “Create it with a command”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); }}data class CreateTask( @field:NotNull val taskId: TaskId, @field:NotNull @field:Valid val details: TaskDetails) { @Apply fun create(sender: Sender) = Task(taskId, details, sender.userId, false)}There are two pieces of business behavior here:
@NotNulland@Validcheck the input, including the task’s nonblank name.@Applycreates 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.sendCommandAndWait<Task>(CreateTask( TaskId("t1"), 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.
Check the first behavior
Section titled “Check the first behavior”Before adding more commands, verify that creation stores the expected task name:
@Testvoid 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()));}@Testfun createsTask() { val taskId = TaskId("t1") TestFixture.create() .whenCommand(CreateTask(taskId, TaskDetails("Submit tutorial"))) .expectSuccessfulResult() .expectThat { 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.
Working with Tasks
Section titled “Working with Tasks”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"); } }}interface TaskUpdate { val taskId: TaskId
@AssertLegal fun assertAuthorized(task: Task, sender: Sender) { if (!sender.isAuthorizedFor(task.assignee)) { throw 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.
Assign a Task
Section titled “Assign a Task”This command assigns a new user to the task:
public record AssignTask(@NotNull TaskId taskId, @NotNull UserId newAssignee) implements TaskUpdate {
@Apply Task assign(Task task) { return task.withAssignee(newAssignee); }}data class AssignTask( override val taskId: TaskId, val newAssignee: UserId) : TaskUpdate {
@Apply fun assign(task: Task): Task { return task.copy(assignee = newAssignee) }}Complete a Task
Section titled “Complete a Task”Completing a task is an update too. The shared interface checks authorization; this method expresses only the change:
public record CompleteTask(@NotNull TaskId taskId) implements TaskUpdate {
@Apply Task complete(Task task) { return task.withCompleted(true); }}data class CompleteTask( override val taskId: TaskId) : TaskUpdate {
@Apply fun complete(task: Task): Task { return task.copy(completed = true) }}Cancel a Task
Section titled “Cancel a Task”To cancel a task, simply return null from your @Apply method:
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; }}data class CancelTask( override val taskId: TaskId) : TaskUpdate {
@AssertLegal fun assertNotCompleted(task: Task) { if (task.completed) { throw IllegalCommandException("Task has already completed") } }
@Apply fun delete(task: Task): Task? { return null }}Testing with JSON
Section titled “Testing with JSON”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:
{ "@class": "CreateTask", "taskId": "t1", "details": { "name": "Submit tutorial" }}{ "@class": "AssignTask", "taskId": "t1", "newAssignee": "alex"}{ "@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); }}class TaskFlowTest { private val fixture = TestFixture.create()
@Test fun createsTask() { fixture.whenCommand("/project/create-task.json") .expectEvents("/project/create-task.json") }
@Test fun assigneeCompletesTask() { fixture.givenCommands("/project/create-task.json", "/project/assign-task.json") .whenCommandByUser("alex", "/project/complete-task.json") .expectEvents("/project/complete-task.json") .expectThat { assertTrue(Fluxzero.loadModel(TaskId("t1")).get()!!.completed) } }
@Test fun 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.
Checkpoint: Does it work?
Section titled “Checkpoint: Does it work?”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.
Create a Project
Section titled “Create a Project”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) {}
@Modelpublic record Project(@EntityId ProjectId id, @With ProjectDetails details, UserId ownerId) {}class ProjectId(id: String) : Id<Project>(id)
data class ProjectDetails( @field:NotBlank val name: String, @field:Size(max = 1000) val description: String? = null)
@Modeldata class Project( @EntityId val id: ProjectId, val details: ProjectDetails, val ownerId: UserId)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)data class CreateProject( @field:NotNull val projectId: ProjectId, @field:NotNull @field:Valid val details: ProjectDetails) { @Apply fun create(sender: Sender) = 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:
public interface ProjectErrors { FunctionalException unauthorized = new UnauthorizedException("Unauthorized for action");}object ProjectErrors { val unauthorized: UnauthorizedException = UnauthorizedException("Unauthorized for action")}The Task and Project are useful independently. We’ll connect them when we add tasks to a project below.
Adding Tasks to a Project
Section titled “Adding Tasks to a Project”We already have standalone tasks. Now let a task optionally belong to a project by adding a projectId field:
@Modelpublic record Task(@EntityId TaskId taskId, @With @Parent(pathInParent = "tasks") ProjectId projectId, @With TaskDetails details, @With UserId assignee, @With boolean completed) {}@Modeldata class Task( @EntityId val taskId: TaskId, @Parent(pathInParent = "tasks") val projectId: ProjectId?, val details: TaskDetails, val assignee: UserId, val completed: Boolean)@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:
@ApplyTask create(Sender sender) { return new Task(taskId, null, details, sender.userId(), false);}@Applyfun create(sender: Sender) = 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.
Reusing business behavior
Section titled “Reusing business behavior”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:
public interface ProjectUpdate { @NotNull ProjectId projectId();
@AssertLegal default void assertAuthorized(Project project, Sender sender) { if (!sender.isAuthorizedFor(project.ownerId())) { throw ProjectErrors.unauthorized; } }}interface ProjectUpdate { val projectId: ProjectId
@AssertLegal fun 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); }}data class UpdateProject( @field:NotNull override val projectId: ProjectId, @field:NotNull @field:Valid val details: ProjectDetails) : ProjectUpdate {
@Apply fun apply(project: Project): Project { return project.copy(details = details) }}data class AddTask( override val projectId: ProjectId, @field:NotNull val taskId: TaskId, @field:NotNull @field:Valid val details: TaskDetails) : ProjectUpdate {
@Apply fun createTask(project: Project, sender: Sender): Task { return 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.
Let project owners manage their tasks
Section titled “Let project owners manage their tasks”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; } }}interface TaskUpdate { val taskId: TaskId
@AssertLegal fun assertAuthorized(task: Task, 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.
Testing Project creation
Section titled “Testing Project creation”Add a project creation resource under src/test/resources, then check creation and duplicate creation:
{ "@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); }}class CreationTest { private val fixture = TestFixture.create()
@Test fun creatingTaskSucceeds() { fixture.whenCommand("/project/create-task.json") .expectEvents("/project/create-task.json") }
@Test fun creatingProjectSucceeds() { fixture.whenCommand("/project/create-project.json") .expectEvents("/project/create-project.json") }
@Test fun creatingProjectTwiceFails() { fixture.givenCommands("/project/create-project.json") .whenCommand("/project/create-project.json") .expectExceptionalResult(Entity.ALREADY_EXISTS_EXCEPTION) }}Testing Project updates
Section titled “Testing Project updates”Let’s ensure that our UpdateProject command succeeds:
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"); }}class UpdateProjectTest { private val fixture = TestFixture.create()
@Test fun renamingProjectSucceeds() { fixture.givenCommands("/project/create-project.json") .whenCommand("/project/rename-project.json") .expectEvents("/project/rename-project.json") }}{ "@class": "UpdateProject", "projectId": "p1", "details": { "name": "Renamed project", "description": "Tutorial starter" }}And add some tests for failure scenarios too:
@Testvoid renamingNonExistentProjectFails() { fixture.whenCommand("/project/rename-project.json") .expectExceptionalResult(Entity.NOT_FOUND_EXCEPTION);}@Testvoid renamingProjectByNonOwnerFails() { fixture.givenCommands("/project/create-project.json") .whenCommandByUser("somebodyElse", "/project/rename-project.json") .expectExceptionalResult(ProjectErrors.unauthorized);}@Testfun renamingNonExistentProjectFails() { fixture.whenCommand("/project/rename-project.json") .expectExceptionalResult(Entity.NOT_FOUND_EXCEPTION)}@Testfun renamingProjectByNonOwnerFails() { fixture.givenCommands("/project/create-project.json") .whenCommandByUser("somebodyElse", "/project/rename-project.json") .expectExceptionalResult(ProjectErrors.unauthorized)}Testing tasks in a project
Section titled “Testing tasks in a project”Let’s verify that tasks can be added and then removed again using the CancelTask command:
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())); }}class CancelTaskTest { private val fixture = TestFixture.create()
@Test fun addTask() { fixture.givenCommands("/project/create-project.json") .whenCommand("/project/add-task.json") .expectEvents("/project/add-task.json") }
@Test fun addAndRemoveTask() { fixture.givenCommands("/project/create-project.json", "/project/add-task.json") .whenCommand("/project/cancel-task.json") .expectEvents("/project/cancel-task.json") .expectThat { assertNull(Fluxzero.loadModel(TaskId("t1")).get()) } }}{ "@class": "AddTask", "projectId": "p1", "taskId": "t1", "details": { "name": "Submit tutorial" }}{ "@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.
Querying Projects
Section titled “Querying Projects”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) {}@Model(searchable = true, graphProjection = GraphProjection(mode = GraphProjectionMode.ASYNC))data class Project(@EntityId val id: ProjectId, val details: ProjectDetails, val ownerId: UserId)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:
public record GetProject(@NotNull ProjectId projectId) implements Request<Project> {
@HandleQuery Project handle() { return Fluxzero.searchGraph(Project.class) .match(projectId, "id") .fetchFirstOrNull(Project.class); }}data class GetProject(val projectId: ProjectId) : Request<Project> {
@HandleQuery fun handle(): Project? { return Fluxzero.searchGraph(Project::class.java) .match(projectId, "id") .fetchFirstOrNull(Project::class.java) }}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:
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); }}data class GetProject(val projectId: ProjectId) : Request<Project> {
@HandleQuery fun handle(sender: Sender): Project? { return Fluxzero.searchGraph(Project::class.java) .match(projectId, "id") .match(if (sender.isAdmin()) null else sender.userId, "ownerId") .fetchFirstOrNull(Project::class.java) }}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.
Specifying return type
Section titled “Specifying return type”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:
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); }}class GetMyProjects : Request<List<Project>> {
@HandleQuery fun find(sender: Sender): List<Project> { return Fluxzero.searchGraph(Project::class.java) .match(sender.userId, "ownerId") .fetch(100, Project::class.java) }}Full-text search out of the box
Section titled “Full-text search out of the box”Fluxzero’s search is full-text and autocomplete-ready from day one. Here’s how to implement lookahead behavior:
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); }}data class SearchProjects(val term: String) : Request<List<Project>> {
@HandleQuery fun search(sender: Sender): List<Project> { return Fluxzero.searchGraph(Project::class.java) .lookAhead(term) .match(if (sender.isAdmin()) null else sender.userId, "ownerId") .fetch(100, Project::class.java) }}You can use .match(), .lookAhead(), and many other search options on this view.
Testing your queries
Section titled “Testing your queries”Okay, let’s write some tests for our queries, starting with GetProject:
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(); }}class ProjectQueryTest { private val fixture = TestFixture.create()
@Test fun gettingProjectSucceeds() { fixture.givenCommands("/project/create-project.json") .whenQuery(GetProject(ProjectId("p1"))) .expectResult(Project::class.java) }
@Test fun otherUserCannotGetProject() { fixture.givenCommands("/project/create-project.json") .whenQueryByUser("someRandomUser", GetProject(ProjectId("p1"))) .expectNoResult() }}Let’s also add tests for our other queries:
@Testvoid getMyProjects() { fixture.givenCommands("/project/create-project.json") .whenQuery(new GetMyProjects()) .expectResult(projects -> projects.size() == 1);}
@Testvoid 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());}@Testfun getMyProjects() { fixture.givenCommands("/project/create-project.json") .whenQuery(GetMyProjects()) .expectResult<List<Project>> { projects -> projects.size == 1 }}
@Testfun searchProjects() { fixture.givenCommands("/project/create-project.json") .whenQuery(SearchProjects("starter")) .expectResult<List<Project>> { projects -> projects.size == 1 } .andThen() .whenQuery(SearchProjects("starting")) .expectResult<List<Project>> { projects -> projects.isEmpty() }}List tasks in a project
Section titled “List tasks in a project”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); }}data class GetProjectTasks(val projectId: ProjectId) : Request<List<Task>> { @HandleQuery fun handle(sender: Sender): List<Task> { val project = Fluxzero.loadModel(projectId).get() if (project == null || !sender.isAuthorizedFor(project.ownerId)) { throw ProjectErrors.unauthorized } return Fluxzero.searchGraph(Task::class.java).whereParent(projectId).fetch(100, Task::class.java) }}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.
Searching across tasks
Section titled “Searching across tasks”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.
Querying your tasks
Section titled “Querying your tasks”You can now add a query to list all tasks assigned to the current user.
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); }}class FindMyTasks : Request<List<Task>> {
@HandleQuery fun find(sender: Sender): List<Task> { return Fluxzero.searchGraph(Task::class.java) .match(sender.userId, "assignee") .fetch(100, Task::class.java) }}Adding an HTTP endpoint
Section titled “Adding an HTTP endpoint”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:
@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)); }}@Component@Path("/projects")class ProjectEndpoint {
@HandlePost fun handle(details: ProjectDetails): ProjectId { val id = Fluxzero.generateId(ProjectId::class.java) Fluxzero.sendCommandAndWait<Project>(CreateProject(id, details)) return id }
@HandleGet fun getProjects(): List<Project> { return Fluxzero.queryAndWait(GetMyProjects()) }
@HandleGet("{projectId}") fun getProject(@PathParam projectId: ProjectId): Project? { return Fluxzero.queryAndWait(GetProject(projectId)) }
@HandlePut("{projectId}") fun updateProject(@PathParam projectId: ProjectId, details: ProjectDetails) { Fluxzero.sendCommandAndWait<Project>(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.
How the request is routed
Section titled “How the request is routed”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:
- HTTP request is logged — captured by Fluxzero’s web proxy as a
WebRequestmessage. - App consumes it — delivered like any other message.
- Handler runs — your method processes the request.
- Response returned — the result is sent back as a
WebResponseto the client.
For more details, see the handling web requests guide.
Testing your endpoint
Section titled “Testing your endpoint”Fluxzero makes it easy to test HTTP endpoint behavior — just like with commands and queries.
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); }}class ProjectEndpointTest {
val fixture = TestFixture.create(ProjectEndpoint())
@Test fun createProjectViaPost() { fixture.whenPost("/projects", "/project/create-project-request.json") .expectResult(ProjectId::class.java) .expectEvents(CreateProject::class.java) }}{ "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:
@Testvoid renameProjectViaHttp() { fixture.givenCommands("/project/create-project.json") .whenPut("/projects/p1", "/project/rename-project-request.json") .expectEvents("/project/rename-project.json");}@Testfun renameProjectViaHttp() { fixture.givenCommands("/project/create-project.json") .whenPut("/projects/p1", "/project/rename-project-request.json") .expectEvents("/project/rename-project.json")}{ "name": "Renamed project", "description": "Tutorial starter"}With .andThen() you can extend a test beyond a single when...() phase:
@Testvoid 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);}@Testfun 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.java)}Let’s also test our GET /projects endpoint:
@Testvoid listProjectsViaGet() { fixture.givenCommands("/project/create-project.json") .whenGet("/projects") .<List<Project>>expectResult(result -> result.size() == 1);}@Testfun listProjectsViaGet() { fixture.givenCommands("/project/create-project.json") .whenGet("/projects") .expectResult<List<Project>> { it.size == 1 }}You can also test error scenarios of course:
@Testvoid renamingNonExistentProjectViaHttpFails() { fixture.whenPut("/projects/p1", "/project/rename-project-request.json") .expectExceptionalResult(Entity.NOT_FOUND_EXCEPTION);}@Testfun renamingNonExistentProjectViaHttpFails() { fixture.whenPut("/projects/p1", "/project/rename-project-request.json") .expectExceptionalResult(Entity.NOT_FOUND_EXCEPTION)}Expose the task list
Section titled “Expose the task list”Add one more method to ProjectEndpoint to expose FindMyTasks:
@HandleGet("tasks")List<Task> getMyTasks() { return Fluxzero.queryAndWait(new FindMyTasks());}@HandleGet("tasks")fun getMyTasks(): List<Task> { return Fluxzero.queryAndWait(FindMyTasks())}Testing task search
Section titled “Testing task search”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.
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); }}class TaskSearchTest { private val fixture = TestFixture.create( ProjectEndpoint() )
@Test fun queryTaskAfterAdding() { fixture.givenCommands("/project/create-project.json", "/project/add-task.json") .whenGet("/projects/tasks") .expectResult<List<Task>> { tasks -> tasks.size == 1 } }
@Test fun queryTaskAfterCanceling() { fixture.givenCommands("/project/create-project.json", "/project/add-task.json", "/project/cancel-task.json") .whenGet("/projects/tasks") .expectResult<List<Task>> { it.isEmpty() } }}Scheduling a notification
Section titled “Scheduling a notification”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) {}data class TaskDetails( @field:NotBlank val name: String, val deadline: Instant? = null)Now let’s create a handler that listens for Task events and is responsible for scheduling and cancelling task expiry:
@Componentpublic 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()); }}@Componentclass TaskScheduler {
@HandleEvent fun handle(event: CreateTask) { event.details.deadline?.let { Fluxzero.schedule(TaskExpiry(event.taskId), "task-expiry-" + event.taskId, it) } }
@HandleEvent fun handle(event: AddTask) { event.details.deadline?.let { Fluxzero.schedule(TaskExpiry(event.taskId), "task-expiry-" + event.taskId, it) } }
@HandleEvent(allowedClasses = [CompleteTask::class, CancelTask::class]) fun handle(event: TaskUpdate) { Fluxzero.cancelSchedule("task-expiry-" + event.taskId) }}The scheduled object, TaskExpiry, looks like this:
public record TaskExpiry(TaskId taskId) {}data class TaskExpiry(val taskId: TaskId)Sending to Slack
Section titled “Sending to Slack”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):
@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() ); } }}@Component@ConditionalOnProperty("slack.webhook.url")class SlackNotifier {
@HandleSchedule fun notifyAssignee(expiry: TaskExpiry) { val task = Fluxzero.loadCurrentGraph(expiry.taskId).get() ?: return if (task.completed) return val message = "Task '${task.details.name}' has expired!" val slackUrl = ApplicationProperties.getProperty("slack.webhook.url") Fluxzero.sendWebRequest( WebRequest.post(slackUrl) .payload(mapOf("text" to 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.
Outgoing API calls
Section titled “Outgoing API calls”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.sendWebRequest( WebRequest.post(slackUrl) .payload(mapOf("text" to 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.
Testing task notifications
Section titled “Testing task notifications”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:
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(); }}class SlackNotifierTest {
private val slackWebhookUrl = "http://slack.test"
private val fixture = TestFixture.create( TaskScheduler(), SlackNotifier() ).withProperty("slack.webhook.url", slackWebhookUrl) .atFixedTime(Instant.parse("2026-01-01T00:00:00Z"))
@Test fun notifyAssigneeWhenDeadlineIsMissed() { fixture .givenCommands( "/project/create-project.json", "/project/add-task-30day-timeout.json" ) .whenTimeElapses(Duration.ofDays(30)) .expectWebRequests("/project/slack-notification.json") }
@Test fun 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:
{ "@class": "AddTask", "projectId": "p1", "taskId": "t1", "details": { "name": "Submit tutorial", "deadline": "2026-01-31T00:00:00Z" }}{ "@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.
Wrapping up
Section titled “Wrapping up”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.
What’s next?
Section titled “What’s next?”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?
- Deploy your app to Fluxzero
- Dive deeper with our technical guides
- Explore real-world patterns in the Fluxzero examples repo
- Try extending the to-do app — maybe add task checklists, priorities, or progress tracking
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