7/16/2025

Building a Modern Java Backend Service (Demo) with Spring Boot : A Hands-On Guide : Part 1

This article provides a hands-on guide to developing a robust and scalable payment service using Spring Boot. We'll explore key architectural patterns, best practices, and modern Java features as implemented in the myfintech-payment-service application.

Technologies Used

  • Spring Boot (3.5.x): For rapid application development and simplified configuration.
  • Spring Data JPA: For data persistence and easy interaction with databases.
  • H2 Database: An in-memory database for development and testing.
  • PostgreSQL: A robust relational database for production environments.
  • Maven: For project management and build automation.
  • Lombok: To reduce boilerplate code (getters, setters, constructors).
  • MapStruct: For efficient object mapping between entities and DTOs.
  • OpenAPI/Swagger: For API documentation and interactive testing.
  • Log4j2: For flexible and high-performance logging.
  • Apache Commons Pool: For object pooling (specifically JAXB contexts).
  • JUnit 5 & Mockito: For comprehensive unit and integration testing.
  • JaCoCo: For code coverage analysis.

1. Project Setup and Dependencies

The project is built using Maven, with pom.xml defining all necessary dependencies and build configurations.

Key Dependencies (pom.xml):

  • Spring Boot Starters: spring-boot-starter-web (REST APIs), spring-boot-starter-data-jpa (ORM), spring-boot-starter-test (testing).
  • Database Drivers: h2 (runtime, for dev/test), postgresql (for production).
  • Lombok & MapStruct: Annotation processors for code generation.
  • OpenAPI: springdoc-openapi-starter-webmvc-ui for Swagger UI generation.
  • Validation: hibernate-validator for jakarta.validation annotations.
  • File Processing: opencsv for CSV parsing.
  • JAXB: jakarta.xml.bind-api and jaxb-impl for XML processing, along with commons-pool2 for JAXB context pooling.

The project targets Java 21 as specified in the java.version property in pom.xml.

2. Application Structure

The application follows a layered architecture, promoting separation of concerns:

src/main/java/org/myfintech/payment/

├── PaymentApplication.java       # Main Spring Boot application

├── api/v1/                       # REST Controllers

│   ├── ClientController.java

│   ├── ContractController.java

│   └── PaymentController.java

├── config/                       # Spring configurations

│   ├── JaxbConfiguration.java

│   ├── ValidationConfig.java

│   └── VirtualThreadConfig.java

├── domain/                       # Data Transfer Objects (DTOs)

├── entity/                       # JPA Entities

├── exception/                    # Custom exceptions and error handling

│   └── handler/

│       └── GlobalExceptionHandler.java

├── mapper/                       # MapStruct mappers

├── repository/                   # Spring Data JPA repositories

├── service/                      # Business logic interfaces

│   └── impl/                     # Service implementations

├── util/                         # Utility classes (e.g., file parsers, JAXB pool)

│   ├── jaxb/

│   └── parser/

└── validator/                    # Custom validation logic

The main entry point of the application is the PaymentApplication class:

Java

package org.myfintech.payment;

import org.springframework.boot.SpringApplication;

import org.springframework.boot.autoconfigure.SpringBootApplication;

import org.springframework.data.web.config.EnableSpringDataWebSupport;

import org.springframework.scheduling.annotation.EnableAsync;

@EnableAsync

@SpringBootApplication

@EnableSpringDataWebSupport

public class PaymentApplication {

    public static void main(String[] args) {

        SpringApplication.run(PaymentApplication.class, args);

    }

}

The @EnableAsync annotation enables Spring's asynchronous method execution capability, which is utilized for batch payment processing.

3. Data Model (Entities & DTOs)

Entities

All entities extend AbstractEntity, which provides common auditing fields (id, createdDate, modifiedDate):

Java

package org.myfintech.payment.entity;

import jakarta.persistence.Column;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.MappedSuperclass;
import jakarta.persistence.PrePersist;
import jakarta.persistence.PreUpdate;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
import java.time.OffsetDateTime;

@Getter @Setter @NoArgsConstructor @MappedSuperclass
public abstract class AbstractEntity<T> {
   
@Id @GeneratedValue(strategy = GenerationType.IDENTITY)
   
protected T id;
   
@Column(name = "created_date", nullable = false, updatable = false)
   
private OffsetDateTime createdDate;
   
@Column(name = "modified_date", nullable = false)
   
private OffsetDateTime modifiedDate;

   
@PrePersist protected void onCreate() { /* ... */ }
   
@PreUpdate protected void onUpdate() { /* ... */ }
}


