Blog › ICP guides

Java developer on retainer: Spring Boot architecture, JVM performance, virtual threads, and reactive programming on monthly retainer

August 15, 2026 · ~20 min read

A logistics platform processing 50,000 orders per day had a Java 11 microservice that had been accumulating technical debt for two years. The service processed each order by fetching the order from PostgreSQL (one query), then fetching each order item in a loop (N queries for N items), then fetching the product details for each item (another N queries), and finally fetching the shipping rules for each product category (up to N more queries). For a batch of 200 orders with an average of 5 items each, this pattern produced over 2,000 database queries per batch. The connection pool was exhausted under load, and the P99 order processing time was 8.4 seconds.

A fractional Java architect on monthly retainer started with SQL logging: enabling Hibernate’s statistics via spring.jpa.properties.hibernate.generate_statistics=true and logging with org.hibernate.stat confirmed 2,200 queries per batch. The fix required rewriting the JPQL queries with JOIN FETCH for items and products, adding an @EntityGraph for the shipping rules (which had a many-to-many relationship that JOIN FETCH could not handle without cartesian product), and batching the shipping rule lookup with an IN clause across the unique category IDs in the batch rather than querying per item. The result: 4 queries per batch regardless of order count. The P99 processing time dropped from 8.4 seconds to 280 milliseconds.

A second engagement addressed a GC pause spike that appeared every 4 hours: GC log analysis revealed an Old Generation filling cycle driven by a static cache that accumulated product metadata entries without eviction, growing to 2.1GB over 4 hours before triggering a full GC with 4-second stop-the-world pauses. Replacing the unbounded static map with a Caffeine cache with size and time-based eviction eliminated the pause pattern entirely.

Java developers, Java architects, and Java consultants on monthly retainer — fractional Java engineers, Spring Boot consultants, and JVM performance advisors — do their highest-value work in the Spring Boot architecture, JVM GC tuning, virtual thread adoption, and Project Reactor design that produces the reliable, high-throughput backend the CTO reports on to the board. This guide covers Spring Boot architecture in depth, JVM internals and performance tuning, Project Loom virtual threads, Project Reactor reactive programming, and Gradle build governance — and how to structure a Java developer retainer that makes the hours behind each investigation visible.

Spring Boot architecture

Spring Boot’s auto-configuration model and the Spring ecosystem’s breadth mean that a Java architect spends significant retainer hours on design decisions that are invisible in the business feature delivered: transaction propagation, security filter chain order, JPA fetch strategy, and event-driven design all produce no user-visible artifact but determine whether the system is correct under concurrent load.

Auto-configuration and @SpringBootApplication

@SpringBootApplication is a composed annotation that combines @SpringBootConfiguration, @EnableAutoConfiguration, and @ComponentScan. Auto-configuration works by scanning the classpath for specific jars and conditionally applying configuration classes using @ConditionalOnClass, @ConditionalOnMissingBean, and @ConditionalOnProperty. Understanding the auto-configuration order is essential for debugging why a bean you expect is not being applied, or why your custom bean is being overridden.

// Inspect which auto-configurations are applied and why:
// Run with --debug flag or set logging.level.org.springframework.boot.autoconfigure=DEBUG

// Custom auto-configuration in a library:
@AutoConfiguration
@ConditionalOnClass(DataSource.class)
@ConditionalOnProperty(prefix = "mylib.datasource", name = "enabled", havingValue = "true")
public class MyLibDataSourceAutoConfiguration {

    @Bean
    @ConditionalOnMissingBean  // user can override by declaring their own bean
    public MyDataSourceWrapper myDataSourceWrapper(DataSource dataSource) {
        return new MyDataSourceWrapper(dataSource);
    }
}
// Register in META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports

Spring Security OAuth2 Resource Server

@Configuration
@EnableWebSecurity
@EnableMethodSecurity  // enables @PreAuthorize, @PostAuthorize
public class SecurityConfig {

    @Bean
    public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
        return http
            .csrf(csrf -> csrf.disable())  // stateless API — no CSRF needed
            .sessionManagement(session ->
                session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
            .authorizeHttpRequests(auth -> auth
                .requestMatchers("/actuator/health", "/api/v1/auth/**").permitAll()
                .requestMatchers(HttpMethod.GET, "/api/v1/products/**").hasAnyRole("USER", "ADMIN")
                .requestMatchers("/api/v1/admin/**").hasRole("ADMIN")
                .anyRequest().authenticated()
            )
            .oauth2ResourceServer(oauth2 -> oauth2
                .jwt(jwt -> jwt.jwtAuthenticationConverter(jwtAuthConverter()))
            )
            .build();
    }

