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.
Taming the JVM Latency Monster
Automating Maven Dependency Upgrades Using AI
Data-driven testing enables testers to execute the same test logic with multiple sets of input data, improving coverage and reliability with minimal effort. By combining CSV files with TestNG’s @DataProvider annotation, test data can be easily separated from the test logic. This approach enables maintainability and makes test automation more scalable and flexible. This article explains how to implement data-driven testing with CSV files and TestNG in a clear, practical, and easy-to-follow manner. Data-Driven Testing Using TestNG’s @DataProvider Annotation and CSV Files The setup and configuration remain the same as discussed in the earlier tutorial. Additionally, the following dependency for Jackson-dataformat-csv should be added to the pom.xml to handle the CSV files. XML <dependency> <groupId>com.fasterxml.jackson.dataformat</groupId> <artifactId>jackson-dataformat-csv</artifactId> <version>2.21.0</version> </dependency> The POST /addOrder API of the RESTful e-commerce demo application will be used for the demonstration. The schema for the API is given below: JSON [ { "user_id": "string", "product_id": "string", "product_name": "string", "product_amount": 0, "qty": 0, "tax_amt": 0, "total_amt": 0 } ] Creating a POJO Class The following POJO class should be created to map the CSV file data to the data provider: Java @Getter @Setter @AllArgsConstructor @NoArgsConstructor public class Order { @JsonProperty ("user_id") private String userId; @JsonProperty ("product_id") private String productId; @JsonProperty ("product_name") private String productName; @JsonProperty ("product_amount") private int productAmount; private int qty; @JsonProperty ("tax_amt") private int taxAmt; @JsonProperty ("total_amt") private int totalAmt; } Code Walkthrough The Order POJO simplifies reading the CSV file by mapping each row to a single object, with each column in the row mapped to its corresponding class variable. Using a POJO helps remove hard-coded mapping logic by allowing CSV parsing libraries to automatically bind column names to class fields based on annotations or naming conventions. Instead of manually extracting values by column index (for example, row[0], row[1], etc.), the POJO maps each CSV column directly to the corresponding property. The @Getter and @Setter annotations automatically create getter and setter methods for all fields during compilation. The @AllArgsConstructor annotation enables quick creation of fully initialized objects, which is useful when manually creating test data. The @NoArgsConstructor annotation generates a default constructor with no parameters. Without it, the library may fail to create instances of the POJO during CSV mapping. In this Order class, the @NoArgsConstructor ensures automatic object creation when reading data from CSV files. If you’d like to dive deeper into testing POST requests with REST Assured in Java, check out this detailed guide. Creating a Utility to Read CSV Files The following CSV file will be used to perform data-driven testing: Plain Text user_id,product_id,product_name,product_amount,qty,tax_amt,total_amt "U001","P1001","Laptop",850,1,42,892 "U002","P1002","Smartphone",600,2,60,1260 "U003","P1003","Headphones",120,3,18,378 "U004","P1004","Keyboard",75,2,7,157 "U005","P1005","Mouse",40,4,8,168 "U006","P1006","Monitor",300,1,15,315 "U007","P1007","Tablet",450,2,45,945 "U008","P1008","Printer",200,1,10,210 "U009","P1009","Webcam",90,3,13,283 "U010","P1010","Speaker",150,2,15,315 Let’s create a utility method to read CSV files from the ‘src/test/resources/` folder. Java public class CSVReader { @SneakyThrows public static List<Order> getOrderData (String filename) { InputStream inputStream = CSVReader.class.getClassLoader () .getResourceAsStream (filename); if (inputStream == null) { throw new RuntimeException ("File not found: " + filename); } CsvSchema schema = CsvSchema.emptySchema () .withHeader (); MappingIterator<Order> iterator; try { iterator = new CsvMapper ().readerFor (Order.class) .with (schema) .readValues (inputStream); } catch (IOException e) { throw new RuntimeException (e); } return iterator.readAll (); } } Code Walkthrough The getOrderData() method is static and reads a CSV file, returning a list of Order objects. The following steps explain the CSV file reading process: Loading the CSV file: It loads the CSV file from the resources folder (src/test/resources/) using the class loader. If the file is not found, it throws a RuntimeException. Java CsvSchema schema = CsvSchema.emptySchema () .withHeader (); Defining the CSV schema: The above code statement defines the CSV schema for Jackson’s parser. The emptySchema().withHeader() tells Jackson to use the first row as column headers to map them to the fields in the Order POJO. Java MappingIterator<Order> iterator; try { iterator = new CsvMapper ().readerFor (Order.class) .with (schema) .readValues (inputStream); } catch (IOException e) { throw new RuntimeException (e); } return iterator.readAll() Reading the CSV file: The CSV file is read using the CsvMapper from the Jackson-dataformat-csv library. It maps each row to the Order object using code readerFor(Order.class).with(schema). The code readValues(inputStream) creates a MappingIterator<Order> that allows iterating over all rows of the CSV. If any I/O error occurs while reading the file, it is caught and rethrown as a RuntimeException to simplify error handling. Finally, the iterator reads all the remaining rows and returns them as a List<Order>. Creating a DataProvider Method The following data provider method returns the test data from the CSV file as Iterator<Object[]>, which is further consumed by the test. Java @DataProvider (name = "orderData") public Iterator<Object[]> getOrders () { List<Order> orderList = CSVReader.getOrderData ("order_data.csv"); return orderList.stream () .map (order -> new Object[] { order }) .iterator (); } Code Walkthrough The getOrders() method reads test data from the “order_data.csv” file. It loads all records as a list of Order objects using the CSVReader utility. The stream operation converts each Order into an Object[] format, and then returns an Iterator, allowing TestNG to efficiently run the same test multiple times with different input data. Writing the API Automation Test Let’s write the test for the POST /addOrder API that creates orders using the test data supplied from the CSV files using the data provider: Java @Test (dataProvider = "orderData") public void testCreateOrder (Order order) { List<Order> orderList = List.of (order); given ().contentType (ContentType.JSON) .when () .log () .all () .body (orderList) .post ("http://localhost:3004/addOrder") .then () .log () .all () .statusCode (201) .and () .assertThat () .body ("message", equalTo ("Orders added successfully!")); } The testCreateOrder() method uses the orderData DataProvider to run the same test multiple times, each time with a different Order object from the CSV file. Before making the POST request, each order is wrapped inside a List using “List.of(order)” as the POST /addOrder API expects a list of orders in the request body. The test then verifies the response by checking that the status code is 201 and confirming that the success message “Orders added successfully” is returned. Checkout this detailed tutorial on How to Perform Response Verification in REST-Assured Java for API Testing. Test Execution When the test is executed, TestNG automatically runs the testCreateOrder() method multiple times, each time using a different set of test data read from the CSV file through the orderData DataProvider. Summary Using CSV files with TestNG’s DataProvider offers a simple and effective way to implement data-driven testing by keeping test data separate from test logic. This approach makes test cases easier to maintain, improves readability, and allows testers to update or extend test scenarios without modifying the code. Happy testing!
So you have a new AI-based idea and need to create an MVP app to test it? If your AI knowledge is limited to OpenAI, I have bad news for you… it’s not going to be free. Even worse, before you deploy your app — while you’re still building and testing locally — yes, you’ll need to spend some money. More tests? Yes, you can add that cost too. And guess what? AI POCs unexpectedly turn into real bills. This problem scales with your team: more developers, bigger bills =( That’s when you realize AI has moved from experimentation to a budget line — and how high the cost of production mistakes can be. You have freemium online options like Groq, but running AI locally is a great way to remove these constraints. Why Running AI Locally Changes the Game When we talk about “no cost,” we mean developing your app with: No token-based pricingNo external API callsNo cloud dependency When your app runs in the cloud, you need to use paid services. So how can we solve this problem? Spring AI is the answer — but we’ll get to that soon. Let me say this again: by running a local LLM (Large Language Model), your team has nothing to pay. Of course, there are some drawbacks, such as higher CPU/RAM usage on the development machine and some setup time for the local AI environment. But it’s totally worth it. Ollama: Local LLMs Made Simple Ollama is an open-source tool designed to run LLMs directly on your local machine (Windows, macOS, or Linux) without needing cloud services. (They also offer a free cloud service, but that’s not the point here.) Ollama is one of the easiest ways to get started with LLMs such as gpt-oss (yes, the LLM provided by OpenAI!), Gemma 3, DeepSeek-R1, Qwen3, and many more. Yes, we have Ollama — a great open-source alternative to paid LLM services. Our quick start is very simple: Download it — just go to https://ollama.com/downloadDownload a model — there are many options, but we’ll use a small and powerful model created by Microsoft: Phi-3 (https://ollama.com/library/phi3) Shell ollama pull phi3 Now we have our local AI ready to go. Let’s test the model: Shell ollama run phi3 "who are you" I'm Phi, developed by Microsoft. How can I help you today? Choosing the Right Model: Why Phi-3? If we have so many free models available, why start with Phi-3? Here are a few reasons. First, the larger the model, the more resources it consumes — and sometimes it’s slower. Picking a small but powerful model is a good way to start. Later, you should definitely test other models. Another powerful and compact model is “ministral-3.” The Ministral 3 family is designed for edge deployment and can run on a wide range of hardware. If you’re new to Ollama, though, Phi-3 is a great starting point. It’s not the best model overall, but it’s one of the best to begin with. Spring Boot and Spring AI: A Natural Fit In the Java world, we have other options, but just like Spring Boot, Spring AI is becoming a mature and reliable choice for AI applications. You can start with Ollama and later switch to OpenAI — or even use multiple models in your app. No problem. Spring AI can handle it easily. This frees you from manually handling all LLM APIs using RestTemplate or RestClient. Spring AI does that for you. We won’t build a complex app here. Instead, we’ll create a very simple one to demonstrate how powerful Spring AI is. We’ll build an app with an API that generates a joke — no input required. I recommend IntelliJ Community Edition, but you can use any IDE. The easiest way is to go to https://start.spring.io and add the Ollama dependency. Or you can create a plain Spring Boot MVC app and add this to your pom.xml: XML <dependencies> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency> <dependency> <groupId>org.springdoc</groupId> <artifactId>springdoc-openapi-starter-webmvc-ui</artifactId> <version>${springdoc-openapi-starter-webmvc-ui.version}</version> </dependency> <dependency> <groupId>org.springframework.ai</groupId> <artifactId>spring-ai-starter-model-ollama</artifactId> </dependency> Our app needs just two more files. First, configure which Ollama model we’ll use in application.yaml: YAML spring: application: name: zerocostapp ai: ollama: chat: model: phi3 Make sure the Ollama service is running and the Phi-3 model is installed. Building a Simple “Jokes as a Service” API Now we create an API to provide our “Jokes as a Service.” Spring AI provides the ChatClient class, which communicates with LLMs and gives developers a Builder to define inputs. Java @RestController public class JokesAPI { @Autowired private ChatClient.Builder chatClient; @GetMapping("/api/new-joke") public String process() { return chatClient .build() .prompt("Tell me a joke") .call() .content(); } } In this case, we use a fixed prompt that asks the LLM to tell a joke. The response is converted to a String and returned by the API. Calling it with curl: Shell curl http://localhost:8080/api/new-joke Why don't scientists trust atoms? Because they make up everything, even jokes! That’s it. You now have a fully functional LLM integrated into a Java application. =) Architecture Overview Let’s recap the flow: HTTP client (curl)Spring REST controller (JokesAPI)Spring AI (ChatClient)Ollama runtimeLocal LLM model (Phi-3) When your app is deployed elsewhere, by changing dependencies and configuration properties, the flow could become: HTTP client (customer)Spring REST controller (JokesAPI)Spring AI (ChatClient)Cloud LLM runtimeCloud-hosted LLM model Limitations and Trade-offs If you encounter performance issues, be careful about drawing conclusions based only on local tests. You may want to run paid remote tests for comparison. As with any online application, security matters. This sample does not expose user input, but whenever you allow input to reach an AI model, you risk prompt injection attacks. What’s Next: From Jokes to Real Applications You might need logging, chat history per user, or database storage. Don’t worry — Spring AI can handle this with just a few lines of code. You can also enrich your model with additional documents to improve response quality. This is called RAG (Retrieval-Augmented Generation), and Spring AI supports it. If you need to call external services — or expose your service to other LLMs — MCP (Model Context Protocol) is an emerging standard created by Anthropic. The Spring AI team helps maintain its Java implementation. This is just a glimpse into the vast world of Ollama models and Spring AI. I hope you enjoyed it!
This article is part of the series “Stranger things in Java,” dedicated to language deep dives that will help us master even the strangest scenarios that can arise when we program. All articles are inspired by content from the book “Java for Aliens” (in English), the book “Il nuovo Java”, and the book “Programmazione Java.” This article is a short tutorial on enumeration types, also called enumerations or enums. They are one of the fundamental constructs of the Java language, alongside classes, interfaces, annotations, and records. They are particularly useful to represent sets of known and unchangeable values, such as the days of the week or the cardinal directions. What Is an Enum? An enum is declared with the enum keyword and typically contains a list of values, called the elements (or values, or also constants) of the enumeration. Let’s consider, for example: Java public enum CardinalDirection { NORTH, SOUTH, WEST, EAST; } Here, we defined an enum named CardinalDirection, with four elements: NORTH, SOUTH, WEST and EAST. The elements defined in the enumeration are the only possible instances of type CardinalDirection, and it is not possible to instantiate other objects of the same type. Therefore, if we tried to instantiate an object from the CardinalDirection enumeration, we would get a compilation error: Java var d = new CardinalDirection(); // ERROR: you cannot create new instances Elements of an Enumeration Using an enumeration, therefore, mainly means using its elements. For example, the following method returns true if the direction parameter matches the NORTH element of CardinalDirection: Java static boolean isNorth(CardinalDirection direction) { return direction == CardinalDirection.NORTH; } In the following example, instead, we assign references to the elements of CardinalDirection: Java CardinalDirection d1 = CardinalDirection.SOUTH; System.out.println(d1 == CardinalDirection.SOUTH); // true var d2 = CardinalDirection.EAST; System.out.println(d2 == CardinalDirection.WEST); // false Each element is implicitly declared public, static and final. In fact, from these examples we can observe that: To use the elements of an enumeration, you must always refer to them via the name of the enumeration (for example CardinalDirection.SOUTH).We can compare elements directly with the == operator because they are implicitly final and unique.The names of enumeration elements follow the naming conventions for constants. During compilation, the CardinalDirection enumeration is transformed into a class similar to the following: Java public class CardinalDirection { public static final CardinalDirection NORTH = new CardinalDirection(); public static final CardinalDirection SOUTH = new CardinalDirection(); public static final CardinalDirection WEST = new CardinalDirection(); public static final CardinalDirection EAST = new CardinalDirection(); } While the compiler ensures that no other elements can be instantiated besides those declared in the enumeration. * Backward compatibility is a fundamental feature of Java that ensures code written for earlier versions of the platform continues to work on more recent versions of the JVM, without requiring changes. Backward compatibility is one of the main reasons why Java is widely used in enterprise environments and long-lived systems. Why Use Enumerations? One of the main advantages of enumerations is the ability to represent a limited set of values in a safe way. Without an enum, there is a risk of using strings or “magic” numbers, which can introduce errors that are difficult to detect. Let us consider the following example: Java public class Compass { public void move(String direction) { String message; if (direction.equals("NORTH")) { message = "You move north"; } else if (direction.equals("SOUTH")) { message = "You move south"; } else if (direction.equals("WEST")) { message = "You move west"; } else if (direction.equals("EAST")) { message = "You move east"; } else { message = "Invalid direction: " + direction; } System.out.println(message); } } With this approach, it is possible to pass any string, even an invalid one. For example, the value of the direction parameter could be "north" or "North", but it should be "NORTH" in order for the method to work correctly. The compiler cannot help us prevent such errors. In the following code, we use the CardinalDirection enumeration to completely eliminate arbitrary values and delegate to the compiler the validation of the allowed values: Java public class Compass { public void move(CardinalDirection direction) { String message = switch (direction) { case NORTH -> "You move north"; case SOUTH -> "You move south"; case WEST -> "You move west"; case EAST -> "You move east"; }; System.out.println(message); } } In this way: The direction parameter can only take the values defined in the enumeration.It is not possible to specify an invalid value: such an error would be detected at compile time.The switch expression must be exhaustive**, therefore the compiler requires that all alternatives are handled in order to compile without errors. Enumerations make code safer and more readable, because they avoid the use of “magic” values or arbitrary strings to represent concepts that have a limited number of alternatives. ** To learn about the concept of exhaustiveness related to switch expressions, introduced in Java 14, you can read the article entitled “The new switch.” Enumerations and Inheritance We have seen that the compiler transforms the CardinalDirection enum into a class whose elements are implicitly declared public, static, and final. However, we have not yet said that such a class: Is itself declared final. This implies that enumerations cannot be extended.Extends the generic class java.lang.Enum. Consequently, it cannot extend other classes (but it can still implement interfaces). In practice, the declaration of the CardinalDirection class will be similar to the following: Java public final class CardinalDirection extends Enum<CardinalDirection> { // rest of the code omitted } Therefore, we cannot create hierarchies of enumerations in the same way we do with classes. Moreover, all enumerations inherit: The methods declared in the Enum class.The methods and properties of the Serializable and Comparable interfaces, which are implemented by Enum.The methods from the Object class. However, in the last paragraph of this tutorial, we will see how it is possible, in some sense, to extend an enumeration. Methods Inherited From the Enum Class By extending Enum, enumerations inherit several methods: name: returns the name of the element as a string (it cannot be overridden because it is declared final).toString: returns the same value as name, but it can be overridden.ordinal: returns the position of the element in the enumeration starting from index 0 (it is declared final).valueOf: a static method that takes a String as input and returns the enumeration element corresponding to the name.values: a static method not actually present in java.lang.Enum, but generated by the compiler for each enumeration. It returns an array containing all enumeration elements in the order in which they are declared. For example, the name method is defined to return the element name, so: Java System.out.println(CardinalDirection.SOUTH.name()); // prints SOUTH will print the string "SOUTH". The equivalent toString method also returns the enum name, so the instruction: Java System.out.println(CardinalDirection.SOUTH); // prints SOUTH produces exactly the same result (since println calls toString on the input object). The difference is that the name method cannot be overridden because it is declared final, while the equivalent toString method can always be overridden. Enum also declares a method complementary to toString: the static method valueOf. It takes a String as input and returns the corresponding enumeration value. For example: Java CardinalDirection direction = CardinalDirection.valueOf("NORTH"); System.out.println(direction == CardinalDirection.NORTH); // prints true The special static values method returns an array containing all enumeration elements in the order in which they were declared. You can use this method to iterate over the values of an enumeration you do not know. For example, using an enhanced for loop, we can print the contents of the CardinalDirection enumeration, also introducing the ordinal method: Java for (CardinalDirection cd : CardinalDirection.values()) { System.out.println(cd + "\t is at position " + cd.ordinal()); } Note that the ordinal method (also declared final) returns the position of an element within the array returned by values. The output of the previous example is therefore: Java NORTH is at position 0 SOUTH is at position 1 WEST is at position 2 EAST is at position 3 For completeness, the Enum class actually declares two other, less interesting methods: getDeclaringClass: returns the Class object associated with the enum type to which the value on which the method is invoked belongs.describeConstable: a method introduced in Java 12 to support advanced constant descriptions for low-level APIs. This is a specialized API that is not used in traditional application development. Methods and Properties Inherited From the Serializable and Comparable Interfaces The Enum class implements the Serializable and Comparable interfaces, and consequently, enumeration objects have the properties of being serializable and comparable. While the marker interface Serializable does not contain methods, the functional interface Comparable makes the natural ordering of the elements of an enumeration coincide with the order in which the elements are defined within the enumeration itself. This means that the only abstract method compareTo of the Comparable interface determines the ordering of two enumeration objects based on the position of the objects within the enumeration. Note that the compareTo method is declared final in the Enum class, and therefore it cannot be overridden in our enumerations. Methods Inherited From the Object Class Enumerations inherit all 11 methods of the Object class. In particular, as already mentioned, we can override the toString method. The other methods we usually override, such as equals, hashCode, and clone, are instead declared final and therefore cannot be overridden. In fact, to compare two enumeration instances, it is sufficient to use the == operator, since enumeration values are constants, and therefore, there is no need to redefine the equals and hashCode methods. Moreover, enumerations cannot be cloned, since their elements must be the only possible instances. In this case as well, the java.lang.Enum class declares the clone method inherited from Object as final. Being also declared protected, it is not even visible outside the java.lang package of Enum. Customizing an Enumeration Since it is transformed into a class, an enumeration can declare everything that can be declared in a class (methods, variables, nested types, initializers, and so on), with some constraints on constructors. In fact, constructors are implicitly considered private and can only be used by the enumeration elements through a special syntax. Compilation will instead fail if we try to create new instances using the new operator. For example, the following code redefines the CardinalDirection enumeration: Java public enum CardinalDirection { NORTH("north"), // invokes constructor 2 SOUTH("south"), // invokes constructor 2 WEST("west"), // invokes constructor 2 EAST; // invokes constructor 1 // equivalent to EAST() // instance variable private String description; // constructor number 1 private CardinalDirection() { this("east"); // calls constructor 2 } // constructor number 2 (implicitly private) CardinalDirection(String direction) { setDescription("direction " + direction); } public String getDescription() { return description; } public void setDescription(String description) { this.description = description; } @Override public String toString() { return "We are pointing " + description; } } We can observe that: We declared two constructors in the enumeration: one explicitly private and the other implicitly private. It is not possible to declare constructors with public or protected visibility. Apart from this, the same rules that apply to class constructors also apply here. As with classes, if we do not declare any constructors, the compiler will add a no-argument constructor for us (the default constructor). Also, as with classes, the default constructor will not be generated if we explicitly declare at least one constructor, as in the previous example.The enumeration elements invoke the declared constructors using a special syntax. In the declaration of the NORTH, SOUTH, and WEST elements, we added a pair of parentheses and passed a string parameter. This ensures that constructor number 2, which takes a String parameter, is invoked when these instances are created. The EAST element, instead, does not use parentheses and therefore invokes the no-argument constructor. Note that we could also have added an empty pair of parentheses to obtain the same result.The declaration of the enumeration elements must always precede all other declarations. If we placed any declaration before the element list, compilation would fail. Note that the semicolon after the element list is optional if no other members are declared. Static Nested Enumerations and Static Imports It is not uncommon to create nested enumerations, which, unlike nested classes, are always static. For example, suppose we want to create an enumeration that defines the possible account types (for example "standard" and "premium") for customers of an online shop. Since the account type is strictly related to the concept of an account, it makes sense to declare the Type enumeration nested within the Account class: Java package com.online.shop.data; public class Account { public enum Type {STANDARD, PREMIUM} // static nested enum // other code omitted... public static void main(String[] args) { System.out.println(Type.PREMIUM); // access to the static enumeration } } If instead we want to print an enumeration element from outside the Account class, we can use the following syntax: Java System.out.println(Account.Type.PREMIUM); Of course, we can also use static import when appropriate, for example: Java import static com.online.shop.data.Account.Type; // ... System.out.println(Type.PREMIUM); Or even: Java import static com.online.shop.data.Account.Type.PREMIUM; // ... System.out.println(PREMIUM); Enumerations and static import were both introduced in Java 5. static import, in fact, allows us to reduce verbosity when using enumerations. Extending an Enumeration We know that we cannot extend an enumeration; however, it is possible to use the anonymous class syntax for each element, redefining the methods declared in the enumeration. We can define methods in the enumeration and override them in its elements. Let us rewrite the CardinalDirection enumeration once again: Java public enum CardinalDirection { NORTH { @Override public void test() { System.out.println("method of NORTH"); } }, SOUTH, WEST, EAST; public void test() { System.out.println("method of the enum"); } } Here, we defined a method called test that prints the string "method of the enum". The NORTH element, however, using a syntax similar to that of anonymous classes, also declares the same method, overriding it. In fact, the compiler will turn the NORTH element into an instance of an anonymous class that extends CardinalDirection. Therefore, the statement: Java CardinalDirection.NORTH.test(); Will print: Java method of NORTH While the statement: Java CardinalDirection.SOUTH.test(); Will print: Java method of the enum Because SOUTH does not override test. The same output will be produced when invoking the test method on the EAST and WEST elements as well. Enumerations and Polymorphism After examining the relationship between enumerations and inheritance, we can now use enumerations by exploiting polymorphism in a more advanced way. For example, let us consider the following Operation interface: Java public interface Operation { boolean execute(int a, int b); } We can implement this interface within an enumeration Comparison, customizing the implementation of the execute method for each element: Java public enum Comparison implements Operation { GREATER { public boolean execute(int a, int b) { return a > b; } }, LESS { public boolean execute(int a, int b) { return a < b; } }, EQUAL { public boolean execute(int a, int b) { return a == b; } }; } With this structure, we can write code such as the following: Java boolean result = Comparison.GREATER.execute(10, 5); System.out.println("10 greater than 5 = " + result); result = Comparison.LESS.execute(10, 5); System.out.println("10 less than 5 = " + result); result = Comparison.EQUAL.execute(10, 5); System.out.println("10 equal to 5 = " + result); Which will produce the following output: Java 10 greater than 5 = true 10 less than 5 = false 10 equal to 5 = false Implementing an interface in an enumeration allows you to associate a behavior with each enum value and exploit polymorphism, making the code more extensible, readable, and robust. Conclusion Enumerations are particularly useful when: The domain of values is closed and known in advance, such as cardinal directions, object states, days of the week, priority levels, and so on.You want to make code safer by eliminating arbitrary strings or “magic” numbers.Each element must be able to have specific properties or methods, while maintaining clarity and readability. They are less suitable when: The elements can vary dynamically over time, for example, if they come from a database or external configurations.You want to model an extensible hierarchy of types, for which classes and interfaces remain more flexible solutions. In this article, we have seen that enumerations are not simply lists of constants, but real classes with predefined instances, methods inherited from Enum, the ability to implement interfaces, and even the possibility to redefine behavior for individual values through anonymous classes. These aspects make enum a surprisingly powerful and, in some cases, unexpected tool: a perfect example of stranger things in Java. Author’s Note This article is based on some paragraphs from chapters 4 and 7 of my book “Programmazione Java” and from my English book “Java for Aliens.”
In the previous article, we explored how to implement data-driven testing using Object arrays and TestNG’s @DataProvider annotation. While this approach works well for small to medium-sized datasets, it is not ideal for handling large volumes of data. To address this limitation, TestNG also supports the use of Iterators, which provide a more efficient way to manage large and dynamic datasets. This article focuses on how to perform data-driven API automation testing using an Iterator with a DataProvider annotation of TestNG. Data-Driven Testing Using @DataProvider and Iterator The setup and configuration remain the same as discussed in the earlier tutorial. No additional dependencies or project configurations are required. We will be using the same POST /addOrder API from the RESTful e-commerce demo application. The following is the schema of the request body of the POST /addOrder API: JSON [ { "user_id": "string", "product_id": "string", "product_name": "string", "product_amount": 0, "qty": 0, "tax_amt": 0, "total_amt": 0 } ] Creating a POJO Class Let’s create a POJO class for the Order object, which will be used in the data provider method. Java @Getter @Setter @AllArgsConstructor @ToString (exclude = "expectedStatus") public class Order { @JsonProperty ("user_id") private String userId; @JsonProperty ("product_id") private String productId; @JsonProperty ("product_name") private String productName; @JsonProperty ("product_amount") private int productAmount; private int qty; @JsonProperty ("tax_amt") private int taxAmt; @JsonProperty ("total_amt") private int totalAmt; private int expectedStatus; } Code Walkthrough The @Getter and @Setter annotations automatically generate getter and setter methods for all fields at compile time.The @AllArgsConstructor annotation creates a constructor that accepts all fields as parameters, making it easier to define test data using the Order object.The @ToString annotation automatically generates a toString() method, excluding the expectedStatus field. This ensures that the values provided in the Order object are printed correctly after test execution.The @JsonProperty annotation instructs Jackson on how to map JSON field names to their corresponding Java variables.The variables defined in this POJO represent the fields of the Order object.An additional field, expectedStatus, is included to verify the status code during testing. This helps in validating both valid and invalid test cases by allowing flexible input and comparison against the expected status code. Creating a Data Provider Method Let’s define a data provider method that returns the test data using an Iterator: Java @DataProvider (name = "orderData") public Iterator<Order> getOrderData () { List<Order> orderList = new ArrayList<> (); orderList.add (new Order ("1", "79", "IPhone 17 Pro", 6999, 1, 699, 7698, 201)); orderList.add (new Order ("2", "81", "Samsung Galaxy S25", 7999, 1, 799, 8798, 201)); orderList.add (new Order ("2", "87", "iPad SE", 4550, 1, 455, 5005, 201)); orderList.add (new Order ("4", "13", "Macbook Pro", 9999, 1, 999, 10098, 201)); orderList.add (new Order ("5", "19", "Macbook Air", 8999, 1, 899, 9898, 201)); return orderList.iterator (); } Code Walkthrough This code supports data-driven testing by storing multiple order objects in an ArrayList, where each object represents a separate test case. The @DataProvider annotation over the getOrderData() method tells TestNG that this method provides test data. The name "orderData" is used to link this data provider to a test method.The getOrderData() method returns an Iterator<Order>, which TestNG uses to retrieve one order object at a time, with each object representing a separate test case.The test data for the order details are stored in an ArrayList, which allows flexible storage, easy modification, and efficient iteration over multiple test cases during execution. Writing the API Automation Tests Using REST Assured, let’s write the API automation tests and pass the dataProvider parameter to the @Test annotation to instruct TestNG to retrieve the required data set from it. Java @Test (dataProvider = "orderData") public void testCreateOrder (Order order) { List<Order> orderList = List.of (order); given ().contentType (ContentType.JSON) .when () .log () .all () .body (orderList) .post ("http://localhost:3004/addOrder") .then () .log () .all () .statusCode (order.getExpectedStatus ()) .and () .assertThat () .body ("message", equalTo ("Orders added successfully!")); } Code Walkthrough This testCreateOrder() method implements data-driven testing using the orderData DataProvider, allowing the same test to run multiple times with different order objects. Since the API endpoint expects a list of orders in the request body, each order is wrapped in a List<Order> using List.of(order) before sending the POST request. The test then validates the response by checking the expected status code and asserting that the success message “Orders added successfully” is returned. Check out my earlier article on how to perform response verification in REST Assured with Java for a detailed understanding of validating status codes and response bodies using robust assertion techniques. Test Execution When the test runs, TestNG automatically executes the testCreateOrder() method multiple times, each time using a different set of data from the orderData DataProvider. This makes it easy to check that the API handles various inputs correctly. Summary Using an Iterator in a TestNG DataProvider allows efficient handling of large or dynamic datasets, as it supplies one test case at a time without loading the entire dataset into memory. This makes tests more memory-friendly and scalable compared to using an Object array. Additionally, it provides flexibility for generating data on the fly or reading from external sources such as files or databases. Happy testing!
When designing a Java library, extensibility is often a key requirement, especially in the later phases of a project. Library authors want to allow users to add custom behavior or provide their own implementations without modifying the core codebase. Java addresses this need with the Service Loader API, a built-in mechanism for discovering and loading implementations of a given interface at runtime. Service Loader enables a clean separation between the Application Programming Interface (API) and its implementation, making it a solid choice for plugin-like architectures and Service Provider Interfaces (SPI). In this post, we’ll look at how Service Loader can be used in practice, along with its advantages and limitations when building extensible Java libraries. Example Usage In the demo project, the library allows customization of the naming strategy based on annotations, for which dedicated SPI implementations are provided. SPI Definition First, let’s start with the SPI in the core library module: Java public interface TypeAliasHandler<T extends Annotation> { Class<T> getSupportedAnnotation(); String getTypeName(T annotation, Class<?> annotatedClass); } To enable the Service Loader API to discover implementations of this interface, a configuration file must be created in the META-INF/services/ directory on the classpath. The file name must exactly match the fully qualified name of the interface. Inside this file, list the fully qualified class names of all implementing classes, one per line. This mechanism allows Service Loader to automatically find and load all available implementations at runtime. Built-in Providers Within the same JAR file, we can define built-in annotations and their default behavior. For architectural consistency and convenience, the handler responsible for the built-in annotation also implements the SPI interface. This approach ensures that both internal and external implementations are treated uniformly by the Service Loader mechanism. Java public class BuiltInTypeAliasHandler implements TypeAliasHandler<TypeAlias> { @Override public Class<TypeAlias> getSupportedAnnotation() { return TypeAlias.class; } @Override public String getTypeName(TypeAlias annotation, Class<?> annotatedClass) { return annotation.value(); } } The annotation is: Java @Retention(RetentionPolicy.RUNTIME) @Target(ElementType.TYPE) public @interface TypeAlias { String value(); } The implementation must be defined in: Plain Text META-INF/services/com.github.alien11689.serviceloaderdemo.coreservice.spi.TypeAliasHandler with the following content: Plain Text com.github.alien11689.serviceloaderdemo.coreservice.builtin.BuiltInTypeAliasHandler Extensions Module You can create a separate project (or JAR file) that provides custom annotations and their implementations. Such an extension module can be developed independently from the main library and added to the classpath as needed. This demonstrates the true power of Service Loader — the ability to add new functionality without modifying the main library’s source code. No recompilation or redeployment of the core library is required. Let’s start with the annotations: Java @Retention(RetentionPolicy.RUNTIME) @Target(ElementType.TYPE) public @interface CustomTypeAlias { String nameOfTheType(); } and Java @Retention(RetentionPolicy.RUNTIME) @Target(ElementType.TYPE) public @interface UpperCasedClassSimpleNameTypeAlias { } Their handlers (SPI implementations): Java @ServiceProvider public class CustomTypeAliasHandler implements TypeAliasHandler<CustomTypeAlias> { @Override public Class<CustomTypeAlias> getSupportedAnnotation() { return CustomTypeAlias.class; } @Override public String getTypeName(CustomTypeAlias annotation, Class<?> annotatedClass) { return annotation.nameOfTheType(); } } and Java @ServiceProvider public class UpperCasedClassSimpleNameTypeAliasHandler implements TypeAliasHandler<UpperCasedClassSimpleNameTypeAlias> { @Override public Class<UpperCasedClassSimpleNameTypeAlias> getSupportedAnnotation() { return UpperCasedClassSimpleNameTypeAlias.class; } @Override public String getTypeName(UpperCasedClassSimpleNameTypeAlias annotation, Class<?> annotatedClass) { return annotatedClass.getSimpleName().toUpperCase(); } } Since I used the @ServiceProvider annotation available from Avaje, I do not need to create the META-INF/services/...TypeAliasHandler file manually. It is generated automatically during the build with the following content: Plain Text com.github.alien11689.serviceloaderdemo.extensions.custom.CustomTypeAliasHandler com.github.alien11689.serviceloaderdemo.extensions.uppercased.UpperCasedClassSimpleNameTypeAliasHandler Discovering the Implementation In one of the modules (even the one providing the SPI), there should be code that uses the Service Loader API to discover all implementations and use them. In this example, I placed the discovery code in the core module, which is a practical approach — the central module can aggregate all available implementations and provide convenient access to the rest of the application. In the static initialization block, Service Loader scans the entire classpath for configuration files and automatically creates instances of all discovered implementations: Java public class TypeAliasProvider { private static Map<Class<? extends Annotation>, TypeAliasHandler> annotationToTypeNameHandler = new HashMap<>(); static { var loader = ServiceLoader.load(TypeAliasHandler.class); loader.forEach(typeNameHandler -> annotationToTypeNameHandler.put(typeNameHandler.getSupportedAnnotation(), typeNameHandler)); } // ... } In the same class, the discovered implementations can then be used based on the annotations present on a given class: Java public class TypeAliasProvider { // ... public String getTypeName(Object o) { var aClass = o.getClass(); for (Annotation annotation : aClass.getAnnotations()) { var typeNameHandler = annotationToTypeNameHandler.get(annotation.annotationType()); if (typeNameHandler != null) { return typeNameHandler.getTypeName(annotation, aClass); } } return aClass.getName(); } } Let’s Test It Together To test the extension mechanism effectively, all SPI implementations must be available on the classpath. This means you need to include both the core module (with the SPI definition) and all extension modules containing specific implementations in the test project. Service Loader will automatically discover all available services and enable their use during test execution. Test classes: Java @TypeAlias("class_a") class ClassWithDefaultTypeAlias { } @CustomTypeAlias(nameOfTheType = "Class B with custom alias") class ClassWithCustomTypeAlias { } @UpperCasedClassSimpleNameTypeAlias class UpperCaseClass { } Parameterized test: Java class TypeAliasExtensionMappingTest { private final TypeAliasProvider typeAliasProvider = new TypeAliasProvider(); @ParameterizedTest @MethodSource("objectToTypeName") void should_map_object_to_type_name(Object o, String expectedTypeName) { Assertions.assertEquals(expectedTypeName, typeAliasProvider.getTypeName(o)); } private static Stream<Arguments> objectToTypeName() { return Stream.of( arguments(new Object(), "java.lang.Object"), arguments(new ClassWithDefaultTypeAlias(), "class_a"), arguments(new ClassWithCustomTypeAlias(), "Class B with custom alias"), arguments(new UpperCaseClass(), "UPPERCASECLASS") ); } } Full Code The full sample code can be found on my GitHub. The demo was initially designed to demonstrate extension possibilities for Javers. Pros Lightweight and dependency-free – Service Loader is part of the JDK and requires no additional runtime libraries.Standardized solution – Works consistently across all JVM environments.Automatic service discovery – Implementations are discovered at runtime without explicit registration in code.Decoupled architecture – Encourages clean separation between core and plugins. Cons No constructor arguments – Service implementations must provide a no-argument constructor, making configuration and dependency passing difficult. Additional SPI methods may be necessary (e.g., void configure(Properties properties)).No built-in dependency injection – Service Loader does not manage dependencies, scopes, or lifecycle.Public class requirement – Implementations must be declared as public, which limits encapsulation.Limited configurability – Conditional or environment-based service loading is not supported out of the box.Harder to debug – Missing or incorrect service definitions may fail silently at runtime.Not ideal for complex systems – For advanced use cases, full DI frameworks such as Spring or Guice offer more flexibility. Summary Service Loader is a simple yet powerful tool for building extensible Java libraries. It excels in scenarios where minimal dependencies, portability, and clear API boundaries are important. While it has notable limitations — particularly around constructor flexibility, dependency injection, and visibility constraints — it remains an excellent choice for lightweight extension mechanisms. With the help of tools like Avaje, some of the traditional pain points of Service Loader can be reduced, making it an even more attractive option for modern Java library design.
On the 16th of September 2025, Java 25 was released. Time to take a closer look at the changes since the last LTS release, which is Java 21. In this blog, some of the changes between Java 21 and Java 25 are highlighted, mainly by means of examples. Enjoy! Introduction What has changed between Java 21 and Java 25? A complete list of the JEPs (Java Enhancement Proposals) can be found at the OpenJDK website. Here you can read the nitty-gritty details of each JEP. For a complete list of what has changed per release since Java 21, the Oracle release notes give a good overview. In the next sections, some of the changes are explained by example, but it is mainly up to you to experiment with these new features in order to get acquainted with them. Do note that no preview or incubator JEPs are considered here. The sources used in this post are available at GitHub. Check out an earlier blog if you want to know what has changed between Java 17 and Java 21. Check out an earlier blog if you want to know what has changed between Java 11 and Java 17. Prerequisites Prerequisites for reading this blog are: You must have a JDK25 installed. I advise using SDKMAN for this purpose, so that you can switch easily between JDKs.You need some basic Java knowledge. JEP512: Compact Source Files and Instance Main Methods When you are new to Java, you are confronted with quite a few concepts before you can even get started. Take a look at the classic HelloWorld.java. Java public class ClassicHelloWorld { public static void main(String[] args) { System.out.println("Hello World!"); } } You are confronted with the following concepts: You must know the concept of a class;You must know the concept of access modifiers (public in this case);You must know what a static modifier is and the difference between a static and an instance;You must know what a void return type is;You must know what a String Array is;You must know about the strange thing System.out. That is quite a lot! And the only thing you have achieved is a simple text output to the console. The purpose of JEP512 is to remove all of the boilerplate code in order to make the onboarding to Java much more accessible. This means in practice: The static method is removed and replaced with simply void main;No need to create a class;A new IO class within the java.lang package is introduced, which contains basic IO-oriented methods. The new HelloWorld in Java 25 looks as follows: Java void main() { IO.println("Hello Java 25 World!"); } Much simpler, right? Do note that even package statements are not allowed here. The purpose is just to provide as easy a starting point as possible in order to use Java. JEP513: Flexible Constructor Bodies In a constructor, it is not possible to add statements before invoking this() (invoking another constructor within the same class) or before invoking super() (invoking a parent constructor). This causes some limitations when you want to validate input parameters, for example. Assume the following Vehicle class. Java public class Vehicle { int numberOfWheels; Vehicle(int numberOfWheels) { if (numberOfWheels < 1) { throw new IllegalArgumentException("a vehicle must have at least one wheel"); } this.numberOfWheels = numberOfWheels; print(); } void print() { System.out.println("Number of wheels: " + numberOfWheels); } } Class Java21Car extends the parent Vehicle class. Java public class Java21Car extends Vehicle { Color color; Java21Car(int numberOfWheels, Color color) { super(numberOfWheels); if (numberOfWheels < 4 || numberOfWheels > 6) { throw new IllegalArgumentException("A car must have 4, 5 or 6 wheels."); } this.color = color; } @Override void print() { System.out.println("Number of wheels: " + numberOfWheels); System.out.println("Color: " + color); } } Two issues exist: If the condition in the subclass at line 7 results to true, the constructor of the parent Vehicle class is invoked unnecessarily.If you instantiate a Java21Car with numberOfWheels equal to 1, an IllegalArgumentException is thrown, but the output of the overridden print method, will print null for the color because the value has not been assigned yet. Run the FlexibleConstructor class in order to see this result. Java public class FlexibleConstructor { static void main() { Java21Car java21Car = new Java21Car(1, Color.BLACK); } } The output is: Java Exception in thread "main" java.lang.IllegalArgumentException: A car must have 4, 5 or 6 wheels. at com.mydeveloperlanet.myjava25planet.constructor.Java21Car.<init>(Java21Car.java:12) at com.mydeveloperlanet.myjava25planet.constructor.FlexibleConstructor.main(FlexibleConstructor.java:7) Number of wheels: 1 Color: null With the introduction of JEP513, these issues can be solved. Move the validation code in the constructor of the subclass above the invocation of super(). Java public class Java25Car extends Vehicle { Color color; Java25Car(int numberOfWheels, Color color) { if (numberOfWheels < 4 || numberOfWheels > 6) { throw new IllegalArgumentException("A car must have 4, 5 or 6 wheels."); } this.color = color; super(numberOfWheels); } @Override void print() { System.out.println("Number of wheels: " + numberOfWheels); System.out.println("Color: " + color); } } Create an instance just like you did before. Java Java25Car java25Car = new Java25Car(1, Color.BLACK); Running this code results in the IllegalArgumentException, but this time the super()is not invoked, and as a consequence, the print method is not invoked. Java Exception in thread "main" java.lang.IllegalArgumentException: A car must have 4, 5 or 6 wheels. at com.mydeveloperlanet.myjava25planet.constructor.Java25Car.<init>(Java25Car.java:11) at com.mydeveloperlanet.myjava25planet.constructor.FlexibleConstructor.main(FlexibleConstructor.java:8) Create an instance with valid input arguments. Java Java25Car java25Car = new Java25Car(4, Color.BLACK); The super() is invoked and the print method outputs the data as expected. Java Number of wheels: 4 Color: java.awt.Color[r=0,g=0,b=0] JEP456: Unnamed Variables and Patterns Sometimes it occurs that you do not use a variable. For example, in a catch block, you do not want to do anything with the Exception being thrown. However, before Java 25, it was still mandatory to give the variable a name. Assume the following example, where the NumberFormatException must be given a name, ex in this case. Java String s = "data"; try { Integer.parseInt(s); } catch (NumberFormatException ex) { System.out.println("Bad integer: " + s); } With the introduction of JEP456, you can be more explicit about this by making this variable an unnamed variable. You do so by using an underscore. Java String s = "data"; try { Integer.parseInt(s); } catch (NumberFormatException _) { System.out.println("Bad integer: " + s); } The same applies to patterns. Assume the following classes. Java abstract class AbstractFruit {} public class Apple extends AbstractFruit {} public class Pear extends AbstractFruit {} public class Orange extends AbstractFruit {} You create a switch where you test which kind of Fruit the instance equals to. You had to give the case elements a name, even if you did not use them. Java AbstractFruit fruit = new Apple(); switch (fruit) { case Apple apple -> System.out.println("This is an apple"); case Pear pear -> System.out.println("This is a pear"); case Orange orange -> System.out.println("This is an orange"); default -> throw new IllegalStateException("Unexpected value: " + fruit); } Also, in this case, you can be more explicit about it and use the underscore. Java AbstractFruit fruit = new Apple(); switch (fruit) { case Apple _ -> System.out.println("This is an apple"); case Pear _ -> System.out.println("This is a pear"); case Orange _ -> System.out.println("This is an orange"); default -> throw new IllegalStateException("Unexpected value: " + fruit); } JEP506: Scoped Values Scoped values are introduced by JEP506 and will mainly be of use between framework code and application code. A typical example is the processing of http requests where a callback is executed in framework code. The handle method is invoked from within the framework, and from the application code a callback is executed in method readUserInfo. Java public class Application { Framework framework = new Framework(this); //@Override public void handle(Request request, Response response) { // user code, called by framework var userInfo = readUserInfo(); } private UserInfo readUserInfo() { // call framework return (UserInfo) framework.readKey("userInfo"); } } In the framework, data is stored in a framework context within a Thread. By means of ThreadLocal, the CONTEXT is created (1). Request-specific data is stored in this context (2) before the application code is invoked. When the application executes a callback to the framework, the CONTEXT can be retrieved again (3). Java public class Framework { private final Application application; public Framework(Application app) { this.application = app; } private static final ThreadLocal<FrameworkContext> CONTEXT = new ThreadLocal<>(); // (1) void serve(Request request, Response response) { var context = createContext(request); CONTEXT.set(context); // (2) application.handle(request, response); } public UserInfo readKey(String key) { var context = CONTEXT.get(); // (3) return context.getUserInfo(); } FrameworkContext createContext(Request request) { FrameworkContext frameworkContext = new FrameworkContext(); UserInfo userInfo = new UserInfo(); // set data from request frameworkContext.setUserInfo(userInfo); return frameworkContext; } } class FrameworkContext { private UserInfo userInfo; public UserInfo getUserInfo() { return userInfo; } public void setUserInfo(UserInfo userInfo) { this.userInfo = userInfo; } } The FrameworkContext object is a hidden method variable. It is present with every method call, but you do not pass it as an argument. Because everything is running within the same thread, readKey has access to the own local copy of the CONTEXT. There are three problems using ThreadLocal: Unconstrained mutability: Every ThreadLocal variable is mutable, when code is able to invoke the get method, it is also able to invoke the set method.Unbounded lifetime: The value of ThreadLocal exists during the entire lifetime of the thread, or until the remove method is called. The latter is often forgotten, whereas per thread, data exists longer than it should.Expensive inheritance: When a child thread is created, the value of the ThreadLocal variable is copied to the child thread. The child thread needs to allocate extra storage for this. No shared storage is possible, and when using a lot of threads, this can have a severe impact. With the introduction of virtual threads, these design flaws have seen increased impact. The solution is scoped values.A scoped value is a container object that allows a data value to be safely and efficiently shared by a method with its direct and indirect callees within the same thread, and with child threads, without resorting to method parameters. It is a variable of type ScopedValue. It is typically declared as a static final field, and its accessibility is set to private so that it cannot be directly accessed by code in other classes. The previous framework code can be rewritten as follows. Instead of creating a ThreadLocal variable, a variable of type ScopedValue is created (1). When invoking application code the static method ScopedValue.where is used to assign the value (2). The readKey method remains unchanged (3). The main advantage is that the context value is only to be used during the lifetime of the run method. Java public class Framework { private final Application application; public Framework(Application app) { this.application = app; } private static final ScopedValue<FrameworkContext> CONTEXT = ScopedValue.newInstance(); // (1) void serve(Request request, Response response) { var context = createContext(request); where(CONTEXT, context) // (2) .run(() -> application.handle(request, response)); } public UserInfo readKey(String key) { var context = CONTEXT.get(); // (3) return context.getUserInfo(); } FrameworkContext createContext(Request request) { FrameworkContext frameworkContext = new FrameworkContext(); UserInfo userInfo = new UserInfo(); // set data from request frameworkContext.setUserInfo(userInfo); return frameworkContext; } } Copying data to child threads is able by means of structured concurrency (using StructuredTaskScope), Scoped values of the parent are automatically available within child threads. Structured concurrency is in its 5th preview, so it will probably be available soon. JEP485: Stream Gatherers A stream consists of three parts: Create the stream;Intermediate operations;A terminal operation. Terminal operations are extensible, but intermediate operations are not. JEP485 introduces a new intermediate stream operation Stream:gather(Gatherer) which can process elements by means of a user-defined entity. Creating a gatherer is complex and is out of scope for this blog. But there are some built-in gatherers which are discussed below. The stream used is a stream of integers. Java List<Integer> numbers = List.of(1, 2, 3, 4, 5, 6, 7, 8, 9); 1. Gatherer: fold Gatherer fold is a many-to-one gatherer that constructs an aggregate incrementally and emits that aggregate when no more input elements exist. Java int sum = numbers.stream() .gather(Gatherers.fold(() -> 0, (acc, x) -> acc + x)) .findFirst() // fold produces a single result .orElse(0); System.out.println("fold sum = " + sum); The output is: Java fold sum = 45 2. Gatherer: mapConcurrent Gatherer mapConcurrent is a stateful one-to-one gatherer that invokes a supplied function for each input element concurrently, up to a supplied limit. Java List<Integer> squares = numbers.stream() .gather(Gatherers.mapConcurrent(4, x -> x * x)) // 4 = parallelism hint .toList(); System.out.println("mapConcurrent squares = " + squares); The output is: Java mapConcurrent squares = [1, 4, 9, 16, 25, 36, 49, 64, 81] 3. Gatherer: scan Gatherer scan is a stateful one-to-one gatherer which applies a supplied function to the current state and the current element to produce the next element, which it passes downstream. Java List<Integer> runningSums = numbers.stream() .gather(Gatherers.scan(() -> 0, (acc, x) -> acc + x)) .toList(); System.out.println("scan running sums = " + runningSums); The output is: Java scan running sums = [1, 3, 6, 10, 15, 21, 28, 36, 45] 4. Gatherer: windowFixed Gatherer windowFixed is a stateful many-to-many gatherer that groups input elements into lists of a supplied size, emitting the windows downstream when they are full. Java int size = 2; List<List<Integer>> windows = numbers.stream() .gather(Gatherers.windowFixed(size)) .toList(); System.out.println("windowFixed(2) = " + windows); The output is: Java windowFixed(2) = [[1, 2], [3, 4], [5, 6], [7, 8], [9]] 5. Gatherer: windowSliding Gatherer windowSliding is a stateful many-to-many gatherer that groups input elements into lists of a supplied size. After the first window, each subsequent window is created from a copy of its predecessor by dropping the first element and appending the next element from the input stream. Java int size = 3; List<List<Integer>> windows = numbers.stream() .gather(Gatherers.windowSliding(size)) .toList(); System.out.println("windowSliding(3) = " + windows); The output is: Java windowSliding(3) = [[1, 2, 3], [2, 3, 4], [3, 4, 5], [4, 5, 6], [5, 6, 7], [6, 7, 8], [7, 8, 9]] 6. Gatherers Final Words In order to conclude this paragraph about Gatherers, some final words of advise given by Venkat Subramaniam. Venkat's four steps to use gatherers: Use familiar functions like map, filter, etc.Use a built-in gatherer.Call a friend for advise.Create a gatherer, but ... this is complex and a lot of work. JEP458: Launch Multi-File Source-Code Programs When writing scripts with Java, you probably want to split the code between different files when the script becomes too long. Also, when writing scripts, you most likely are not using a build tool in order to create a jar-file. JEP458 allows you to resolve other classes needed in your main class without extra effort. Assume the following class. Java public class Application { public static void main() { Helper helper = new Helper(); helper.run(); } } Run this class using Java 21. When you use SDKMAN, switch to a Java 21 JDK. Execute the Application.java file. Java $ java Application.java Application.java:6: error: cannot find symbol Helper helper = new Helper(); ^ symbol: class Helper location: class Application Application.java:6: error: cannot find symbol Helper helper = new Helper(); ^ symbol: class Helper location: class Application 2 errors error: compilation failed As you can see, the compilation fails because the Helper class, which is located next to the Application class cannot be found. Switch to a Java 25 JDK. Execute the Application.java file and now the Helper class can be found and the program executes as expected. Java $ java Application.java Do something JEP467: Markdown Documentation Comments With Java 21, you can format comments by means of HTML tags, as can be seen in this example. Java /** * Returns a hash code value for the object. This method is * supported for the benefit of hash tables such as those provided by * {@link java.util.HashMap}. * <p> * The general contract of {@code hashCode} is: * <ul> * <li>Whenever it is invoked on the same object more than once during * an execution of a Java application, the {@code hashCode} method * must consistently return the same integer, provided no information * used in {@code equals} comparisons on the object is modified. * This integer need not remain consistent from one execution of an * application to another execution of the same application. * <li>If two objects are equal according to the {@link * #equals(Object) equals} method, then calling the {@code * hashCode} method on each of the two objects must produce the * same integer result. * <li>It is <em>not</em> required that if two objects are unequal * according to the {@link #equals(Object) equals} method, then * calling the {@code hashCode} method on each of the two objects * must produce distinct integer results. However, the programmer * should be aware that producing distinct integer results for * unequal objects may improve the performance of hash tables. * </ul> * * @implSpec * As far as is reasonably practical, the {@code hashCode} method defined * by class {@code Object} returns distinct integers for distinct objects. * * @return a hash code value for this object. * @see java.lang.Object#equals(java.lang.Object) * @see java.lang.System#identityHashCode */ public int htmlHashCode() { return 0; } However, Markdown is also quite a lot used by developers. With JEP467, you are able to use Markdown for documentation comments. Some notes about it: Markdown comments are indicated by means of ///.<p> is not necessary anymore and can be replaced by means of a blank line.Markdown bullets can be used.Font changes use the Markdown syntax, for example, an underscore for italic font.Backticks can be used for the code font.Markdown links are also supported.The Markdown syntax to be used is the CommonMark syntax. The previous example rewritten with Markdown. Java /// Returns a hash code value for the object. This method is /// supported for the benefit of hash tables such as those provided by /// [java.util.HashMap]. /// /// The general contract of `hashCode` is: /// /// - Whenever it is invoked on the same object more than once during /// an execution of a Java application, the `hashCode` method /// must consistently return the same integer, provided no information /// used in `equals` comparisons on the object is modified. /// This integer need not remain consistent from one execution of an /// application to another execution of the same application. /// - If two objects are equal according to the /// [equals][#equals(Object)] method, then calling the /// `hashCode` method on each of the two objects must produce the /// same integer result. /// - It is _not_ required that if two objects are unequal /// according to the [equals][#equals(Object)] method, then /// calling the `hashCode` method on each of the two objects /// must produce distinct integer results. However, the programmer /// should be aware that producing distinct integer results for /// unequal objects may improve the performance of hash tables. /// /// @implSpec /// As far as is reasonably practical, the `hashCode` method defined /// by class `Object` returns distinct integers for distinct objects. /// /// @return a hash code value for this object. /// @see java.lang.Object#equals(java.lang.Object) /// @see java.lang.System#identityHashCode public int markdownHashCode() { return 0; } Conclusion In this blog, you took a quick look at some features added since the last LTS release, Java 21. It is now up to you to start thinking about your migration plan to Java 25 and a way to learn more about these new features and how you can apply them to your daily coding habits. Tip: IntelliJ will help you with that!
Major version upgrades of search infrastructure are often treated as dependency and configuration exercises. In practice, when search sits upstream of machine-learning pipelines and directly impacts revenue, such upgrades can fail in far more subtle — and harder to diagnose — ways. This article describes how a long-stalled migration of a production ads retrieval system from Apache Solr/Apache Lucene 5 to 8 was unblocked after multiple prior attempts had failed. The failures were not caused by missing dependencies or misconfiguration, but by cumulative semantic drift and execution-path changes that only manifested under real production conditions. System Context The system performs candidate retrieval for ads, extracts features for downstream machine-learning reranking, and feeds auction execution. It operates under strict correctness and tail-latency constraints: small regressions in recall or p99 latency directly affect auction quality and revenue. Solr was deployed in embedded mode, running inside the same JVM as a lightweight hosting service responsible for request routing and business logic. Search, feature extraction, and response construction all shared the same memory space and execution context. The Solr query itself consisted of: A main retrieval query to identify matching documentsRetrieval of multiple stored and computed fields, with several values produced via transformers during response construction As a result, a significant portion of request latency occurred after document matching, during per-document field loading and transformer execution. Remaining on Solr/Lucene 5 was no longer viable due to security exposure, lack of upstream support, and broader platform modernization requirements. Several migration attempts had already been tried and rolled back. The task was to understand why those attempts failed and deliver a production-safe migration. Baseline Migration Work (Necessary but Not Sufficient) As with any major Solr/Lucene upgrade, the migration included standard foundational steps: Upgrading to a supported Java runtimeUpdating Solr, Lucene, and related library dependenciesResolving API and compatibility issuesValidating basic query correctness All of this had already been completed correctly in earlier attempts. None of it addressed the failures described below, because the root causes were not dependency-level issues. What Broke After the Upgrade After upgrading to Solr/Lucene 8, the system exhibited failures that were not visible at the API or configuration level: Relevance degradation for identical queries, manifested as rank inversions within the top-N results and increased churn in the top-K candidate set across retries.Silent query behavior changes, where certain sub-queries were internally rewritten or disallowed, producing different result sets without errors.~2× increase in p99 retrieval latency under production traffic, while average latency remained largely unchanged.Early candidate loss before ML reranking, reducing recall, and degrading auction quality. These issues were intermittent and workload-dependent. Unit tests and standard regression suites passed consistently, which is why earlier migration attempts were unable to isolate a single root cause. Why Tests Passed but Production Failed The failures escaped tests for structural reasons: Score changes were relative rather than absolute, making them invisible to threshold-based assertions.Correctness depended on downstream ML feature distributions, not raw retrieval scores.Tail-latency regressions only appeared under production-level concurrency and payload sizes.Query rewrites and candidate suppression produced valid responses, just not equivalent ones. As a result, test environments reported “correct” behavior while production systems degraded. Root Cause: Cumulative Semantic and Execution-Path Changes The failures did not stem from a single breaking change. They emerged from interacting internal changes across Lucene versions. Scoring and Similarity Drift Changes in similarity formulas, normalization behavior, and primitive-type handling altered relative score ordering. While each change was documented in isolation, its combined effect violated implicit assumptions baked into downstream ranking and feature pipelines. Function Queries and Negative Scores Under Solr 5, negative function boosts were tolerated and behaved predictably. Under Lucene 8, negative intermediate scores could lead to the silent suppression of documents. In one representative case, a function-based boost produced negative intermediate values under Lucene 8, causing documents to be excluded from the candidate set entirely. Under Solr 5, those same documents were retained and reranked downstream. This single difference cascaded into recall loss without query failures or errors. Query Rewrite Differences Certain previously valid subqueries were rewritten or disallowed internally. These changes did not fail requests but altered retrieval semantics in ways that were only visible through side-by-side behavioral comparison. Retrieval and Response Construction Costs At a high level, Solr first determines the matching document ID set and then constructs the final response by loading requested fields and executing transformers for each selected document. In this system, the second phase dominated tail latency. Because multiple transformers are executed per document, response construction costs scale with both result size and concurrency. Lucene 8 introduced execution-path changes that amplified this effect. Average latency remained stable — masking the issue in standard dashboards — while p99 latency regressed significantly under production load. ML Feature Compatibility as the Breaking Point The production-ranking models in this system were well established and could not be retrained on demand. Model updates followed a defined launch path: offline training, controlled experimentation on limited traffic, and only then gradual production rollout. As a result, the retrieval layer was required to preserve feature semantics across the Solr upgrade. However, changes introduced in Solr 8 altered score normalization, relative ordering, feature scale, and candidate set composition. The resulting feature distributions were technically valid but semantically incompatible with the expectations encoded in the existing production models. Because retraining was neither immediate nor guaranteed to converge to equivalent behavior, restoring retrieval semantics was a prerequisite for recovering model quality. Until retrieval behavior was reconciled, model performance could not be restored through experimentation or tuning alone. What Changed: Reframing the Migration The migration was reframed as a semantic reconciliation and execution-path optimization problem, not a tuning exercise. Making Silent Failures Observable Introduced Solr-level metrics to detect hidden relevance degradation.Ran Solr 5 and Solr 8 side-by-side against identical traffic to surface rank churn, candidate loss, and feature drift. Semantic Reconciliation Restored Solr 5–equivalent similarity behavior by reconciling scoring and normalization semantics that had changed across Lucene major versions, where required for compatibility with existing ranking models.Aligned primitive-type similarity semantics.Introduced an offset mechanism for function boosts to preserve relative ordering and prevent negative-score suppression. Execution-Path Optimization Because Solr was embedded and shared a JVM with the hosting service, the response construction path could be optimized directly rather than treated as a black box. Once the matching document ID set was produced, document retrieval and response construction were parallelized in a controlled manner. This required understanding Solr’s execution flow and Lucene’s segment-level reader behavior. From a Lucene perspective, parallelism was intentionally constrained within a single segment, avoiding cross-segment parallel reads during response construction, where Lucene does not uniformly support safe or efficient parallel access to stored fields and doc values. Within these boundaries, field retrieval and transformer execution were integrated into in-memory response assembly. This eliminated unnecessary serialization and deserialization between intermediate representations while preserving identical response semantics. Given the transformer-heavy query shape, removing this overhead produced a meaningful reduction in CPU cost and p99 latency on the critical path. Additional parallelism in document retrieval further reduced tail latency. A systematic validation framework was built to compare hundreds of retrieved fields and features across Solr 5 and Solr 8, ensuring semantic and performance equivalence. These changes required architectural judgment and deep Lucene internals knowledge—not configuration tuning. Validating Semantic and Performance Equivalence Because many failures were silent, validation required explicit side-by-side comparison rather than reliance on aggregate metrics. Behavioral validation compared: Top-N document identity and ordering.Score distributions.Extracted feature values.Candidate set stability across retries. Performance validation focused on: p99 latency rather than averages.CPU time on retrieval and response construction paths.Concurrency sensitivity under realistic production load. Several regressions were only visible under sustained traffic and realistic payload sizes, explaining why earlier testing did not catch them. What This Was Not This was not: JVM tuning issueCache misconfigurationMissing dependencyQuery bug The failures persisted under conservative configurations and isolated environments until semantic and execution-path mismatches were explicitly addressed. Outcome The final solution: Restored retrieval correctness and result qualityEliminated silent candidate lossReduced p99 retrieval latencyReduced CPU overhead on the response pathEnabled a successful Solr 5 to Solr 8 migrationUnblocked modernization of the ad-serving platform. This resolved a class of failures that had blocked progress across multiple prior attempts. Patterns and Learnings From Large Solr/Lucene Migrations While motivated by a specific system, several reusable patterns emerged that apply broadly to large Solr/Lucene upgrades, especially in ML-driven retrieval systems. Relative Score Stability Matters More Than Absolute Scores Rank churn or ordering instability without explicit failures often indicates semantic drift in scoring or normalization, not query-level bugs. Negative Scores Are a Hidden Recall Hazard Changes in negative-score handling can silently suppress candidates before reranking, reducing recall without producing errors or obvious signals. ML Pipelines Encode Retrieval Assumptions In mature production systems, ranking models cannot be retrained on demand. Retrieval semantics must remain compatible across upgrades, as changes in score meaning, ordering, or candidate composition directly break model expectations. Tail Latency Hides Behind Stable Averages Execution-path changes frequently impact p99 latency without materially affecting mean latency, allowing regressions to go unnoticed in standard dashboards. Query Shape Drives Response-Path Cost Transformer-heavy queries shift significant work to response construction. Field loading, transformer execution, and response assembly must be treated as first-class contributors to tail latency. Embedded Deployments Enable Deeper Optimizations When Solr shares a JVM with application logic, unnecessary data movement and serialization on the response path can be eliminated, often yielding gains comparable to query-level optimizations. Changelogs Describe Changes, Not Interactions Migration failures often arise from emergent behavior across multiple documented changes, especially when scoring, execution paths, and ML pipelines interact under production load. Broader Relevance As search systems increasingly sit upstream of ML pipelines, large Solr/Lucene upgrades fail not because of missing documentation, but because internal semantics evolve independently of downstream assumptions. The approach described here is broadly applicable to: Large-scale Solr/Lucene migrationsPreserving ML feature correctness across retrieval changesDiagnosing silent relevance regressionsEliminating avoidable serialization overheadImproving tail latency in production search systems These challenges extend well beyond a single deployment and are common across organizations operating high-scale search and ads infrastructure. Practical Checklist While working through this migration, I found that many of the hardest problems were not covered by standard upgrade guides or changelogs. To avoid repeating the same mistakes, I wrote down a short, practical checklist covering the things that actually caused trouble — silent relevance changes, ML feature compatibility, response-path latency, and tail-latency validation. I’ve published this checklist as a small public repository on GitHub, so other teams doing large Solr/Lucene upgrades can use it as a sanity check before and after a migration. It’s not a replacement for official documentation, but a field-tested companion based on real production failures.
Point-of-Sale (POS) systems are no longer just cash registers. They are becoming real-time, connected platforms that handle payments, manage inventory, personalize customer experiences, and feed business intelligence. Small and medium-sized merchants can now access capabilities once reserved for enterprise retailers. Mobile payment platforms like Square, SumUp, and Shopify make it easy to sell anywhere and integrate sales channels seamlessly. At the same time, data streaming technologies such as Apache Kafka and Apache Flink are transforming retail operations. They enable instant insights and automated actions across every store, website, and supply chain partner. This post explores the current state of mobile payment solutions, the role of data streaming in retail, how Kafka and Flink power POS systems, the SumUp success story, and the future impact of Agentic AI on the checkout experience. Mobile Payment and Business Solutions for Small and Medium-Sized Merchants The payment landscape for small and medium-sized merchants has undergone a rapid transformation. For years, accepting card payments meant expensive contracts, bulky hardware, and complex integration. Today, companies like Square, SumUp, and Shopify have made mobile payments simple, mobile, and affordable. Block (Square) offers a unified platform that combines payment processing, POS systems, inventory management, staff scheduling, and analytics. It is especially popular with small retailers and service providers who value flexibility and ease of use. SumUp started with mobile card readers but has expanded into full POS systems, online stores, invoicing tools, and business accounts. Their solutions target micro-merchants and small businesses, enabling them to operate in markets that previously lacked access to digital payment tools. Shopify integrates its POS offering directly into its e-commerce platform. This allows merchants to sell in physical stores and online with a single inventory system, unified analytics, and centralized customer data. These companies have blurred the lines between payment providers, commerce platforms, and business management systems. The result is a market where even the smallest shop can deliver a payment experience once reserved for large retailers. Data Streaming in the Retail Industry Retail generates more event data every year. Every scan at a POS, every online click, every shipment update, and every loyalty point redemption is a data event. In traditional systems, these events are collected in batches and processed overnight or weekly. The problem is clear: by the time insights are available, the opportunity to act has often passed. Data streaming solves this by making all events available in real time. Retailers can instantly detect low stock in a store, trigger replenishment, or offer dynamic discounts based on current shopping patterns. Fraud detection systems can block suspicious transactions before completion. Customer service teams can see the latest order updates without contacting the warehouse. In previous retail industry examples, data streaming has powered: Omnichannel inventory visibility for accurate stock counts across stores and online channels.Dynamic pricing engines that adjust prices based on demand and competitor activity.Personalized promotions triggered by live purchase behavior.Real-time supply chain monitoring to handle disruptions immediately. Emerging Trend: Unified Commerce The next stage beyond omnichannel is Unified Commerce. Here, all sales channels — physical stores, online shops, mobile apps, marketplaces, and social commerce — operate on a single, real-time data foundation. Instead of integrating separate systems after the fact, every transaction, inventory update, and customer interaction flows through one unified platform. Data streaming technologies like Apache Kafka make Unified Commerce possible by ensuring all touchpoints share the same up-to-date information instantly. This enables consistent pricing, seamless cross-channel returns, accurate loyalty balances, and personalized experiences no matter where the customer shops. Unified Commerce turns fragmented retail technology into a single, connected nervous system. Data Streaming with Apache Kafka and Flink for POS in Retail In an event-driven retail architecture, Apache Kafka acts as the backbone. It ingests payment transactions, inventory updates, and customer interactions from multiple channels. Kafka ensures these events are stored durably, replayable for compliance, and available to downstream systems within milliseconds. Apache Flink adds continuous stream processing capabilities. For POS use cases, this means: Running fraud detection models in real time, with alerts sent instantly to the cashier or payment gateway.Aggregating sales data on the fly to power live dashboards for store managers.Updating loyalty points immediately after a purchase to improve customer satisfaction.Ensuring that both physical stores and e-commerce channels reflect the same stock levels at all times. Together, Kafka and Flink create a foundation for operational excellence. They enable a shift from manual, reactive processes to automated, proactive actions. Using data streaming at the edge for POS systems enables ultra-low latency processing and local resilience, but scaling and managing it across multiple locations can be challenging. Running data streaming in the cloud offers central scalability and simplified governance, though it depends on reliable connectivity and may introduce slightly higher latency. SumUp: Real-Time POS at Global Scale with Data Streaming in the Cloud SumUp processes millions of transactions per day across more than 30 countries. To handle this scale and maintain high availability, they adopted an event-driven architecture powered by Apache Kafka and fully managed Confluent Cloud. In the Confluent customer story, SumUp explains how Kafka has allowed them to: Process every payment event in real time.Maintain a unified data platform across regions, ensuring compliance with local payment regulations.Scale easily to handle seasonal transaction spikes without service interruptions.Speed up developer delivery cycles by providing event data as a service across teams. Implementing Critical Use Cases Across the Business More than 20 teams at SumUp now rely on Confluent Cloud to deliver mission-critical capabilities. Global Bank Tribe: Operates SumUp’s banking and merchant payment services. Real-time data streaming keeps transaction records updated instantly in merchant accounts. Reusable data products improve resilience for high-volume processes such as 24/7 monitoring, fraud detection, and personalized recommendations.CRM Team: Delivers customer and product information to operational teams in real time. Moving away from batch processing creates a smoother customer experience and enables data sharing across the organization.Risk Data and Machine Learning Platform: Feeds standardized, near-real-time data into machine learning models. These models make decisions on the freshest data available, improving outcomes for both teams and merchants. By embedding Confluent Cloud across multiple domains, SumUp has turned event data into a shared asset that drives operational efficiency, customer satisfaction, and innovation at scale. For merchants, this means faster transaction confirmations, improved reliability, and new digital services without downtime. The Future of POS and Impact of Agentic AI The POS of tomorrow will be more than a payment device. It will be a connected intelligence hub. Agentic AI, with autonomous systems capable of proactive decision-making, will play a central role. Future capabilities could include: AI-driven recommendations for upsells, customized to each shopper’s behavior and context.Predictive inventory replenishment that automatically places supplier orders when stock is low.Automated fraud prevention that adapts in real time to emerging threats.Dynamic loyalty program offers tailored at the exact moment of purchase. When Agentic AI is powered by real-time event data from Kafka and Flink, decisions will be both faster and more accurate. This will shift POS systems from passive endpoints to active participants in business growth. For small and medium-sized merchants, this evolution will unlock capabilities previously available only to enterprise retailers. The result will be a competitive, data-driven retail landscape where agility and intelligence are built into every transaction.
When TLD Generation Tooling Falls Behind Java 17 The vulnerabilities introduced by upgrades to the Java platform tend not to lie in the application code itself, but rather in the ecosystem of build-time tools that enterprise systems rely on. This was made clear by a migration to Java 17, in which a long-standing dependency on TldDoclet to generate Tag Library Descriptor (TLD) was compromised. TldDoclet, a widely used tool for generating TLD metadata from Java tag handler classes, is no longer supplied or compatible with current Java versions. The effect of this gap was not so obvious. The application itself compiled and executed well with Java 17, and the underlying JSP tag handlers remained functional. But TLD generation did not come up with a congenial mechanism, consequently placing a hard blocker late in the build. What once was a constant and unseen component of the toolchain turned into a migration issue with a high risk. Another solution was to rewrite all custom tags to use .tag files, which was technically feasible but operationally impractical. The system contained more than 700 custom tags, scattered across more than 6,000 production files, spanning years of production experience and implicit contracts that depended on several teams. Massive rewrites at this tier come with a great deal of regression risk, extended validation, and unpredictability that large organizations often cannot afford. This paper explains the origins of these constraints that prompted the design and development of a custom, annotation-based TLD generator, which is compatible with both Java versions, including Java 17, and none of the existing JSP tag libraries are altered. It will not focus on the replacement of JSP or tag libraries, but rather on bridging a tooling gap that remains in the promotion of modern Java, both in the context of the realities of large and long-lived enterprise codebases. The Hidden Cost of Abandoned TLD Generation Tooling TldDoclet handled a little yet very important issue, viz., to locate the metadata within Amazon Java tag handler classes and automatically create valid TLD files. The functions have, over time, been incorporated into the full build pipelines of many enterprise systems, in most cases without active maintenance or inspection. That dependence has been intangible ever since Java versions had not acquired much speed, and the backward compatibility assumption had been introduced. With Java 9 and later releases, this assumption no longer holds. The Java Platform Module System and changes to compiler APIs, limitations on the use of reflection and doclets, among others, were fundamental changes that transformed the landscape on which the TldDoclet tools would operate. To a large extent, TldDoclet must be modified in order to be able to make use of Java 17, as old-fashioned doclet APIs and classpath behavior no longer work with Java 17 anyway. There is scarcely any such negligence regarding the cost of a tool. It was due to the inability of a TLD generator to obtain the desired results in this case that led to a re-shift in thinking in terms of the generation and validation of tag metadata. The building failures were late in the pipeline, as they were already mostly done with the migration work, and the building failures were by then a point in the architecture decision rather than a mechanical upgrade. At the enterprise scale, these situations also give rise to unwanted requirements, such as halting Java version upgrades, internal maintenance of obsolete tools, or high-risk refactorings that provide no business value. The challenge was, then, not only to rid herself of TldDoclet, but to do it such that they are caused with minimal uproar, which will be sustainable as long as the next iterations of Java will persist. Why Rewriting .tag Files Was Not a Viable Option From a modern Jakarta perspective, tag files are a clean and supported way to define custom JSP tags. For small systems or greenfield projects, migrating to .tag files can be an attractive solution. In large enterprise systems, however, the cost profile is very different. The application in question contained hundreds of custom tags, many with complex attribute definitions and custom data types. These tags were referenced across thousands of JSP files, often with implicit assumptions about attribute behavior that were not fully documented. Rewriting these tags would have required coordinated changes across multiple teams, extensive regression testing, and prolonged stabilization periods. More importantly, the migration effort was not driven by a desire to modernize the presentation layer, but by a Java platform upgrade. Introducing widespread functional changes unrelated to the original goal would have increased risk without delivering proportional value. In regulated or business-critical systems, this level of uncertainty is often unacceptable. The guiding constraint, therefore, was clear: existing JSPs and tag handlers had to remain unchanged. Any solution that required widespread rewrites was considered non-viable, regardless of its theoretical elegance. From a modern Jakarta perspective, .tag files give a subtle and authorized way of formulating custom JSP tags. In the case of small systems or greenfield projects, the temptation to migrate to .tag files may become viable. But at very large-scale enterprise systems, the profile is quite different. The application under question had hundreds of custom tags, many of which contained the complex definition of the attributes and custom data types. The tags were referenced in thousands of JSP files only half as often, due to implicit assumptions about the behavior of unspecified attributes. It would have required both cross-functional coordination and extensive regression testing, as well as an extended shelf life, to reimplement these tags. A desire to modernize the presentation layer was not the purpose of undertaking the migration exercise, but rather a Java platform upgrade. The threat of making pervasive functional modifications that were unrelated to the original rationale would have been greater when there was not a corresponding value increment. Uncertainties of this kind are not allowed among regulated or business-critical systems. The motivation force was very self-evident, though: the very existence of JSPs and tag handlers could not be limited. A solution that required a wholesale overhaul was considered, in any case, non-viable regardless of its theoretical loveliness. Defining the Design Goals for a Replacement TLD Generator It was not supposed to be feature-to-feature, but a replacement under these constraints that would suit modern realities in the Java world and business requirements. Among the basic design goals, which were also formulated at the initial stages, the following are: Java version independence: The solution should be compatible with Java 17 and all prior versions of Java, without relying on deprecated APIs.Zero JSP change: Base Existing JSP program and tag usages do not have to be changed.Accurate TLD compatibility: Built TLD files must be the same as the old descriptors without semantic differences.Integration of builds: Build TLD will be added to the existing build pipeline.Scalability: The solution must have the capacity to serve hundreds of tags and thousand files. These targets removed the heavy-exercise reflection run-time solutions and favoured a deterministic build-time model with explicit metadata. Using Annotations to Model Tag and Attribute Metadata The solution essentially includes an annotation-based metadata model. Tag and attribute information is provided as explicit custom annotations rather than as output from Javadoc parsing or doclet APIs. Higher still, a tag handler class is annotated with @Tag, which stores tag-level information, such as the tag name. The individual attributes are modeled with the assistance of the annotations as the @Attribute on the setter methods level primarily, which is one way of implementing JSP tags. An annotation is a capture of each annotation attribute, which captures: Attribute nameThe attribute may be compulsory or not.The attribute typeMetadata expression: runtime support. One of the more complex aspects of this type of approach is type resolution. The names of types are not most often physical primitives (or strings), but domain types of a custom type. The generator is not entirely dependent on reflection; however, it exploits a symbol and type resolver, which is used depending on compiled class files to arrive at the conclusion of the appropriate nature of attributes on module boundaries. The explicit annotation model puts the developer intent rather than the tooling heuristics in the metadata and makes the metadata more explicit, maintainable, and less delicate due to the parsing logic of the metadata. Build-Time Class Scanning in a Modern Java Environment To keep up with current Java, the generator is entirely build-time, that is, it scans compiled class files and not source code. The generation stage is carried out as part of a Maven phase and coordinated by an Ant task that can be easily integrated with an existing Jenkins pipeline. Compiled classes have a number of benefits: Java compatibility: Compatibility across Java versions.Accurate type resolutionNo reliance on the outdated compiler or documentation APIs. The generator first searches the corresponding classpath and purports the classes that are annotated with the annotation of Tag, analyzes method annotations that have been annotated, resolves attribute metadata, and builds the corresponding TLD representation in a deterministic manner. Since the process uses a bytecode format and annotations, there is no reason it should be affected by changes to the source format or documentation style. Generating Deterministic and Backward-Compatible TLD Files One of the most important conditions was that the generated TLD files had to match the legacy descriptors. This contains tag names, attribute definitions, ordering, and the namespace declaration. Even the slightest variations might lead to some unexpected behaviour or make validation harder in enterprise settings. The generator uses a controlled, deterministic process that generates TLD files to be compatible. The results are compared to the available descriptors to establish the semantic equivalence. As a matter of fact, the TLDs generated coincided with the legacy files with zero difference, and thus, they could be dropped and left to rest without any additional modifications. This fidelity was a requirement of adoption. Teams might believe that the new generator made no difference, only the means by which metadata was created. Integrating the Generator Into Existing CI/CD Pipelines The generator is included in the typical construction, and it is automatically run when CI builds are being done. Its requirement to run on compiled classes as well as to generate artifacts identical to those generated previously meant that it did not need much modification to existing pipelines. This combination meant that the generation of TLD remained: AutomatedRepeatableOpen to tag consumers. The generator acts like any other build step, which, in terms of CI/CD, eases the cognitive and operational load of the migration. Migration Outcomes at Enterprise Scale The effects of such a strategy became visible when implemented on a large scale. There was support of over 700 custom tags in more than 6000 files without ever having to rewrite a JSP or tag handler. Migration to Java 17 was done without presentation-layer risk, and the same generator still remains compatible across Java versions. Probably the most crucial argument is that the solution was future-proofed. Since it is based on annotations and scanning of the bytecode, it is not sensitive to most of the changes that have occurred with previous tools. Lessons for Enterprise Java Modernization The experience provides several general lessons: Tooling gaps can be the real impediments to platform upgrades.Large-scale rewrites are not often acceptable in the enterprise setting.The explicit metadata model is more sustainable than the tooling on the basis of a heuristic.Whereas it is not always safe, build-time solutions can be safer than the runtime adaptations when migrating. To manage such issues, tooling should be treated as a first-class issue and not as something that happens afterwards. Looking Ahead JSP and tag libraries are not trendy, but they are ingrained in numerous enterprise systems. As Java is still evolving, it will probably become the case that other areas will have similar tooling gaps. The method outlined below, which is annotation-driven, build-time, and backward-compatible, provides a model for addressing such gaps in a pragmatic manner. Instead of making them rewrite portions that need not be rewritten, teams are able to maintain the steady functionality and conform their tooling to the current platform restrictions. In large, long-serving systems, this equilibrium can lead to successful modernization or to a nonprogressive effort.
There’s no shortage of reporting tools, but a good number of them are either part of heavyweight BI systems or cloud services. Many line‑of‑business applications, however, just want a discreet, built‑in reporting option that can be customized. Having recently tested several Java‑based document generation tools and libraries, I thought a short, plain-spoken, and up-to-date review could be worth sharing. Top 3 Side-by-Side In the Java world, there are several battle-proven reporting tools with Jasper Reports as a clear frontrunner. Nevertheless, I also had a look at two less popular, yet very capable options. Here's a side-by-side view of their fit for common reporting requirements: ToolData Sourcesintegration optionsoutput formatsreport designerJasper Reports JDBC, JNDI, JavaBeans, Collections, XML, JSON, CSV, Big Data, NoSQL, APIs Java or other platforms via REST API / Report Server embeddable via iframe PDF, HTML, DOCX, XLSX, PPTX, ODT, ODS, CSV, XML, JSON, RTF, TXT Desktop designer / Web designer Eclipse BIRT JDBC, XML, Excel, Flat Files, MongoDB, Hive, POJO, Web Services Java or other platforms via REST API PDF, HTML, DOCX, XLSX, PPTX, ODT, ODS, PostScript Visual designer on top of Eclipse IDE Jmix Reports JDBC, custom data sources Java or other platforms via REST API PDF, HTML, DOCX, XLSX, HTML, ODT, CSV, TXT Templating strategy: target format templates or Java class templates While the tools are largely comparable, the designer aspect differs noticeably and can have a practical impact. I'll demonstrate using each with a quick demo. Then we’ll also have a glance at how each product integrates with enterprise applications and the broader reporting pipeline. 1. Jasper Reports Jasper Reports is a powerful and adaptable reporting solution. To cater to businesses of all sizes, the product is split into two tiers: editioncomponentstarget audienceCommunity Edition Jaspersoft Studio (desktop report designer)JasperReports Java librarySmall-to-mid teams, open-source projectsCommercial Edition JasperReports ServerWeb-based report designerAdvanced APIs and professional supportLarge enterprises, SaaS providers The Community Edition already has everything to build a complete end‑to‑end workflow — from visual design to programmatic execution. Installing Jaspersoft Studio Jaspersoft Studio is a desktop designer available for Windows, Mac, and Linux. It is essentially a one-click install that you can get from the official download page. Designing a Report With Jaspersoft Studio A new report can be created using a template or from an empty page. Once the creation wizard completes, the new report appears in the workspace. A report is divided into bands, each telling the engine where to place elements in the final document. The Title band renders once, while the Detail band repeats for every row of data. Data Sources Before adding anything to bands, use Repository Explorer to create a data adapter. There are plenty of options to choose from. In my case, that's a locally running PostgreSQL instance that I'll configure with Database JDBC Connection. Throughout this article, I'm going to use the "world" schema, a sample three-table database that ships with various SQL systems and is commonly used for learning and testing. Now that the data is connected, the canvas can be populated with content. Begin by dragging the table element onto the details band. The table wizard prompts a query to retrieve the appropriate data. This can be done using a direct SQL query or by constructing it visually by selecting tables and columns from the tree on the left. You'll see the table with parameters added to the canvas – they will appear as $F{parameter} placeholders. The side panel offers additional text and visual elements to enrich the report. A variety of charts are available for configuration through their own wizards. All the design choices are represented in a .jrxml file that JasperReports' engine can read at runtime. Switch to the Source tab to see that. Or, switch to the Preview tab to see the report rendered. If you like what you see, export to the desired human-readable format, or compile to a .jasper file for programmatic generation using the JasperReport Java Library. Integrating into the Application Community license assumes using the JasperReports Java Library to integrate reports into your application. This is what the process can look like: Nonetheless, you are not limited to just importing .jasper files and programmatically filling them with data. The library's breadth lets you handle everything from data source connection to report generation. Numerous samples are available to assist in building a custom reporting pipeline. Or, if you hold a commercial license, reports can be integrated virtually anywhere in three additional ways: JasperReports Server – a complete reporting portal featuring ad-hoc self-service reports and a web report designer (with a feature set comparable to the desktop designer) that can be embedded via iframe.REST API – to enable your application call the server and let it handle the rest.Visualize.js – JavaScript library for seamless frontend embeddings.The path to follow depends on the features you need, the development effort you're willing to invest, and the level of customization you expect. 2. Eclipse BIRT The BIRT (Business Intelligence and Reporting Tools) Project is an open-source Eclipse-based reporting system that integrates with your Java application to produce professional reports. There are two main components in BIRT: BIRT Report Designer based on Eclipse IDE.A runtime component that you can add to your application server. Installing BIRT Report Designer You can choose one of these paths to start using BIRT: Eclipse users can add the BIRT Report Designer plug-in to their IDE. The plug-in adds the report editor right into the current workspace.Standalone approach – download the all-in-one Report Designer package. After extracting the archive, users get a portable Eclipse bundled with BIRT, ready to run with no extra setup. Designing a Report With BIRT Since Eclipse is a multi-purpose IDE, it is necessary to specify that the new project is a report project. This gives you a clean workspace ready for your first report. Let's start by adding a data source. The same "world" database I used with Jasper can be set up througha JDBC Data Source. I'm also using the same query to declare a data set, so we should see matching reports. To get a minimal report with a table, just drag and drop that data set onto the canvas. Similar to Jasper, let's add a title and a pie chart. After the layout is finalized, switch between the editor tabs to inspect the XML source code or preview the report. Then, if you want to open the report in your browser, click the "Play" button at the top bar. The report will then run in the BIRT Report Viewer. From there, you can export to a variety of formats, such as DOCX, Open Document, and PDF. I tried several formats, with each producing sharp, publication-ready results. Integrating into the Application As it was shown, reports can run locally in the BIRT Report Viewer. This viewer itself is an AJAX-based Java application that can be of help if you set out to build your own reporting portal. See the GitHub repo. The BIRT reporting engine is also deployable as a component of any Java stack, including Spring Boot applications. For a walkthrough of BIRT and Spring dependency management, see Baeldung's article. Additionally, BIRT can be installed on standard application servers like Tomcat and JBoss. A full list of options and instructions is available in the official documentation. 3. Jmix Reports Jmix combines rapid application development with extensive reporting capabilities that it gets from a versatile reporting library known as YARG (see Yet Another Report Generator? on DZone to learn its story). Over the years, YARG was folded into the Jmix ecosystem as part of the Reports add-on, resulting in three easy-to-use components: Jmix Framework – an open-source foundation for building web applications with Java.Jmix Studio – an IntelliJ IDEA plugin that scaffolds an app and automates the creation of UI and business logic.Jmix Reports add-on – a plug-and-play module that adds admin UI for designing and managing reports directly within your application. When combined, these components create a full-stack web application that includes the report designer. The application can be expanded to include a user-facing portal for viewing and downloading reports. Installing Jmix With the Reports Add-On The installation steps will be most familiar to Java developers using IntelliJ IDEA: Install the Jmix Studio plugin into your IntelliJ IDEA (the plugin and the framework are installed simultaneously. Follow the instructions for guidance.)Create a new Jmix project.Install the Reports add-on from the Jmix marketplace. Data Source Out of the box, the Jmix application can connect to popular relational databases, including MySQL, Oracle, MariaDB, and PostgreSQL. By default, an in-file HSQLDB is used. To switch to another, right-click the Main Data Store -> Manage Data Store and enter the database URL and credentials. Once again, I'm using the local PostgreSQL instance with the sample "world" schema. Being an application development framework, Jmix follows a common practice of creating a data model on top of the database, providing a uniform way to access data across the application. To map the table to the data model, right-click the Data Store and select Generate Model from Database. In the wizard that opens, select the tables you want to include: After that, they will show up under the Data Model section. Now run the application in the browser and navigate to Reports in the main menu. On that page, click Create -> New to start creating your first report. Designing Report Jmix does not have a dedicated visual designer, but it still offers a true "what you see is what you get" experience. The idea is to create a report template directly in the target output format, DOCX, XLSX, or another, by adding placeholders and named regions. When the report runs, the engine will swap those markers for actual data. This approach offers natural benefits: there is no need to onboard a new design tool; you can just stick to a familiar office suite with its standard formatting options. In practice, you may even ask a customer to share a sample report, then mirror its structure and visual style within your template. For a moment, I was uncertain how to add charts — something the previous two designers had done easily. Yet the solution was obvious: insert the charts as you normally would. Just make sure to place them within a separate named region and rely on placeholders instead of actual data. With the template ready, you can get back to the application and use it for the report you created. Just upload it to the Templates tab. Next, create bands to gather data for each placeholder in the template. JPQL is recommended for retrieving report data consistently with the application's data model, but SQL can still be used for direct database access. There's also the option to write Groovy scripts to handle specific logic or transformations. Now that bands are defined, save and run the report. The engine will generate the file for you to download. Building Reporting Portal A user-facing portal can be built into the very same application. Jmix Studio supplies ready-made page templates with UI components that automatically connect to the database and the framework's core subsystems. It was easy to create user accounts and limit their access to just the portal page. The result is a seamless experience where the reporting UI and the rest of the application share the same security, theming, and data access layer. In fact, it is a standard Spring Boot project, so it can be easily deployed anywhere — on-premises, in the cloud, or in containers. Integrating to Application Alternatively, Jmix can serve as a "reporting back-end". For that, enable the Available through REST API flag for the reports. External applications and services can then request the report by hitting the endpoint. This gives front-end teams the freedom to embed the output wherever needed: mobile apps, third-party dashboards, or custom web portals. Conclusion With the right tools, reporting can be much easier. Which reporting suite is best for you will depend on your expectations for developer experience, integration depth, and the impact of any existing reports you might already have. To summarize, here are some key takeaways: JasperReports – the go-to choice when you need: a desktop designer producing pixel-perfect reports.a comprehensive reporting library.the ability to expand into a full-scale BI ecosystem. Eclipse BIRT – ideal for projects that: are built around Eclipse or use the Eclipse IDE.require a reporting library with high customization potential.need a straightforward API that covers standard reporting requirements. Jmix Reports – best when you want: reporting within a full-stack web development platform based on IntelliJ IDEA.tight integration with the application codebase.unified security and data model for apps and reports.
Shai Almog
OSS Hacker, Developer Advocate and Entrepreneur,
Codename One
Ram Lakshmanan
yCrash - Chief Architect