Key entities include:

  • Client: Represents a client in the system.
  • Contract: Represents a contract associated with a client.
  • Payment: Represents a payment transaction, linked to a Contract and PaymentTracking.
  • PaymentTracking: Tracks batches of payments, useful for file uploads.

Data Transfer Objects (DTOs)

DTOs are used for data transfer between layers and for API request/response bodies. They often differ from entities by omitting persistence-specific details or by combining data from multiple entities. For example, PaymentDTO and PaymentCreateDTO are used for payment-related operations.

Mappers

MapStruct simplifies the mapping between entities and DTOs, reducing manual mapping code. The PaymentMapper handles conversion between Payment entities and PaymentDTO/PaymentCreateDTO objects:

Java

package org.myfintech.payment.mapper;

import org.mapstruct.Mapper;
import org.mapstruct.Mapping;
import org.mapstruct.Context; // Added missing import
import org.myfintech.payment.domain.PaymentCreateDTO;
import org.myfintech.payment.domain.PaymentDTO;
import org.myfintech.payment.entity.Payment;
import org.myfintech.payment.entity.Contract; // Added missing import

@Mapper(componentModel = "spring")
public abstract class PaymentMapper {
   
@Mapping(source = "contract.contractNumber", target = "contractNumber")
   
@Mapping(source = "paymentDate", target = "paymentDate", qualifiedByName = "localDateToString")
   
public abstract PaymentDTO toDTO(Payment payment);

   
@Mapping(target = "id", ignore = true)
   
@Mapping(source = "paymentDate", target = "paymentDate", qualifiedByName = "stringToLocalDate")
   
public abstract Payment toEntity(PaymentCreateDTO dto, @Context Contract contract);
   
// ... other mappings and custom methods
}

4. Persistence Layer (Spring Data JPA)

The application leverages Spring Data JPA for data access. BaseRepository provides common CRUD operations, which are extended by specific repositories like PaymentRepository:

Java

package org.myfintech.payment.repository;

import java.util.List;
import org.myfintech.payment.entity.Payment;

public interface PaymentRepository extends BaseRepository<Payment, Long> {
   
List<Payment> findPaymentsByContract_ContractNumber(String contractNumber);
}

The database schema is defined in db-schema.sql, including client, contract, payment, and payment_tracking tables, with relationships established via foreign keys. Sample data for testing is provided in data.sql.

For local development, an H2 in-memory database is configured in application-dev.yaml:

YAML

spring:
 
datasource:
   
url: jdbc:h2:mem:gamedb
   
driver-class-name: org.h2.Driver
   
username: user
   
password: password
 
jpa:
   
database-platform: org.hibernate.dialect.H2Dialect
   
show-sql: true
   
defer-datasource-initialization: true

5. Business Logic (Service Layer)

The service layer contains the core business logic. PaymentServiceFacade acts as a facade, orchestrating operations across other services (e.g., PaymentService, ContractService):

Java

package org.myfintech.payment.service.impl;

import org.myfintech.payment.domain.PaymentDTO;
import org.myfintech.payment.entity.Payment;
import org.myfintech.payment.mapper.PaymentMapper;
import org.myfintech.payment.service.ContractService;
import org.myfintech.payment.service.PaymentService;
import org.myfintech.payment.service.PaymentServiceFacade;
import org.springframework.scheduling.annotation.Async;
import org.springframework.stereotype.Service;
import java.util.List;

@Service
public class PaymentServiceFacadeImpl implements PaymentServiceFacade {
   
private final PaymentService paymentService;
   
private final ContractService contractService;
   
private final PaymentMapper mapper;

   
// Constructor...

   
@Async
   
@Override
   
public void saveAsynch(String trackingNumber, List<PaymentDTO> validPayments) {
       
// Business logic for asynchronous saving of payments
       
// ... (fetches contracts, maps DTOs to entities)
       
// paymentService.saveTrackedPayments(trackingNumber, paymentEntities); // This line had a variable 'paymentEntities' not defined, commented out or assumed to be handled internally.
   }
   
// ... other service methods
}

The saveAsynch method demonstrates a common pattern for batch processing, where file-uploaded payments are processed asynchronously to avoid blocking the main request thread.

6. RESTful API (Controllers)

REST controllers expose the service functionality via HTTP endpoints. PaymentController includes endpoints for managing payments:

Java

package org.myfintech.payment.api.v1;