    @Bean
    public JwtAuthenticationConverter jwtAuthConverter() {
        var converter = new JwtGrantedAuthoritiesConverter();
        converter.setAuthoritiesClaimName("roles");       // claim name in JWT
        converter.setAuthorityPrefix("ROLE_");            // Spring Security prefix

        var authConverter = new JwtAuthenticationConverter();
        authConverter.setJwtGrantedAuthoritiesConverter(converter);
        return authConverter;
    }
}

// Method-level authorization with SpEL:
@Service
public class OrderService {
    @PreAuthorize("hasRole('ADMIN') or #userId == authentication.name")
    public Order getOrder(String orderId, String userId) { /* ... */ }

    @PostAuthorize("returnObject.ownerId == authentication.name")
    public Order getOrderById(String orderId) { /* ... */ }
}

JPA N+1 prevention and transaction design

// N+1 problem: lazy loading triggers one query per order item:
@Entity
public class Order {
    @OneToMany(fetch = FetchType.LAZY)  // each access triggers a query
    private List<OrderItem> items;
}

// Fix 1: JOIN FETCH in JPQL:
@Query("SELECT o FROM Order o JOIN FETCH o.items i JOIN FETCH i.product WHERE o.id = :id")
Optional<Order> findByIdWithItemsAndProducts(@Param("id") String id);

// Fix 2: @EntityGraph for complex associations (avoids cartesian product from multiple JOINs):
@EntityGraph(attributePaths = {"items", "items.product", "shippingAddress"})
Optional<Order> findWithGraphById(String id);

// Transaction propagation — key design decisions:
@Service
@Transactional  // REQUIRED by default — joins existing or creates new
public class OrderService {

    @Transactional(propagation = Propagation.REQUIRES_NEW)
    public void recordAuditEvent(AuditEvent event) {
        // Runs in its own transaction — commits even if outer tx rolls back.
        // Use for audit logging, outbox events, and notifications that must
        // not be lost even when the business transaction fails.
        auditRepository.save(event);
    }

    @Transactional(propagation = Propagation.NESTED)
    public void attemptInventoryReservation(OrderItem item) {
        // Savepoint-based: rolls back to savepoint on failure,
        // not the entire outer transaction. Use for partial failure handling.
        inventoryRepository.reserve(item.getProductId(), item.getQty());
    }
}

// Domain event publishing — decouples business logic from side effects:
@Service
@Transactional
public class OrderService {
    private final ApplicationEventPublisher eventPublisher;

    public Order placeOrder(PlaceOrderCommand command) {
        var order = Order.place(command);
        orderRepository.save(order);
        // Event published AFTER transaction commits (using @TransactionalEventListener):
        eventPublisher.publishEvent(new OrderPlacedEvent(order.getId()));
        return order;
    }
}

@Component
public class OrderNotificationListener {
    @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
    public void onOrderPlaced(OrderPlacedEvent event) {
        // Only called if the transaction committed — no spurious notifications.
        notificationService.sendOrderConfirmation(event.orderId());
    }
}

JVM internals and performance tuning

JVM performance tuning is the Java platform work that most visibly requires a specialist: GC algorithm selection, heap sizing, and JIT compilation behavior each require deep platform knowledge and are invisible in the application code diff. The output of a GC tuning engagement is a set of JVM flags and a GC log analysis report, not a code commit.

GC algorithm selection: G1GC, ZGC, and Shenandoah

G1GC is the default since Java 9 and is appropriate for most applications: it balances throughput and latency, adapts its region sizes and GC frequency to the application’s allocation rate, and pauses the application for less than 200ms in most configurations. ZGC (production since Java 15) and Shenandoah (upstream Java since Java 15) are concurrent GC algorithms that perform most GC work concurrently with the application, achieving sub-millisecond pause times at the cost of slightly higher CPU overhead and reduced throughput. They are appropriate for latency-sensitive applications (real-time APIs, trading systems, interactive user-facing services) where P99 pause times must be below 10ms.

# Enable detailed GC logging for analysis:
-Xlog:gc*:file=/var/log/app/gc.log:time,uptime,level,tags:filecount=5,filesize=20m

