htmx on Domino with Jakarta EE

Mon Nov 10 14:51:29 EST 2025

Recently, the estimable Heiko Voigt has been writing a blog series on htmx with Domino. Since this post will build on the current state of that directly, I recommend you read each of those posts, which will give you an explanation of what htmx is and some examples of using it with Domino data.

I've been meaning to kick the tires on htmx for a while now, and this was a good excuse. It's a neat tool and just so happens to dovetail perfectly with the strengths of the XPages Jakarta EE project. So: let's see how that shakes out!

For starters, I made an NSF much like the state in part 4 of Heiko's blog series - a trip tracker with the same fields and a view. The implementation after that diverges, though.

Data Access

To get to the data, I created a NoSQL entity to represent the trips, using the convenient syntax of records:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
package model;

import java.time.LocalDate;
import java.util.stream.Stream;

import org.openntf.xsp.jakarta.nosql.mapping.extension.DominoRepository;
import org.openntf.xsp.jakarta.nosql.mapping.extension.ViewEntries;

import jakarta.nosql.Column;
import jakarta.nosql.Entity;
import jakarta.nosql.Id;

@Entity
public record Trip(
	@Id String unid,
	@Column("Date") LocalDate date,
	@Column("User") String user,
	@Column("Start") String start,
	@Column("Destination") String destination,
	@Column("KM") int km,
	@Column("Expenses") double expenses,
	@Column("Description") String description
) {
	public interface Repository extends DominoRepository<Trip, String> {
		@ViewEntries("Trips")
		Stream<Trip> listTrips();
	}
	
}

With this, the listTrips() method is pretty equivalent to the view reading that Heiko's example does, just handled by the framework.

Pages

For the front end, we'll start with a basic Jakarta Pages (JSP) page. This could really be plain HTML, since it has almost no logic to it, but it's nice to stick with the same tool as we'll use later. It's pretty thin, with the CSS and JS being the same ones from Heiko's second post.

This file is "WebContent/WEB-INF/views/home.jsp":

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
<%@page contentType="text/html" pageEncoding="UTF-8" trimDirectiveWhitespaces="true" %>
<!DOCTYPE html>
<html>
	<head>
		<title>htmx Tester</title>
		
		<link rel="stylesheet" href="${pageContext.request.contextPath}/htmx-main.css" />
		<script type="text/javascript" src="${pageContext.request.contextPath}/htmx.min.js"></script>
	</head>
	<body>
		<header id="main-header">
			<h1>htmx and HCL Domino w/ Jakarta EE</h1>
		</header>
		
		<main>
			<div hx-get="${mvc.basePath}/apihandler/trips" hx-trigger="load">
			</div>
		</main>
	</body>
</html>

There are a few things to note that differ from the original:

  • ${pageContext.request.contextPath} is used to get the path to the app. In XPages, a preceding "/" in an element like <xp:styleSheet/> will imply this, but we need to bring our own here. It'll be something like "/dev/htmx.nsf" here.
  • ${mvc.basePath} is a handy way to reference the base path of the REST app. It'll be something like "/dev/htmx.nsf/xsp/app" here.
  • Since we'll have a REST endpoint specifically for the Trip objects instead of an adaptable any-view one, the hx-get URL doesn't include the replica ID.

In Heiko's post, he uses a JSON extension to htmx to allow calling REST endpoints that emit JSON and smoothly translating those to HTML on the client side. Since Jakarta Pages is a perfectly-good HTML-templating engine in its own right, though, we'll do that work on the server and skip the extension.

