Dirk Lemmermann and I have known each other for quite a while. Dirk is active in the German Java community, and we have met at various Java events over the years. He is also the creator of FlexGanttFX, a JavaFX control for visualizing and editing schedules.
I had wanted to spend some proper time with FlexGanttFX. When Dirk posted about the recent license change, I finally got the chance to test it. And I wanted to bring it closer to, you guessed it: Quarkus. So here is my little experiment.
I picked a dock-door planner because a drag creates a real backend problem very quickly. A dispatcher moves a booking from Door 3 to Door 5, and the bar follows the mouse. Door 5 might already be occupied. Another dispatcher might have changed the same booking a moment earlier. The network might disappear between mouse-up and commit. If the desktop keeps the bar where it was dropped, it can show a schedule the server never accepted.
FlexGanttFX gives me the timeline, rows, activities, renderers, and drag editing. I keep the desktop lean. It turns server DTOs into a chart, sends one command after an edit, and renders the answer. Quarkus owns the schedule and makes the final decision.
Horizontal drags change time, and vertical drags change doors. I treat both as proposals. The change stays visible only after Quarkus accepts it.
I kept the boundary smaller than the UI
I split the Maven reactor into three modules:
flexganttfx-quarkus-planner/
├── pom.xml
├── contract/
├── backend/
└── desktop/I keep five JSON records in contract. The backend module contains Quarkus, Hibernate ORM with Panache, Flyway, and PostgreSQL. The desktop module contains JavaFX, FlexGanttFX, and the JDK HTTP client. The two application modules only meet through the contract.
The API has two operations:
GET /api/board?from=...&to=...returns doors and bookings that intersect the visible window.PUT /api/bookings/{id}/scheduleproposes a door, start, end, and the version last seen by the client.
This is the exact stack I tested: Quarkus 3.38.3, Java 25, FlexGanttFX 12.4.0, OpenJFX 25.0.4, Maven 3.9+, and Podman.
Since the license change started this experiment, here is the short version: FlexGanttFX 12.4.0 is available under AGPLv3 or a commercial DLSC license. Check which path fits your product before you ship it.
My first build hit a JavaFX dependency trap
FlexGanttFX 12.4.0 brings JavaFX 17 modules transitively. In my first build I added javafx-controls 25.0.4 to the desktop and assumed Maven would select the same version for base and graphics. It did not.
The mixed graph compiled. The application then died at startup with:
NoClassDefFoundError: com/sun/javafx/SecurityUtilI fixed it by managing all three JavaFX artifacts at one version in the parent POM:
<dependencyManagement>
<dependencies>
<dependency>
<groupId>com.flexganttfx</groupId>
<artifactId>view</artifactId>
<version>${flexganttfx.version}</version>
</dependency>
<dependency>
<groupId>org.openjfx</groupId>
<artifactId>javafx-base</artifactId>
<version>${javafx.version}</version>
</dependency>
<dependency>
<groupId>org.openjfx</groupId>
<artifactId>javafx-graphics</artifactId>
<version>${javafx.version}</version>
</dependency>
<dependency>
<groupId>org.openjfx</groupId>
<artifactId>javafx-controls</artifactId>
<version>${javafx.version}</version>
</dependency>
</dependencies>
</dependencyManagement>JavaFX modules are an implementation set and need to move together. Pinning only controls leaves room for a runtime that Maven can compile and JavaFX cannot start.
Before I run the two applications separately, I install the reactor once:
./mvnw install -DskipTestsThis puts the parent and the planner-contract artifact in my local Maven repository. Both launch commands can then resolve them.
I reject bad commands at the REST edge
The shared command carries the version last seen by the desktop together with the proposed schedule. I use Jakarta Validation here so incomplete JSON fails before the service touches it:
package com.themainthread.planner.contract;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.PositiveOrZero;
import java.time.Instant;
public record ScheduleBookingCommand(
@NotBlank String doorId,
@NotNull Instant startsAt,
@NotNull Instant endsAt,
@PositiveOrZero long expectedVersion) {
}I also require both query boundaries on the board resource. A missing from or to returns 400 Bad Request at the HTTP boundary. It never reaches the service as null.
I only need three backend properties:
quarkus.datasource.db-kind=postgresql
quarkus.hibernate-orm.schema-management.strategy=validate
quarkus.flyway.migrate-at-start=trueI leave the development JDBC URL out. With Podman running, Quarkus Dev Services starts PostgreSQL for me. Flyway then creates five doors and three seeded bookings.
I start the backend from its module directory:
cd backend
../mvnw quarkus:devThen I check the board slice:
curl -s \
'http://localhost:8080/api/board?from=2026-08-20T06%3A00%3A00Z&to=2026-08-20T12%3A00%3A00Z'For the board query I use the usual half-open interval test:
booking.startsAt < requestedTo AND booking.endsAt > requestedFromI use the same predicate when a booking moves. BoardService.schedule(...) runs in a transaction and checks these conditions in order:
The booking exists.
expectedVersionmatches the entity’s@Versionvalue.startsAtis beforeendsAt.The target door exists.
No other booking overlaps that door and interval.
After the update, I flush the persistence context so the response contains the incremented version. An overlap returns 409 OVERLAPPING_BOOKING. A version mismatch returns 409 STALE_BOOKING together with the current server DTO. The desktop needs that DTO to replace its stale bar.
The service-level overlap check keeps this experiment readable. For a production scheduler I would also add a PostgreSQL exclusion constraint. Two concurrent transactions can both see an empty slot and claim it, so the database needs to enforce that rule as well.
I turn DTOs into rows and activities
I kept the desktop mapping simple:
A
DockDoorDtobecomes aDockDoorRow.A
BookingDtobecomes aBookingActivity.One
LayernamedBookingscontains the activities.A map from door id to row supports rollback and server-side replacement.
BookingActivity keeps the last server DTO as its user object. FlexGanttFX changes the displayed start and end while the user drags or resizes a bar. The DTO stays untouched, so I always have the last accepted state for rollback.
package com.themainthread.planner.desktop;
import com.flexganttfx.model.activity.MutableActivityBase;
import com.themainthread.planner.contract.BookingDto;
public final class BookingActivity extends MutableActivityBase<BookingDto> {
public BookingActivity(BookingDto booking) {
apply(booking);
}
public void apply(BookingDto booking) {
setUserObject(booking);
setName(booking.reference());
setStartTime(booking.startsAt());
setEndTime(booking.endsAt());
}
public String bookingId() {
return getUserObject().id();
}
public long version() {
return getUserObject().version();
}
}In PlannerApp, I fetch the initial board on a virtual thread. I switch to Platform.runLater(...) only to render the returned DTOs. This keeps network waits away from the JavaFX application thread.
I send one proposal after each edit
FlexGanttFX fires different event shapes for a horizontal drag, a vertical drag, and a resize. They do not populate the same old-state fields. I therefore keep rollback independent of those optional fields and use the last server DTO.
One detail caused the first vertical-drag bug in my experiment. For a vertical drag, event.getActivityRef().getRow() still refers to the source activity reference. The destination row is event.getNewRow(). A horizontal edit leaves getNewRow() null, so I fall back to the activity row there.
I keep the complete client-side protocol in one coordinator:
package com.themainthread.planner.desktop;
import com.flexganttfx.model.Layer;
import com.flexganttfx.model.Row;
import com.flexganttfx.view.graphics.ActivityEvent;
import com.themainthread.planner.contract.BookingDto;
import com.themainthread.planner.contract.ScheduleBookingCommand;
import java.util.Collections;
import java.util.HashMap;
import java.util.HashSet;
import java.util.Map;
import java.util.Set;
import java.util.function.Consumer;
import javafx.application.Platform;
final class ScheduleProposalCoordinator {
private final Layer bookingsLayer;
private final BoardClient boardClient;
private final ScheduleResponseReducer reducer = new ScheduleResponseReducer();
private final Consumer<String> statusSink;
private final Set<String> pendingBookings = new HashSet<>();
private Map<String, DockDoorRow> rowsByDoorId = Collections.emptyMap();
private final Map<String, DockDoorRow> currentRowsByBookingId = new HashMap<>();
ScheduleProposalCoordinator(
Layer bookingsLayer,
BoardClient boardClient,
Consumer<String> statusSink) {
this.bookingsLayer = bookingsLayer;
this.boardClient = boardClient;
this.statusSink = statusSink;
}
void setBoardState(
Map<String, DockDoorRow> rowsByDoorId,
Map<String, BookingActivity> activitiesById) {
this.rowsByDoorId = Map.copyOf(rowsByDoorId);
currentRowsByBookingId.clear();
activitiesById.forEach((bookingId, activity) -> currentRowsByBookingId.put(
bookingId,
this.rowsByDoorId.get(activity.getUserObject().doorId())));
}
void onActivityChangeFinished(ActivityEvent event) {
if (!(event.getActivityRef().getActivity() instanceof BookingActivity activity)) {
return;
}
BookingDto original = activity.getUserObject();
Row<?, ?, ?> currentRow = targetRow(event);
if (!(currentRow instanceof DockDoorRow doorRow)) {
restore(activity, original);
return;
}
currentRowsByBookingId.put(activity.bookingId(), doorRow);
if (!pendingBookings.add(activity.bookingId())) {
restore(activity, original);
statusSink.accept("Already saving " + activity.getName());
return;
}
ScheduleBookingCommand command = new ScheduleBookingCommand(
doorRow.doorId(),
activity.getStartTime(),
activity.getEndTime(),
original.version());
boardClient.proposeSchedule(activity.bookingId(), command)
.whenComplete((result, error) -> Platform.runLater(() -> {
pendingBookings.remove(activity.bookingId());
if (error != null) {
restore(activity, original);
statusSink.accept("Network error: " + error.getMessage());
return;
}
ScheduleResponseReducer.Decision decision = reducer.decide(
result.statusCode(), result.booking(), result.problem());
switch (decision.action()) {
case COMMIT -> {
replace(activity, decision.booking());
statusSink.accept("Saved " + decision.booking().reference());
}
case REPLACE -> {
replace(activity, decision.booking());
statusSink.accept(decision.message());
}
case RESTORE -> {
restore(activity, original);
statusSink.accept(decision.message());
}
}
}));
}
static Row<?, ?, ?> targetRow(ActivityEvent event) {
return event.getNewRow() == null
? event.getActivityRef().getRow()
: event.getNewRow();
}
private void restore(BookingActivity activity, BookingDto original) {
renderServerState(activity, original);
}
private void replace(BookingActivity activity, BookingDto booking) {
renderServerState(activity, booking);
}
private void renderServerState(BookingActivity activity, BookingDto booking) {
DockDoorRow targetRow = rowsByDoorId.get(booking.doorId());
if (targetRow == null) {
return;
}
DockDoorRow currentRow = currentRowsByBookingId.get(activity.bookingId());
if (currentRow != null) {
currentRow.removeActivity(bookingsLayer, activity);
}
activity.apply(booking);
targetRow.addActivity(bookingsLayer, activity);
currentRowsByBookingId.put(activity.bookingId(), targetRow);
}
}I also track the row that currently owns each activity. FlexGanttFX uses an interval-tree repository, so removal has to target the owning row and happen before I apply the server times. For every response I remove the activity, apply the DTO, and add it to the target row. This reindexes the interval and leaves the activity in exactly one row with the state returned by Quarkus. A late response after a second drag goes through the same path.
I put the HTTP decision in ScheduleResponseReducer, away from JavaFX:
200commits the returned booking.A stale
409replaces the bar withcurrentBooking.An overlap or other rejection restores the saved DTO.
The reducer is plain Java, so its tests do not need a running JavaFX window.
Now I can run the experiment
With Quarkus still running, I open a second terminal at the reactor root:
./mvnw -pl desktop javafx:runThe window loads three seeded bookings. I use two of them for the first check: TRUCK-1042 starts on Door 3 from 08:00 to 09:30 UTC, and TRUCK-2017 occupies Door 5 from 09:00 to 10:30.
I drag TRUCK-1042 onto the occupied stretch on Door 5. Quarkus returns OVERLAPPING_BOOKING, and the bar returns to its server position. That is the rollback path I wanted to see first.
For the optimistic-lock case, I keep the desktop open and update the same booking from another terminal:
curl -s -X PUT \
http://localhost:8080/api/bookings/booking-42/schedule \
-H 'Content-Type: application/json' \
-d '{
"doorId": "door-4",
"startsAt": "2026-08-20T08:00:00Z",
"endsAt": "2026-08-20T09:00:00Z",
"expectedVersion": 0
}'The shell update succeeds and advances the server version to 1. My still-open desktop holds version 0. Its next drag receives STALE_BOOKING with the current Door 4 snapshot. The client then replaces its local bar with that state.
I test the protocol without dragging by hand
I did not want manual dragging to be the only proof. The test suite covers the protocol on both sides:
Board windows include only intersecting bookings.
Missing query boundaries and invalid command bodies return 400.
Valid schedule changes return the incremented version.
Overlap and stale writes return distinct 409 responses.
The stale response includes the current booking.
Vertical edit events select
getNewRow()while horizontal events retain the activity row.The mapper and response reducer remain independent of a running JavaFX window.
I run everything from the reactor root:
./mvnw testThe example currently runs twelve tests: six against the Quarkus application and six in the desktop module.
Quarkus keeps the final say
This little experiment gave me the split I was looking for. FlexGanttFX renders the schedule, maintains the row and activity model, and gives me precise edit events. Quarkus handles transactions, validation, optimistic locking, and persistence.
I treat every drag as a proposal and every server response as the state to render. That rule keeps the desktop lean and makes rollback part of the protocol. If Quarkus rejects a move, the bar goes back. If another client changed the booking, the bar moves to the current server state.
That was the part I wanted to test, and FlexGanttFX fits this setup nicely. You can find the complete experiment in the flexganttfx-quarkus-planner project.