# G1GC tuning — most applications:
-XX:+UseG1GC
-XX:MaxGCPauseMillis=200      # target pause (G1 adjusts region sizes to meet this)
-XX:G1HeapRegionSize=16m      # larger regions reduce region count overhead for big heaps
-XX:InitiatingHeapOccupancyPercent=35  # start concurrent GC earlier to avoid full GC

# ZGC — latency-critical applications (Java 15+):
-XX:+UseZGC
-XX:SoftMaxHeapSize=6g        # ZGC may use more heap than this but GC more aggressively
-XX:ZCollectionInterval=300   # force GC at least every 5 minutes to prevent heap bloat

# Shenandoah — latency-critical applications:
-XX:+UseShenandoahGC
-XX:ShenandoahGCHeuristics=adaptive   # adaptive (default), static, compact, aggressive

# Heap sizing — containerized environments:
-XX:InitialRAMPercentage=50.0  # start at 50% of container memory limit
-XX:MaxRAMPercentage=75.0      # max at 75% (leave room for native memory, thread stacks)
# Avoid -Xmx and -Xms in containers — they use absolute values and ignore cgroup limits

JMH microbenchmarks

import org.openjdk.jmh.annotations.*;
import org.openjdk.jmh.infra.Blackhole;

@BenchmarkMode(Mode.AverageTime)
@OutputTimeUnit(TimeUnit.MICROSECONDS)
@State(Scope.Benchmark)
@Fork(value = 2, jvmArgs = {"-Xms4g", "-Xmx4g"})
@Warmup(iterations = 5, time = 1)
@Measurement(iterations = 10, time = 1)
public class OrderSerializationBenchmark {

    private Order sampleOrder;
    private ObjectMapper objectMapper;
    private JsonbSerializer jsonbSerializer;

    @Setup
    public void setup() {
        sampleOrder = OrderFixture.createOrder(20); // 20 items
        objectMapper = new ObjectMapper();
        jsonbSerializer = new JsonbSerializer();
    }

    @Benchmark
    public void jacksonSerialization(Blackhole bh) throws Exception {
        bh.consume(objectMapper.writeValueAsBytes(sampleOrder));
    }

    @Benchmark
    public void jsonbSerialization(Blackhole bh) {
        bh.consume(jsonbSerializer.serialize(sampleOrder));
    }

    // Run: mvn clean package -P benchmark
    //      java -jar target/benchmarks.jar OrderSerializationBenchmark
    // Use Blackhole to prevent JIT from eliminating the computation (dead code elim).
}

Project Loom: virtual threads and structured concurrency

Project Loom, production-ready since Java 21, introduces virtual threads (also called green threads or fibers): lightweight threads managed by the JVM rather than the OS, costing ~1KB of memory each rather than ~1MB for platform threads. Virtual threads make blocking I/O cheap: when a virtual thread blocks on a database query, file read, or HTTP call, the JVM suspends the virtual thread and parks the OS carrier thread to serve other virtual threads. This eliminates the thread pool sizing problem for I/O-bound workloads.

Virtual thread adoption

// Spring Boot 3.2+: enable virtual threads for Tomcat and @Async:
// application.properties:
// spring.threads.virtual.enabled=true

// Manual virtual thread executor:
import java.util.concurrent.Executors;

var executor = Executors.newVirtualThreadPerTaskExecutor();
executor.submit(() -> {
    // Blocking I/O on a virtual thread — does NOT block an OS thread:
    var result = jdbcTemplate.queryForObject("SELECT ...", String.class);
    return result;
});

// @Async with virtual threads (Spring Boot 3.2+):
@Configuration
public class AsyncConfig {
    @Bean
    public Executor asyncExecutor() {
        return Executors.newVirtualThreadPerTaskExecutor();
    }
}

@Service
public class ReportService {
    @Async
    public CompletableFuture<Report> generateReport(String reportId) {
        // Runs on a virtual thread — safe to block:
        var data = expensiveJdbcQuery(reportId);
        return CompletableFuture.completedFuture(buildReport(data));
    }
}

Structured concurrency and carrier thread pinning

// Structured concurrency — fan-out and fan-in with automatic cancellation:
import java.util.concurrent.StructuredTaskScope;

