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:
- 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
- Optimized XmlPaymentFileParser
- Uses the pool to borrow/return Unmarshaller instances
- Proper resource management with try-finally blocks
- Enhanced error handling and logging
- 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
- 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)
- 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
- 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
- @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
- 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)
- 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
- JVM Metrics
Properties
# Add to application.properties
management.metrics.enable.jvm.threads=true
management.endpoint.threaddump.enabled=true
- 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);
}
}
- 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
- 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();
- 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();
}
}
- 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.