This file is "WebContent/WEB-INF/views/trips-table.jsp":

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
<%@page contentType="text/html" pageEncoding="UTF-8" trimDirectiveWhitespaces="true" %>
<%@taglib prefix="c" uri="jakarta.tags.core" %>
<table id="mytable">
	<thead>
		<tr>
			<th>Date</th>
			<th>User ID</th>
			<th>Start</th>
			<th>Destination</th>
			<th>KM</th>
			<th>Expenses</th>
			<th>Description</th>
		</tr>
	</thead>
	<tbody id="tbody">
	<c:forEach items="${trips}" var="trip">
		<tr>
			<td><c:out value="${trip.date}"/></td>
			<td><c:out value="${trip.user}"/></td>
			<td><c:out value="${trip.start}"/></td>
			<td><c:out value="${trip.destination}"/></td>
			<td><c:out value="${trip.km}"/></td>
			<td><c:out value="${trip.expenses}"/></td>
			<td><c:out value="${trip.description}"/></td>
		</tr>
	</c:forEach>
	</tbody>
</table>

You can see that the concept is basically the same. Instead of having htmx do the interpolation, we'll use Pages's loops. That <c:forEach/> is basically <xp:repeat/> from XPages, while <c:out/> is basically <xp:text/>.

Controller Glue

Now that we have the data model and the HTML/htmx view, we'll tie them together with some controller classes. The first is the one that serves up the home page, and it's lean:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
package controller;

import jakarta.mvc.Controller;
import jakarta.mvc.View;
import jakarta.ws.rs.GET;
import jakarta.ws.rs.Path;

@Path("/")
@Controller
public class HomeController {
	@GET
	@View("home.jsp")
	public void get() {
	}
}

With this in place, visiting "dev/htmx.nsf/xsp/app" will load up index.jsp from above.

The controller class for our API is a bit more complicated:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
package controller;

import jakarta.inject.Inject;
import jakarta.mvc.Controller;
import jakarta.mvc.Models;
import jakarta.mvc.View;
import jakarta.ws.rs.GET;
import jakarta.ws.rs.Path;
import model.Trip;

@Path("apihandler")
@Controller
public class ApiController {
	@Inject
	private Trip.Repository tripRepository;
	
	@Inject
	private Models models;
	
	@Path("trips")
	@GET
	@View("trips-table.jsp")
	public void listTrips() {
		models.put("trips", tripRepository.listTrips().toList());
	}
}

Here, we use CDI to @Inject the repository for our Trip entity. The Models object comes from MVC, and it's what we use to populate the data that the .jsp file will need for its loop. You can then see the method where we call "dev/htmx.nsf/xsp/app/apihandler/trips" - it reads in the Trip objects and puts them in the "trips" Models field. That's how the <c:forEach/> in the Pages file gets its hands on it.

And with that, we're done! The result looks just the same as Heiko's example, and that was the goal. This was good to prove to myself that htmx is a great match here, and I'm tempted to try it in a larger project now.

Adding Java Flight Recorder Reports To Domino-Container-Run Tests

Mon Nov 03 15:55:13 EST 2025

About a year and a half ago, I wrote a post talking about adding JaCoCo code coverage to the integration-test suite of the XPages JEE project.

Today, I happened to notice that, though unheralded, Domino 14.5 FP1 bumped the JVM from Semeru 21.0.6 to 21.0.8. Normally, these little patch-level bumps are pretty irrelevant, but this one was a big deal: Java Flight Recorder support came to Semeru 21 in 21.0.7, so now we have it.

JFR Basics

Java Flight Recorder, in turn, is a built-in capability to monitor and profile your Java applications, similar to things like YourKit. The prime advantage that JFR brings is that it's very low-overhead, enough that it's possible to just run passively in all but particularly-performance-sensitive situations. Even if you don't keep it on all the time, though, it's possible to turn it on and off at will for a running application, whether or not you configured profiling ahead of time. That makes it very intriguing for production environments where you would be disinclined to install, say, the YourKit agent ahead of time.

With HotSpot JVMs, you can automatically enable JFR via a JVM argument, but Semeru doesn't currently implement that. Instead, Semeru supports just the manual way (which is also in HotSpot), using the jcmd tool, which comes with the JDK.

Working with JFR in Domino