public OrderSummary buildOrderSummary(String orderId) throws Exception {
    try (var scope = new StructuredTaskScope.ShutdownOnFailure()) {
        // Fork concurrent tasks:
        var orderTask    = scope.fork(() -> orderRepo.findById(orderId));
        var invoiceTask  = scope.fork(() -> invoiceService.getLatest(orderId));
        var shippingTask = scope.fork(() -> shippingService.getStatus(orderId));

        scope.join()           // wait for all tasks
             .throwIfFailed(); // propagate first failure

        // All tasks succeeded — access results:
        return new OrderSummary(orderTask.get(), invoiceTask.get(), shippingTask.get());
        // If any task fails, scope cancels the others — no leaked threads.
    }
}

// Carrier thread pinning — virtual threads are PINNED (cannot unmount from carrier)
// when they hold a monitor (synchronized block or synchronized method) and block.
// This converts a virtual thread into a platform thread for the blocking duration.
// Diagnosis: -Djdk.tracePinnedThreads=full logs pinning events to stdout.

// PINNING problem:
public synchronized void processWithLock() {
    blockingJdbcCall(); // pins the carrier thread while holding the monitor
}

// Fix: replace synchronized with ReentrantLock (supports virtual thread unmounting):
private final ReentrantLock lock = new ReentrantLock();

public void processWithLock() {
    lock.lock();
    try {
        blockingJdbcCall(); // virtual thread can unmount while waiting
    } finally {
        lock.unlock();
    }
}

Project Reactor: reactive programming

Project Reactor is the reactive library underlying Spring WebFlux. It provides Mono (0 or 1 item) and Flux (0 to N items) as composable, lazy, push-based streams. Reactive programming with Reactor is appropriate for I/O-bound workloads where back-pressure (the consumer signaling the producer to slow down) is a genuine requirement, and inappropriate for CPU-bound workloads where it adds complexity without benefit. With Project Loom virtual threads now available in Java 21+, the use case for WebFlux has narrowed to scenarios requiring streaming backpressure or WebSocket-based real-time communication.

Mono and Flux operators

import reactor.core.publisher.Mono;
import reactor.core.publisher.Flux;
import reactor.core.scheduler.Schedulers;

// Mono composition:
Mono<Order> getEnrichedOrder(String orderId) {
    return orderRepository.findById(orderId)          // Mono<Order>
        .switchIfEmpty(Mono.error(new OrderNotFoundException(orderId)))
        .flatMap(order -> productService               // Mono<EnrichedOrder>
            .getProducts(order.getProductIds())        // Mono<List<Product>>
            .map(products -> order.enrich(products))
        )
        .onErrorMap(DataAccessException.class,
            ex -> new ServiceUnavailableException("database", ex))
        .timeout(Duration.ofSeconds(3))
        .retryWhen(Retry.fixedDelay(2, Duration.ofMillis(500))
            .filter(ex -> ex instanceof ServiceUnavailableException));
}

// Flux with backpressure:
Flux<Report> generateReports(Flux<String> reportIds) {
    return reportIds
        .flatMap(id -> reportService.generate(id), 8)  // max 8 concurrent
        .onBackpressureBuffer(100,                      // buffer up to 100
            dropped -> log.warn("Report dropped: {}", dropped),
            BufferOverflowStrategy.DROP_OLDEST)
        .publishOn(Schedulers.boundedElastic());         // offload to blocking executor
}

// StepVerifier — unit testing reactive chains:
import reactor.test.StepVerifier;

@Test
void testGetEnrichedOrder() {
    StepVerifier.create(getEnrichedOrder("order-123"))
        .assertNext(order -> {
            assertThat(order.getId()).isEqualTo("order-123");
            assertThat(order.getProducts()).isNotEmpty();
        })
        .verifyComplete();
}

@Test
void testOrderNotFound() {
    StepVerifier.create(getEnrichedOrder("nonexistent"))
        .expectError(OrderNotFoundException.class)
        .verify();
}

Gradle and Maven build governance

Build system governance is the Java platform work that is most invisible to business stakeholders and most impactful for long-term development velocity. A Gradle build that takes 8 minutes provides slower feedback than one that takes 90 seconds; a Maven build without dependency convergence enforcement silently allows different versions of the same library to coexist, creating classpath conflicts that surface as runtime errors. A Java architect on retainer addresses these problems systematically.

Gradle multi-project and version catalog

# gradle/libs.versions.toml — single source of truth for all dependency versions:
[versions]
spring-boot   = "3.3.2"
spring-cloud  = "2023.0.3"
jackson       = "2.17.2"
testcontainers = "1.20.1"