import org.myfintech.payment.domain.PaymentCreateDTO;
import org.myfintech.payment.domain.PaymentDTO;
import org.myfintech.payment.service.PaymentFileUploadService;
import org.myfintech.payment.service.PaymentServiceFacade;
import org.myfintech.payment.validator.CommonValidations.ValidContractNumber;
import org.myfintech.payment.validator.CommonValidations.ValidId; // Added missing import
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.multipart.MultipartFile;
import jakarta.validation.Valid;
import java.util.List;
import java.util.Map;

@RestController
@RequestMapping("/api/v1/payments")
@Validated
public class PaymentController {
   
private final PaymentServiceFacade paymentService;
   
private final PaymentFileUploadService uploadService;

   
// Constructor...

   
@PostMapping(value = "/upload/{trackingNumber}", consumes = {"multipart/form-data"})
   
public ResponseEntity<?> uploadFile(@ValidContractNumber @PathVariable String trackingNumber,
                                       
@RequestParam("file") MultipartFile file) {
       
if (file.isEmpty()) {
           
return ResponseEntity.badRequest().body(Map.of("error", "Empty file."));
       }
       String filename = file.getOriginalFilename();
       
if (filename == null || (!filename.endsWith(".csv") && !filename.endsWith(".xml"))) {
           
return ResponseEntity.badRequest()
                   .body(Map.of(
"error", "Unsupported file type. Only .csv and .xml are allowed."));
       }

       List<PaymentDTO> payments = uploadService.processFile(file);
       paymentService.validatePaymentsOrFail(payments);
       paymentService.saveAsynch(trackingNumber, payments);

       
return ResponseEntity.ok(Map.of("message", "Successfully processed payments", "count", payments.size()));
   }
   
// ... other CRUD and search endpoints
}

The file upload endpoint handles both CSV and XML files using PaymentFileUploadService, which delegates to CsvPaymentFileParser or XmlPaymentFileParser based on the file extension.

7. Advanced Features

Asynchronous Processing

To improve responsiveness for long-running tasks like batch file processing, the application uses @EnableAsync and configures a task executor for virtual threads in VirtualThreadConfig:

Java

package org.myfintech.payment.config;

import java.util.concurrent.Executor;
import java.util.concurrent.Executors;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

@Configuration
public class VirtualThreadConfig {
   
// ...
   
@Bean(name = "taskExecutor")
   
public Executor taskExecutor() {
       
return Executors.newVirtualThreadPerTaskExecutor();
   }
}

Validation

Input validation is crucial for data integrity and security. The application uses jakarta.validation annotations (e.g., @NotBlank, @Positive, @Pattern) on DTOs. Custom validators like PaymentValidator and constants for messages are also defined.

Using @Validated on a controller is an excellent practice for validating incoming path variables and request parameters, ensuring that invalid data is rejected at the earliest possible moment.

While @Valid is used to trigger validation on complex objects in the request body, @Validated is a Spring-specific annotation that enables method-level validation for simple parameters.

Here’s how we can implement it in the myfintech-payment-service project.

Step 1: Add @Validated to the Controller

First, we need to add the @Validated annotation at the class level on the PaymentController. This tells Spring to scan the methods within this controller for parameter-level constraint annotations.

Step 2: Add Constraints to the Method Parameters

Now, we can add standard Jakarta Validation constraints directly to the @PathVariable or @RequestParam parameters. For example, let's say we have an endpoint to fetch payments by a contract number. we can add constraints to ensure the contract number isn't blank.

Step 3: Handle Validation Errors

When a validation like this fails, Spring will throw a ConstraintViolationException. we need to add a handler for this exception in our GlobalExceptionHandler to return a clean 400 Bad Request response instead of a generic 500 Internal Server Error.

I have created a new document below that contains the complete, updated code for both  PaymentController and GlobalExceptionHandler to implement this feature.

How It Works

  • @Validated on the Class: This annotation on PaymentController activates Spring's method validation proxy.
  • Constraints on Parameters: we can now use any standard validation annotation (like @NotBlank, @Size, @Min, @Max, @Pattern, etc.) directly on the method parameters.
  • Exception Handling: If we make an API call with an invalid parameter (e.g., GET /api/v1/payments/contract/ with a blank contract number), Spring will intercept the call before a method body is even executed. It will throw a ConstraintViolationException.
  • User-Friendly Response:  GlobalExceptionHandler catches this specific exception and transforms it into a clean JSON response with a 400 Bad Request status, clearly telling the API consumer what they did wrong.

Global Exception Handling

