Java is an object-oriented programming language that allows engineers to produce software for multiple platforms. Our resources in this Zone are designed to help engineers with Java program development, Java SDKs, compilers, interpreters, documentation generators, and other tools used to produce a complete application.
OBO SSO in Java Applications: Securely Calling Downstream APIs on Behalf of a User
HTTP QUERY in Java: The Missing Method for Complex REST API Searches
With the increasing number of security threats, organizations have invested heavily in cybersecurity initiatives to protect their applications, infrastructure, and sensitive data. Security vulnerabilities are rarely introduced intentionally. Most of them creep into applications through shortcuts, overlooked edge cases, outdated libraries, or some bad coding habits. Modern Java has significantly improved its security capabilities, but no framework or JVM version can completely protect an application from insecure coding practices. As developers, we still need to understand where vulnerabilities originate and how to prevent them before they reach production. In this article, I am trying to summarize some of the most common Java security vulnerabilities and practical techniques used to prevent them. These are the same security best practices and lessons learned that I frequently share with new team members joining my team. I am sharing them here in the hope that they can serve as a practical handbook for Java developers looking to build more secure applications. 1. SQL Injection SQL injection remains one of the oldest and most dangerous vulnerabilities. It occurs when user input is directly concatenated into SQL statements. Consider the following example: Java String query = "SELECT * FROM users WHERE username = '" + username + "'"; Statement stmt = connection.createStatement(); ResultSet rs = stmt.executeQuery(query); If an attacker enters, the query can be manipulated to return unintended results. SQL admin' OR '1'='1 Prevention Always use parameterized queries. Java String query = "SELECT * FROM users WHERE username = ?"; PreparedStatement stmt = connection.prepareStatement(query); stmt.setString(1, username); ResultSet rs = stmt.executeQuery(); Prepared statements separate data from executable SQL, eliminating injection opportunities. 2. Hardcoded Secrets One of the most common findings during security reviews is hardcoded credentials. Java private static final String API_KEY = "abcd123456789"; This may seem harmless during development, but once committed to source control, secrets often remain exposed indefinitely. Prevention Store secrets externally. SQL String apiKey = System.getenv("PAYMENT_API_KEY"); Better alternatives are to include it in AWS Secrets Manager, Azure Key Vault, HashiCorp Vault, or Kubernetes Secrets. Secrets should never live inside source code repositories. 3. Insecure Deserialization Java serialization has been responsible for numerous security incidents. Example: Java ObjectInputStream input = new ObjectInputStream(request.getInputStream()); Object obj = input.readObject(); The danger is that attackers can craft malicious serialized objects that execute unexpected code during deserialization. Prevention Avoid Java serialization whenever possible. Prefer formats such as JSON, XML (with secure parsing), or Protocol Buffers. Example using Jackson: Java ObjectMapper mapper = new ObjectMapper(); User user = mapper.readValue(json, User.class); Using structured formats reduces attack surfaces significantly. 4. Cross-Site Scripting (XSS) Although often associated with front-end applications, backend services can accidentally enable XSS vulnerabilities when user-generated content is returned without sanitization. Example: Java String comment = request.getParameter("comment"); response.getWriter().write(comment); If the user submits, the browser executes the script. HTML <script>alert('Hacked')</script> Prevention Always encode output. Using Spring: Java String safeComment = HtmlUtils.htmlEscape(comment); Additionally, validate inputs, sanitize rich text, and implement Content Security Policies (CSP). 5. Path Traversal Attacks File download functionality often introduces path traversal vulnerabilities. Example: Java String file = request.getParameter("file"); Path path = Paths.get("/documents/" + file); An attacker could submit and potentially access sensitive files. Shell ../../../etc/passwd Prevention Normalize and validate paths. Java Path base = Paths.get("/documents"); Path resolved = base.resolve(file).normalize(); if (!resolved.startsWith(base)) { throw new SecurityException( "Invalid file path"); } Never trust file names coming directly from user input 6. Weak Password Storage Storing passwords improperly remains surprisingly common. Bad practice: Java String passwordHash = DigestUtils.md5Hex(password); MD5 and SHA-1 are no longer considered secure for password storage. Prevention Use adaptive hashing algorithms. Example with BCrypt: Java BCryptPasswordEncoder encoder = new BCryptPasswordEncoder(); String hash = encoder.encode(password); BCrypt automatically includes salting and work-factor adjustments. Other strong alternatives include Argon2, PBKDF2 or SCrypt 7. Dependency Vulnerabilities Modern Java applications often contain more third-party code than custom code. A secure application can still become vulnerable because of outdated dependencies. Prevention Integrate dependency scanning into CI/CD pipelines. Example Maven plugin: XML <plugin> <groupId>org.owasp</groupId> <artifactId>dependency-check-maven</artifactId> </plugin> Additionally, tools such as Snyk can automatically identify known vulnerabilities. We have been using Snyk for the last couple of years, and it is effective. Regular dependency updates should be part of every release cycle. 8. Improper Logging of Sensitive Data Developers often log information for troubleshooting without considering security implications. Example: Java logger.info( "Login request received for user={} password={}", username, password); This exposes credentials inside log files. Prevention Mask or exclude sensitive information. Java logger.info( "Login request received for user={}", username); Never log passwords, access tokens, credit card information, Personal health information (PHI), or PII information. This is especially important in regulated industries such as healthcare, like ours. 9. Insufficient Authentication and Authorization Authentication verifies identity, and authorization determines access. Many applications perform authentication correctly but fail to enforce authorization consistently. Example: Java @GetMapping("/admin/users") public List<User> getUsers() { return userService.findAll(); } Without authorization checks, any authenticated user might gain access. Prevention Use role-based security. Java @PreAuthorize("hasRole('ADMIN')") @GetMapping("/admin/users") public List<User> getUsers() { return userService.findAll(); } Security should be enforced at every layer, not just the UI. 10. Lack of Input Validation Many vulnerabilities originate from accepting unexpected input. Example: Java String age = request.getParameter("age"); int userAge = Integer.parseInt(age); Invalid input can cause exceptions or unexpected behavior. Prevention Validate all external input. Java @Min(18) @Max(120) private Integer age; Bean Validation provides a simple and consistent approach for validating request payloads. Never assume user input is safe. Final Thoughts Security is not a feature that can be added at the end of a project. It needs to be part of the development process from the very beginning. The vulnerabilities discussed here are not theoretical. They are among the most common findings during security assessments, penetration tests, and production incident investigations. Fortunately, modern Java provides mature frameworks, libraries, and tools that make secure development significantly easier than it was a decade ago. The key is building security awareness into everyday development practices: Use parameterized queriesProtect secrets properlyValidate all inputsKeep dependencies updatedApply strong authentication and authorizationLog responsiblyContinuously scan for vulnerabilities Security is ultimately about reducing risk. Small improvements applied consistently across a codebase can prevent incidents that would otherwise become expensive lessons later.
This is the first follow-up to Friday's release post, and it covers the two changes from this release that affect how you iterate on a Codename One app rather than what the app itself does. On-device debugging that treats Java as Java on a real iPhone or a real Android device, and standard JUnit 5 against the JavaSE simulator. The first is the one we have been wanting for a long time, and is the one that takes the most explaining, so most of the post is about it. On-Device Debugging That Treats Java as Java Codename One has always supported on-device debugging in the strict technical sense. You could attach Xcode to a .ipa, you could attach Android Studio to a running APK, you could read the native call stack, you could step through Objective-C or the C that ParparVM emits. What you could not do was set a breakpoint in MyForm.java, hit it on a real iPhone, and inspect a Java field on a Java object as a Java object. You also could not debug an iOS app without a Mac in the loop somewhere, because the only debugger that understood the binary was Xcode. The translation step between the Java you wrote and the C that ParparVM produces left no way back across the gap on the device. PR #4999 (iOS) and PR #5012 (Android) close that gap. As of this week, any JDWP-speaking debugger (IntelliJ IDEA, jdb, VS Code's Java Debugger, Eclipse, NetBeans) can attach to a Codename One app and treat the running process as a JVM. Supported targets: iOS The iOS Simulator (requires a Mac, because the iOS Simulator only runs on a Mac),A real iPhone reached over Wi-Fi from the developer machine on the same network. You do not need a local Mac to debug on a real iPhone. The Codename One build cloud runs the iOS build for you and produces a signed .ipa; install it on your iPhone the usual way (TestFlight, ad-hoc, or the standard Build Cloud install link), and the JDWP attach over Wi-Fi works from a Linux or Windows IDE just as well as from a Mac. The Mac is only required for the local Xcode build path and for running the iOS Simulator. Android The Android emulatorA real Android phone over USBA real Android phone over wireless adb The Android attach uses standard adb, so you need the Android SDK platform tools installed on the developer machine. Those are available on macOS, Linux, and Windows, so any of the three is fine for Android debugging. What It Looks Like A breakpoint inside an iOS app, hit on the iOS Simulator next to IntelliJ IDEA: The same Debug tool window you use for any other Java project. The frames panel on the left has the full Java call stack. The Variables panel shows this and the locals as Java values, with the same drill-down you would get on a regular JVM. The simulator on the right is the real iOS app, paused at the breakpoint, waiting for the next step. How the Pieces Fit Together On iOS, the IDE never talks to the device directly. The CN1 Debug Proxy is a small Java process you run on your developer machine. It binds two TCP ports: one for the iOS app to dial into using the CN1 wire protocol, and one that speaks standard JDWP for the IDE. The IDE sees a normal remote JVM. The iOS app sees a debug proxy. The proxy translates between the two and walks the ParparVM struct layout so Java fields, method calls, and values round-trip cleanly in both directions. On Android, the proxy is unnecessary. Dalvik/ART implements JDWP themselves, so IntelliJ attaches directly to the device through adb's built-in JDWP forwarder. The Maven plugin's new cn1:android-on-device-debugging goal does the adb orchestration and the port forwarding for you. A capability difference between the two platforms worth knowing up front: on Android, a native interface's Impl class is regular Java, so the JDWP attach steps through it the same way it steps through any other class in your project. On iOS the Impl is Objective-C, which JDWP does not speak, so you cannot step through it from the IDE. You can still step through the Codename One framework code and your own Java up to and through the native-interface call, and you can inspect the value the call returns; the body of the Objective-C method is the only thing that is opaque from the JDWP side. Attach Xcode in parallel if you need to step through the Objective-C as well. Tutorial: IntelliJ + iOS The Codename One archetype now generates two run configurations under an On-Device Debug folder in the IntelliJ run-config dropdown: CN1 Debug Proxy and CN1 Attach iOS. The tutorial below assumes a project generated from the Initializr recently enough to have those. If you have an older project, generate a new project with initializr and copy over the .idea directory and maven pom.xml files. 1. Enable the Build Hints Open common/codenameone_settings.properties and uncomment the four lines the archetype generated: Properties files ios.onDeviceDebug=true ios.onDeviceDebug.proxyHost=127.0.0.1 ios.onDeviceDebug.proxyPort=55333 ios.onDeviceDebug=true flips the iOS build into the instrumented variant. The other three configure the proxy connection. The fourth hint, ios.onDeviceDebug.waitForAttach=true, is the block-on-load option, and we recommend leaving it on. With it enabled, the iOS app shows a "Waiting for debugger" overlay at launch and does not progress past Display.init until the proxy issues its first resume. The recommendation is mostly about making the on-device-debug variant visible. Without the overlay it is easy to launch an on-device-debug build expecting the debugger to attach and not realize it is silently waiting for a proxy that is not running, and it is also easy to mistake an on-device-debug build for a regular build and then be surprised when it does not perform as smoothly as the release variant. The overlay rules out both of those. For a physical iPhone the proxyHost value should be the laptop's LAN IP (run ifconfig | grep "inet " to find it) rather than 127.0.0.1. The iOS Simulator can always use 127.0.0.1. 2. Build the iOS App Either path works: Local Xcode build (mvn cn1:buildIosXcodeProject) and then run from Xcode.Cloud build for a real device (mvn cn1:buildIosOnDeviceDebug) and install the resulting .ipa. Both produce an iOS binary instrumented for on-device debugging because the build hint is set. 3. Start the Proxy In IntelliJ, pick CN1 Debug Proxy from the run-config dropdown and click the green Run button (not the bug icon; Debug on this config would attach IntelliJ to the proxy itself, which is not what you want). The Run tool window shows: Plain Text On-device-debug proxy starting: symbols : .../cn1-symbols.txt device : listening on tcp://0.0.0.0:55333 jdwp : listening on tcp://0.0.0.0:8000 [device] listening on port 55333 for ParparVM app to dial in When the [jdwp] line appears, the proxy is ready. 4. Attach the Debugger Switch the run-config dropdown to CN1 Attach iOS and click the Debug button. IntelliJ connects to localhost:8000 and opens its standard Debug tool window. You can now set breakpoints anywhere in your Java code or in the framework. 5. Launch the App Launch the iOS app under the iOS Simulator (from Xcode) or on the tethered device. With waitForAttach=true it pauses at the "Waiting for debugger" overlay until the proxy issues its first resume. Hit Resume on the IntelliJ Debug toolbar; the app proceeds, your breakpoints fire as the app exercises them. The proxy's Run window is also your device console. Anything the app writes to System.out, Log.p, printf, or NSLog from native code is forwarded to the proxy and printed in the CN1 Debug Proxy Run window with a [device] prefix. This is genuinely useful and is one fewer thing you need Xcode for. The caveat is that the forwarding starts when the proxy connection is established, so output written during the very first millisecond of process launch (before Display.init) is not always captured. If you need every byte from t=0, attach Xcode's console for that specific run. Tutorial: IntelliJ + Android Android is simpler because the proxy is not needed. The archetype generates two run configurations under the same On-Device Debug folder: CN1 Android On-Device Debug (Maven, builds and installs the APK and forwards JDWP) and CN1 Attach Android (Remote JVM Debug at localhost:5005). 1. Enable the Build Hint In common/codenameone_settings.properties: Properties files android.onDeviceDebug=true This single hint flips the manifest to debuggable="true" and turns R8 / Proguard off for this build. Release builds without the hint are unaffected. 2. Run CN1 Android On-Device Debug Picks up the hint, builds the APK, installs it on the connected device or emulator, sets the debug-app for wait-for-attach, launches the Activity, forwards JDWP to localhost:5005, and streams logcat --pid=<pid> into the Run window with a [device] prefix. For wireless adb, pass -Dcn1.android.onDeviceDebug.wireless=<ip:port> and the goal will adb connect before installing. Both the Android 11+ adb pair flow and the legacy adb tcpip flow work. 3. Attach the Debugger Switch to CN1, Attach Android, and click Debug. IntelliJ connects to localhost:5005. Set breakpoints anywhere; they fire when exercised. Source resolution covers both the codenameone-core and codenameone-android sources jars, so breakpoints inside the framework or inside the Android port resolve to the right files. On Android, native interfaces are themselves Java, so a breakpoint inside the Impl class of your own native interface fires just like a breakpoint anywhere else in your code; you can step through the implementation, inspect locals, and evaluate expressions the same way. The dev guide has the full reference, including the wireless-pairing flows, the VS Code and Eclipse equivalents, and a troubleshooting section: iOS on-device debugging and Android on-device debugging. When to Use It (and When Not To) For most bugs, the JavaSE simulator is still, by a large margin, the fastest loop. Reach for on-device debugging when the bug is platform-specific: ParparVM-specific threading, an iOS-only layout glitch under the modern native theme, a real-radio Bluetooth interaction, a Touch ID gate, an Android-only manifest interaction, anything that only reproduces under iOS background memory pressure. The kind of bug that previously sent you reaching for Log.p and a rebuild loop. That bug now has a debugger pointed at it. JUnit 5 Against the Simulator The other change in this release is the new JUnit 5 integration in the JavaSE port (PR #5032). To be clear about what this is: it is standard JUnit 5. There is no fork of JUnit in com.codename1.testing.junit. That package holds a small set of annotations and a CodenameOneExtension that plugs into the regular JUnit Jupiter lifecycle. You write @Test methods using org.junit.jupiter.api.Test, you assert with org.junit.jupiter.api.Assertions, and your IDE's native test runner picks them up the way it does on any other Java project. Why a separate integration at all? The legacy com.codename1.testing.AbstractTest framework, driven by the cn1:test Maven goal, still exists and is still the only way to run tests on a real iOS or Android device (JUnit Jupiter is not available on ParparVM). The trade-off is that AbstractTest tests have to compile under the Codename One device subset, with no reflection, no java.net.http, no java.nio.file, no Mockito, no AssertJ, no assertThrows. JUnit-style tests run only on the JavaSE simulator JVM, but the JVM is a regular JVM, so reflection, Mockito, AssertJ, and parameterized tests are all available. Both styles coexist in the same project under common/src/test/java. You pick per test class. The runners discover disjoint sets (cn1:test looks for UnitTest implementers; Surefire looks for @Test methods), so a mvn install runs both passes in the same phase without overlap. A Minimal Test Tests live in common/src/test/java. The shape most apps want is one that boots the project's app class through the same init / start sequence the simulator uses, then asserts against the form the app actually opens: Java package com.example.myapp; import com.codename1.testing.junit.CodenameOneTest; import com.codename1.testing.junit.RunOnEdt; import com.codename1.ui.CN; import com.codename1.ui.Display; import com.codename1.ui.Form; import org.junit.jupiter.api.Test; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertTrue; @CodenameOneTest class GreetingFormTest { @Test @RunOnEdt void formShowsExpectedTitle() { MyAppName app = new MyAppName(); app.init(null); app.start(); assertEquals("Hi World", Display.getInstance().getCurrent().getTitle()); assertTrue(CN.isEdt(), "@RunOnEdt method runs on the Codename One EDT"); } } That is more useful than constructing a Form directly in the test because it exercises the same startup path the simulator runs. The assertions check the form your app opens, not a form the test wrote. The natural way to run it is from the IntelliJ gutter. Click the green icon next to the class declaration: The results land in the standard Run tool window: Click the green icon next to a specific @Test method to run just that method. The same flow works in VS Code's Test Explorer and in Eclipse's JUnit view. If you prefer the command line: Shell mvn -Ptest test # run the JUnit suite mvn -Ptest test -Dtest=GreetingFormTest # one class mvn -Ptest test -Dtest=GreetingFormTest#formShowsExpectedTitle @CodenameOneTest is the class-level entry point. It wires the simulator extension into the JUnit Jupiter lifecycle, boots Display.init(null) once per JVM (idempotent, so subsequent classes share the same Display), and skips the class with a TestAbortedException if the JVM is genuinely headless (so CI runners that have no display do not poison the rest of the run). @RunOnEdt dispatches the test body through CN.callSerially, which is what you want any time the body touches UI state. It rethrows the body's exceptions on the JUnit thread so the stack trace stays clickable in the IDE. Place it on the method for one test, on the class to apply to every test. A Couple More Common Cases A test that exercises a plain validator, with no UI involved at all: Java @CodenameOneTest class EmailValidatorTest { @Test void rejectsEmptyString() { assertFalse(new EmailValidator().isValid("")); } @Test void acceptsCommonAddress() { assertTrue(new EmailValidator().isValid("[email protected]")); } } This is the "pure model code" shape. No @RunOnEdt, no UI, runs on the JUnit worker thread, fast. A test of a form under a specific visual configuration: Java @CodenameOneTest class GreetingFormVisualTest { @Test @RunOnEdt @DarkMode @LargerText(scale = 1.6f) void titleStillFitsInDarkModeAtAccessibilityScale() { new GreetingForm().show(); Form current = Display.getInstance().getCurrent(); assertEquals("Hello", current.getTitle()); assertTrue(current.getPreferredW() <= Display.getInstance().getDisplayWidth()); } } The visual-config annotations (@Theme, @DarkMode, @LargerText, @Orientation, @RTL) apply on the EDT in one batch, followed by a single theme refresh, so the test body sees the simulator in the exact configuration you asked for without flicker. A test that injects a custom property for the duration of one method: Java @Test @RunOnEdt @SimulatorProperty(name = "feature.flag", value = "on") void newCodePathRunsWhenFlagIsOn() { // Display.getProperty("feature.flag", "off") returns "on" here runFeature(); assertEquals("expected", Display.getInstance().getCurrent().getTitle()); Class-level @SimulatorProperty applies to every method in the class. Method-level overrides class-level. Use the container @SimulatorProperties for more than one (the package source level rules out @Repeatable). The full reference, including the dependency-block YAML for common/pom.xml and javase/pom.xml and the @Theme / @Orientation / @RTL details, is at Testing with JUnit 5 in the developer guide. Wrapping Up That is the workflow half of this release. Tomorrow's post covers the new platform APIs that moved into the core this week: AI and OAuth/OIDC are the headline pieces, with wifi/connectivity and a few smaller items alongside them. Back to the weekly index.
The MovieManager project has been updated to use JDK 25 and the AOT cache from project Leyden. Project Leyden is part of the OpenJDK project and provides cached linking and cached performance statistics. That means the time spent linking at startup is moved to build time, and the statistics are created during a test run at build time as well. Because of that, the JVM loads the needed classes already linked and starts compiling the hot code paths immediately. The MovieManager application starts in less than half the time with these optimizations without any code changes. All these advantages come with preconditions: Exactly the same JVM version at build time, training time, and run timeThe same OS(Linux is used here) and libc at all steps -> (No Alpine-based Docker Images)Same CPU architecture, for example, AMD64 or ARM64 The steps to use Project Leyden: Build the Spring Boot ApplicationExtract the Spring Boot ApplicationDo a training run with the extracted Application to create the AOT cacheCreate the Docker Image with the extracted Application and the AOT cache Building and Training the Application The first step is to build the Spring Boot JAR. The MovieManager project has an integrated build that builds the Angular frontend and the Spring Boot backend with this Maven command: Shell ./mvnw clean install -Ddocker=true -Dnpm.test.script=test-chromium Project Leyden does not support Spring Boot Jars. The Jar has to be extracted to help Project Leyden find the used library jars of the project. To do that, this command needs to be used: Shell java -Djarmode=tools -jar backend/target/moviemanager-backend-0.0.1-SNAPSHOT.jar extract --destination extracted The result is the directory ‘extracted’ with the application jar and a sub-directory ‘lib’ that contains the used libraries. The second step is to create the AOT cache. To do that, the application has to run in production conditions. That means using a real PostgreSQL database with the database driver. That enables the JDK to record all the needed classes of the project and to create realistic performance statistics for the code compilation. To do this, a PostgreSQL database has to be started(done here in a Docker container), and the Application has to do the full startup. These commands are needed: Shell docker pull postgres:13 docker run --name local-postgres -e POSTGRES_PASSWORD=sven1 -e POSTGRES_USER=sven1 -e POSTGRES_DB=movies -p 5432:5432 -d postgres java -XX:+UseG1GC -XX:MaxGCPauseMillis=50 -XX:+UseCompressedOops -XX:+UseCompactObjectHeaders -XX:+ExitOnOutOfMemoryError -XX:MaxDirectMemorySize=64m -XX:+UseStringDeduplication -Xlog:aot -XX:AOTCacheOutput=app.aot -Dspring.context.exit=onRefresh -Djava.security.egd=file:/dev/./urandom -jar extracted/moviemanager-backend-0.0.1-SNAPSHOT.jar --spring.profiles.active=prod The Java command runs the application with the parameter ‘-Dspring.context.exit=onRefresh’ that makes Spring Boot do the full startup and exit then. The parameters ‘-Xlog:aot -XX:AOTCacheOutput=app.aot’ enable the logging of the AOT process and the creation of the ‘app.aot’ that is the AOT cache. The AOT cache contains everything that is needed for a fast startup of the application. If the AOT cache should also contain information to improve production performance, it would have to start up and process realistic production requests. That is beyond the scope of this article. The third step is to test the new application setup: Shell java -XX:+UseG1GC -XX:MaxGCPauseMillis=50 -XX:+UseCompressedOops -XX:+UseCompactObjectHeaders -XX:+ExitOnOutOfMemoryError -XX:MaxDirectMemorySize=64m -XX:+UseStringDeduplication -Xlog:class+path=info -XX:AOTCache=app.aot -Xlog:aot -Djava.security.egd=file:/dev/./urandom -jar extracted/moviemanager-backend-0.0.1-SNAPSHOT.jar --spring.profiles.active=prod The start-up time of the new setup with the AOT cache can be compared to the start-up time of the Spring Boot jar. On a medium-powered laptop, the times are: 9 seconds for the Spring Boot Jar3.5 seconds for the new setup with the AOT cache Creating a Docker Image To use the application in production, it needs to be packaged into a Docker image. The Docker image needs to contain the extracted application setup and the AOT cache. The base image needs to have the exact same JDK version, OS, and the same libc. That means small base images like Alpine cannot be used. The created Image can not be small because it contains 180 MB of AOT cache and a larger base image. This can be done with this Dockerfile: Dockerfile FROM eclipse-temurin:25.0.3_9-jdk-jammy WORKDIR /application ARG JAR_FILE=extracted/*.jar COPY ${JAR_FILE} moviemanager-backend-0.0.1-SNAPSHOT.jar COPY extracted/ ./ COPY app.aot app.aot ENV JAVA_OPTS="-XX:+UseG1GC \ -XX:MaxGCPauseMillis=50 \ -XX:+UseCompressedOops \ -XX:+UseCompactObjectHeaders \ -XX:+ExitOnOutOfMemoryError \ -XX:MaxDirectMemorySize=64m \ -XX:+UseStringDeduplication" ENTRYPOINT exec java $JAVA_OPTS -XX:+AOTClassLinking \ -XX:AOTCache=app.aot \ -Xlog:class+path=info \ -Djava.security.egd=file:/dev/./urandom \ -jar moviemanager-backend-0.0.1-SNAPSHOT.jar It copies the new application setup in the image and adds the AOT cache. The name of the application jar is in the AOT cache and has to be exactly the same as during the creation of the AOT cache. The ‘JAVA_OPTS’ also have to be the same. If the JDK version in the build environment changes, the version of the base image has to be adjusted accordingly. The parameter ‘-Xlog:class+path=info’ makes analyzing AOT problems much easier. The Docker container size is 705 MB. That makes the container about double the size of a Docker container with a Spring Boot Jar and an Alpine-based JDK image. Creating a Build Pipeline Creating Docker images for an application by hand is unsustainable in a production environment. A build pipeline is needed. The MovieManager project is hosted on GitHub; because of that, the project uses a GitHub Workflow as a build pipeline. The complete code for the build pipeline is in the script. The steps of the GitHub pipeline can be recreated in other environments too. The first step is to set up the PostgreSQL database service to be used in this build: YAML jobs: analyze: name: Analyze runs-on: ubuntu-latest env: POSTGRES_URL: jdbc:postgresql://localhost:5432/movies services: postgres: image: postgres:latest env: POSTGRES_USER: sven1 POSTGRES_PASSWORD: sven1 POSTGRES_DB: movies ports: - 5432:5432 options: >- --health-cmd="pg_isready -U sven1 -d movies" --health-interval=10s --health-timeout=5s --health-retries=5 The commands set up the PostgreSQL service in the build pipeline with user, password, dbname, and dbport. The ‘POSTGRES_URL’ is set to access the database later. The second step is to check out the project: YAML steps: - name: Checkout repository uses: actions/checkout@v3 It checks out the contents of the master branch. The third step is to provide the JDK: YAML - name: Setup Java JDK uses: actions/setup-java@v3 with: distribution: 'temurin' java-version: 25 JDK version 25 is the minimum to use the project Leyden with linking and performance statistics. The fourth step builds the Spring Boot Jar: YAML - name: Build with Maven if: matrix.language == 'java' run: | ./mvnw clean install -Ddocker=true That is the Maven command to build the project. The fifth step is to find the Spring Boot jar: YAML - name: Find fat jar if: matrix.language == 'java' id: jar run: | JAR_PATH=$(find ./backend/target -type f -name "*SNAPSHOT.jar" | head -n 1) echo "Found JAR: $JAR_PATH" echo "jar=$JAR_PATH" >> $GITHUB_OUTPUT The sixth step is to extract the Spring Boot jar: YAML - name: Unpack fat jar if: matrix.language == 'java' id: UNPACK run: | java -Djarmode=tools -jar ${{ steps.jar.outputs.jar } extract --destination extracted EXTRACTED_PATH=$(find . -type d -name "extracted" | head -n 1) echo "Found directory: $EXTRACTED_PATH" echo "extracted=$EXTRACTED_PATH" >> $GITHUB_OUTPUT The seventh step is to get the name of the extracted application jar: YAML - name: find extracted jar if: matrix.language == 'java' id: EXTRACT run: | EXTRACTED_JAR=$(find "${{ steps.UNPACK.outputs.extracted }" -type f -name "*.jar" | head -n 1) EXTRACTED_JAR=${EXTRACTED_JAR#./} echo "Found extracted JAR: $EXTRACTED_JAR" echo "extracted=$EXTRACTED_JAR" >> $GITHUB_OUTPUT The eighth step is to create the AOT cache: YAML - name: Create AOT cache if: matrix.language == 'java' id: AOT env: JAVA_TOOL_OPTIONS: "" _JAVA_OPTIONS: "" JDK_JAVA_OPTIONS: "" run: | EXTRACTED_JAR="${{ steps.EXTRACT.outputs.extracted }" echo "jar=$EXTRACTED_JAR" echo "JAVA_TOOL_OPTIONS=$JAVA_TOOL_OPTIONS" echo "_JAVA_OPTIONS=$_JAVA_OPTIONS" echo "JDK_JAVA_OPTIONS=$JDK_JAVA_OPTIONS" JAVA_OPTS="-XX:+UseG1GC -XX:MaxGCPauseMillis=50 -XX:+UseCompressedOops -XX:+UseCompactObjectHeaders -XX:+ExitOnOutOfMemoryError -XX:MaxDirectMemorySize=64m -XX:+UseStringDeduplication" java $JAVA_OPTS \ -XX:+AOTClassLinking \ -XX:AOTCacheOutput=app.aot \ -Xlog:aot \ -Dspring.context.exit=onRefresh \ -Dspring.datasource.url="${{ env.POSTGRES_URL }" \ -Dspring.profiles.active=prod \ -jar "$EXTRACTED_JAR" || echo "AOT Training finished with exit code $?" This runs the application startup with the PostgreSQL database to create the AOT cache. The ninth step shows the exact JDK version used in the AOT cache generation: YAML - name: Show Jdk version if: matrix.language == 'java' id: JDK run: | JDK_VERSION=$(java -version 2>&1) VERSION=$(echo "$JDK_VERSION" | sed -n 's/.*build \([^[:space:]]*\)-LTS.*/\1/p') echo "JDK_VERSION=$JDK_VERSION" echo "VERSION=$VERSION" MY_VERSION="jdk=$VERSION" In case of problems with using the AOT cache. The first check is the version shown here against the JDK version in the Docker base image. The tenth step creates the Docker image: YAML - name: Build and push uses: docker/build-push-action@v6 if: matrix.language == 'java' with: context: . file: ./Dockerfile build-args: | JAR_PATH=${{ steps.EXTRACT.outputs.extracted } LIB_PATH=${{ steps.aot.outputs.extracted } push: false tags: angular2guy/moviemanager:latest This step can push the Docker image to an image repository. Conclusion The results of using the AOT cache of project Leyden are impressive. Cutting the startup time in half without any code change is amazing. The effort to create the AOT cache and set up the new application is a one-time investment. The impact of the larger Docker Images is low. That makes scaling application instances in Kubernetes clusters up and down much more flexible because the time to the availability of a new application instance is much lower. In Kubernetes environments with scaling of application instances, the AOT cache is a significant step forward and should be used. For serverless applications 3.5 seconds startup time is too slow. Their project, CrAC or Native Image, would be needed. Project CrAC needs code changes and testing. Native Image has the closed-world assumption, which makes it hard to prove that larger applications work correctly. Alternatives are Node.js with Nest.js and TypeScript, or Go with its libraries. Project Leyden is not finished in JDK 25. There are plans to add compiled code to the AOT cache in the future. The JVM is an impressive piece of technology that is still improving further.
This is the third follow-up to Friday's release post. Saturday's was about how you iterate; yesterday's was about new platform APIs in the core; today's is about a run of pieces that change how you write the structural parts of an app. The pieces are an OpenAPI client generator, a SQLite ORM, JSON and XML mappers, a component binder with validation, build-time SVG and Lottie transcoders, and a declarative router with deep links. All ride on a single build-time codegen pipeline: a Maven-plugin pass that reads annotations or declarative source files at build time and emits typed Java that compiles into your binary. No reflection, no service loader, no Class.forName. The "How it works" section at the end of this post covers the codegen plumbing once you have seen what it powers. OpenAPI Client Generation The headline of this release for any team that talks to a backend. A new cn1:generate-openapi-client Mojo reads an OpenAPI 3.x JSON spec (a URL or a local file) and writes typed Codename One client code that compiles into your app: One @Mapped POJO per components.schemas entry.One <Tag>Api.java class per OpenAPI tag, with one fluent method per operation.Every method routes through Rest.<verb> + Mappers.toJson + fetchAsMapped / fetchAsMappedList, so the generated surface integrates with the rest of the framework instead of dragging in a separate HTTP stack. Wire it into the project's pom.xml: XML <plugin> <groupId>com.codenameone</groupId> <artifactId>codenameone-maven-plugin</artifactId> <executions> <execution> <id>petstore-client</id> <goals><goal>generate-openapi-client</goal></goals> <configuration> <specUrl>https://petstore3.swagger.io/api/v3/openapi.json</specUrl> <basePackage>com.example.petstore</basePackage> </configuration> </execution> </executions> mvn generate-sources picks the spec up, downloads it, and writes one file per schema and one per tag under target/generated-sources/. The Petstore reference spec exercised end-to-end produces six model classes (Pet, Order, Customer, Tag, Category, User) and three API classes (PetApi, StoreApi, UserApi), and the nine generated .class files compile cleanly against codenameone-core. Documented at the OpenAPI codegen Maven goal. In application code you call the generated Api class the same way you would call any other Java method: Java PetApi pets = new PetApi(); // Returns AsyncResource<Pet>; resolves with the deserialised object. pets.getPetById(42).onResult((pet, err) -> { if (err == null) Log.p("Got " + pet.getName()); }); // Returns AsyncResource<List<Pet>>. pets.findPetsByStatus("available").onResult((list, err) -> { if (err == null) { for (Pet p : list) Log.p(p.getName()); } }); // POST with a request body. addPet takes a Pet, returns a Pet. Pet candidate = new Pet(); candidate.setName("Mittens"); candidate.setStatus("available"); pets.addPet(candidate).onResult((created, err) -> { /* ... */ }); There is no hand-rolled ConnectionRequest setup, no manual JSON parsing, no string-typed request bodies. The generated client takes a typed Pet, serializes it with Mappers.toJson(...), fires the right HTTP verb, deserializes the response with Mappers.fromJson(...), and surfaces the result through the framework's AsyncResource so your callback fires on the EDT. For teams who already publish an OpenAPI spec as part of their backend (most modern backend frameworks do this automatically; FastAPI, Spring's springdoc-openapi, NestJS, ASP.NET Core, Go's gnostic), the practical effect is that the mobile client's bindings stay in sync with the backend without anyone hand-writing a single network call. Update the spec, re-run mvn generate-sources, and the new and changed endpoints land in your app as typed Java; the IDE picks up immediately. It is the kind of change that is most useful when you do not know you have it: pull a fresh spec, rebuild, and your IDE highlights every place in the codebase that called a renamed endpoint or passed the wrong type to a parameter. SQLite ORM @Entity marks the class; @Id and @Column shape the schema; @DbTransient opts a field out: Java @Entity public class TodoItem { @Id @Column long id; @Column String title; @Column(name = "completed_at") Date completedAt; @DbTransient Object cachedView; } Dao<TodoItem> dao = EntityManager.open("todos.db").dao(TodoItem.class); dao.createTable(); dao.insert(new TodoItem(0, "Read the post", null)); List<TodoItem> open = dao.find("completed_at IS NULL", new Object[] {}); TodoItem byId = dao.findById(42); dao.delete(byId); The generated DAO does the typed work underneath. No reflection in insert; the generated code calls setString(1, e.title) and setLong(2, e.id) directly against the SQLite PreparedStatement. Validation at build time catches missing @Id, fields that look like relationships but are not yet supported, and abstract entity classes; the build fails with a class name and a reason. For JPA/Hibernate developers, the API is intentionally familiar. @Entity, @Id, @Column, and @Transient (here renamed @DbTransient to avoid colliding with java.beans.Transient) carry the same meaning they do under javax.persistence / jakarta.persistence. The EntityManager name is the same. Dao#findById, Dao#findAll, Dao#find(where, params), Dao#insert, Dao#update, Dao#delete line up with the basic JPA repository contract. The query language is plain SQL (there is no JPQL or Criteria DSL), but the annotation surface, the lifecycle, and the runtime methods will feel like a long-lost friend to anyone with server-side Java persistence experience. JSON/XML Mapping @Mapped marks a class as a transferable POJO. @JsonProperty and @XmlElement (plus @XmlRoot, @XmlAttribute, @JsonIgnore, @XmlTransient) shape the wire format. The runtime entry points are Mappers.toJson(...), Mappers.fromJson(...), Mappers.toXml(...), Mappers.fromXml(...): Java @Mapped public class User { @JsonProperty("user_id") long id; @JsonProperty String name; @JsonProperty("created_at") Date createdAt; @JsonIgnore String passwordHash; } String json = Mappers.toJson(user); User back = Mappers.fromJson(json, User.class); The same @Mapped POJO is the type the typed Rest helpers accept: Java Rest.get("https://api.example.com/users/42") .fetchAsMapped(User.class) .onResult((user, err) -> { /* ... */ }); Rest.get("https://api.example.com/users") .fetchAsMappedList(User.class) .onResult((users, err) -> { /* ... */ }); Rest.fetchAsJsonList (top-level JSON arrays, no {"root":[...]} envelope trick), JSONWriter (the complement of JSONParser, with fluent builders and streaming variants for Writer and OutputStream), and URLImage.setDefaultBearerToken (auth headers on image fetches) all ship alongside. For JAXB developers, the XML surface (@XmlRoot, @XmlElement, @XmlAttribute, @XmlTransient) is a direct port of the long-established javax.xml.bind.annotation surface. The same model class can be both @XmlRoot-decorated and @JsonProperty-decorated, which gives you a single source of truth for both wire formats. The JSON surface adopts the Jackson convention (@JsonProperty, @JsonIgnore) that nearly every modern JVM JSON binding (Jackson, Moshi, kotlinx-serialization) inherited. Component Binding With Validation The fourth annotation processor on the same pipeline is the component binder. @Bindable marks a model class; @Bind(name = "userField") ties a field to a component on a form by the component's name. Field-level validation annotations compose with @Bind on the same field: Java @Bindable public class SignupModel { @Bind(name = "userField") @Required @Length(min = 3) private String user; @Bind(name = "emailField") @Required @Email private String email; @Bind(name = "ageField") @Numeric(min = 13, max = 120) private String age; @Bind(name = "roleField") @ExistIn({ "admin", "editor", "viewer" }) private String role; } The matching form sets a name on each component so the binder can find them: Java TextField user = new TextField(); user.setName("userField"); TextField email = new TextField(); email.setName("emailField"); TextField age = new TextField(); age.setName("ageField"); ComboBox<String> role = new ComboBox<>("admin", "editor", "viewer"); role.setName("roleField"); Button submit = new Button("Sign up"); Form form = new Form("Sign Up", BoxLayout.y()); form.add(user).add(email).add(age).add(role).add(submit); form.show(); SignupModel model = new SignupModel(); Binding binding = Binders.bind(model, form); binding.getValidator().addSubmitButtons(submit); Binding is the handle: refresh() re-reads the model into the components, commit() writes the components back, disconnect() tears the listeners down. Multiple validation annotations on a single field compose via Validator.addConstraint(Component, Constraint...) and GroupConstraint (first failure wins). @Validate(MyClass.class) is the escape hatch for hand-written Constraint implementations. The validation set: @Required, @Length, @Regex, @Email, @Url, @Numeric, @ExistIn, @Validate. The new BindAttr enum lets @Bind target a specific attribute of the component (TEXT, UIID, SELECTED, ...) when the default ("write a String field into the component's text") is not what you want. SVG at Build Time Drop an SVG into src/main/css/, alongside theme.css: Shell src/main/css/ theme.css star.svg gradient_circle.svg path_arrow.svg rounded_button.svg wave.svg pro_badge.svg After the next build, every SVG is a regular Codename One Image. An SVG handled by the transcoder is a vector image, but it is still an Image. Everywhere a raster Image works (Label.setIcon, Button.setIcon, BorderLayout.NORTH, the toolbar, a MultiButton's leading icon, a CSS background: url(...) rule), the SVG works too. The difference is that it stays crisp at any size: the same source file is sharp at a 16-point list-row icon, a 64-point hero header, and a 256-point launch screen, on every DPI bucket. A grid of the static SVGs from the hellocodenameone fixture, rendered through the new pipeline: Sizing in Millimeters The SVG transcoder's most useful feature is also the one most easily missed: size every SVG in millimeters from CSS. SVGs in the wild routinely declare odd width / height attributes (a 1024×1024 export of a 24×24 icon, no dimensions at all, design-pixel values from one specific framework). Pinning the rendered size in millimeters sidesteps all of that. CSS HomeIcon { background: url(home.svg); cn1-svg-width: 6mm; cn1-svg-height: 6mm; bg-type: image_scaled_fit; } LogoBanner { background: url(logo.svg); cn1-svg-width: 32mm; cn1-svg-height: 12mm; } A 6 mm icon is 6 mm tall on a 1× desktop, 6 mm on a high-DPI handset, and 6 mm on a 4K tablet. The transcoder routes both values through Display.convertToPixels() at install time, the same way font-size: 3mm already behaves elsewhere in Codename One CSS. No design-pixel guesswork, no DPI bucket to choose, no scaling surprise when the artist re-exports the source SVG at a different resolution. If a project does not use CSS for theming, the two-float constructor on the generated class takes millimeters directly: new com.codename1.generated.svg.Home(6f, 6f). Coverage and What We Still Want Feedback On The transcoder is a maven/svg-transcoder/ module that parses SVG with javax.xml StAX. No Batik, no Flamingo, no external dependencies. Coverage targets what real-world icon SVGs use: rect (rounded corners included), circle, ellipse, line, polyline, polygon, the full path grammar (M / L / H / V / C / S / Q / T / A / Z plus relative-coordinate and smooth-curve reflection), groups with affine transforms (translate, scale, rotate, skew, matrix), linear gradients via LinearGradientPaint, fill, stroke, stroke-width, linecap, linejoin, opacity. SMIL animations are supported in the same pipeline: <animate>, <animateTransform> (translate, scale, rotate), and <set>. Time values interpolate against wall-clock time on every paint, with from / to / values / begin / dur / repeatCount / fill="freeze" honored. Text and clip-path landed in the follow-up PR for the static SVG fixtures, and both are visible in the screenshot above (the "Codename One / build-time SVG" wordmark in the rounded button, the "PRO" badge text, and the clip-path-shaped rounded-corner badge underneath). <text> and <tspan> work with single-style fills and transforms; <clipPath> referenced via clip-path="url(#id)" works against rect, circle, and path clip shapes (nested clip refs are ignored). What is still not supported: SVG filter primitives, <mask> (treated as a clip, so alpha masking falls back to opaque), <radialGradient> (falls back to the first-stop color), and CSS-in-SVG (style rules inside the SVG document; the transcoder reads presentation attributes and the inline style="..." attribute, but a <style> element with selectors is not parsed). If you hit an SVG that does not transcode the way you expect, please open an issue at github.com/codenameone/CodenameOne/issues and attach the source file. The fastest way to extend the coverage is for us to run the failing case through the test fixtures and watch the output. Every SVG we ship test goldens for started as somebody else's "this doesn't render right" report. Caveat on iOS: The transcoded SVGs use the framework's shape API (fillShape, drawShape, LinearGradientPaint). The full surface is implemented on the Metal renderer. The deprecated GL ES 2 pipeline does not have parity on every operation, so an SVG drawn under ios.metal=false will often render with visible artifacts (missing gradients, clipped fills, distorted paths) rather than the placeholder you might expect. Now that Metal is the default for new iOS builds as of last Friday, this is a non-issue on most apps; if you have explicitly pinned ios.metal=false, expect some visual regressions on SVG content and let us know which. The coverage matrix and troubleshooting are in the SVG Transcoder in the developer guide. Lottie at Build Time The same pipeline carries Lottie. Drop a Bodymovin export into the same src/main/css/: JSON src/main/css/ theme.css pulse.json spinner.json After the next build, both are real Image instances on every platform that exposes the shape API. The same vector-everywhere story as SVG: a Lottie animation renders crisply at any size and slots into any Image slot in the framework. Java Image pulse = Resources.getGlobalResources().getImage("pulse"); Image spinner = Resources.getGlobalResources().getImage("spinner"); Animation runs against wall-clock time on every paint, with no Timer and no allocation in the hot path. A capture of the hellocodenameone Lottie fixture in motion: The Lottie transcoder lives in maven/lottie-transcoder/. It parses Bodymovin JSON with no external dependencies (the framework's built-in JSON parser carries the load) and lowers each file into the same SVGDocument model the SVG path uses. The same JavaCodeGenerator emits the same GeneratedSVGImage subclass, and the same SVGRegistry registers it under the source filename. No new Image base class, no new registry, no per-port wiring, since the SVG path's JavaSE reflective load and iOS / Android Stub weaving already cover the new format. Coverage in v1: shape layers (rc / el / sh) with solid fills and strokes; layer transforms (anchor, position, scale, rotation, opacity); animated rotation, position, and scale collapsed to a two-keyframe loop; solid-color layers as filled rects. Most icon-grade Bodymovin exports lower cleanly. Complex character animations from After Effects with image references, masks, and effects do not, and the transcoder logs which layers it dropped so the source of any blank output is obvious. Same ask as for SVG: if a Lottie / Bodymovin file does not transcode the way you expect, please open an issue at github.com/codenameone/CodenameOne/issues and attach the source .json. The transcoder grows one shape family at a time from the cases the community reports. The same iOS caveat applies: the renderer leans on the shape API, so the deprecated GL ES 2 pipeline shows artifacts on the more elaborate Lottie animations. Use the Metal default (now on by default for new iOS builds). Deep Links and Routing Two pieces of plumbing for apps that handle URLs from outside themselves (notification taps, marketing links, share targets, Universal Links from Safari and the equivalent App Links from Chrome on Android). Deep Links Codename One has had deep-link support for a long time through Display.setProperty("AppArg", url). The platform plumbing already writes the incoming URL into that property on cold launch, and an app-resume sets it again on warm launch; reading it back from start() works fine for a small number of patterns. Where the AppArg-only approach gets fragile is consistency. The cold and warm paths execute different lifecycle code, the value is a flat string with no parsing, and the trickiest case is the one where a user lands in the middle of the app via a link and then continues to interact: their next navigation needs to compose with the entry point, the back-stack needs to make sense as if they had arrived through the usual flow, and "fall off the edge of the app" on back is a common bug. With a hand-rolled AppArg reader it is easy to miss one of these and ship a half-working flow. This release introduces a typed DeepLink and a single handler that fires for both cold and warm launches: Java Display.getInstance().setDeepLinkHandler(link -> { // link is a normalised DeepLink: scheme, host, path, // segments, query map, fragment. Same shape cold or warm. if ("/users".equals(link.path()) && link.segments().size() == 2) { showUserDetailForm(link.segments().get(1)); return true; } return false; AppArg still works for projects that depend on it, but the new handler is what we recommend going forward. The handler runs on a consistent lifecycle path on both cold and warm starts, and the parsed DeepLink value carries the scheme, host, path segments, query map, and fragment, so app code does not need to roll its own URL parser. Routing For projects that handle more than a handful of URL patterns, the second piece is the declarative router in com.codename1.router. We built it on the same build-time codegen pipeline as the ORM and the mappers (the router was actually the first concrete consumer of the new preprocessor), so the two surfaces compose: a deep-link handler that delegates to the router becomes a one-liner. Each form declares its own path with a @Route annotation: Java @Route("/") public class HomeForm extends Form { /* ... */ } @Route("/users/:id") public class UserDetailForm extends Form { public UserDetailForm(RouteMatch match) { String userId = match.param("id"); // build UI for user `userId` } } @Route("/about") Router.navigate("/users/42") resolves the path, instantiates UserDetailForm, and shows it. The deep-link handler now collapses to: Java Display.getInstance().setDeepLinkHandler(link -> Router.navigate(link.toString())); Each form owns its own routing rule. Adding or moving a screen is a one-class change. The "what screens does this app have, and at what paths?" question is answered by an IDE search for @Route, not by reading every form constructor in the project. For Spring developers, the shape is familiar by design. @Route plays the same role as Spring MVC's @RequestMapping: a class-level declaration that announces "this controller handles URLs of this shape". The :id parameter syntax mirrors Spring's {id} path-variable syntax; RouteMatch.param("id") is the same kind of accessor as Spring's @PathVariable. The mental model carries over from server-side Java with almost no friction. The same recognition is available to anyone with React Router, Vue Router, or Angular Router experience; the :param convention is the cross-framework default. The build-time processor validates that each annotated class extends Form, that the path starts with /, that the constructor is accessible, and that there are no duplicate patterns. Any rule violation fails the build with a class name and a reason, not at runtime with a stack trace. The rest of the router surface covers the kind of thing that has become table stakes in modern client routing: Route guards run before navigation completes and can cancel or redirect.Per-tab navigation stacks via TabsForm, where each tab keeps its own back stack.Location listeners so anything in the app can subscribe to "the route changed".Form.setPopGuard(PopGuard) intercepts hardware back, toolbar back, or Router.pop() with a chance to ask "are you sure?".Sheet.showForResult() returns an AsyncResource<T> that auto-cancels with null if the user dismisses the sheet. The API is opt-in. Apps that prefer the existing Form.show() / Form.showBack() flow keep using that; nothing changes. For the link-publishing side, an AasaBuilder emits the iOS apple-app-site-association JSON and an AssetLinksBuilder emits the Android assetlinks.json. The full setup walk-through (entitlements, the Android intent-filter, the .well-known/ upload on your origin server) is at Routing and Deep Links in the developer guide. The JavaScript port bridges the router into window.history so navigating the in-app router pushes a real entry into the browser's session history. Back and forward in the browser drive the router; reloading the page lands at the deep-link URL; sharing the URL out of the address bar takes a colleague to the same in-app location. How It Works: The Build-Time Codegen Pipeline Everything above sits on a single Maven-plugin pass. The plugin has an AnnotationProcessor SPI and two new Mojos: cn1:generate-annotation-stubs (in generate-sources) and cn1:process-annotations (in process-classes). The orchestrator ASM-scans target/classes, dispatches to every registered processor, validates the annotated classes, and emits a typed runtime artifact next to each one plus a tiny Index class that registers everything with a public runtime registry. Adding a new processor later is a matter of dropping it into META-INF/services with no orchestrator changes. The reason this runs against bytecode rather than against source text is that the source-regex prototype was scrapped early. The bytecode pass sees the JVM's view of the project (extends Form is a thing the JVM actually knows, not a pattern we have to hope the user wrote a specific way), rule violations come back with class names and reasons, and the build fails fast before any generated .class lands on disk. The infrastructure shares the ASM passes that the BytecodeComplianceMojo's existing String rewrites already use. A small stub source is emitted under target/generated-sources/cn1-annotations/ during generate-sources so application code that references the generated registry resolves at compile time. The real .class overwrites the stub later in process-classes. Standard "compile against a stub, link against the real thing" pattern; it just works inside a single Maven build instead of needing a multi-module split. cn1-core ships a no-op stub of each generated index (RoutesIndex, MappersIndex, BindersIndex, DaosIndex), so application code compiles even when the project has no annotated classes. The build-time processor shadows each stub with the real implementation before packaging. The SVG and Lottie transcoders sit on a parallel pipeline (declarative graphics files in place of annotations), but they emit the same shape of code and obey the same constraints. The practical effect is that the kind of code that historically required reflection at runtime (with all the obfuscation hazards and surprise allocations that come with that) now happens once at build time and produces direct, dead-code-eliminable, rename-safe symbol references. Wrapping Up That closes this release's post series. We already have some pretty big features lined up for this Friday's release post; the headline pieces are the most substantial things to land in months and are worth checking back for. Back to the weekly index.
Artificial intelligence is evolving beyond basic chat interfaces to play an active role in enterprise applications. While initial AI integrations often focus on text generation, summarization, or retrieval-augmented generation (RAG), many business challenges demand more advanced solutions. These require breaking down complex objectives into sequenced tasks and coordinating their execution. The Planning Pattern addresses this need by enabling AI to function as both a content generator and a strategist that creates execution plans. For software engineers and architects, the Planning Pattern marks a significant advancement in intelligent systems. It separates reasoning from execution, allowing applications to use large language models while ensuring governance, observability, and reliability in enterprise settings. This article demonstrates how to implement the Planning Pattern in Java, showing how an AI model can convert a high-level business goal into an actionable plan executed by deterministic application services. The resulting architecture blends AI creativity with the predictability and control needed for production systems. Project Setup and Dependencies To demonstrate the Planning Pattern, we will build a simple customer service application using Jakarta EE, CDI, and LangChain4j. The scenario is intentionally limited to highlight architectural concepts over business complexity. The application will serve as a customer support assistant, interpreting user requests and routing them to the correct workflow. For this article, we will implement only order cancellation. This approach keeps the AI layer independent from the business implementation. The assistant interprets customer intent and creates a plan, while application services remain deterministic and enforce business rules. This separation aligns with the Planning Pattern, which treats reasoning and execution as distinct responsibilities. The following dependencies form the foundation of our sample. Weld SE enables Jakarta CDI in standalone Java applications, SmallRye Config provides configuration support, and LangChain4j CDI integrates AI models into the Jakarta EE programming model. XML <dependencies> <dependency> <groupId>io.smallrye.config</groupId> <artifactId>smallrye-config-core</artifactId> <version>3.17.2</version> <scope>compile</scope> </dependency> <dependency> <groupId>io.smallrye.config</groupId> <artifactId>smallrye-config</artifactId> <version>3.17.2</version> </dependency> <dependency> <groupId>org.jboss.weld.se</groupId> <artifactId>weld-se-core</artifactId> <version>6.0.4.Final</version> </dependency> <dependency> <groupId>dev.langchain4j.cdi</groupId> <artifactId>langchain4j-cdi-portable-ext</artifactId> <version>${langchain4j-cdi.version}</version> </dependency> <dependency> <groupId>dev.langchain4j.cdi.mp</groupId> <artifactId>langchain4j-cdi-config</artifactId> <version>${langchain4j-cdi.version}</version> </dependency> <dependency> <groupId>dev.langchain4j</groupId> <artifactId>langchain4j-open-ai</artifactId> <version>1.15.0</version> </dependency> </dependencies> With the project configured, the next step is to create our first AI agent. With the project configured, the next step is to create the first AI agent. This agent will serve as the entry point for customer support, receiving natural-language requests and converting them into structured execution plans. Creating the AI Agent Contract The first component of our solution is the agent contract. In LangChain4j, an agent is represented as a simple Java interface, enabling developers to focus on business logic instead of framework details. This interface serves as the application's entry point to the AI model. In our customer support scenario, the agent's role is to receive customer requests and determine the appropriate resolution. Java public interface CustomerResolutionAgent { String resolveCustomer(String text); } While this interface appears simple, it represents a key architectural concept. Rather than embedding prompts, workflows, or AI-specific logic across the application, we define a business-oriented contract. LangChain4j dynamically generates the implementation, allowing the AI component to function like any other CDI-managed service. Implementing Enterprise Tools The Planning Pattern separates reasoning from execution. The model determines required actions, while business operations are implemented as deterministic services. These services are exposed as tools the AI can invoke when building and executing a plan. Java @ApplicationScoped public class EnterpriseTools { @Tool("Finds the internal customer id given a customer email address") public String getCustomerId(String email) { System.out.println("searching for email " + email); return "CUS-001"; } @Tool("Finds the order id given a customer id") public String getOrder(String customerId) { System.out.println("searching for customer " + customerId); return "ORD-001"; } @Tool("Cancels an order given its order id") public String cancelOrder(String orderId) { System.out.println("cancelling order " + orderId); return "cancelled"; } } Each method represents a business capability available to the agent. The @Tool annotation offers a natural language description to help the model determine when to use each operation. In production, these methods would interact with databases, external APIs, messaging systems, or domain services. For this example, we simulate the workflow by returning predefined values. The order cancellation process consists of several independent operations. The AI first identifies the customer, then locates the order, and finally executes the cancellation. This decomposition highlights the value of the Planning Pattern: the model determines the sequence of actions, while the application ensures each action is executed safely and predictably. Building and Running the Agent With the contract and tools defined, we can assemble the agent. The factory connects the language model, toolset, and interface contract into a single CDI-managed component. Java @ApplicationScoped public class ResolutionAgentFactory { @Inject private ChatModel chatModel; @Inject private EnterpriseTools tools; @Produces public CustomerResolutionAgent create() { return AiServices.builder(CustomerResolutionAgent.class) .chatModel(chatModel) .tools(tools) .build(); } } Conclusion The Planning Pattern represents an important architectural evolution in enterprise AI systems. Rather than treating a language model as a simple text generator, it elevates AI to the role of strategist, capable of decomposing business objectives into executable plans while leaving execution to deterministic application services. By separating reasoning from execution, architects gain the flexibility of AI-driven decision-making without sacrificing governance, observability, or reliability. The language model determines what should happen, while enterprise services remain responsible for how those actions are performed. This distinction preserves existing business rules, security controls, and integration boundaries while enabling more adaptive user experiences. In this article, we implemented a customer support assistant using Jakarta EE, CDI, and LangChain4j. The agent interpreted a high-level customer request, identified the required sequence of operations, and coordinated enterprise tools to complete the workflow. Although the example focused on order cancellation, the same architecture can support a wide range of enterprise scenarios, including customer onboarding, account management, claims processing, inventory management, and operational workflows. As organizations move beyond chatbots and retrieval-based applications, patterns such as Planning become increasingly valuable. They provide a structured approach for integrating AI into business processes while maintaining the predictability and control expected from enterprise software. The result is an architecture where AI contributes reasoning and adaptability, while deterministic services continue to provide the reliability required for production environments.
If you've ever written raw JDBC, you know what's coming. Open a connection, create a PreparedStatement, set parameters by index (hope you counted right), iterate a ResultSet, close everything in a finally block, declare SQLException on every method signature… It's a lot of ceremony for "give me some rows." I've been experimenting with Ujorm3, a new lightweight ORM library for Java 17+. Here's a realistic example — a JOIN query that maps results including a nested relation: Java static final ResultSetMapper<Employee> EMPLOYEE_MAPPER = ResultSetMapper.of(Employee.class); List<Employee> findEmployees(Connection connection, Long minId) { return SqlQuery.run(connection, query -> query .sql(""" SELECT e.id, e.name, c.name AS "city.name" FROM employee e JOIN city c ON c.id = e.city_id WHERE e.id >= :minId """) .bind("minId", minId) .toStream(EMPLOYEE_MAPPER.mapper()) .toList()); } Let me walk through what makes this tick. Fluent API The whole operation is one readable chain. No juggling Statement objects, no passing things between methods — you declare the SQL, bind parameters, specify the mapper, and collect. Done. Named Parameters Instead of Positional ? Classic JDBC: Java stmt.setLong(1, minId); // hope you counted correctly Ujorm3: Java .bind("minId", minId) You reference parameters by name in the SQL (:minId) and bind them by name. No counting, no off-by-one errors when you insert a new parameter in the middle of a query, and the SQL stays readable. No Checked Exceptions SQLException is a checked exception, so vanilla JDBC forces you to handle or rethrow it everywhere — even when there's nothing useful to say. Ujorm3 wraps these internally, so your methods stay clean: Java // JDBC — forced to declare or catch List<Employee> findEmployees(Connection c, Long minId) throws SQLException { ... } // Ujorm3 — nothing to declare List<Employee> findEmployees(Connection connection, Long minId) { ... } Smart Object Mapping — Including Relations ResultSetMapper is a thread-safe class that prepares its mapping model on first use and reuses it across all subsequent calls. This significantly reduces overhead when processing a large number of queries. Mapping is inferred automatically by default. You can optionally annotate your domain classes with standard jakarta.persistence annotations (@Table, @Column, @Id) for explicit control, but they're not required. The interesting bit is how it handles relations. The aliased column "city.name" uses dot notation to map directly into a nested object — no extra configuration needed: SQL -- maps to employee.getCity().getName() automatically c.name AS "city.name" The library supports M:1 relations. 1:M collections are intentionally left out — a deliberate design choice to avoid hidden queries and N+1 problems. Want Compile-Time Safety? There's a Metamodel for That The string-based alias approach works great for getting started, but if you want the compiler to catch typos in column mappings, the optional APT plugin generates Meta* classes from your domain objects. The query then looks like this: Java List<Employee> findEmployees(Connection connection, Long minId) { return SqlQuery.run(connection, query -> query .sql(""" SELECT e.id AS ${e.id} , e.name AS ${e.name} , c.name AS ${c.name} FROM employee e JOIN city c ON c.id = e.city_id WHERE e.id >= :id """) .label("e.id", MetaEmployee.id) .label("e.name", MetaEmployee.name) .label("c.name", MetaEmployee.city, MetaCity.name) .bind("id", minId) .toStream(EMPLOYEE_MAPPER.mapper()) .toList()); } The ${placeholder} syntax in the SQL template and the label() method work together — the metamodel keys are type-parameterized descriptors that resolve column labels at runtime and carry full type information. Automatic Resource Management SqlQuery.run(...) handles closing the underlying PreparedStatement and ResultSet for you. No try-with-resources, no resource leaks if mapping throws partway through. There's More Than Just SqlQuery The library offers three levels of abstraction — pick what fits your use case: EntityManager – the fastest path for CRUD on a single table using a primary key; generates the SQL itself.SelectQuery – for fetching data including relations; supports type-safe Criterion filters composable with AND/OR operators; JOIN type (INNER vs LEFT) is inferred automatically from the nullable property of @Column.SqlQuery – low-level, full native SQL control; what we've been looking at above. SelectQuery in Action In many cases, the full SELECT statement — columns, JOINs, and WHERE clause — can be generated automatically by SelectQuery from the metamodel, so you don't have to write SQL at all. You still get the same object mapping under the hood. First, set up the shared context and entity manager (once, typically as static fields): Java // EntityContext controls SQL logging; false = no param values in logs static final EntityContext CTX = EntityContext.ofSqlInfoWithParams(false); static final EntityManager<Employee, Long> EMPLOYEE_EM = CTX.entityManager(Employee.class); Then the query itself: Java List<Employee> findEmployees(Connection connection, Long minId) { return SelectQuery.run(connection, EMPLOYEE_EM, query -> query .columns(true) // select all columns, including foreign keys .column(MetaEmployee.city, MetaCity.name) // add the city.name JOIN column .where(MetaEmployee.id.whereGe(minId)) // WHERE id >= minId .tail("ORDER BY", MetaEmployee.id) // append raw SQL fragment at the end .toList() ); } A few things worth noting: .columns(true) expands to all mapped columns of Employee, including foreign key values (e.g. city_id). The true argument does not affect JOIN generation yet — that is driven by the next call..column(MetaEmployee.city, MetaCity.name) adds a specific column from a related entity. The library resolves which JOIN to emit based on the metamodel..where(...) takes a type-safe Criterion. Conditions compose naturally with .and() / .or(), and because they're built from metamodel descriptors, a typo in an attribute name is a compile error, not a runtime surprise..tail("ORDER BY", MetaEmployee.id) appends a raw SQL fragment after the generated WHERE clause — a handy escape hatch for ORDER BY, LIMIT, window hints, or anything else the query builder doesn't cover. The result mapping works exactly the same way as in the SqlQuery examples above — same ResultSetMapper machinery, same dot-notation for nested objects. Performance Instead of reflection, the library generates and compiles its own bytecode at runtime for reading and writing domain object fields — performance comparable to handwritten code. In benchmark comparisons against Hibernate, Jdbi, MyBatis, and others (running on PostgreSQL and H2) it performs very well. The entire compiled module, including Ujorm3 itself, is under 3 MB, which is nice for microservices. What This Is NOT Not Hibernate. No entity scanning, no session factory, no proxy objects, no lazy loading surprises. You write SQL, you get objects back. Not jOOQ either — there's no Java DSL for building queries. You write plain SQL strings, which means you get full access to any database-specific syntax: window functions, CTEs, vendor extensions, whatever your DB supports. Getting Started Java 17+, final version 3.0.0 available on Maven Central: XML <dependency> <groupId>org.ujorm</groupId> <artifactId>ujo-core</artifactId> <version>3.0.3</version> </dependency> <dependency> <groupId>org.ujorm</groupId> <artifactId>ujorm-orm</artifactId> <version>3.0.3</version> </dependency> Optional APT plugin for metamodel generation: XML <annotationProcessorPaths> <path> <groupId>org.ujorm</groupId> <artifactId>ujorm-meta-processor</artifactId> <version>3.0.3</version> </path> </annotationProcessorPaths> Integration tests cover PostgreSQL, MySQL, MariaDB, Oracle, and MS SQL Server (all via Docker). When Does This Make Sense? If you need JPA portability across databases or your company mandates a standard ORM, use Hibernate. If you want full SQL control, transparent behavior, and no hidden magic — and you'd rather not write raw JDBC — this hits a nice sweet spot. Useful links: Project homepagePetStore demoBenchmark testsJavaDocMore examples as JUnit tests Curious whether others are using similar lightweight wrappers, or if you've landed on a different approach for native SQL without going full ORM.
Apache Spark is one of the most powerful tools in the data and AI engineering world. It helps process massive datasets and is widely used across industries, irrespective of cloud platforms. But when you move from learning Spark to running it in production, you start seeing real challenges. This is from practical experience. 1. JVM Overhead Spark runs on the Java Virtual Machine (JVM). At first, this looks fine. But in real workloads, it creates overhead. What actually happens: Extra memory is consumed by the JVM itselfData moves between Python and JVM (serialization)Job startup takes more time Why it matters: Even if your logic is simple, the JVM layer adds hidden cost and latency. Especially in PySpark workloads, this becomes very noticeable. 2. Garbage Collection (GC) Issues The JVM uses garbage collection (GC) to manage memory. In small workloads, no problem. In large workloads, big problem. What we generally observe: Sudden pauses during execution, Jobs becoming slow without a clear reason, and performance behaving inconsistently. Real Challenge We often need to tune: memory settings, GC configuration, and executor behavior. Without proper tuning, performance becomes unpredictable. 3. Cluster Complexity Spark is not just a tool — it is a distributed system. To run it, you must manage infrastructure. What we need to handle: Cluster setup, executors and memory configuration, partition tuning, scaling (up/down). Impact in real projects: Higher infrastructure cost, more operational effort, requires deep expertise, and this adds overhead beyond just writing data pipelines. Rust Changes Everything Rust solves these problems at the language level. No JVM Rust compiles directly to machine code. So, no virtual machine and no runtime overhead. No Garbage Collection Rust uses ownership-based memory management. Memory is handled at compile time No runtime GC pauses Predictable Performance Better memory control, no hidden pauses, Efficient execution Result: Faster and more stable systems When we look at Rust tools, we see different ways: Replace Parts of Spark PolarsDataFrame processingDataFusionSQL engineBallistaDistributed executionRisingWaveStreamingSailFullSpark replacement Lakesail has came up with all together at once place. What Is Sail? Sail is an open-source computation framework that serves as a drop-in replacement for Apache Spark (SQL and DataFrame API) in both single-host and distributed settings. Built in Rust, Sail runs ~4x faster than Spark while reducing hardware costs by 94%. In simple terms: Sail = Spark experience + Rust performance + no JVM/GC problems It is not just a library. It is a full data platform / compute engine. Core Idea of Sail Traditional Spark: Plain Text PySpark → JVM → Spark Engine → Execution Sail: Plain Text PySpark → Spark Connect → Sail (Rust Engine) → Execution Key difference: Spark depends on JVMSail removes the JVM completely Where Sail Is Strong Sail is a good choice if you are already using Apache Spark and want better performance.It allows you to continue using the same Spark SQL and DataFrame APIs without rewriting your code.It removes JVM and garbage collection overhead, which helps improve speed and memory usage.Because it runs on a Rust-native engine, it provides more stable and predictable performance.It can help reduce infrastructure cost while keeping your existing development approach. Where You Should Be Careful Sail is still a new technology and not as mature as the Spark ecosystem.The number of connectors, integrations, and community support is smaller compared to Spark.Some advanced Spark features may not be fully supported yet.It is important to test Sail with your own workload before using it in production. Sail supports almost all modern platforms' emerging features: Local mode (single machine)Cluster mode (Kubernetes) It includes: Task schedulingResource managementDistributed execution Similar to a Spark cluster, but lighter Lakehouse Support Sail supports: Delta LakeApache Iceberg That means: Works with modern data lakesCompatible with existing data Storage Support Sail can read/write from: AWS S3Azure Data LakeGoogle Cloud StorageHDFSLocal files So, it integrates with existing ecosystems Catalog Integration Supports: Unity CatalogIceberg REST Catalog Important for: GovernanceAccess controlEnterprise data management Multimodal + AI Workloads Sail goes beyond Spark. It supports: Structured dataImagesPDFsAI workloads This is called: Multimodal lakehouse. Performance and Cost Sail claims: ~4x faster executionUp to 8x in some workloads~94% lower cost Reasons: No JVM overheadNo GCBetter memory usage Conclusion Sail is a new way to run Spark workloads using Rust instead of the JVM. It removes garbage collection and reduces memory and performance issues, making execution faster and more stable. One of its biggest advantages is that you can keep the same Spark code with little or no changes. This helps reduce infrastructure cost and complexity. However, it is still a new technology and not as mature as Spark yet. In the future, the best approach will be to use the right mix of Spark and Rust tools together.
Last week was about Metal and the Skin Designer. This week, the headline items are about what a brand new project looks like when you generate it: the default JDK is Java 17, and every generated project ships with an AGENTS.md authoring skill that lets any modern AI agent work on the project intelligently. There are also some other things worth covering: a runtime accent palette on the new native themes, three Metal follow-ups (one of which introduces a new matrix-correct translate API), the JDK 11+ String API gap closed, and iOS push permission that no longer fires at app launch. What is Codename One? Codename One is an open-source framework for building native iOS, Android, desktop, and web apps from a single Java or Kotlin codebase. Learn more at codenameone.com. Java 17 by Default We changed the default projects generated by the Initializr to Java 17+ to focus on the future of Codename One. The existing Java 8 option in the Initializr is still selectable from the radio panel if you have a reason to use it. Pick whichever you want. The Java 17 path is the one we now recommend for new work. Generated projects build with any JDK from 17 onwards (we routinely test on 21 and 25); you do not need to install Java 17 specifically. The bigger picture of how Java 17 support works in the toolchain, including which language features land in your app code and how the iOS / Android ports handle the newer bytecode, was covered in Official Experimental Java 17 Support earlier this year. The change this week is the default and the wording: the (Experimental) tag is gone, and Java 17 is now what you get unless you opt out. AGENTS.md and the Codename One Skill The other change in PR #4946 is that every Java 17 project the Initializr generates now ships an AGENTS.md file at the project root and a Codename One authoring skill alongside it. AGENTS.md is the convention for handing project-specific context to any AI agent. Claude Code, Cursor, Codex, Aider; they all look for it. Codename One projects now ship one. The actual skill content lives under .agent-skills/codename-one/ (vendor-neutral) and the source for it is in the repo at scripts/initializr/common/src/main/resources/skill if you want to read through it directly. There is also a thin stub at .claude/skills/codename-one/SKILL.md so Claude Code's /skills picker indexes it; the stub redirects to the same vendor-neutral content. We deliberately scoped this to Java 17 projects. The older Java 8 build had additional constraints (Java 5/8 source target, retrolambda, the historical bytecode rewrite rules) that made the "what can I actually use" answer noticeably more complicated. Restricting the skill to Java 17 lets us give agents a cleaner picture of the language level, the toolchain, and the build commands without spending half the SKILL.md on caveats. If you stay on Java 8, you keep the project layout you had; nothing changes for you. A few things the skill makes possible that I think are genuinely useful: Agents can debug a Codename One app under jdb. This is the one I am most pleased with. The simulator is a regular JVM, so the standard Java Debugger attaches cleanly, but agents previously had no idea this workflow was available. The skill's debugging.md reference walks through starting the simulator with the right -Xrunjdwp flags, attaching jdb, setting breakpoints, dumping locals, and stepping. The same workflow works in CI and any headless context where a graphical debugger is not an option. For an LLM that is otherwise reduced to "add a println and hope", this is a much sharper tool. Agents can check whether an API is part of the Codename One subset before they suggest it. Codename One targets a Java 5/8 shaped JDK, so the same bytecode translates to iOS, Android, and JavaScript. An agent that has only read regular Java idioms will routinely reach for java.nio.file, java.time, or pieces of java.util.concurrent that the framework does not include. The skill ships a single-file IsApiSupported.java tool that an agent can invoke to verify a class or method before writing code against it. Agents can validate a CSS snippet before applying it. Codename One CSS is its own subset; rules that look fine to a browser developer get silently dropped by the compiler. The IsCssValid.java tool lets the agent confirm the compiler will accept a snippet without booting the simulator. These three things together are most of why an agent that was previously polite-but-not-useful on a Codename One project is now actually productive on one. If you do not use agents, the same Markdown is one of the better tours of the framework's mental model that we have written; open .agent-skills/codename-one/SKILL.md in any project you generate today and read top to bottom. Native Theme Accents PR #4884 closes the loop on the new iOS Modern and Material 3 native themes we shipped two weeks ago. The native themes now expose their accent palette as named theme constants, so rebranding your app to your own colors is a five-line CSS change instead of a fork. Override the constants inside the #Constants block of your own theme.css: CSS #Constants { includeNativeBool: true; darkModeBool: true; --accent-color: #ff2d95; --accent-color-dark: #ff2d95; --accent-pressed-color: #c71a75; --accent-on-color: #ffffff; That is it. Every accent-bearing UIID picks up the new color. Light and dark are independent (--accent-color vs --accent-color-dark), and partial overrides are fine; anything you do not redeclare stays at the framework default. Material 3 has a couple of additional container-tier constants for the elevated-surface tone; iOS ignores those. There is also a runtime path for dynamic theming (in-app accent toggles, branded flavors, A/B tests). It uses the same constants. The Native Themes chapter of the developer guide covers it in detail, along with the full iOS and Android constant tables and the places where the binding system intentionally does not apply: Accent palette override. The point worth pulling out: the parts of theming that do not change per app (which UIIDs participate in the accent palette, which states they expose, which dark-mode counterparts they have) live inside the framework and stay there. The parts that do change per app (your colors) live in your project as five constants and nothing else. That is the whole reason this change exists. Metal Follow-Ups Last week was about shipping the Metal renderer. This week is the follow-up week: three PRs, plus one new API on Graphics that I think will quietly pay for itself many times over. Per-Axis Scale Decomposition (#4939, fixes #3302) Long-standing issue #3302 had a clear repro: g.translate + g.scale(sx, sy) + fillShape with sx != sy produced shapes that visibly drifted off the axis-aligned drawRect and drawLine calls the framework emitted alongside them. Triangles inscribed in rectangles escaped their bounding rect. The cause was that the legacy alpha-mask path rasterized the shape at a uniform scale (the diagonal ratio h2/h1), then stretched the resulting texture non-uniformly through the GPU matrix to recover the requested aspect. The bbox math is exact in real numbers, but the texture is pixel-rounded at the intermediate uniform scale, so the stretch drifted the rasterized shape off the pixel grid that drawRect and drawLine were already on. The fix factors the user transform's 2x2 linear part by taking the column norms as (sx, sy), rasterizes the path at S(sx, sy) so the per-axis stretch happens at rasterization time against a vector path rather than a pixel grid, and applies only the residual transform * S(1/sx, 1/sy) on the GPU. The residual is pure rotation (and shear in the worst case), so no per-axis stretch happens at sample time, and the alpha-mask texture lands on the same pixel grid as its drawRect siblings. The change is gated to Metal; the GL ES2 path keeps its legacy branch, so the existing GL goldens are byte-identical. A new InscribedTriangleGrid screenshot test was registered with Cn1ssDeviceRunner so the inscribed-triangle property is now visually verifiable in CI. Clip-Under-Rotation Diagnostic (#4924, towards #3921) PR #4924 does not fix a bug, it localizes one. Issue #3921 is "clip-under-rotation behaves wrong on some ports", entangled with a getClip / setClip(int[]) round-trip limitation the reporter himself called out as a separate issue. To split the two, we shipped a screenshot test that uses only pushClip / popClip and rotateRadians. The clip becomes non-axis-aligned via clipRect inside a 30-degree rotation, which forces the framework through its polygon-clip branch. The expected outcome is a 30-degree-tilted red fill that overlaps the navy outline at two diagonal corners and falls short at the other two. Two distinguishable failure modes are pre-labeled in the PR: the clip widened to its axis-aligned bbox (red exactly matches the navy outline), or the polygon clip dropped entirely (red fills the whole cell). When the iOS Metal cell of this test renders, we know within a glance which of the three behaviors we are looking at. The expected-failure cell is also a hypothesis: ClipRect.m's polygon initialiser stores x = y = w = h = -1, and the Metal execute path then calls CN1MetalSetScissor(0, 0, -2, -2), whose width <= 0 / height <= 0 branch sets the scissor to the full framebuffer instead of the intended polygon. If the screenshot confirms the hypothesis, the fix is a one-line replacement of the polygon-scissor fallback. iOS Metal Color Space Hint (#4909, fixes #4908) PR #4909 adds an ios.metal.colorSpace build hint. Until this week, the Metal layer's CAMetalLayer.colorspace was hard-coded to sRGB. For most apps, that is right; sRGB is what your existing assets are authored in. But on iPhone XR and later, Apple's screens are wide-gamut (Display P3), and a marketing-led brand that ships P3 artwork was visibly losing saturation by being routed through the sRGB pipeline. Accepted values are sRGB (default), displayP3, deviceRGB, linearSRGB, extendedSRGB, extendedLinearSRGB, and none. Set it in codenameone_settings.properties: Java codename1.arg.ios.metal.colorSpace=displayP3 The hint is dormant when ios.metal=false, so existing GL builds are unchanged. Unrecognized values produce a warning log and fall back to sRGB. Documented under Working-With-iOS.asciidoc. The New translateMatrix API The Inscribed-Triangle-Grid test in #4939 also surfaced a quiet papercut in Graphics that is worth pulling out as its own feature. Graphics.translate(int, int) does not compose into the affine transform the way scale() and rotateRadians() do. It accumulates into a per-Graphics integer offset that is added to draw coordinates before the impl matrix is applied. That is a holdover from the very first version of the framework, when Graphics did not have a matrix at all. Today the consequence is surprising: a subsequent g.scale(sx, sy) multiplies the integer translate too, which means the same code produces visibly different positions depending on whether you scale before or after you translate. The new Graphics.translateMatrix(float, float) composes the translation directly onto the impl matrix, in the same way scale and rotateRadians already do. The result is uniform "post-multiply translate onto the current transform" semantics across iOS (both GL and Metal), JavaSE, Android, and the JavaScript port. Same code, same on-screen position, whether you are drawing into a Form's Graphics or a mutable Image's Graphics. Java // Matrix-correct composition. Use this when you want translate to // behave like scale and rotate (composed into the affine transform). g.translateMatrix(centerX, centerY); g.rotateRadians(angle); g.scale(sx, sy); g.translateMatrix(-centerX, -centerY); For app code writing affine-transform pipelines (the "translate to pivot, rotate, scale, translate back" idiom from Java2D and AWT), this is the API you want. isTranslateMatrixSupported() returns true on every modern port. The old translate(int, int) is not deprecated and is not going anywhere; half the framework's internal scrolling code is built on it. The new method is the one to reach for in new drawing code, particularly anything that combines translate with scale or rotate. String API: replace(CharSequence, CharSequence), replaceAll, replaceFirst PR #4893 closes a long-standing gap reported in issue #4878. The JDK 1.5+ overload of String.replace that takes CharSequence arguments (the one nearly every modern Java tutorial reaches for) was missing from the Codename One subset. So were String.replaceAll(String, String) and String.replaceFirst(String, String). Because none of the three were on the bootclasspath, code that reached for them did not compile against a Codename One project at all; you had to know to fall back to the older replace(char, char) overload and to roll your own regex. All three are now wired in. String.replace(CharSequence, CharSequence) has a real implementation in vm/JavaAPI. replaceAll and replaceFirst are wired through the bytecode-compliance rewriter to a new JdkApiRewriteHelper pair that delegates to the existing RE regex engine (the same pattern we have been using for years on String.split). New compliance tests cover both rewrite rules. It is a small change in line count. In practice, it is a noticeable reduction in how often "I copied a snippet from Stack Overflow, and it didn't work on iOS" turns into a real bug. Three of the most-reached-for String methods in modern Java are now part of the on-device API. iOS Push Permission No Longer Fires at App Launch PR #4894 fixes issue #4876. With ios.includePush=true the framework used to call requestAuthorizationWithOptions from application:didFinishLaunchingWithOptions:, which meant the iOS system permission dialog fired as soon as the app finished launching, before the user had seen any of your screens. There is no good way to recover from a "Don't Allow" tap at that point. The user has not experienced the app yet, does not know why notifications matter, and tapping Don't Allow is the path of least resistance. Once denied, re-prompting requires sending the user out to Settings. The fix moves the prompt to the natural points. Push.register() triggers the system prompt (this code path already requested permission inside IOSNative.m; we just stopped firing it ahead of time). LocalNotification.schedule() also triggers it, via a new requestAuthorizationWithOptions call in sendLocalNotification. Same flow Android has been on for years. The practical consequence is that you can now show your own rationale screen ("we'd like to ping you when your order ships") before the system dialog fires. If you have an app that needs the legacy launch-time behavior, a backward-compatibility build hint restores it: Java codename1.arg.ios.notificationPermissionAtLaunch=true The default is false, so existing apps that did not opt in pick up the new behavior on next rebuild. Documented in Push-Notifications.asciidoc. The cloud-side build server change shipped as BuildDaemon #71, so local and cloud builds match. One thing to flag if you are updating an existing iOS app: if your onboarding flow was relying on the launch-time prompt happening automatically, your prompt now never fires unless Push.register() or LocalNotification.schedule() is invoked somewhere. That is almost certainly what you want, but check that the call lands. Skin Designer FAQ Follow-Up A few questions came up on discussion #4928 after last week's Skin Designer post, worth pulling forward here because they keep coming up in the same shape: Skins do not affect CSS. The skin is simulator scaffolding (device frame, screen rect, cutouts, safe-area insets); your theme.css and your native theme are unrelated.For a known device, the defaults are usually right. Pick the device, hit Pick a shape, click Finish. The customization UI is there for when our device database is incomplete (the iPhone 17e entry might say "no notch" when it actually has one, or the notch position might be off by a few pixels); when you have a physical device to measure against, that is where you refine.Themes are leaving skins. Historically, the native theme was bundled inside each skin because that is what made sense at the time. Going forward, the right home for them is the framework itself, distributed via Maven, so you pick up updates automatically. The new native themes already work this way. The per-skin embedded theme stays for legacy compatibility, and the Skin Designer still writes one for you, but the Native Theme menu we shipped two weeks ago is the path forward. The device database the Skin Designer reads from is open at scripts/skindesigner/common/src/main/resources/devices.json if you want to file a PR with a device we are missing or a row whose details are off. Wrapping Up Two reminders. First, flip ios.metal=true on your real app this week if you have not. The default flip is days away, and we would rather find any remaining edge case against your screens than against the install base on launch day. Second, if you have not generated a project from the Initializr recently, do it; the Java 17 default and the AGENTS.md skill are both worth seeing for yourself. A specific thank-you this week to the reporter on #3302 for sticking with the inscribed-triangle bug for as long as GL was the only target, Durank for the iOS push permission report on #4876, and the reporter on #4878 who flagged the missing String.replace(CharSequence, CharSequence); that one had been sitting in the gap for a long time. The issue tracker is here, the Playground and Initializr are the easiest places to poke at the new defaults, and the Skin Designer from last week is still there if you have a device shape you need a skin for.
This post walks through building and running a real-world agentic workflow with Agentican and Quarkus. Specifically, an agentic workflow to automate market research and information sharing: Identify the top vendors within a market category.Research the positioning and strengths of each vendor.Classify the findings as either standard or urgent.Draft a brief to share with others in the company. Prerequisites QuarkusJava 25Maven (or Gradle)LLM provider API key Step 1: Add the dependency Create a Quarkus app, and add the Agentican Quarkus runtime module: XML <dependency> <groupId>ai.agentican</groupId> <artifactId>agentican-quarkus-runtime</artifactId> <version>0.1.0-alpha.3</version> </dependency> Step 2: Define Agents, Skills, and the Workflow Create an `agentican-catalog.yaml` file on the classpath. This is where you describe: Who does the work (agents)What they need to do it (skills)How they will do it (workflows) YAML agents: - id: researcher name: researcher role: | Expert at finding accurate, sourced information about companies and markets. Quotes sources. Distinguishes opinion from fact. - id: writer name: writer role: | Synthesizes research into structured, concise briefs. Avoids hedging language. Cites concrete evidence. skills: - id: web-search name: web-search instructions: | When a question requires external information, call the search tool first. Quote sources in your answer. Update the `agentican-catalog.yaml` file to define the workflow. YAML workflows: - id: market-brief name: market-brief description: Research vendors in a market and produce a structured brief outputStep: deliver params: - name: topic description: Market to research required: true - name: vendor_count description: Number of vendors defaultValue: "5" steps: - name: identify agent: researcher skills: [web-search] instructions: | Identify the top {{param.vendor_count} vendors in {{param.topic}. Return a JSON array of vendor names — names only, no commentary. - name: deep-dive type: loop over: identify steps: - name: analyze agent: researcher skills: [web-search] instructions: | Deep-dive vendor {{item}: positioning, key strengths, recent news. Quote sources. - name: classify agent: writer instructions: | Read the per-vendor deep-dives below. If any vendor has launched a competitive feature in the last 30 days, return the single word 'urgent'. Otherwise return 'standard'. Deep-dives: {{step.deep-dive.output} dependencies: [deep-dive] - name: deliver type: branch from: classify default: standard branches: - name: urgent steps: - name: urgent-brief agent: writer instructions: | Synthesize a vendor brief flagged URGENT for executive review. Lead with the recent competitive moves. Topic: {{param.topic} Deep-dives: {{step.deep-dive.output} - name: standard steps: - name: standard-brief agent: writer instructions: | Synthesize a vendor brief. Topic: {{param.topic} Deep-dives: {{step.deep-dive.output} A few things worth flagging: agent: researcher references the agent for a step, skills referenced by name, too.outputStep designates the step whose output becomes the workflow's typed result.{{param.X} interpolates workflow inputs into step instructions.{{step.X.output} interpolates an upstream step's output.{{item} is the current value inside a loop iteration.type: loop steps take an over reference (a step that produced a list, or a list-typed param).type: loop steps run their nested steps once per item, in parallel, and on virtual threads.type: branch steps take a from reference (a step whose output is used to select a branch).branches: mutually exclusive steps (or sets of steps) with default for unrecognized values. The framework loads agentican-catalog.yaml from the classpath, or you can define where it's loaded from: Properties files agentican.catalog-config=/etc/agentican/agentican-catalog.yaml Note: Agents, skills, and workflows can be defined via a fluent builder API as well. Step 3: Configure the Models Agentican reads the engine configuration from `application.properties`. The minimum is one LLM: Properties files agentican.llm[0].api-key=${ANTHROPIC_API_KEY} The provider defaults to `anthropic`, and the model defaults to `claude-sonnet-4-5`. Want OpenAI instead? Properties files agentican.llm[0].provider=openai agentican.llm[0].api-key=${OPENAI_API_KEY} agentican.llm[0].model=gpt-4o-mini Want to mix and match? Configure `name`s and reference them per-agent in the YAML catalog: Properties files agentican.llm[0].name=default agentican.llm[0].api-key=${ANTHROPIC_API_KEY} agentican.llm[1].name=efficient agentican.llm[1].provider=openai agentican.llm[1].api-key=${OPENAI_API_KEY} agentican.llm[1].model=gpt-4o-mini Step 4: Create a Typed Workflow Instance Define the workflow input and output records: Java public record ResearchParams(String topic, int vendorCount) {} public record VendorBrief(String topic, List<Vendor> vendors) { public record Vendor(String name, String positioning, List<String> strengths) {} } Then inject the typed workflow, and call it from a REST endpoint: Java @Path("/market-brief") public class VendorBriefResource { @Inject @AgenticanWorkflow(name = "market-brief") Workflow<ResearchParams, VendorBrief> brief; @POST @Path("/{topic}") public VendorBrief generate(@PathParam("topic") String topic) { return brief.start(new ResearchParams(topic, 5)).await(); } } Now, test the endpoint: Shell curl -X POST http://localhost:8080/market-brief/data%20observability%20platforms A few things worth flagging — they're what set this apart from a generic "call an LLM" library: ResearchParams.vendorCount becomes the workflow parameter vendor_count via SNAKE_CASE mapping.start() returns a WorkflowRun<VendorBrief> and await() parses the output step's text into a VendorBrief.@AgenticanWorkflow(name = "vendor-brief") resolves the registered workflow at injection time. Note: WorkflowRun itself exposes future() for a CompletableFuture<R>, and there's a ReactiveWorkflow<P, R> Mutiny variant for Vert.x stacks. Step 5: Add Agent Tools Agentican ships two integrations out of the box: MCP (Model Context Protocol) There is one config block per server. Tools are auto-discovered: Properties files agentican.mcp[0].slug=github agentican.mcp[0].name=GitHub agentican.mcp[0].url=https://mcp.github.com/sse agentican.mcp[0].headers.Authorization=Bearer ${GITHUB_TOKEN} Composio 100+ SaaS toolkits — Slack, Notion, Linear, Salesforce, GitHub, Google Workspace: Properties files agentican.composio.api-key=${COMPOSIO_API_KEY} agentican.composio.user-id=user-123 Tools are referenced by name within agent steps: YAML steps: - name: research agent: researcher tools: [github_search_repositories] instructions: "Profile open-source vendors in {{param.topic}." Structured agentic workflows for the JVM. Where to Go Next Getting Started — install, configure, and run workflowsCore Concepts — architecture, terminology, and data flowWorkflows & Steps — CDI surface, beans, qualifiers, override patterns.Agents — defining agents, skills, and rolesGetting Started (Quarkus) — dependency setup, config, first taskCDI Integration — injection, qualifiers, lifecycle events, bean overridesREST API — endpoints, SSE streaming, WebSocket, error codesObservability — Micrometer metrics, OTel tracing, Prometheus queries
Artificial intelligence is rapidly transforming software development. Many developers now use AI-powered tools to generate code, but the next advancement is integrating AI directly into applications. Modern systems increasingly use large language models (LLMs) to answer questions, automate workflows, summarize information, and enhance user experiences. Software engineers must therefore combine traditional enterprise development practices with AI capabilities while ensuring reliability, scalability, and maintainability. This evolution offers Jakarta EE developers a significant opportunity. Jakarta EE provides a mature platform for enterprise applications, with standards for dependency injection, RESTful services, configuration, persistence, and cloud-native development. By integrating Jakarta EE with LangChain4j, developers can access advanced AI models through a straightforward Java API, adding intelligent features without leaving the familiar Jakarta EE environment. In this article, we will build a simple "Hello World" AI application to demonstrate how easily a Large Language Model can be integrated into a Jakarta EE application using LangChain4j. Configuring LangChain4j With Jakarta EE Technologies Before developing your first AI-powered application, it is important to understand LangChain4j’s role in the Java ecosystem and its popularity for AI integration. LangChain4j serves as an orchestration layer between Java applications and AI providers. It simplifies AI integration by offering a consistent programming model, regardless of the underlying vendor. If you are familiar with Spring Data or Jakarta Data, this concept will be familiar. With Spring Data and Jakarta Data, developers define repository interfaces and use annotations to specify behavior. Implementation details are handled by a provider that generates the concrete implementation and manages database communication. This allows developers to focus on business logic rather than low-level database operations. LangChain4j uses a similar approach for artificial intelligence. Instead of writing HTTP clients, building JSON payloads, and managing provider-specific APIs, developers define Java interfaces representing AI capabilities. LangChain4j then generates the implementation and manages communication with the chosen AI provider. LangChain4j can be viewed as the AI equivalent of Jakarta Data or Spring Data, with the AI provider dependency functioning like a JDBC driver. Switching from one AI provider to another, such as from OpenAI to a different provider, usually only requires updating the dependency and configuration, while the application code remains largely unchanged. While this article uses a Java SE application for simplicity, the same approach applies to Jakarta EE, Spring Boot, Quarkus, Helidon, Micronaut, and other Java platforms. Project Dependencies The first step is to create a Maven Quickstart project and add the required dependencies for CDI, Eclipse MicroProfile Config, and LangChain4j: XML <dependency> <groupId>io.smallrye.config</groupId> <artifactId>smallrye-config-core</artifactId> <version>3.17.2</version> <scope>compile</scope> </dependency> <dependency> <groupId>io.smallrye.config</groupId> <artifactId>smallrye-config</artifactId> <version>3.17.2</version> </dependency> <dependency> <groupId>org.jboss.weld.se</groupId> <artifactId>weld-se-core</artifactId> <version>6.0.4.Final</version> </dependency> <dependency> <groupId>dev.langchain4j.cdi</groupId> <artifactId>langchain4j-cdi-portable-ext</artifactId> <version>${langchain4j-cdi.version}</version> </dependency> <dependency> <groupId>dev.langchain4j.cdi.mp</groupId> <artifactId>langchain4j-cdi-config</artifactId> <version>${langchain4j-cdi.version}</version> </dependency> <dependency> <groupId>dev.langchain4j</groupId> <artifactId>langchain4j-open-ai</artifactId> <version>1.15.0</version> </dependency> This example uses the langchain4j-open-ai dependency, which serves as the provider-specific driver for communicating with OpenAI models. The application code remains independent of the provider implementation. Configuring the AI Provider LangChain4j integrates with Eclipse MicroProfile Config, allowing you to externalize all provider settings. Create a microprofile-config.properties file and add the following configuration: Properties files dev.langchain4j.cdi.plugin.chat-model.class=dev.langchain4j.model.openai.OpenAiChatModel dev.langchain4j.cdi.plugin.chat-model.config.api-key=<<API_KEY>> dev.langchain4j.cdi.plugin.chat-model.config.model-name=gpt-5 This configuration specifies the chat model implementation, the authentication API key, and the model that will process prompts. A key advantage of this approach is flexibility. If you choose another provider in the future, you typically only need to replace the provider dependency and update the configuration. The application code often remains unchanged, reinforcing the provider dependency’s role as similar to that of a JDBC driver in traditional data access. For this sample, you can place the API key directly in the configuration file or provide it through environment variables. In production, use environment variables, secret managers, or vault solutions. Never commit API keys to source control, as exposed credentials can lead to unauthorized use, unexpected costs, and security risks. Your First AI Service With the project configured, we can now build our first AI-powered service. As is customary in software development, we will begin with a “Hello World” example. Rather than printing a static message, we will send a question to an AI model and display its response. This example uses the simplest contract: a String as input and a String as output. Although real-world applications typically use more complex domain objects, starting with plain text helps us focus on the core LangChain4j programming model and understand how to create and use AI services. The first step is defining an AI service interface: Java import dev.langchain4j.cdi.spi.RegisterAIService; import jakarta.enterprise.context.ApplicationScoped; @RegisterAIService @ApplicationScoped public interface AssistantService { String chat(String prompt); } This interface does not include an implementation. LangChain4j generates the implementation automatically at runtime. The @RegisterAIService annotation directs LangChain4j to create an AI-backed implementation for this interface. The @ApplicationScoped annotation makes the generated implementation available as a CDI bean, which can be injected or accessed like any other Jakarta EE component. The method signature defines the AI contract. When the chat method is called, the parameter serves as the prompt for the AI model, and the returned value contains the generated response. In this example, both the request and response are simple strings. Next, we need a client application to consume this service: Java import jakarta.enterprise.context.control.RequestContextController; import jakarta.enterprise.inject.se.SeContainer; public class App { public static void main(String[] args) { try (SeContainer container = jakarta.enterprise.inject.se.SeContainerInitializer .newInstance() .initialize()) { RequestContextController requestContextController = container.select(RequestContextController.class).get(); requestContextController.activate(); AssistantService assistantService = container.select(AssistantService.class).get(); String response = assistantService.chat("What is the capital of France?"); System.out.println("Assistant response: " + response); requestContextController.deactivate(); } } } The application starts a CDI container using Weld SE, which provides dependency injection in a Java SE environment. After initializing the container, we activate the request context and obtain an instance of AssistantService from CDI. Although there is no concrete implementation in the codebase, CDI returns a fully functional service generated by LangChain4j. When the chat method is called, LangChain4j sends the prompt to the configured AI model, waits for the response, and converts the result into a Java String. Running the application produces an output similar to the following: Plain Text Assistant response: Paris is the capital of France. The exact wording may vary because large language models are probabilistic systems. Unlike traditional methods that always return the same result for a given input, AI models may produce slightly different responses while maintaining the same meaning. While using strings is useful for learning the fundamentals, enterprise applications rarely exchange raw text between layers. Business applications typically use structured data, domain objects, commands, and responses to ensure stronger contracts and better maintainability. In the next section, we will enhance this example by replacing raw strings with dedicated input and output classes, enabling LangChain4j to map between Java objects and AI interactions in a more type-safe and expressive manner. Working With Structured Input and Output The previous example showed a basic AI interaction: a string input produces a string output. While this illustrates the fundamentals, real-world applications rarely use unstructured text alone. Enterprise systems typically exchange well-defined objects that represent business concepts, making code more expressive, maintainable, and type-safe. LangChain4j’s key strength is its ability to map Java objects directly to AI interactions. It automatically converts structured input into prompts and transforms AI responses into strongly typed Java objects, eliminating the need for manual serialization and parsing. Developers can work with domain concepts instead of raw text. To demonstrate this, we will build a simple book recommendation engine. Given a book title and author, the AI will suggest three books that logically follow in a learning journey. We begin by defining the input object: Java public record BookRequest(String title, String author) { } This record captures the user’s input. Instead of manually creating a textual prompt, we provide a structured Java object with the book’s title and author. Next, we define the domain model representing a recommended book: Java import java.util.List; public record Book( String title, String author, String description, List<String> keywords) { } This record contains richer information than a simple title. This record includes more than just the title and author. It also provides a short description and a set of keywords to further characterize the recommendation, with the reason why the book was selected: Java public record Recommendation(Book book, String reason) { } Finally, we create a wrapper object that represents the complete response returned by the AI service: Java import java.util.List; public record NextReadBooks(List<Recommendation> recommendations) { } At this stage, we have a complete domain model for both the request and the expected response. Next, we define the AI service: Java import dev.langchain4j.cdi.spi.RegisterAIService; import dev.langchain4j.service.SystemMessage; import jakarta.enterprise.context.ApplicationScoped; @ApplicationScoped @RegisterAIService public interface NextReadBookService { @SystemMessage(""" Recommend up to 3 books that should naturally follow the provided book in a learning journey. Recommendations should prioritize: - conceptual progression - complementary knowledge - technical depth - thematic similarity For each recommendation provide: - title - author - concise description - relevant keywords - a short recommendation reason Keep recommendations concise, technically relevant, and focused on software engineering and architecture learning. """) NextReadBooks nextReadBooks(BookRequest bookRequest); } This example also introduces the concept of a system message. The @SystemMessage annotation provides instructions that guide the model’s behavior. Unlike user input, which varies with each request, the system message serves as a permanent set of rules for AI responses. Here, we instruct the model to recommend up to three books, explain each recommendation, and return the information using our defined Java records. The method signature uses only domain objects: BookRequest as input and NextReadBooks as output. There is no need for manual JSON handling, prompt creation, or response parsing, as LangChain4j manages these tasks automatically. The application code remains straightforward: Java import jakarta.enterprise.context.control.RequestContextController; import jakarta.enterprise.inject.se.SeContainer; public class BookApp { public static void main(String[] args) { try (SeContainer container = jakarta.enterprise.inject.se.SeContainerInitializer .newInstance() .initialize()) { RequestContextController requestContextController = container.select(RequestContextController.class).get(); requestContextController.activate(); var bookService = container.select(NextReadBookService.class).get(); BookRequest request = new BookRequest( "The Great Gatsby", "F. Scott Fitzgerald"); var recommendations = bookService.nextReadBooks(request); for (var recommendation : recommendations.recommendations()) { System.out.println( "Recommended book: " + recommendation.book().title() + " by " + recommendation.book().author()); System.out.println( "Reason: " + recommendation.reason()); } requestContextController.deactivate(); } } } When executed, LangChain4j converts the BookRequest into a prompt, sends it to the model, validates the response against the target structure, and maps the result back into NextReadBooks. For developers, this interaction is similar to calling a standard Java service. This approach offers clear advantages over raw string-based interactions. The code is easier to understand, IDE autocompletion enhances productivity, and refactoring is safer because inputs and outputs are explicit domain models. The application can also adapt more easily to new business requirements. So far, our examples have used explicit user requests and static system instructions. However, modern AI applications often need additional context beyond user input. In the next section, we will explore how to enrich AI interactions with external knowledge and context, enabling the model to produce more accurate and relevant responses aligned with the application’s domain.
Shai Almog
Co-founder at Codename One,
Codename One
Ram Lakshmanan
yCrash - Chief Architect