[libraries]
spring-boot-starter-web = { module = "org.springframework.boot:spring-boot-starter-web", version.ref = "spring-boot" }
spring-boot-starter-data-jpa = { module = "org.springframework.boot:spring-boot-starter-data-jpa", version.ref = "spring-boot" }
jackson-databind = { module = "com.fasterxml.jackson.core:jackson-databind", version.ref = "jackson" }
testcontainers-postgresql = { module = "org.testcontainers:postgresql", version.ref = "testcontainers" }

[bundles]
spring-web = ["spring-boot-starter-web", "jackson-databind"]  # common groupings

[plugins]
spring-boot = { id = "org.springframework.boot", version.ref = "spring-boot" }
dependency-check = { id = "org.owasp.dependencycheck", version = "10.0.3" }

# In subproject build.gradle.kts:
dependencies {
    implementation(libs.spring.boot.starter.web)
    implementation(libs.bundles.spring.web)
    testImplementation(libs.testcontainers.postgresql)
}
// OWASP dependency vulnerability scan:
// build.gradle.kts:
plugins { alias(libs.plugins.dependency.check) }
dependencyCheck {
    failBuildOnCVSS = 7.0f   // fail on High or Critical CVEs
    formats = listOf("HTML", "JSON")
    suppressionFile = "dependency-check-suppression.xml"
}
// Run: ./gradlew dependencyCheckAnalyze

// GraalVM native image configuration:
// build.gradle.kts:
plugins {
    id("org.graalvm.buildtools.native") version "0.10.2"
}
graalvmNative {
    binaries {
        named("main") {
            imageName.set("order-service")
            buildArgs.addAll(
                "--no-fallback",
                "-H:+ReportExceptionStackTraces",
                "--initialize-at-build-time=org.slf4j.LoggerFactory"
            )
        }
    }
}
// Run: ./gradlew nativeCompile (requires GraalVM JDK)

Logging Java retainer hours so clients understand the work

Java retainer work is invisible in the same way that all platform engineering work is invisible: a JVM GC tuning engagement that eliminates 4-second stop-the-world pauses produces no new feature, no new endpoint, and no visible change in the business logic. A JPA N+1 elimination that reduces database queries per request from 200 to 4 produces a faster API but no new API. A virtual thread migration that doubles throughput at the same infrastructure cost produces no change the product manager can see in the feature list.

The work log entry is what connects the invisible Java platform work to its concrete business outcome. A good entry captures: the advisory category (GC tuning, JPA optimization, virtual thread migration, Spring Security configuration, Project Reactor design, Gradle build governance, JMH benchmarking), the specific service or module being worked on, the task performed, the GC log or Hibernate statistics finding, the implementation approach, and the measured outcome.

HourTab turns this structured work log into a public retainer URL that the client can bookmark — a live view of hours logged, progress against the monthly allocation, and the work summaries behind each line. When the client asks “what has our Java architect been doing this month?”, the HourTab URL answers with the GC log analysis, the N+1 query findings, and the throughput improvements, without requiring a status call.

Retainer structure for Java developer engagements

A Java developer retainer typically covers four functional areas: feature development (new Spring Boot endpoints, new JPA entities and repositories, new Spring Security configurations), JVM performance advisory (GC tuning, allocation profiling, JMH benchmarking), concurrent programming migration (virtual thread adoption, structured concurrency, ThreadPoolTaskExecutor sizing), and build governance (Gradle or Maven dependency management, OWASP vulnerability scanning, Testcontainers integration test infrastructure). Each area should have its own hour allocation in the retainer agreement.

Monthly retainer amounts for Java developer advisory and architecture consulting typically range from $5,000 to $10,000 per month for backend architecture advisory retainers (15 to 30 hours per month at mid-to-senior rates), increasing to $11,000 to $24,000 per month for full-stack Java and Spring architecture consulting engagements (30 to 60 hours per month) covering JVM tuning, virtual thread adoption, reactive programming, Spring Security hardening, and Gradle build governance.

The retainer pays for itself when it prevents a single production incident: a codebase that grows for 18 months without JPA query review typically has 20 to 40 N+1 patterns that collectively drive database CPU to 90% under load — the kind of incident that triggers emergency weekend work. Monthly retainer advisory prevents that accumulation.


HourTab is a public retainer dashboard for freelance Java developers and Java consulting firms. Upload your time-tracker CSV and get a shareable URL your client can bookmark — a live view of hours logged, remaining allocation, and work log summaries. No client login, no portal. Try it free with one active retainer.