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.
Jakarta EE 12: Entering the Data Age of Enterprise Java
Zero-Downtime Deployments for Java Apps on Kubernetes
Traditional CRUD systems store only the current state of an entity. When a record is updated, the previous value is overwritten and lost forever. Event Sourcing inverts this model: instead of persisting state, the system persists the sequence of events that caused each state transition. The current state is never stored directly, but it is always derived by replaying the event history. Command Query Responsibility Segregation (CQRS) separates the write model from the read model. A command expresses intent to change state, for example PlaceOrder, AddItem, ShipOrder. A query reads state without modifying it. The two sides use separate models, separate logic, and, in a full implementation, separate storage. CQRS and event sourcing are complementary: the event stream is the write side's source of truth, while one or more projections (read models) are derived from those events for fast querying. This article aims at showing how to apply, in practice, these concepts, using for illustration purposes a modified version of one of Markus Eisele's article, from the 27th of December 2025 on Substack. In his article, Markus shows a Quarkus-based project implementing a simplified order management system. Here, I'm presenting the Spring Boot implementation of this same system, to change. You can find it here. Terminology In a *classical* order management system, by analyzing the associated data model, we can gather a lot of information about orders and their flow in the organization. But while we would be able to account for any order's current status, the data and the data model analysis wouldn't allow us to reconstitute the story of how each order got to its current state. Event Sourcing The event sourcing pattern introduces the dimension of time into the data model. Instead of a schema reflecting the orders' current state, an event-sourcing-based system persists events documenting every change in the orders' lifecycle. Then, by querying these events, we can reconstitute the whole story of a given order, or any other general aggregate, from its initial creation until its current status. CQRS The only problem here is that querying a single aggregate instance event story at a time doesn't allow us to retrieve and consolidate data relative to other aggregates in the data model. Hence, the CQRS pattern is closely related to the event sourcing one, designed to provide the possibility of materializing projected models into logical data structures, reliable enough to support flexible querying options. Commands CQRS dedicates commands to execute operations that modify the system state. The command-based execution model is then the only one able to implement business logic, to validate rules, and to enforce invariants. Projections The system can define as many models as required to provide data to users or to other systems. Thus, a read model is a fast, denormalized, and pre-cached projection containing read-only data that the application needs to answer queries. The system project changes from the command execution model to all its read models. The projection notion is similar to that of a materialized view in relational databases, meaning that whenever the source tables are updated, the changes have to be reflected in all the read model views. Model Segregation In a CQRS architecture, the responsibilities of the system's models are segregated according to their type. A command can only operate on its own execution model, while a query cannot directly modify any of the system's persisted state. A Use Case The use case presented here is a *true* CQRS implementation (not just a naming convention) because: The write path never reads from the read model. CommandHandler reconstructs state exclusively by replaying events from the event store via EventProjection.replayEvents(). It never touches OrderReadModel or OrderRepository.The read path never touches the event store. OrderResource.getOrderReadModel() reads directly from the denormalized ORDERS table. It is a pure query with no business logic.There are two physically distinct storage tables: EVENT_STORE (write side) and ORDERS (read side).The read model is a projection, not a view. OrderProjection listens to domain events and rebuilds the read model incrementally. The ORDERS table could be dropped and rebuilt from scratch by replaying the event store.Commands return CommandResult, a sealed type that communicates success or failure without leaking state. The caller must query the read model separately if it needs current state. Let's look now at the project's key implementation details: Modeling State With Records OrderState is a Java record immutable by construction. No setters, no mutation. Every command produces a *new* state object: Java public record OrderState( UUID orderId, String customerEmail, List<OrderLine> items, OrderStatus status, BigDecimal total ) { public static OrderState initial(UUID orderId, String email) { ... } public static OrderState empty() { ... } } OrderLine is likewise a record with a derived field: Java public record OrderLine(String productName, int quantity, BigDecimal price) { public BigDecimal lineTotal() { return price.multiply(BigDecimal.valueOf(quantity)); } } lineTotal() is a derived record component: it is computed, not stored, demonstrating that records can carry behavior alongside. Events as a Sealed Type Hierarchy OrderEvent is a sealed interface, restricting all permitted implementations to a known, closed set: Java public sealed interface OrderEvent permits OrderEvent.OrderPlaced,OrderEvent.ItemAdded, OrderEvent.ItemRemoved,OrderEvent.OrderCancelled, OrderEvent.OrderShipped { UUID orderId(); OrderState applyTo(OrderState current); record OrderPlaced(UUID orderId, String customerEmail) implements OrderEvent { public OrderState applyTo(OrderState s) { return OrderState.initial(orderId, customerEmail); } } // ... other event types } Using a sealed interface means the compiler enforces exhaustiveness in switchexpressions. Adding a new event type without handling it is a compile error, not a runtime surprise. Each event carries only the data it needs and knows how to apply itself to the current state via applyTo(OrderState). This is the self-describing event pattern. The Fold (Event Replay) A fold, also known as left reduction, is the process of reconstructing state from a list of events over the event stream: Java // EventProjection.java public OrderState replayEvents(List<OrderEvent> events) { return events.stream() .reduce(OrderState.empty(), this::apply, (a, b) -> b); } private OrderState apply(OrderState state, OrderEvent event) { return event.applyTo(state); } OrderState.empty() is the identity element or the seed. Each event is a step function that transforms one immutable state into the next. This is pure functional programming: no side effects, no shared mutable state, entirely deterministic and testable in isolation. Commands as Sealed Records Commands are sealed records grouped in a container interface: Java public sealed interface Command permits Command.PlaceOrderCommand, Command.AddItemCommand,Command.ShipOrderCommand, Command.CancelOrderCommand { record PlaceOrderCommand(String customerEmail) implements Command {} record AddItemCommand(UUID orderId, String productName, int quantity, BigDecimal price) implements Command {} // ... } \Sealed records give commands value semantics (equality by content, toString for free. and type safety (exhaustive pattern matching in the handler). Command Results as Sealed Types CommandResult is a sealed interface expressing all possible outcomes without exceptions: Java public sealed interface CommandResult permits CommandResult.Success, CommandResult.InvalidState, CommandResult.NotFound, CommandResult.ValidationError { record Success(UUID aggregateId) implements CommandResult {} record InvalidState(String message) implements CommandResult {} record NotFound(String message) implements CommandResult {} record ValidationError(String message) implements CommandResult {} } The caller can switch on the result exhaustively. There are no checked exceptions, no nullable returns, and the type system documents all possible failure modes. The Event Store EventStore is the write-side infrastructure. It does two things atomically: Persists the event to EVENT_STORE (JPA via EventRepository).Publishes the event to the Spring application event bus. Java public void append(UUID aggregateId, String aggregateType, OrderEvent event) { int version = nextVersion(aggregateId); String json = objectMapper.writeValueAsString(event); StoredEvent entity = new StoredEvent(aggregateId, aggregateType, version, eventType, json); eventRepository.save(entity); applicationEventPublisher.publishEvent(event); } Versioning provides a lightweight optimistic concurrency guard by preventing concurrent writes from corrupting the stream, based on the unique value aggregateId + version. The Read-Side Projection OrderProjection is a Spring component that listens for domain events and updates the read model: Java @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) public void on(OrderEvent event) { OrderReadModel model = orderRepository.findByOrderId(event.orderId()) .orElse(new OrderReadModel()); // update fields from event ... orderRepository.save(model); } @TransactionalEventListener(phase = AFTER_COMMIT) ensures the read model is only updated after the event store transaction commits successfully, preventing this way phantom updates if the write-side transaction rolls back. Running the Application Prerequisites: Java 21, Maven, Docker (for PostgreSQL via TestContainers in tests). Shell # Build and run all tests (requires Docker) ./mvnw clean package # Run the application (requires a running PostgreSQL instance) ./mvnw spring-boot:run # Skip tests ./mvnw clean package -DskipTests API Reference MethodPathDescriptionPOST/ordersPlace a new orderPOST/orders/{id}/itemsAdd an item to an orderPOST/orders/{id}/shipShip an orderPOST/orders/{id}/cancelCancel an orderGET/orders/{id}Reconstruct current state from eventsGET/orders/{id}/eventsRetrieve the full event streamGET/orders/{id}/read-modelRetrieve the denormalized read model Place an order: JSON POST /orders { "customerEmail": "[email protected]" } Add an item: JSON POST /orders/{id}/items { "productName": "Widget", "quantity": 3, "price": 9.99 } Ship an order: JSON POST /orders/{id}/ship { "trackingNumber": "TRACK-001" }
The security audit report landed unexpectedly. It highlighted a critical vulnerability in our payment processing module. We had passed all unit tests. We had passed all integration tests. The code review looked clean. Yet the auditors found a hardcoded API key hidden in a utility class. This key allowed access to our third-party payment gateway. Anyone with access to the repository could see it. We were lucky the auditors found it before a malicious actor did. This incident was a wake-up call. We realized manual code reviews were not enough. We needed automated static analysis. We needed SonarQube. In this article, I will share how we integrated SonarQube into our Java development workflow. I will explain the specific rules that exposed our vulnerabilities. I will detail how we configured quality gates to prevent future regressions. This is not a generic installation guide. It is a record of how we shifted security left in our pipeline. Static analysis is not just about finding bugs. It is about building a culture of quality. The Blind Spot in Our Testing Our testing strategy relied heavily on functional correctness. We wrote tests to ensure features worked as expected. We did not write tests to ensure secrets were absent. We did not write tests to check for SQL injection patterns. These security concerns fell outside the scope of standard unit testing. Developers focused on delivering features quickly. Security was an afterthought. This mindset created technical debt. It also created risk. The hardcoded key incident showed us the gap. The developer who wrote the code intended to replace the key later. They forgot. The code merged to the main branch. It reached production. We rotated the key immediately, but the exposure window was dangerous. We needed a safety net. We needed a tool that could scan every commit for these patterns. SonarQube offered this capability. Integrating SonarQube into CI/CD We chose to integrate SonarQube into our Jenkins pipeline. This ensured every build was analyzed. We did not want developers to run the scan manually. Manual steps get skipped. Automation enforces consistency. We added a stage to our Jenkinsfile specifically for static analysis. This configuration triggered the Maven Sonar plugin. It sent code metrics to the SonarQube server. The server analyzed the code against a set of rules. These rules covered bugs and vulnerabilities, and code smells. The analysis happened in parallel with our tests. It added minimal time to the build. The results were available immediately after deployment. The Rule That Caught Us SonarQube has thousands of rules. We did not enable all of them initially. We started with the Sonar Way profile. This profile includes a curated set of essential rules. One specific rule flagged our hardcoded credential issue. The rule key is java:S2068. It searches for strings that look like passwords or keys. Here is the code that triggered the alert. SonarQube marked this line as a critical vulnerability. It recognized the pattern of a secret key. It suggested moving the value to an environment variable. This feedback was immediate. The developer saw the issue in the pull request dashboard. They fixed it before merging. This prevented the vulnerability from reaching production. Configuring Quality Gates Finding issues is only half the battle. You must prevent bad code from merging. We configured quality gates in SonarQube. A quality gate defines the conditions for a build to pass. We set strict thresholds for our main branch. New bugs: Must be zero.New vulnerabilities: Must be zero.New security hotspots: Must be reviewed.Code coverage: Must be above 80 percent. If a pull request failed these conditions, the pipeline failed. This gave us leverage. We could tell developers they could not merge until the issues were resolved. It enforced accountability. It also prevented technical debt from accumulating. We treated security violations like compilation errors. They blocked progress until fixed. Handling False Positives No tool is perfect. SonarQube sometimes flags safe code as risky. We encountered this with utility methods that handled strings resembling passwords. The tool raised alarms unnecessarily. This creates noise. Noise leads to alert fatigue. Developers start ignoring the warnings. We learned to tune the rules. We marked specific issues as false positives in the dashboard. We added comments to the code to explain why the pattern was safe. This annotation told SonarQube to ignore the rule for this line. We used this sparingly. We required a justification for every suppression. This ensured we did not hide real vulnerabilities. We reviewed suppressions during code review. This kept the process honest. Security Hotspots vs. Vulnerabilities SonarQube distinguishes between vulnerabilities and security hotspots. Vulnerabilities are confirmed issues. Hotspots are code that needs manual review. This distinction is important. Not every risky pattern is a bug. Some patterns require context to evaluate. We established a process for reviewing hotspots. A senior engineer reviewed each hotspot. They determined if it was a real risk. If it were, they converted it to a vulnerability. If not, they marked it as Safe. This human-in-the-loop approach reduced false positives. It also educated developers on security patterns. They learned why certain code was risky. This improved overall code quality over time. Lessons Learned and Best Practices Our journey with SonarQube taught us several lessons. We incorporated these into our development standards. Fail fast: Run analysis on every commit. Do not wait for nightly builds. Immediate feedback helps developers fix issues while context is fresh.Start small: Do not enable all rules at once. Start with the critical security rules. Add more rules gradually. This prevents overwhelming the team.Fix the root cause: Do not just suppress warnings. Understand why the rule exists. Fix the underlying code pattern. This prevents similar issues elsewhere.Track trends: Monitor the technical debt ratio over time. It should decrease. If it increases, investigate why. Are we rushing features? Are we skipping reviews?Educate the team: Tools do not replace knowledge. Hold sessions on secure coding practices. Explain the SonarQube rules. Help developers understand the why behind the rules.Integrate with IDE: Install the SonarLint plugin in IntelliJ or Eclipse. This brings analysis to the developer workstation. They see issues before committing code. This is the fastest feedback loop.Secure the server: SonarQube itself holds sensitive data. Secure the server with authentication. Restrict access to project settings. Rotate admin passwords regularly. Do not expose the dashboard to the public internet. Conclusion Integrating SonarQube transformed our approach to Java development. We moved from reactive security to proactive quality assurance. The hardcoded key incident never happened again. The tool caught similar patterns early in the cycle. Developers became more aware of security implications. They wrote cleaner code. They respected the quality gates. Static analysis is not a silver bullet. It does not catch logic errors or business rule violations. It does not replace penetration testing. However, it is a powerful layer in a defense-in-depth strategy. It catches the low-hanging fruit automatically. This frees up security engineers to focus on complex threats. We continue to refine our rules and thresholds. We add custom rules for our specific domain logic. We treat the SonarQube dashboard as a health monitor for our codebase. It tells us when our code is getting sick. We treat it before the patient crashes. Java provides a robust ecosystem for building secure applications. SonarQube helps us uphold that standard. Happy coding and keep your code clean.
Modern software systems rarely fail due to poor coding skills. Most failures occur when teams lose sight of the business problem they are addressing. As systems evolve, requirements shift, teams expand, and new integrations are added, codebases often become collections of technical decisions that lack business context. Classes become generic managers and services, methods devolve into procedural scripts, and communication between developers and domain experts diminishes. Tactical Domain-Driven Design (DDD) addresses this issue by emphasizing software that directly reflects business language in code, rather than focusing solely on infrastructure or frameworks. The term “semantic” comes from the Greek semantikos, meaning “significant” or “meaningful,” which is central to Tactical DDD. The objective is not just to reorganize classes, but to ensure code communicates intent clearly to both engineers and business experts. In modern Java systems, where complexity increases due to distributed architectures, integrations, and ongoing business changes, this clarity is essential for long-term maintainability. Tactical DDD provides practical patterns, such as entities, value objects, aggregates, repositories, factories, and domain services, to preserve codebase meaning and manage complexity. This article will examine these patterns step by step using Java and a soccer championship scenario to show how semantic code improves system understanding, evolution, and maintenance. Entity Before applying Tactical DDD patterns, it is important to recognize that they should not be the starting point of the design process. A common mistake in software projects is to begin with entities, repositories, and aggregates without first understanding the business. Tactical patterns serve as implementation tools, not discovery tools. Strategic DDD should begin with defining domain boundaries, the ubiquitous language, and the business context. Only after clarifying the problem space should you translate that understanding into code using tactical patterns. An Entity is a core Tactical DDD pattern. The term originates from the Latin ens, meaning “being” or “existing thing.” In software design, it refers to maintaining its identity throughout its lifecycle. An entity is defined not by its current attributes, but by the business’s recognition of it as the same conceptual object over time. Entities are useful when the domain must track the lifecycle of something important to the business. In a soccer championship, a player is a clear example of an entity. A player may change teams, positions, salary, or statistics during a career, but the system continues to recognize the player as the same individual within the domain. Therefore, identity is more important than changes to attributes. The following Java class illustrates this concept: Java import java.util.UUID; public class Player { private UUID id; private String name; private Position position; public Player(UUID id, String name, Position position) { this.id = id; this.name = name; this.position = position; } } The Player class demonstrates the Entity pattern by using a unique identifier in the id field. This identifier enables the application to distinguish one player from another, regardless of changes to other attributes. While name and position may change, the identity remains constant. This characteristic defines the object as an entity rather than a simple data structure or value object. Value Object Entities are defined by identity, but not all domain concepts require lifecycle tracking or unique identification. Many business concepts describe characteristics, measurements, classifications, or immutable meanings. The Value Object pattern addresses these cases. Here, “value” means the object is defined solely by its attributes, not by identity. In Tactical DDD, value objects reduce the need for primitives and clarify the domain language within the codebase. A Value Object is an immutable object that represents a descriptive aspect of the domain. Unlike entities, two value objects with identical values are considered the same. Value objects are often used for concepts such as money, addresses, coordinates, statuses, measurements, or classifications. Their primary purpose is to improve semantic clarity and encapsulate domain rules for specific concepts. In a soccer championship scenario, player position is a good example of a value object because the application does not need to track its lifecycle. The domain is concerned only with the meaning of the value. The following Java enum illustrates this concept: Java public enum Position { GOALKEEPER, DEFENDER, MIDFIELDER, FORWARD } The Position enum applies the Value Object pattern by representing a business classification rather than using primitive strings throughout the codebase. By using explicit types instead of raw text, such as "forward" or "goalkeeper", the application improves readability, reduces invalid states, and reinforces a shared language between developers and domain experts. Factory As domain models evolve, object creation often requires more than calling a constructor. Business rules, validations, default values, and initialization steps may spread into controllers, services, or application layers, leading to duplication and fragmented domain knowledge. The Factory pattern centralizes object creation, ensuring new domain objects are valid and meaningful. A Factory is a Tactical DDD pattern that encapsulates the creation logic of entities or aggregates. Its purpose is not just to “hide the constructor,” but to express domain intent during object creation. Originating from manufacturing, factories ensure objects are assembled correctly before use. In software, this approach maintains consistency and enforces business rules during instantiation. In a soccer championship scenario, creating a player requires more than allocating memory. A new forward player must have the correct position and a unique identity. Centralizing this logic in a factory prevents duplication across the system. Java import java.util.UUID; public class PlayerFactory { public Player createFoward(String name) { return new Player( UUID.randomUUID(), name, Position.FORWARD ); } } PlayerFactory implements the Factory pattern by encapsulating the details of Player creation. The application layer does not need to manage identifier generation or position assignment for a forward player. The method name communicates business intent, allowing the code to express a meaningful domain operation rather than low-level construction details. Aggregate Root As systems scale, maintaining consistency between related entities becomes more challenging. Without clear boundaries, business rules can spread across services, repositories, and transactions. Tactical DDD addresses this by defining explicit consistency boundaries within the domain model. Aggregate and Aggregate Root patterns are essential to this approach. An Aggregate is a group of related entities and value objects managed as a single consistency boundary. The Aggregate Root serves as the main entry point, coordinating and protecting the aggregate’s internal state. In practice, the aggregate root ensures controlled modifications and maintains business rule consistency during state changes. In a soccer championship scenario, a team acts as an aggregate root by managing the lifecycle and consistency of its players. The application should not modify the player collection directly; all changes should occur through the team’s defined behaviors: Java import java.util.Collections; import java.util.List; public class Team { private TeamId id; private String name; private List<Player> players; public Team(TeamId id, String name, List<Player> players) { this.id = id; this.name = name; this.players = players; } public List<Player> getPlayers() { return Collections.unmodifiableList(players); } public void remove(Player player) { players.remove(player); } public void add(Player player) { players.add(player); } } The Team class implements the Aggregate Root pattern, managing access to its player collection. Rather than allowing direct modification, it has add and remove. This method enables business rules to evolve and ensures consistency across the application. The aggregate root safeguards the domain boundary and maintains the cycle. Repository One of the biggest challenges in enterprise applications is avoiding tight coupling between business logic and persistence concerns. Over time, SQL queries, database operations, caching logic, and infrastructure details can start leaking into the domain layer, making the code harder to maintain and evolve. Tactical DDD addresses this problem with the Repository pattern, which provides a collection-like abstraction for managing aggregates. The term “repository” originates from the Latin repositorium, meaning “a place where things are stored.” In Domain-Driven Design, a repository is not merely a DAO or a utility class for executing queries. Its primary goal is to provide access to aggregates while hiding infrastructure complexity from the domain model. A repository allows the application to work with domain concepts instead of persistence mechanisms, preserving the separation between business logic and technical implementation. In the soccer championship scenario, the application needs a mechanism to persist and retrieve teams without exposing database details to the business flow. Since Team acts as the aggregate root, the repository is responsible for managing it as a consistency boundary: Java public interface TeamRepository { Team save(Team team); } The TeamRepository applies the Repository pattern by abstracting persistence operations behind a domain-oriented contract. The application layer does not need to know whether the data is stored in PostgreSQL, MongoDB, Redis, or another technology. More importantly, the repository communicates the business intention directly through the aggregate itself. Instead of manipulating tables or records, the code works with meaningful domain concepts such as Team, preserving the semantic clarity of the model and reducing coupling between the domain and infrastructure layers. Domain Service Not every business operation fits within an entity or aggregate root. As the domain evolves, some rules involve multiple entities or coordination logic that do not belong to a single object. Assigning these responsibilities to entities can lead to bloated models and reduced cohesion. Tactical DDD addresses this with the Domain Service pattern. A Domain Service contains business logic that does not belong to a specific entity or value object, but remains part of the domain model. Its role is to execute meaningful business operations involving multiple domain objects, not to handle technical orchestration or infrastructure. In DDD, a service encapsulates domain behavior across aggregates while maintaining the model’s clarity. In the soccer championship scenario, transferring a player between teams is a business operation involving multiple aggregates. The responsibility does not belong exclusively to the player or to a single team. Instead, the operation represents a domain action coordinating both source and destination teams: Java public class TransferService { public void transfer( Player player, Team source, Team destination) { source.remove(player); destination.add(player); } } TransferService implements the Domain Service pattern by encapsulating the business logic for transferring a player between teams. This service expresses the domain concept directly, rather than spreading logic across controllers or application layers. The method communicates business intent clearly using the domain’s ubiquitous language. Instead of exposing low-level details, the code now reflects a meaningful operation recognized by both developers and business experts: transferring a player during the championship lifecycle. Domain Event In complex systems, important business actions rarely affect only a single part of the application. A change in one domain often triggers reactions in other contexts, such as notifications, analytics, integrations, auditing, or external workflows. Directly coupling these concerns creates rigid architectures in which every new requirement increases dependencies across the system. Tactical DDD addresses this challenge with the Domain Event pattern. A Domain Event represents an important event that has already occurred within the business domain. The emphasis on the past tense is intentional because events describe facts, not commands or intentions. The term “event” originates from the Latin eventus, meaning “outcome” or “occurrence.” In Domain-Driven Design, domain events allow systems to communicate meaningful business changes while reducing coupling between components and bounded contexts. Instead of directly invoking every dependent operation, the domain publishes events that other parts of the system may react to independently. In the soccer championship scenario, hiring a new player is an important business occurrence that other parts of the system may care about. The championship may want to notify fans, update statistics, trigger merchandising actions, or synchronize with external systems. Instead of embedding all these responsibilities directly into the transfer logic, the application can represent the occurrence explicitly through a domain event: Java public record NewSoccerHired( Team team, Player player) { } The event can then be published once the business operation finishes successfully: Java eventPublisher.publish( new NewSoccerHired(destination, player) ); The NewSoccerHired record applies the Domain Event pattern by representing a meaningful business fact inside the domain model. Instead of tightly coupling multiple responsibilities, the system now exposes a semantic business occurrence that other parts of the architecture can react to independently. This approach improves extensibility, reduces direct dependencies, and preserves the ubiquitous language across the application lifecycle. Application Service As systems evolve, business operations often require coordination across domain components, persistence, and integration points. Without a clear orchestration layer, this logic may spread across controllers, APIs, and infrastructure classes, resulting in tightly coupled, hard-to-maintain applications. Tactical DDD addresses this with the Application Service pattern. An Application Service orchestrates use cases and coordinates domain operations. Unlike domain services, which encapsulate business rules, an application service manages the execution flow of business actions. In DDD, it serves as the coordination layer, connecting repositories, domain operations, and external interactions, while keeping the domain model focused on business behavior. In a soccer championship scenario, transferring a player between teams requires more than one business rule. This operation coordinates transfer logic, persistence, and event publication. The following class centralizes this orchestration in a single use case: Java public class TransferPlayerUserCase { private final TeamRepository teamRepository; private final TransferService transferService; private final EventPublisher eventPublisher; public TransferPlayerUserCase( TeamRepository teamRepository, TransferService transferService, EventPublisher eventPublisher) { this.teamRepository = teamRepository; this.transferService = transferService; this.eventPublisher = eventPublisher; } public void execute( Player player, Team source, Team destination) { transferService.transfer(player, source, destination); teamRepository.save(source); teamRepository.save(destination); eventPublisher.publish( new NewSoccerHired(destination, player) ); } } The TransferPlayerUserCase demonstrates the Application Service pattern by orchestrating the entire player transfer process. Rather than placing orchestration logic in controllers or entities, this class coordinates domain operations, persistence, and event publication within a single workflow. The method represents a meaningful business action within the domain: transferring a player between teams during the championship. Conclusion Tactical Domain-Driven Design does not aim to add unnecessary complexity or apply patterns indiscriminately. Its purpose is to help engineers build software that clearly communicates business meaning through code. By introducing concepts such as entities, value objects, factories, aggregates, repositories, domain services, domain events, and application services, developers create systems that are easier to understand, maintain, and adapt as business needs evolve. Tactical DDD also bridges the gap between technical and business perspectives, making code a semantic representation of the domain. This article introduces core Tactical DDD patterns using Java and a soccer championship scenario. The aim is not to cover every aspect of Domain-Driven Design, but to show how these patterns help build expressive and maintainable systems. As projects become more complex, preserving business meaning within the codebase is increasingly important, especially in modern distributed architectures where technical complexity can obscure domain language.
Executive Summary Modern health data analytics increasingly leverage AI agent software components that process information and make decisions, often using large language models (LLMs) or machine learning models. In Java, you can build agentic systems using libraries like DJL (Deep Java Library), Spring AI, or by integrating LLM APIs. This document includes Maven setup, minimal Spring Boot code (controllers and services), a simple agent example, diagrams, and a comparison of different agent approaches. Flowchart Maven Dependencies Define the necessary dependencies in pom.xml (Spring Web, Validation, DJL, OpenAI SDK, etc.): XML <dependencies> <!-- Spring Boot Web for API --> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency> <!-- Validation (optional) --> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-validation</artifactId> </dependency> <!-- OpenAI Java SDK (LLM API) --> <dependency> <groupId>com.openai</groupId> <artifactId>openai-java</artifactId> <version>4.0.0</version> </dependency> <!-- DJL for local ML inference --> <dependency> <groupId>ai.djl</groupId> <artifactId>api</artifactId> <version>0.36.0</version> </dependency> <dependency> <groupId>ai.djl.mxnet</groupId> <artifactId>mxnet-engine</artifactId> <version>0.36.0</version> </dependency> <!-- (Optional) Spring AI --> <dependency> <groupId>org.springframework.experimental</groupId> <artifactId>spring-boot-starter-ai</artifactId> <version>0.0.1</version> </dependency> </dependencies> This setup assumes you will use the OpenAI Java SDK and DJL. Replace the spring-boot-starter-ai version with the latest as needed. 1. Domain Model & Configuration Define simple data classes for health analysis requests and responses in com.example.health: Java package com.example.health; public record VitalSigns( double temperature, double bloodPressure, int heartRate ) {} This example record holds patient vitals. You can also add a Spring @Configuration if needed, for example, to configure the DJL engine: Java package com.example.config; import org.springframework.context.annotation.Configuration; import ai.djl.Engine; @Configuration public class DjLConfig { public DjLConfig() { Engine.getEngine("MXNet"); } } No special configuration is required for OpenAI; it reads API keys from the OPENAI_API_KEY environment variable. 2. Service Layer / Agent Component Implement a service that acts as your “agent.” It can use a local DJL model or call an external LLM API: Java package com.example.agent; import com.openai.client.OpenAIClient; import com.openai.client.okhttp.OpenAIOkHttpClient; import com.openai.models.responses.Response; import com.openai.models.responses.ResponseCreateParams; import org.springframework.stereotype.Service; @Service public class HealthAgent { private final OpenAIClient openAiClient; public HealthAgent() { this.openAiClient = OpenAIOkHttpClient.fromEnv(); } public String analyzeVitals(String patientId, VitalSigns vitals) { String prompt = String.format( "Patient %s has temperature %.1f°C, blood pressure %.0f/%d, heart rate %d. Suggest the next diagnostic step.", patientId, vitals.temperature(), vitals.bloodPressure(), vitals.bloodPressure(), vitals.heartRate() ); ResponseCreateParams params = ResponseCreateParams.builder() .model("health") .input(prompt) .build(); Response response = openAiClient.responses().create(params); return response.outputText(); } } This HealthAgent service builds a prompt from VitalSigns and uses the OpenAI Java SDK to call the LLM. 3. REST Controller Expose an HTTP API to trigger the agent: Java package com.example.api; import com.example.agent.HealthAgent; import com.example.health.VitalSigns; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.*; @RestController @RequestMapping("/api/health") public class HealthController { private final HealthAgent agent; public HealthController(HealthAgent agent) { this.agent = agent; } @PostMapping("/analyze") public ResponseEntity<String> analyze( @RequestParam String patientId, @RequestBody VitalSigns vitals) { String result = agent.analyzeVitals(patientId, vitals); return ResponseEntity.ok(result); } } This controller maps POST /api/health/analyze?patientId=XYZ with a JSON body containing VitalSigns. It delegates to the HealthAgent and returns the response. 4. Agent Patterns and Architecture AI “agents” can follow different patterns. Spring AI documentation distinguishes workflows from fully agentic systems that act autonomously. In healthcare, predictable workflows are often preferred for safety, although LLM-based agents can be dynamic. Common agent patterns include: Chaining: Multi-step reasoningParallelization: Running tasks in parallel and combining resultsRouting: Directing inputs to specialized prompts or toolsLooping: Iterating until a goal is achieved Spring AI provides abstractions to implement these patterns. For example, a chain workflow may sequentially call ChatClient.prompt(...).call() while passing outputs between steps. The example above is a single-step LLM call, but the architecture can be expanded: Clients → Spring Boot API → Agent (Spring AI or custom logic) → LLM/model → API response 5. Table: Agent Approaches Approach Pros Cons Use Cases Custom Java Logic Fully controllable code no external calls Limited intelligence no learning or language understanding Simple rule-based analysis strict data privacy requirements DJL Runs on JVM, GPU support full control of model and data Heavy dependency must train/download models resource-intensive On-premises analytics private data processing when a fixed ML model suffices LLM API (OpenAI, etc.) State-of-art language understanding managed by provider Latency, cost, and data privacy concerns requires API key NLP-heavy tasks prototyping and research Spring AI Patterns High-level workflows (chain, parallel, routing) built-in integrates with Spring Underlying calls are still to models/APIs complexity overhead Enterprise applications needing structured LLM agents, combining LLM with Spring ecosystem 6. Implementation Timeline 2026-02-01: Project kickoff (set up Spring Boot)2026-02-03: Define domain models (VitalSigns, etc.)2026-02-05: Configure dependencies (DJL, OpenAI SDK)2026-02-07: Implement HealthAgent service (LLM call)2026-02-10: Add REST controller and test endpoint2026-02-12: Optional: integrate DJL model (local inference)2026-02-14: Add error handling and validation2026-02-15: Final testing and local deployment 7. Running Locally Set your API key: Ensure OPENAI_API_KEY is set as an environment variable. Build and run (using Maven): Plain Text mvn spring-boot:run By default, the app runs on http://localhost:8080. Test the API: Plain Text curl -X POST "http://localhost:8080/api/health/analyze?patientId=123" \ -H "Content-Type: application/json" \ -d '{"temperature": 38.5, "bloodPressure": 130, "heartRate": 95}' You should see the AI agent’s response as plain text. 8. Assumptions Java Version: Java 17 (no license restrictions assumed)External Services: OpenAI or other LLM APIs require network access and valid API keysData Privacy: Health data must be handled securely (e.g., encryption, HIPAA compliance)Machine Learning: DJL uses MXNet by default; GPU acceleration requires proper setupArchitecture: Kafka, WebSockets, or databases are omitted for brevity Conclusion This document outlines how to integrate AI agents into a Spring Boot Java application. Key takeaways: Project Setup: Use Spring Boot for REST APIs with ML/LLM dependenciesAgent Logic: Implement services that call LLM APIs or local ML modelsPatterns: Use structured agent patterns for complex workflowsTrade-offs: Choose between custom logic, local ML, or LLM APIs based on requirements
"Do the simplest thing that could possibly work." — Kent Beck, creator of Extreme Programming and pioneer of Test-Driven Development. I believe the Java language architects didn't exactly hit the mark when designing the API for the original JDBC library for database operations. As a result, a significant number of various libraries and frameworks have emerged in the Java ecosystem, differing in their approach, level of complexity, and quality. I would like to introduce you to a brand-new lightweight ORM library, Ujorm3, which I believe beats the competition thanks to its simplicity, transparent behavior, and low overhead. The goal of this project is to offer a reliable, safe, efficient, and easy-to-understand tool for working with relational databases without hidden magic and complex abstractions that often complicate both debugging and performance. The final release is now available in the Maven Central Repository, released under the free Apache License 2.0. The library builds on the familiar principles of JDBC but adds a thin layer of a user-friendly API on top. It works with clean, stateless objects and native SQL, so the developer has full control over what is actually executed in the database. Ujorm3 deliberately avoids implementing SQL dialects and instead uses native SQL complemented by type-safe tools for mapping database results to Java objects. It does not cache the results of any user queries. To achieve maximum speed, however, Ujorm3 retains certain metadata. Application API Classes The library offers a type-safe SelectQuery builder for constructing SQL SELECT queries smoothly in Java, while still fully supporting the classic SqlQuery for writing raw native SQL. Both approaches utilize the generated Meta classes for mapping and aliases, preventing SQL typos and ensuring compile-time safety. The SelectQuery automatically generates JOIN clauses based on the entity metadata. The type of join is determined by the @JoinColumn annotation: INNER JOIN: Used when the attribute is marked as mandatory (e.g., @JoinColumn(nullable = false)).LEFT JOIN: Used by default or when the attribute is explicitly marked as nullable (e.g., @Nullable or @JoinColumn(nullable = true)). Data filtering can be defined using the where() method, which accepts a Criterion object. This object can represent a complex logical structure in the form of a binary tree, providing a clear and type-safe way to build nested conditions. Automatically generated Meta* classes enable safe column mapping without the use of typo-prone text strings. The use of a SELECT statement can then look like this, for example: Java final EntityContext CTX = EntityContext.ofDefault(); final EntityManager<Employee, Long> EMPLOYEE_EM = CTX.entityManager(Employee.class); List<Employee> select() { return SelectQuery.run(connection(), EMPLOYEE_EM, query -> query .sql("SELECT") // Optional: "SELECT" is the default .columnsOfDomain(true) .column(MetaEmployee.city, MetaCity.name) // INNER JOIN (nullable = false) .column(MetaEmployee.city, MetaCity.countryCode) .column(MetaEmployee.boss, MetaEmployee.name) // LEFT JOIN (nullable = true) .where(MetaEmployee.id.whereGe(1L)) .tail("ORDER BY", MetaEmployee.id) .toList() ); } If you need full control over building the SQL SELECT statement, use the SqlQuery class. This class provides an API with methods for type-safe insertion of database columns or just their labels. The individual approaches differ only in the way the SQL query is constructed. Regardless of the chosen approach, the database columns are ultimately mapped to entities using column aliases in the format: "city.name". The resulting ResultSet is also mapped to entities using this same mechanism via the ResultSetMapper class. The EntityManager is used for working with entities, providing simple CRUD operations — including batch commands — through a Crud object. An interesting feature is the possibility of partial updates — the developer can specify an enumeration of columns to be updated, or pass the original object to the library, from which it will infer the changes itself. The mentioned classes are illustrated in a simplified class diagram. All listed methods are public: Performance Ujorm3 achieves great results in benchmark tests, where it is compared with some popular ORM libraries. The mechanism of writing values to domain objects also contributes to the good score. Instead of the traditional approach using Java reflection, the library generates and compiles its own classes at runtime. Such an approach generally reduces memory requirements, minimizes overhead, and saves work for the Garbage Collector. The library has no dependencies on external libraries, and the compiled benchmark module (including the Ujorm3 library itself) is less than 3 MB, which is advantageous for microservices and embedded environments. However, it is good to keep in mind that in a production environment, in conjunction with slower databases, the differences in performance may partially blur. Getting Started To try the library in your Java 17+ project, simply add the dependency to your Maven configuration: XML <dependency> <groupId>org.ujorm</groupId> <artifactId>ujo-core</artifactId> <version>3.0.0</version> </dependency> <dependency> <groupId>org.ujorm</groupId> <artifactId>ujorm-orm</artifactId> <version>3.0.0</version> </dependency> To automatically generate metamodel classes, add the optional APT configuration to the build element: XML <plugins> <plugin> <groupId>org.apache.maven.plugins</groupId> <artifactId>maven-compiler-plugin</artifactId> <version>3.14.1</version> <configuration> <annotationProcessorPaths> <path> <groupId>org.ujorm</groupId> <artifactId>ujorm-meta-processor</artifactId> <version>3.0.0</version> </path> </annotationProcessorPaths> </configuration> </plugin> </plugins> The Ujorm module from the Benchmark project can be used as a template for a sample implementation. The library's codebase is currently covered by JUnit tests that utilize an in-memory H2 database (in addition to mocked objects). Before releasing the final version, I plan to add integration tests for PostgreSQL, MySQL, Oracle, and MS SQL Server databases. Useful Links Project HomepagePetStore DemoBenchmark TestsMore Examples as a JUnit Test
This is not "just another article about Springdoc," I promise. This is a ready-to-use recipe I was struggling to find one day, and had to build it from scratch. Have you ever needed to generate OpenAPI documentation directly from your code and, more importantly, do it in a way that fits cleanly into a CI pipeline? Swagger UI is commonly used in Spring Boot applications to visualize and test APIs from the browser. It can also expose the generated OpenAPI definition through a configurable endpoint, and that endpoint is exactly what we will use in this article. Why OpenAPI Documentation Matters Frontend Client Generation One of the most practical uses of OpenAPI documentation is automatic client generation. Tools such as OpenAPI Generator or Swagger Codegen can take an OpenAPI definition and produce TypeScript, JavaScript, or Java clients with very little manual effort. Mocking a Service Before It Is Ready In early development stages, a team may want to spin up a mock server before the real endpoints are fully implemented. Tools such as Mockoon or WireMock can use an OpenAPI specification to simulate the service. This is especially useful for frontend teams that need to move forward while backend work is still in progress. Verifying Contracts Between Services When multiple services depend on one another, compatibility becomes critical. OpenAPI documentation can be used together with tools such as Spring Cloud Contract to verify that both providers and consumers still conform to the agreed contract. The Manual Approach to Generating OpenAPI Documentation Let us start with a simple Spring Boot project. Add the following dependencies to pom.xml: XML <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter</artifactId> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-security</artifactId> </dependency> <dependency> <groupId>org.springdoc</groupId> <artifactId>springdoc-openapi-starter-webmvc-ui</artifactId> <version>2.6.0</version> </dependency> Then add Springdoc configuration to application.yml: YAML springdoc: api-docs: path: /api-docs enabled: true swagger-ui: url: /api-docs enabled: true Now create a simple REST controller: Java @RestController @Tag(name = "default", description = "General API") @RequestMapping("/api/v1/default") public class WebRestController { private static final Logger log = LoggerFactory.getLogger(WebRestController.class); @GetMapping(produces = MediaType.TEXT_PLAIN_VALUE) @ResponseStatus(HttpStatus.OK) public String get() { log.info("GET method called"); return "Hello!"; } @PostMapping( consumes = MediaType.TEXT_PLAIN_VALUE, produces = MediaType.APPLICATION_JSON_VALUE ) @ResponseStatus(HttpStatus.OK) public Set<String> post(@RequestBody String body) { log.info("POST method called"); return Set.of(body); } Finally, add a security configuration that allows access to both the REST API and to Swagger UI: Java @Configuration @EnableWebSecurity @EnableMethodSecurity public class WebSecurityConfig { @Profile("!openapi") @Bean public SecurityFilterChain filterChain(HttpSecurity httpSecurity) throws Exception { return httpSecurity.authorizeHttpRequests( request -> request .requestMatchers("/api-docs", "/api-docs/**").permitAll() .requestMatchers("/swagger-ui/*").permitAll() .requestMatchers("/api/v1/default").permitAll() .requestMatchers("/**").authenticated() ) .csrf(CsrfConfigurer::disable) .build(); } @Profile("openapi") @Bean public SecurityFilterChain filterChainOpenApi(HttpSecurity httpSecurity) throws Exception { return httpSecurity.authorizeHttpRequests( request -> request.anyRequest().permitAll() ) .csrf(CsrfConfigurer::disable) .build(); } Notice the separate openapi profile. We will use it later during automated generation. At this point, you can run the application and open Swagger UI at http://localhost:8080/swagger-ui/index.html. From there, the generated OpenAPI document is available at http://localhost:8080/api-docs. You can save that response manually and use it as your specification file. This works, but it is repetitive and not very practical for build automation. So let us move to the more useful approach: generating the spec during the Maven build. Automatic Generation To generate an OpenAPI file automatically, it helps to understand what actually happens during the build. The springdoc-openapi-maven-plugin does not generate the specification out of thin air. It calls the application endpoint that exposes the OpenAPI definition. In other words, your Spring Boot application must be running while the plugin executes. That is why the spring-boot-maven-plugin and springdoc-openapi-maven-plugin are typically used together. Because the application has to be started during the build, the security configuration must also allow the documentation endpoint to be accessed in that scenario. This is exactly why the separate openapi Spring profile is useful. Add a Dedicated Maven Profile Add the following Maven profile to pom.xml: XML <profile> <id>openapi</id> <properties> <maven.test.skip>true</maven.test.skip> </properties> <build> <plugins> <!-- When the Maven profile is openapi, run Spring with the openapi profile --> <plugin> <artifactId>spring-boot-maven-plugin</artifactId> <groupId>org.springframework.boot</groupId> <configuration> <jvmArguments> -Dspring.application.admin.enabled=true -Dspring.profiles.active=openapi </jvmArguments> </configuration> <executions> <execution> <id>pre-integration-test</id> <goals> <goal>start</goal> </goals> </execution> <execution> <id>post-integration-test</id> <goals> <goal>stop</goal> </goals> </execution> </executions> </plugin> <!-- Generate the OpenAPI file during the build --> <plugin> <artifactId>springdoc-openapi-maven-plugin</artifactId> <groupId>org.springdoc</groupId> <version>1.4</version> <configuration> <skip>false</skip> <apiDocsUrl>http://localhost:8080/api-docs.yaml</apiDocsUrl> <outputDir>${project.build.directory}</outputDir> <outputFileName>openapi.yml</outputFileName> </configuration> <executions> <execution> <id>integration-test</id> <goals> <goal>generate</goal> </goals> </execution> </executions> </plugin> </plugins> </build> </profile> The important parts here are: We create openapi Maven and openapi Spring profiles, but they are not the same (and should not necessarily have those exact names or share one name).When openapi Maven profile is run, we run Spring app with openapi profile (look at jvmArguments)-Dspring.profiles.active=openapi enables the relaxed security profile created specifically for documentation generation.apiDocsUrl points to the endpoint that returns the OpenAPI document.outputDir and outputFileName control where the generated file is written. These are the exact parts I struggled to find in one place, hence the "recipe" article. Run the Generation Step Once the profile is in place, generating the spec is easy: Shell ./mvnw verify -Popenapi After the build completes, the generated OpenAPI spec should be here: YAML ./target/openapi.yml Using It in a CI Pipeline This setup is CI-friendly because the same command can run locally and in your pipeline: YAML ./mvnw verify -Popenapi From there you can archive target/openapi.yml as a build artifact, publish it to an artifact repository, pass it to frontend code generators, mock servers, and contract verification jobs. Conclusion Generating OpenAPI documentation manually from Swagger UI is fine for quick inspection, but it does not scale well when you need repeatability. By wiring Spring Boot and Springdoc into a dedicated Maven profile, you can generate the specification automatically during the build in your CI. That gives you a reliable OpenAPI artifact that can support client generation, service mocking, and contract verification without adding a separate manual step to the development workflow. Bonus: Represent Set as an Array In some cases, you may want a Set to be represented as a regular array in the generated OpenAPI specification instead of an array with uniqueItems: true. This can be useful when downstream tools expect a plain array schema (this is the exact request I once got from the frontend team). You can customize Springdoc behavior with a small configuration class: Java import org.springdoc.core.utils.SpringDocUtils; import io.swagger.v3.oas.models.media.Schema; import java.util.Collections; import java.util.Set; public class SwaggerConfig { // Make springdoc generate an Array schema for Set.class // and remove uniqueItems: true public SwaggerConfig() { var schema = new Schema<Set<?>>(); schema.type("array").example(Collections.emptyList()); SpringDocUtils.getConfig().replaceWithSchema(Set.class, schema); } With this adjustment in place, the generated schemas for Set will be emitted as an array, which can simplify integration with some client generators and consumers.
Image classification is now a key part of many applications. Whether you’re automating photo organization, filtering uploaded content, or enriching product catalogs with visual tags, knowing what’s in an image can be just as important as knowing what a user typed. For Java developers, the challenge is familiar: most computer vision examples live in Python notebooks, while the systems that actually need image classification run on the JVM. Bridging that gap usually means standing up a separate Python microservice, managing REST calls, and dealing with serialization overhead. That’s a lot of ceremony for what should be a single processing step. This tutorial will show you how to build an image classification pipeline in pure Java with Apache Camel and the Deep Java Library (DJL). We’ll cover watching folders for new images, running classification with a pre-trained ResNet model, tidying up the predictions into clean reports, and routing results to output files, all while leaning on those trusty Enterprise Integration Patterns you’re probably already familiar with. What You'll Learn By the time you’re done here, you’ll be comfortable with: Develop a file-based image classification pipeline using Apache Camel.Use a pre-trained ResNet image classification model via Camel’s DJL component.Understand the djl: URI syntax and model configuration for computer vision tasks in Apache Camel.Structure routes with content-based routing and multiple formatter beans.Run image classification locally using Java and Apache Camel, without external APIs or Python services. Frameworks Used Apache Camel Apache Camel is an awesome open-source integration framework built on Enterprise Integration Patterns. It has great components for connecting systems, moving data, and orchestrating workflows using declarative routes. In this project, we look at file ingestion, message transformation, content-based routing, bean integration, error handling, and output persistence. Deep Java Library (DJL) DJL is a deep learning framework for Java that is engine-agnostic. It provides a high-level API for inference, training, and serving deep learning models right on the JVM. We use the Camel-DJL component to load a pre-trained ResNet model from the DJL Model Zoo, run image classification inference inside the JVM, and return structured classification results. ResNet for Image Classification Residual Network (ResNet) is a deep convolutional neural network architecture that introduced skip connections to solve the vanishing gradient problem. The model we use here is pre-trained on the ImageNet dataset, which covers 1,000+ categories — animals, vehicles, everyday objects, food items, you name it. It strikes a nice balance between accuracy and inference speed for CPU-based classification. Project Structure Let's look at the project structure below: reStructuredText camel-image-classifier/ ├── src/main/java/com/example/imageclassifier/ │ ├── MainApp.java # Application entry point │ ├── routes/ │ │ └── ImageClassificationRoutes.java # Camel route for image processing │ └── processor/ │ ├── ClassificationsFormatter.java # Formats DJL Classifications output │ ├── MapResultsFormatter.java # Formats Map-based results │ └── FallbackFormatter.java # Handles unexpected outputs ├── src/main/resources/ │ └── application.properties # Camel configuration ├── data/ │ ├── input/ # Drop JPEG images here │ ├── output/ # Classification results (text files) │ └── classified/ # Processed images archive ├── gradle/wrapper/ # Gradle wrapper files ├── build.gradle # Project dependencies ├── settings.gradle # Gradle settings ├── gradlew.bat # Gradle wrapper script ├── README.md # Main documentation Gradle Dependencies build.gradle Groovy plugins { id 'java' id 'application' } group = 'com.example' version = '1.0.0' description = 'Image Classification with Apache Camel and DJL' java { toolchain { languageVersion = JavaLanguageVersion.of(21) } } application { mainClass = 'com.example.imageclassifier.MainApp' } repositories { mavenCentral() } dependencies { // Apache Camel implementation 'org.apache.camel:camel-core:4.4.0' implementation 'org.apache.camel:camel-main:4.4.0' implementation 'org.apache.camel:camel-file:4.4.0' implementation 'org.apache.camel:camel-djl:4.4.0' // DJL (Deep Java Library) for image classification implementation platform('ai.djl:bom:0.28.0') implementation 'ai.djl:api' // MXNet engine for image classification (used by Camel DJL component) implementation 'ai.djl.mxnet:mxnet-engine' implementation 'ai.djl.mxnet:mxnet-model-zoo' // Use CPU-only MXNet runtime for Windows runtimeOnly 'ai.djl.mxnet:mxnet-native-mkl:1.9.1:win-x86_64' // Logging implementation 'org.slf4j:slf4j-simple:2.0.9' } A few things to note here compared to a typical NLP setup. For image classification, we use the MXNet engine instead of PyTorch. MXNet’s model zoo ships with a well-tested ResNet model optimized for image classification, and the mxnet-native-mkl dependency gives you CPU-optimized native libraries via Intel MKL. The DJL BOM makes sure the versions are consistent across engines and models. Application Entry Point The application starts up using the MainApp class and starts Camel using Main: Java package com.example.imageclassifier; import com.example.imageclassifier.routes.ImageClassificationRoutes; import org.apache.camel.main.Main; public class MainApp { public static void main(String[] args) throws Exception { System.out.println("================================================="); System.out.println("Image Classification with Apache Camel and DJL"); System.out.println("================================================="); // Create and configure Camel Main Main main = new Main(); // Add routes main.configure().addRoutesBuilder(new ImageClassificationRoutes()); // Start Camel System.out.println("\nStarting Apache Camel..."); System.out.println("Watching folder: data/input"); System.out.println("Output folder: data/output"); System.out.println("Press Ctrl+C to stop\n"); main.run(); } } Image Classification Route The ImageClassificationRoutes.java is where the core logic is implemented using the Camel DJL component’s URI. It uses the “from” component for image ingestion (watches for JPEG files, processes them one at a time to extract the raw bytes, archives them with a timestamp), and uses a single “to” URI endpoint to run image classification using the DJL component URI. The route then dispatches to the right formatter using Camel’s content-based routing. ImageClassificationRoutes.java Java package com.example.imageclassifier.routes; import com.example.imageclassifier.processor.ClassificationsFormatter; import com.example.imageclassifier.processor.FallbackFormatter; import com.example.imageclassifier.processor.MapResultsFormatter; import org.apache.camel.builder.RouteBuilder; import java.io.File; import java.nio.file.Files; /** * Apache Camel routes for image classification. */ public class ImageClassificationRoutes extends RouteBuilder { @Override public void configure() throws Exception { // Route to process JPEG images from input folder from("file:data/input?include=.*\\.(jpg|jpeg|JPG|JPEG)&noop=false&move=../classified/${date:now:yyyyMMdd-HHmmss}-${file:name}") .routeId("image-classification-route") .log("Processing image: ${file:name}") // Read file into bytes so the DJL component can create an Image internally. .process(exchange -> { File imageFile = exchange.getIn().getBody(File.class); exchange.getIn().setBody(Files.readAllBytes(imageFile.toPath())); }) // Run inference via Camel DJL component. .to("djl:cv/image_classification?artifactId=ai.djl.mxnet:resnet:0.0.1") // Convert output to a text report using Camel choice/bean components. .choice() .when(body().isInstanceOf(ai.djl.modality.Classifications.class)) .bean(new ClassificationsFormatter(), "format") .when(body().isInstanceOf(java.util.Map.class)) .bean(new MapResultsFormatter(), "format") .otherwise() .bean(new FallbackFormatter(), "format") .end() .log("Inference done for ${file:name}") // Write results to output folder .to("file:data/output?fileName=${date:now:yyyyMMdd-HHmmss}-${file:name.noext}.txt") .log("Results saved to output folder"); } } Let’s break this down: Stage 1: File Ingestion Java from("file:data/input?include=.*\\.(jpg|jpeg|JPG|JPEG)&noop=false&move=../classified/${date:now:yyyyMMdd-HHmmss}-${file:name}") The from component watches the data/input/ folder for JPEG files. The regex pattern include=.*\\.(jpg|jpeg|JPG|JPEG) makes sure only image files get picked up. Once processed, each image is moved to data/classified/ with a timestamp prefix, which prevents reprocessing and provides a clean audit trail. Setting noop=false means the file is consumed (moved), not left in place. Stage 2: Image to Bytes Java .process(exchange -> { File imageFile = exchange.getIn().getBody(File.class); exchange.getIn().setBody(Files.readAllBytes(imageFile.toPath())); }) The DJL component expects the image as a byte[] so it can construct a DJL Image object internally. This inline processor reads the file into a byte array and replaces the message body with it. It’s a small but essential step; without it, the DJL component would receive a File reference instead of raw pixel data. Stage 3: DJL Inference Java .to("djl:cv/image_classification?artifactId=ai.djl.mxnet:resnet:0.0.1") This single line is the heart of the pipeline. Let’s unpack the URI: djl – The Camel DJL componentcv/image_classification – The computer vision task type (as opposed to nlp/sentiment_analysis used in NLP tasks)artifactId=ai.djl.mxnet:resnet:0.0.1 – Identifies the pre-trained ResNet model from DJL’s MXNet Model Zoo This single line replaces what would otherwise be hundreds of lines of model loading, image preprocessing, tensor conversion, and inference code. Stage 4: Content-Based Routing Java .choice() .when(body().isInstanceOf(ai.djl.modality.Classifications.class)) .bean(new ClassificationsFormatter(), "format") .when(body().isInstanceOf(java.util.Map.class)) .bean(new MapResultsFormatter(), "format") .otherwise() .bean(new FallbackFormatter(), "format") .end() Here’s something you’ll run into with image classification that you won’t see in the sentiment analysis setup: the DJL component can return different types depending on the engine and model version. Most of the time, you get a Classifications object, but some MXNet model variants hand back a Map<String, Float> instead. Rather than assuming one type and risking a ClassCastException in production, we use Camel’s Content-Based Router pattern to dispatch to the right formatter bean. The FallbackFormatter catches anything unexpected — so the pipeline never crashes silently. This is a classic Enterprise Integration Pattern, and it’s one of the biggest advantages of using Camel for ML pipelines. The routing logic is declarative, testable, and easy to extend. Formatter Beans ClassificationsFormatter.java This is the primary formatter, handling the standard Classifications output from DJL: Java package com.example.imageclassifier.processor; import ai.djl.modality.Classifications; import org.apache.camel.Exchange; import java.util.List; /** * Bean to format DJL Classifications object into a text report. */ public class ClassificationsFormatter { public String format(Classifications classifications, Exchange exchange) { StringBuilder sb = new StringBuilder(); String fileName = exchange.getIn().getHeader("CamelFileName", String.class); sb.append("Image: ").append(fileName).append('\n'); List<Classifications.Classification> topK = classifications.topK(5); if (!topK.isEmpty()) { Classifications.Classification top = topK.get(0); sb.append("Top Prediction: ").append(top.getClassName()) .append(" (Confidence: ").append(String.format("%.2f%%", top.getProbability() * 100)) .append(")\n\n"); } sb.append("Top 5 predictions:\n"); for (int i = 0; i < topK.size(); i++) { Classifications.Classification c = topK.get(i); sb.append(String.format("%d. %s: %.2f%%\n", i + 1, c.getClassName(), c.getProbability() * 100)); } return sb.toString(); } } The topK(5) call extracts the five most confident predictions. Each classification carries a class name (e.g., “golden retriever”) and a probability score. The formatter produces a clean, human-readable report with the top prediction highlighted and all five ranked below it. MapResultsFormatter.java Some MXNet model variants return results as a Map<String, Float> instead of a Classifications object. This formatter handles that case: Java package com.example.imageclassifier.processor; import org.apache.camel.Exchange; import java.util.ArrayList; import java.util.List; import java.util.Map; /** * Bean to format Map-based classification results into a text report. * Handles HashMap output from MXNet models. */ public class MapResultsFormatter { public String format(Map<String, Float> results, Exchange exchange) { StringBuilder sb = new StringBuilder(); String fileName = exchange.getIn().getHeader("CamelFileName", String.class); sb.append("Image: ").append(fileName).append('\n'); // Convert to sorted list by probability (descending) List<Map.Entry<String, Float>> sortedResults = new ArrayList<>(results.entrySet()); sortedResults.sort((a, b) -> Float.compare(b.getValue(), a.getValue())); // Get top 5 List<Map.Entry<String, Float>> top5 = sortedResults.subList(0, Math.min(5, sortedResults.size())); if (!top5.isEmpty()) { Map.Entry<String, Float> topEntry = top5.get(0); sb.append("Top Prediction: ").append(topEntry.getKey()) .append(" (Confidence: ").append(String.format("%.2f%%", topEntry.getValue() * 100)) .append(")\n\n"); } sb.append("Top 5 predictions:\n"); for (int i = 0; i < top5.size(); i++) { Map.Entry<String, Float> entry = top5.get(i); sb.append(String.format("%d. %s: %.2f%%\n", i + 1, entry.getKey(), entry.getValue() * 100)); } return sb.toString(); } } Since a Map has no inherent ordering, we sort the entries by value in descending order before pulling out the top 5. The output format mirrors ClassificationsFormatter exactly, so downstream consumers don’t need to care which formatter produced the report. FallbackFormatter.java In case of an unexpected output type, the FallbackFormatter makes sure the pipeline keeps producing meaningful output rather than crashing. This follows a critical production pattern - fail softly: Java package com.example.imageclassifier.processor; import org.apache.camel.Exchange; /** * Bean to format unexpected result types into a text report. */ public class FallbackFormatter { public String format(Object result, Exchange exchange) { StringBuilder sb = new StringBuilder(); String fileName = exchange.getIn().getHeader("CamelFileName", String.class); sb.append("Image: ").append(fileName).append('\n'); sb.append("Raw result type: ").append(result == null ? "null" : result.getClass().getName()).append('\n'); sb.append("Result:\n").append(String.valueOf(result)).append('\n'); return sb.toString(); } } How to Run the Application Build and run using Gradle: gradlew clean run. Then drop JPEG images into data/input/. For example, place a photo of a dog. The classification result is written to data/output/, and the original image is archived to data/classified/ with a timestamp. Example output: Plain Text Image: golden_retriever.jpg Top Prediction: golden retriever (Confidence: 95.67%) Top 5 predictions: 1. golden retriever: 95.67% 2. Labrador retriever: 2.34% 3. tennis ball: 1.12% 4. cocker spaniel: 0.45% 5. Irish setter: 0.23% The model recognizes 1,000+ ImageNet categories - animals, vehicles, everyday objects, food items, plants, and more. Sentiment Analysis vs. Image Classification: Side by Side If you read my previous article on building a sentiment analysis pipeline with Camel and DJL, you’ll notice a deliberate symmetry between the two projects. The table below highlights the key differences: Aspect Sentiment Analysis Image Classification DJL Task Type nlp/sentiment_analysis cv/image_classification Model DistilBERT (PyTorch) ResNet (MXNet) Input Text files (.txt) JPEG images (.jpg, .jpeg) Input Preprocessing Files.readString() → String Files.readAllBytes() → byte[] DJL Engine PyTorch MXNet Output Positive/Negative with confidence Top 5 category predictions Formatter Count 2 (Classifications + Fallback) 3 (Classifications + Map + Fallback) The core Camel route structure — file ingestion, DJL inference, content-based routing, and formatted output — is identical. That’s the power of the Camel + DJL integration: switching from NLP to computer vision is essentially a URI change and a different set of dependencies. The integration pattern stays the same. DJL Behind the Scenes On first execution, the ResNet model (~100MB) is downloaded automatically from the DJL Model Zoo, and MXNet native libraries are initialized. The model is cached locally under ~/.djl.ai/, so subsequent runs load from cache, making startup significantly faster. The DJL component handles all the heavy lifting internally: image decoding, resizing to the model’s expected input dimensions, tensor conversion, forward pass through the neural network, and softmax normalization of the output probabilities. You don’t write any of this code - the Camel DJL component abstracts it away entirely. Production Considerations For performance, always warm up the model on startup if latency is a concern. The first inference call triggers model loading and JIT compilation, which can take several seconds. Allocate sufficient JVM heap: image classification models are memory-intensive and typically require 500MB–1GB. Scale horizontally with multiple Camel instances watching different input directories, or vertically using GPU-enabled DJL engines. MXNet supports CUDA out of the box— swap the mxnet-native-mkl dependency for mxnet-native-cu* to enable GPU acceleration. The content-based router with a fallback formatter makes sure the pipeline doesn’t crash on unexpected model output. For production deployments, consider adding Camel’s onException handler for retries and dead-letter routing. And Camel’s built-in metrics and JMX support give you visibility into processing rates, error counts, and route performance, critical for production ML pipelines. Conclusion This tutorial demonstrates that computer vision doesn’t need to be a separate system. With Apache Camel and DJL, image classification becomes just another step in your integration flow — composable, observable, and production-ready. There’s no per-request API cost, image data stays on-premise, and you have full control over routing and error handling. Compared to calling external vision APIs (Google Vision, AWS Rekognition, Azure Computer Vision), you get zero network latency for inference, no data leaving your infrastructure, and predictable cost regardless of volume. Compared to standing up a Python Flask service with TensorFlow or PyTorch, you get native integration with enterprise Java systems and first-class support for Enterprise Integration Patterns. If you already use Camel, adding computer vision capabilities is no longer a leap. It’s a small, well-structured step.
API testing has become increasingly popular in recent times. Since it doesn’t involve a UI, it is generally faster and easier to execute. This makes API testing a preferred choice for validating end-to-end system functionality. Additionally, integrating automated API tests into CI/CD pipelines enables teams to receive quicker feedback on their builds. In this tutorial, we will explore DELETE API requests and learn how to handle them with Rest-Assured in Java for automated testing. The following topics will be covered: What is a DELETE request?How to test the DELETE API using Rest-Assured Java? What Is a DELETE API Request? A DELETE API request is used for removing a specific resource from the server. In most cases, DELETE requests do not return a response body. The resource to be deleted is identified using a URI, and once the request is processed, it is permanently removed from the server. Because of this, DELETE operations can have side effects, such as removing records from a database. Here are some important considerations when working with DELETE requests: Data removed using a DELETE request cannot be recovered, so it should be handled carefully.It is not considered a safe method, as it directly impacts server data and may lead to inconsistencies if not managed properly.DELETE is not always idempotent in practice. For example, the first DELETE call may return a 204 (No Content) status indicating successful deletion, while a subsequent call on the same resource may return 404 (Not Found) since the resource no longer exists. Application Under Test For this tutorial, we will use freely available RESTful e-commerce APIs that provide multiple endpoints related to order management, enabling operations such as creating, retrieving, updating, and deleting orders. This application can be set up locally using Docker or Node.js. Here’s an example of a DELETE API endpoint from the RESTful e-commerce project. DELETE /deleteOrder/{id} This API expects an order_id as a path parameter to identify the order that should be deleted from the system. There’s no need to send a request body for this DELETE request. However, for security purposes, a valid authentication token must be included in the request header. Once the API is successfully executed, the specified order is removed from the system, and a 204 (No Content) status code is returned. If the order doesn’t exist, or if the token is missing or invalid, the API will return an appropriate error response. How to Test DELETE API Requests Using REST-Assured Java Testing the DELETE APIs is important for maintaining the stability and reliability of an application. Since these APIs are responsible for removing data from the system, it’s necessary to validate their behavior to avoid unintended data loss and inconsistencies in the system. In this demonstration, we will use REST-Assured Java to test the DELETE /deleteOrder/{id} endpoint, which is used to delete an existing order from the system. Getting Started It is recommended to go through the earlier tutorial to understand the prerequisites, setup, and configuration. Check out What Is API Testing to learn how to test APIs efficiently. Test Scenario 1: Delete a Valid Order The following test scenario will be used in this demonstration of testing a delete API request: Markdown ### Precondition - There should be some valid orders available in the system. ### Test Steps: 1. Delete the order with order_id "1" using the DELETE request. 2. Verify that 204 Status Code is returned in the response. Test Implementation To implement this test scenario, we need to have valid orders available in the system. These orders can be injected by running the POST /addOrder API. Check out this tutorial on how to test a POST /addOrder API, which discusses different approaches to generating request payloads. Additionally, we need the authorization token before we hit the Delete API request. This test scenario will be implemented in two parts: Hitting the POST /auth API endpoint and extracting the token from it.Hitting the Delete /deleteOrder/{id} API endpoint using the token generated in Step 1. Step 1: Generating and Extracting the Token Let’s create a new Java class, TestDeleteRequestExamples, and add the test method testTokenGeneration() to it. Java public class TestDeleteRequestExamples { private String token; @Test public void testTokenGeneration () { String requestBody = """ { "username": "admin", "password": "secretPass123" }"""; token = given ().contentType (ContentType.JSON) .when () .body (requestBody) .post ("http://localhost:3004/auth") .then () .statusCode (201) .and () .body ("token", notNullValue ()) .extract () .path ("token"); } //.. } The testTokenGeneration() test method sends a POST request to the /auth endpoint with a username and password to generate an authentication token. Since this is a demo API, the login credentials are hardcoded and used directly as a String. In a real-world scenario, credentials should not be exposed and should instead be accessed using an environment variable. It validates that the response status code is 201 and checks that the token field is present in the response body. It then extracts the token from the response and stores it in the token variable for further use in subsequent API requests. Step 2: Testing the Delete Order API Let’s create a new test method, testDeleteOrder(), to implement the test to delete the order. Java public class TestDeleteRequestExamples { private String token; int orderId = 1; @Test public void testDeleteOrder () { given ().header ("Authorization", token) .log () .all () .when () .delete ("http://localhost:3004/deleteOrder/" + orderId) .then () .log () .all () .statusCode (204); } //.. } The testDeleteOrder() test method sends a DELETE request to the /deleteOrder/{id} endpoint, passing an authorization token in the request header. header(): The header() method is used to add the Authorization header to the request. It ensures the API call is authenticated, allowing only valid users to perform the delete operation.token: The token variable holds the authentication token generated using the testAuthorizationToken() test method.delete(): This method sends a DELETE request to the /deleteOrder/{id} endpoint, instructing the server to delete the specified order(1).statusCode(): The test verifies that the response returns a 204 (No Content) status code, indicating that the request was successful and the resource was deleted, with no response body. Test Scenario 2: Fetch the Deleted Order This test scenario ensures that the specified resource was successfully deleted from the system. Hit the GET /getOrder API, try retrieving the order with order_id 1.Verify that the 404 status code is returned in the response. Test Implementation Let’s create a new test method, testFetchingDeletedOrder(), in the existing test class where we wrote the delete API test. Java @Test public void testFetchingDeletedOrder () { given ().when () .queryParam ("id", orderId) .get ("http://localhost:3004/getOrder") .then () .statusCode (404); } The testFetchingDeletedOrder() method sends a request to the GET /getOrder API using the same order_id that was previously deleted with the DELETE /deleteOrder API. The assertion of this test verifies that a 404 (Not Found) status code is returned, to check that the resource has been deleted and is no longer available in the system. Check out this tutorial on how to test a GET API request using REST-Assured Java. Although the assertions used in the previous delete test scenario already confirm that the data was deleted. However, testing by fetching the deleted resources scenario can be included as an additional validation, especially when performing end-to-end API testing. Check out this tutorial, which provides a comprehensive list of assertion examples for verifying the response data, status codes, headers, and more. Test Execution Let’s create a testng.xml file and add all the tests for verifying the DELETE API requests sequentially. XML <?xml version="1.0" encoding="UTF-8"?> <!DOCTYPE suite SYSTEM "http://testng.org/testng-1.0.dtd"> <suite name="Restful ECommerce Test Suite"> <test name="Restful ECommerce delete order tests"> <classes> <class name="restfulecommerce.tutorial.TestDeleteRequestExamples"> <methods> <include name="testTokenGeneration"/> <include name="testDeleteOrder"/> <include name="testFetchingDeletedOrder"/> </methods> </class> </classes> </test> </suite> Now, all three tests should run in sequence. The first will generate the token, the second will delete the order with order_id 1, and the last will try to retrieve the deleted order. The screenshot above shows that the tests ran successfully and the DELETE API works as expected. Summary DELETE API requests are used to remove resources from the system. Since deletion is a critical part of CRUD operations, it’s important to test it thoroughly to ensure the system behaves as expected. At the same time, keep in mind that DELETE operations are irreversible, so they should always be handled with care. From a practical standpoint, a good approach is to call the GET API after executing a DELETE request to verify that the resource has been successfully removed from the system. Next, learn how to perform data-driven testing with REST Assured using files such as JSON, Excel, and CSV. Happy testing!
Testing is an essential step in the API development process to ensure that APIs are working correctly. There are multiple HTTP methods in RESTful APIs, including POST, GET, PUT, PATCH, and DELETE. In our earlier articles, we learned how to perform automated testing of POST, PUT, and GET APIs using Rest-Assured Java. In this tutorial article, we will discuss and cover the following points: What is a PATCH API request?How to test PATCH API requests using REST-Assured Java? What Is a PATCH API Request? A PATCH request is used to update a resource partially. While it is similar to a PUT request, the key difference is that PUT requires the entire request body to be sent, whereas PATCH allows you to send only the specific fields that need to be updated. Let’s take an example of the following PATCH API that we’ll be using in this tutorial for demonstration purposes: PATCH (/partialUpdateOrder/{id}) This API endpoint partially updates the existing order in the system as per the provided Order ID. To update an existing order, this API requires the order ID as a path parameter so it knows which record to modify. The updated details should be provided in JSON format in the request body. Since this is a PATCH request, there’s no need to send the entire payload. Only the required field that needs to be updated should be included in the request body. Difference Between PATCH and PUT APIs The following table shows the difference between the PATCH and PUT APIs: criteriapatchputPurposePartially updates a resourcePartially updates a resourceRequest BodyOnly includes fields that need to be updatedRequires the full resource representationData SentOnly changed fieldsEntire data payloadIdempotencyNot always idempotentAlways idempotentUse CaseUpdating specific fieldsReplacing an entire recordRisk of Data LossLow, as the unchanged fields remain intactHigh, if some fields are omitted, they may be overwritten or removed How to Test PATCH APIs Using REST-Assured Java Let’s use the PATCH API /partialUpdateOrder/{id} and update an existing order partially in the system. Test scenario: Markdown ## Test Scenario Title: Partially update an existing orders in the system. ## Pre-condition: Valid orders are available in the system ## Test 1. Update the product_name and product_id for the order ID - 2 2. Verify that the Status Code 200 is returned in the response. 3. Assert that the order details have been updated correctly. Test Implementation The PATCH API is protected with authentication, so we would need the authentication token to access it successfully. To implement this test scenario, we’ll have to: Write a test to hit the Authorization API, generate and extract the token.Use the token generated in the first step and hit the PATCH API to update the order partially. Step 1: Write a test to hit the Authorization API, generate and extract the token. The POST /auth API endpoint should be hit with the following valid credentials to generate the token. JSON { "username": "admin", "password":"secretPass123" } It returns the following response: JSON { "message": "Authentication Successful!", "token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VybmFtZSI6ImFkbWluIiwiaWF0IjoxNzc1NjMzMDU5LCJleHAiOjE3NzU2MzY2NTl9.jHQwCyts9IejhwKGAZEm4Uyo9dgu5Kpe4OjTiYw1dm8" } The following test method is created to execute the POST /auth API request and extract the token from the response. Java @Test public void testTokenGeneration () { String requestBody = """ { "username": "admin", "password": "secretPass123" }"""; token = given ().contentType (ContentType.JSON) .when () .body (requestBody) .post ("http://localhost:3004/auth") .then () .statusCode (201) .and () .body ("token", notNullValue ()) .extract () .p The testTokenGeneration() test sends a POST request with login credentials to generate an authentication token using REST Assured. It verifies that the response returns a 201 status code and checks that a token is included in the response. Once the token is received, it’s extracted and stored in a global variable called token, so it can be reused across other test cases. Step 2: Partially updating the record with a PATCH API request. In this step, let’s add a new test method, testPartialUpdateOrder(), that sends a partial update request using the PATCH endpoint. The request body needs to be constructed with the required fields,i.e., product_id and product_name. We’ll use the Google Gson and Datafaker library to generate the request body. Java public class TestPatchRequestExamples { private String token; @Test public void testPartialUpdateOrder () { Faker faker = new Faker (); String productId = String.valueOf (faker.number () .numberBetween (1, 2000)); String productName = faker.commerce () .productName (); JsonObject orderDetail = new JsonObject (); orderDetail.addProperty ("product_id", productId); orderDetail.addProperty ("product_name", productName); //.. } This piece of code uses the Faker class from the Datafaker library and generates a random value for the product_id and product_name fields. The JSON object required for the request body is generated using the JsonObject class of the Google Gson library. The following request body is generated using this code: JSON {"product_id":"702","product_name":"Sleek Silk Plate"} Next, let’s write the automated test to update the record using the PATCH API endpoint. Java @Test public void testPartialUpdateOrder () { int orderId = 2; //.. given ().contentType (ContentType.JSON) .header ("Authorization", token) .when () .log () .all () .body (orderDetail.toString ()) .patch ("http://localhost:3004/partialUpdateOrder/" + orderId) .then () .log () .all () .statusCode (200) .and () .assertThat () .body ("message", equalTo ("Order updated successfully!"), "order.product_id", equalTo (productId), This test sends a PATCH API request for the order ID 2. The request body we created earlier is included in the request, containing only the fields that need to be updated. given().contentType(ContentType.JSON): It specifies that the request body will be in JSON format..header(“Authorization”, token): It adds the authentication token to the request header, which is required to authorize the API call..when().log().all(): This statement starts the request execution and logs all request details(headers, body, etc.)..body(orderDetail.toString()): It sets the request payload. The orderDetails (created earlier) JSON contains only the fields that need to be updated..patch(“http://localhost:3004/partialUpdateOrder/”+ orderId): It sends the PATCH request to update the order partially with the specified order ID..then().log().all(): It logs the full response for better visibility of the test execution..statusCode(200): It verifies that the API request was sent and the API responded with a 200 OK status..and().assertThat().body(…): It performs multiple assertions on the response body as shown below: The value of the “message” field should be “Order updated successfully!”The value of the “product_id” and “product_name” fields in the order object should be the same as supplied in the request. Using a dynamic approach to generate the request body with DataFaker helps eliminate repetitive code and promotes better reusability across test cases. Check out this tutorial for more information related to response verification Test Execution As we discussed in the earlier tutorial on testing PUT API requests with REST Assured, we need to follow the same approach to generate the token first, then use it to hit the PATCH API request. Let’s create the following testng.xml file for executing the tests sequentially: XML <?xml version="1.0" encoding="UTF-8"?> <!DOCTYPE suite SYSTEM "http://testng.org/testng-1.0.dtd"> <suite name="Restful ECommerce Test Suite"> <test name="Restful ECommerce End to End tests"> <classes> <class name="restfulecommerce.tutorial.TestPatchRequestExamples"> <methods> <include name="testTokenGeneration"/> <include name="testPartialUpdateOrder"/> </methods> </class> </classes> </test> </suite> The following screenshot of test execution shows that the tests were executed successfully and the order was partially updated. The following log was printed in the console after test execution, showing the request and the response details: Plain Text Request method: PATCH Request URI: http://localhost:3004/partialUpdateOrder/2 Proxy: <none> Request params: <none> Query params: <none> Form params: <none> Path params: <none> Headers: Authorization=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VybmFtZSI6ImFkbWluIiwiaWF0IjoxNzc1NjMyMDEzLCJleHAiOjE3NzU2MzU2MTN9.A10-amp24LKDrDKrRJ6BW1KKtkVLQ-QK71U_Jl1ctDs Accept=*/* Content-Type=application/json Cookies: <none> Multiparts: <none> Body: { "product_id": "702", "product_name": "Sleek Silk Plate" } HTTP/1.1 200 OK X-Powered-By: Express Content-Type: application/json; charset=utf-8 Content-Length: 188 ETag: W/"bc-NGDglqodj+ZJKoZsbosa9746aT0" Date: Wed, 08 Apr 2026 07:06:54 GMT Connection: keep-alive Keep-Alive: timeout=5 { "message": "Order updated successfully!", "order": { "id": 2, "user_id": "1", "product_id": "702", "product_name": "Sleek Silk Plate", "product_amount": 750, "qty": 1, "tax_amt": 7.99, "total_amt": 757.99 } } It can be seen that the two fields product_id and product_name have randomly generated values and are sent in the request along with the order ID 2. In the response, a 200 OK status code is returned along with the full response body showing the same product_id and product_name. These details, as well as the assertions used in the tests, confirm that the order was successfully updated. Summary Effectively testing PATCH APIs in automation involves validating partial updates by sending only the required fields and verifying that unchanged data remains intact. Using a dynamic approach such as DataFaker, Google Gson, and constructing a request body with POJOs, Builder Pattern, JSON files, or Java Map helps generate fresh test data, reducing duplication issues and easing maintenance. Following best practices such as proper authentication handling, validating response body and status code, and keeping tests reusable and maintainable ensures robust and scalable API test automation. Happy testing!
The Problem You're running a Java application in a Docker container on your M1 Mac. Everything works fine, but you notice something strange: The resident set size (RSS) keeps growing, even though your heap usage is stable. After hours of investigation, you find mysterious rwxp memory regions, each exactly 128 MB, accumulating in your process memory map. What's causing this? Is it a memory leak? A JVM bug? Something else entirely? The Investigation Our journey began with monitoring RSS growth in a Java 17 application deployed on Docker-backed Minikube. Despite stable heap usage and no obvious memory leaks, RSS continued to grow by hundreds of megabytes over time. Initial Observations RSS growth: ~500-700 MB over 11 hoursHeap usage: Stable and within limitsThread count: StableNative memory tracking: No obvious leaks Deep Dive Into Memory Maps Using /proc/PID/maps and /proc/PID/smaps, we discovered the growth was coming from anonymous executable memory regions: Shell $ cat /proc/1/maps | grep rwxp efffd1d7c000-efffd9d7c000 rwxp 00000000 00:00 0 efffdb185000-efffe3185000 rwxp 00000000 00:00 0 efffe3d85000-efffebd85000 rwxp 00000000 00:00 0 ... Each region was exactly 128 MB, in the 0xefff* address range, with read-write-execute permissions. But what was in them? The Discovery Reading the memory content revealed something unexpected: ARM64 machine code instructions. But wait, the Java binary was x86-64, and the process reported x86_64 architecture. What was ARM64 code doing there? The "Aha!" Moment The answer: Rosetta 2 translation cache. When running x86-64 containers on ARM64 M1 Macs via Docker Desktop, Rosetta 2 translates x86-64 instructions to ARM64. The translated code is cached in executable memory regions-those mysterious RWXP regions we were seeing! The Root Cause Here's what was happening: JIT compilation: Java's JIT compiler generates x86-64 native code for hot methodsRosetta 2 intercepts: When x86-64 code executes, Rosetta 2 translates it to ARM64Translation cache: Translated ARM64 code is stored in 128 MB RWXP memory regionsGrowth: More JIT-compiled methods = more translations = more RWXP regions Evidence ObservationExplanationRWXP regions contain ARM64 codeRosetta 2's translated codeExactly 128 MB per regionRosetta 2 allocation granularityAnonymous (no file backing)Runtime translation cacheGrowth correlates with JIT activityMore compiled methods = more translations The Proof To definitively prove JIT was the trigger, we disabled JIT compilation using the -Xint flag: Java -Xint # Run in interpreter-only mode Results MetricBefore (JIT Enabled)After (JIT Disabled)RWXP Regions5 -> 12 -> 15 (growing)1 (stable, no growth)RWXP Memory~1.9 GB~128 MBGrowth RateMultiple regions/hour0 regions/hourCompiled Methods25,606 nmethods0 nmethods Result: With JIT disabled, RWXP growth completely stopped. Monitoring over 1+ hour confirmed zero growth. Why This Happens The Perfect Storm ARM64 host: M1 Mac (Apple Silicon)x86-64 container: Docker image built for AMD64Rosetta 2 enabled: Docker Desktop uses Rosetta 2 for emulationDynamic code generation: Java JIT compiler When all four conditions are met, Rosetta 2 must translate every JIT-compiled method from x86-64 to ARM64, storing the translations in executable memory regions that count toward process RSS. The Solution Option 1: Use Native ARM64 Images (Recommended) The best solution is to use ARM64-native Docker images: Shell # Build for ARM64 docker build --platform linux/arm64 ... # Or use multi-arch images docker pull --platform linux/arm64 your-image:tag Benefits: No Rosetta 2 translation neededNo RWXP growthBetter performance (native execution)Lower memory usage Option 2: Deploy to x86-64 Infrastructure If ARM64 images aren't available, deploy to x86-64 servers or cloud instances where Rosetta 2 isn't needed. Option 3: Accept and Monitor If you must use x86-64 containers on M1 Macs: Increase container memory limitsMonitor RWXP growthPlan for periodic restarts if needed Not Recommended Don't disable JIT in production (-Xint). While it stops RWXP growth, it dramatically reduces performance. Use it only for testing/debugging. Key Takeaways Rosetta 2 translation cache causes RWXP memory growth in x86-64 containers on ARM64 MacsJIT compilation is the primary trigger; each compiled method needs translationNative ARM64 images eliminate the problem entirelyThis is expected behavior, not a bug-it's the cost of emulation Conclusion What started as mysterious RSS growth turned out to be Rosetta 2's translation cache storing ARM64 translations of JIT-compiled Java code. By understanding the mechanism and testing with JIT disabled, we proved the root cause and identified the best solution: use native ARM64 images. If you're experiencing similar RSS growth in Java applications on M1 Macs, check for RWXP regions in your process memory map. If you see them, Rosetta 2 translation is likely the culprit. How to Check Shell # Check for RWXP regions cat /proc/PID/maps | grep rwxp # Count RWXP regions cat /proc/PID/maps | grep rwxp | wc -l # Check if Rosetta 2 is active cat /proc/PID/maps | grep rosetta Have you encountered similar issues? Share your experience in the comments below!
Shai Almog
Co-founder at Codename One,
Codename One
Ram Lakshmanan
yCrash - Chief Architect