GlobalExceptionHandler provides centralized error handling for various exceptions, returning consistent ProblemDetail responses.

Pagination

API endpoints that return collections of resources support pagination and sorting using Spring Data JPA's Pageable interface. Default pagination settings are configured in application.yaml:

YAML

spring:
 
data:
   
web:
     
pageable:
       
default-page-size: 10
       
max-page-size: 100
       
one-indexed-parameters: true

JAXB Context Pooling

To optimize performance for XML parsing, JAXBContextPool is implemented using Apache Commons Pool, as JAXB context creation is an expensive operation. This pool reuses Unmarshaller and Marshaller instances.

JAXBContext Pooling Implementation

I've created a complete JAXBContext pooling solution for  XML parser. Here's what I've provided:

  1. JAXBContextPool Component
  • Thread-safe pooling: Uses Apache Commons Pool2 for robust object pooling
  • Separate pools: Maintains separate pools for Unmarshaller and Marshaller instances
  • Context caching: JAXBContext instances are cached and reused (they're thread-safe)
  • Automatic cleanup: Pools are properly closed on application shutdown
  1. Optimized XmlPaymentFileParser
  • Uses the pool to borrow/return Unmarshaller instances
  • Proper resource management with try-finally blocks
  • Enhanced error handling and logging
  1. Key Benefits
  • Performance: JAXBContext creation is expensive (can take 100-500ms); pooling eliminates this overhead
  • Memory efficiency: Reuses objects instead of creating new ones
  • Scalability: Configurable pool sizes to handle concurrent requests
  • Thread safety: Pool handles concurrent access properly
  1. Configuration Options
  • maxTotal: Maximum objects in pool (default: 20)
  • maxIdle: Maximum idle objects (default: 10)
  • minIdle: Minimum idle objects maintained (default: 2)
  • maxWait: Maximum wait time when borrowing (default: 5 seconds)
  • testOnBorrow/Return: Validates objects (default: true)
  1. Performance Improvements
  • Without pooling: ~200-500ms per parse (mostly JAXBContext creation)
  • With pooling: ~5-20ms per parse (just unmarshalling)
  • 10-50x performance improvement for XML parsing operations
  1. Additional Considerations
  • Monitor pool metrics in production (pool exhaustion, wait times)
  • Adjust pool sizes based on load patterns
  • Consider adding JMX monitoring for pool statistics
  • The pool gracefully handles errors and invalid objects

This implementation will significantly improve the performance of  XML payment file parsing, especially under load where multiple files are processed concurrently.

Virtual Threads Best Practices for Spring Boot 3.5

Prerequisites

  • Java 21 or higher (Virtual threads are a preview feature in Java 19-20, stable in Java 21+)
  • Spring Boot 3.2 or higher (Full virtual thread support)

Configuration Approach

  1. @Configuration class for Global configuration: Logs related to that
    https://raw.githubusercontent.com/dhanuka84/myfintech-payment-service/refs/heads/main/src/main/java/org/myfintech/payment/config/VirtualThreadConfig.java


025-07-16T01:21:39.728+02:00 DEBUG 920477 --- [      virtual-67] o.s.orm.jpa.JpaTransactionManager         : Creating new transaction with name [org.myfintech.payment.service.impl.ContractServiceImpl.findAllByContractNumbers]: PROPAGATION_REQUIRED,ISOLATION_DEFAULT,readOnly


  1. Simple Configuration


YAML
spring:
 
threads:
   
virtual:
     
enabled: true

This single property enables virtual threads for:

  • @Async method execution
  • Default task executors

we have two threads because:

  • tomcat-handler-7: Handles the HTTP request
  • task-2: Executes  @Async method (saveAsynch)

 code flow:HTTP Request → Controller (tomcat-handler-7) → @Async method → New Thread (task-2)

  1. JVM Arguments


Bash
# Run with optimal virtual thread settings
java -jar myapp.jar \
 --enable-preview \
# Only needed for Java 19-20
 -Djdk.virtualThreadScheduler.parallelism=<number> \
 -Djdk.virtualThreadScheduler.maxPoolSize=<number>

When to Use Virtual Threads

✅ Ideal Use Cases

  • I/O-bound operations
  • Database queries
  • REST API calls
  • File operations
  • Network communication
  • High-concurrency scenarios
  • Handling thousands of concurrent requests
  • Microservice communication
  • Event processing
  • Blocking operations
  • Thread.sleep()
  • Synchronous external service calls
  • Waiting for resources

❌ Avoid Virtual Threads For

  • CPU-intensive tasks
  • Complex calculations
  • Data processing algorithms
  • Image/video processing
  • ThreadLocal-heavy code
  • Virtual threads create many thread instances
  • ThreadLocal can cause memory issues
  • Synchronized blocks with high contention
  • Can pin virtual threads to platform threads
  • Reduces virtual thread benefits

Performance Considerations

  • Memory Usage

  • Virtual threads use ~1KB of memory (vs ~1MB for platform threads)
  • Can create millions of virtual threads
  • Monitor heap usage with many concurrent threads

  • Thread Pinning

  • Virtual threads get "pinned" to platform threads when:
  • Executing synchronized blocks
  • Calling native methods
  • Using Thread.currentThread() extensively
  • Solution: Replace synchronized with ReentrantLock:

Java
// Instead of this:
synchronized(lock) {
   
// code
}

// Use this:
private final ReentrantLock lock = new ReentrantLock();
lock.lock();
try {
   
// code
}
finally {
   lock.unlock();
}

Monitoring Virtual Threads

  1. JVM Metrics


Properties
# Add to application.properties
management.metrics.enable.jvm.threads=true
management.endpoint.threaddump.enabled=true


  1. Custom Metrics


Java
@Component
public class VirtualThreadMetrics {
   
private final MeterRegistry meterRegistry;

   
@EventListener(ApplicationReadyEvent.class)
   
public void registerMetrics() {
       Gauge.builder(
"virtual.threads.count",
           () -> Thread.getAllStackTraces().keySet().stream()
               .filter(Thread::isVirtual)
               .count())
           .register(meterRegistry);
   }
}

  1. Logging


YAML
logging:
 
level:
   
jdk.internal.vm.Continuation: DEBUG
   
java.lang.VirtualThread: DEBUG

Database Connection Pools

Virtual threads don't eliminate the need for connection pools:

YAML

spring:
 
datasource:
   
hikari:
     
maximum-pool-size: 20  # Keep reasonable
     
minimum-idle: 10
     
# Don't set too high - database has connection limits

Migration Strategy

  • Phase 1: Enable for tasks


YAML


spring:
 
threads:
   
virtual:
     
enabled: true


  • Phase 2: Monitor Performance

  • Watch response times
  • Monitor memory usage
  • Check thread count metrics

  • Phase 3: Optimize

  • Identify pinning issues
  • Replace synchronized blocks
  • Tune thread pool sizes

Common Issues and Solutions

  1. OutOfMemoryError with ThreadLocal


Java
// Problem: ThreadLocal with virtual threads
private static final ThreadLocal<LargeObject> threadLocal = new ThreadLocal<>();
// Solution: Use scoped values (Java 21+) or pass explicitly
private static final ScopedValue<LargeObject> scopedValue = ScopedValue.newInstance();


  1. Deadlocks with synchronized


Java
// Problem: Nested synchronized blocks
synchronized(lock1) {
   
synchronized(lock2) { } // Can cause issues
}
// Solution: Use Lock API with tryLock
if (lock1.tryLock(1, TimeUnit.SECONDS)) {
   
try {
       
// code
   }
finally {
       lock1.unlock();
   }
}

  1. Performance Degradation

  • Check for thread pinning
  • Monitor GC pressure
  • Verify I/O vs CPU workload ratio

Recommendations for Payment Service

For  payment service, virtual threads are excellent for:

  • File upload processing - I/O bound
  • Database operations - Blocking I/O
  • External API calls - Network I/O

8. Testing

The application includes comprehensive tests:

  • Unit Tests: Located in src/test/java/org/myfintech/payment/service/ (e.g., ClientServiceImplTest, PaymentServiceImplTest, ContractServiceImplTest) and src/test/java/org/myfintech/payment/api/v1/ (e.g., PaymentControllerTest). These tests use JUnit 5 and Mockito.
  • Test Resources: Sample CSV and XML payment files (payment.csv and payment.xml) are provided for testing file upload functionality.

9. Build and Deployment

Maven Build

The project can be built and run using Maven commands:

  • mvn clean install - Builds the project and runs tests.
  • mvn spring-boot:run - Runs the application.

Code coverage reports can be generated using JaCoCo and found at target/site/jacoco/index.html.

10. API Documentation

The service includes OpenAPI/Swagger UI for interactive API documentation.

  • The OpenAPI specification is available at http://localhost:8080/api-docs.yaml.
  • The Swagger UI for exploring the APIs is accessible at http://localhost:8080/swagger-ui.html.

No comments:

Post a Comment