The way jcmd works is that you give it the PID of a running compatible Java application and execute one of a number of commands. The tool predates JFR, but I hadn't had a need to know about it until now.

Domino doesn't ship with jcmd, but the one in a download of a Semeru JDK will work. I don't know if it has to be the same patch version or not, but I got the matching one for my tests. Then, you can run jcmd -l to get a list of running JVMs, like:

C:\java\jdk-21.0.8+9\bin>jcmd -l
10904
8884 <no information available>

Those are... well, they're just the PIDs of Java apps. Here, I happen to know that Domino's nhttp.exe is 10904 because it's the one that doesn't say "no information available", but in practice you'll want to do something proper to get your desired PID. In a Domino container, pgrep http will likely give you the number you want.

PID in hand, you can run execute a "JFR.start" command to start recording:

C:\java\jdk-21.0.8+9\bin>jcmd 10904 JFR.start filename=C:\out.jfr
Start JFR recording to C:\out.jfr

I recommend specifying a filename like that, since otherwise it'll always be the same stock one based on a JFR profile. If you're running automated tools, you'll want to avoid collisions.

Once you do whatever it is you want to profile, you can stop the recording:

C:\java\jdk-21.0.8+9\bin>jcmd 10904 JFR.stop
Stop JFR recording, and dump all Java threads to C:\out.jfr

That's about it, at least for the normal case. That file can be read by a number of tools. I use the aforementioned YourKit, since I have and like it, but you can also use the jfr command-line tool, the official JDK Mission Control app, IntelliJ, and likely other things.

Hooking It Into The Test Suite

Doing manual sampling like this is very useful on its own, and I do this sort of thing a lot when getting into the weeds of performance tuning. The low overhead, though, means it's a perfect addition to a full test suite as a "may as well" sort of thing.

Getting this going took a couple steps, but it wasn't too bad once I figured it out.

The first hurdle is the lack of jcmd in the Domino container. Fortunately, this is a straightforward fix: just grab it from the Semeru Docker image during build.

1
COPY --from=ibm-semeru-runtimes:open-21-jdk /opt/java/openjdk/bin/jcmd /opt/hcl/domino/notes/latest/linux/jvm/bin

Next, I want to kick off JFR before any tests run. Initially, I put this in the containerIsStarted method of the Testcontainer class, but the timing didn't work out there. Instead, it ended up in the PostInstallFactory Java class I have to do post-HTTP-init tasks:

1
2
3
4
5
6
7
Path jcmd = Paths.get("/opt/hcl/domino/notes/latest/linux/jvm/bin/jcmd");
if(Files.isExecutable(jcmd)) {
	long pid = ProcessHandle.current().pid();
	new ProcessBuilder(jcmd.toString(), Long.toString(pid), "JFR.start", "filename=/tmp/flight.jfr")
		.start()
		.waitFor();
}

This command will fail on Domino before 14.5FP1, but that's fine.

Finally, when the test runtime sees it's on a high-even version, it grabs the file on container exit:

1
2
3
4
5
6
7
8
if(useJfr()) {  // Does some checks based on container label
	// Find the PID for http to pass to jcmd
	String pid = this.execInContainer("pgrep", "http").getStdout();
	if(pid != null) {
		this.execInContainer("/opt/hcl/domino/notes/latest/linux/jvm/bin/jcmd", pid, "JFR.dump");
		this.copyFileFromContainer("/tmp/flight.jfr", target.resolve("flight-" + System.currentTimeMillis() + ".jfr").toString());
	}
}

With that in place, running the test suite will drop - alongside the log files and JaCoCo report - the JFR results in the "target" directory. Et voilà:

JFR flame graph in YourKit

The information isn't as fine-grained as full profiling may get, but it'll give you a good comparison of relative expense of various calls, and that's often all you really need.

This is very neat and should be a handy addition. I'm looking forward to seeing what other good uses I can get out of JFR now that we have access to it.