Manually Analyze JFR Files to Find Optimization Targets

 

A systematic guide to reading Java Flight Recorder dumps and turning the data into faster startup, lower memory pressure, and higher throughput.


When your Java application takes too long to start or burns too much CPU under load, intuition is a poor compass. Guessing at bottlenecks — "maybe it's the database queries," "maybe we have too many objects to wire up" — leads to wasted effort. You need data.

A profiler is a tool that records what a program is actually doing while it runs. Unlike logs, which tell you only what your application chose to report, a profiler observes the JVM itself — where CPU time goes, what objects get allocated, which methods get compiled, where threads wait, and how memory changes over time. Java Flight Recorder (JFR) is the JVM's built-in profiler, designed to run with very low overhead (typically well under 1–2% with default settings, and still modest with the fuller profile configuration). It captures an extraordinary breadth of telemetry: CPU sampling, allocation profiling, GC behavior, lock contention, I/O activity, class loading, JIT compilation — all from a single recording file. A single .jfr file is a rich forensic trace of what your JVM did during the recording window.

But a JFR file is not a report. It's a dense, binary event log containing over a hundred built-in event types. The skill is knowing which events to extract, how to interpret them, and what optimization they point to.

This post walks through a manual, systematic methodology for analyzing a JFR recording. You can follow along using the JDK's built-in jfr CLI, JDK Mission Control (JMC), or an IDE profiler such as IntelliJ's. The goal is not just to run a tool, but to understand what you're looking at and what to do about it.


1. Capturing a Useful JFR Recording

Before you can analyze anything, you need a good recording. The default JFR configuration captures a minimal set of events. For startup or performance analysis, use the profile setting, which adds allocation sampling and other detail on top of the default:

# Attach to a running JVM
jcmd <PID> JFR.start settings=profile name=startup-analysis

# Wait for the event you care about (startup, request burst, etc.)
# then stop and dump to disk:
jcmd <PID> JFR.stop name=startup-analysis filename=recording.jfr

For startup analysis specifically, pass JFR flags directly to the JVM so recording begins at launch:

java -XX:StartFlightRecording=settings=profile,filename=startup.jfr \
     -jar my-application.jar

Key principle: Record exactly the window you care about. A 30-second recording during startup is far more actionable than a 5-minute recording that mixes startup, steady-state, and idle periods. The metrics become averages over too many phases, and the signal washes out.

A note on warmup: during startup the JVM is doing several expensive things at once — loading and linking classes, compiling hot methods, allocating the objects that make up your object graph, and initializing caches and connection pools. All of that activity is concentrated into a short window, which is exactly why startup recordings look so different from steady-state recordings: heavier class-loading and compilation cost, a burst of allocation, and comparatively little of the request-handling activity you'd see later. Don't be surprised if a startup profile and a steady-state profile of the same application barely resemble each other — they're capturing different phases of the JVM's life, and both are "correct."


2. The Analysis Framework: 12 Dimensions

A JFR recording spans several distinct dimensions of JVM behavior. Each answers a different question about your application. Rather than jumping between them haphazardly, work through them in this order — each section's findings inform the next:

# Dimension Key Question
1 CPU Hotspots Where is CPU time actually being spent?
2 Memory Allocation What objects are being created, and from where?
3 GC / Heap Timeline How is the heap growing, and how much time is spent collecting?
4 GC Pause Times How long are stop-the-world pauses, and which GC phases cause them?
5 Class Loading How many classes are loaded, and is that excessive?
6 Thread Creation How many threads are spawned, and by which subsystems?
7 Lock Contention Where are threads blocking each other?
8 JIT Compilation How much CPU goes into compiling bytecode, and which methods cost the most?
9 Socket / File I/O How much I/O is happening, to what, and how long does it wait?
10 CPU Load Timeline Is the JVM CPU-bound, I/O-bound, or uncontested?
11 Thread Idle / Sleep Time Which threads are idle or waiting, and for how long?
12 Before/After Comparison Did your optimization actually help?

Let's walk through each dimension with concrete examples of what to extract, what patterns to look for, and what optimizations each pattern suggests.


3. Dimension 1: CPU Execution Hotspots

JFR does sampling profiling, not instrumentation. Instead of measuring every single method call — which would slow the application down considerably and change the very behavior you're trying to observe — the JVM periodically interrupts each running thread and records what it's doing. If a method shows up in a large share of the samples, it probably consumed a significant amount of CPU time; a method that never shows up may still have run, just not enough (or not at the right moments) to be caught.

Data source: jdk.ExecutionSample events. These are periodic thread samples that capture the full stack trace of a running thread. The sampling interval depends on your recording configuration and JDK version rather than a single fixed number — check the settings file (e.g., profile.jfc) or the recording metadata if you need the exact value for your run. Each sample counts as one "vote" — a method appearing in 500 samples was on-CPU for roughly 500 sampling periods, so treat the resulting time estimate as approximate, not exact.

A limitation worth knowing: sampling profilers can't reliably measure very short-lived methods, because a method can start and finish entirely between two samples and never get caught. A method with only a handful of samples in a large recording isn't necessarily unimportant — but it also isn't necessarily important. Don't over-interpret single-digit sample counts either way; focus your attention on methods with enough samples that the pattern is statistically meaningful.

What to Extract

Using the JDK's jfr CLI:

# Text mode gives full stack traces in a readable format
jfr print --events jdk.ExecutionSample recording.jfr

Each block looks like this:

jdk.ExecutionSample {
  sampledThread = "main" (javaThreadId = 1)
  state = "STATE_RUNNABLE"
  stackTrace = [
    java.lang.ClassLoader.defineClass1(ClassLoader.java:0)     ← LEAF (hottest)
    java.lang.ClassLoader.defineClass(ClassLoader.java:539)
    java.security.SecureClassLoader.defineClass(...)
    java.net.URLClassLoader.defineClass(...)
    java.net.URLClassLoader$1.run(...)
    ...
  ]
}

A quick glossary, since the rest of this post leans on this terminology constantly. A stack trace is the chain of method calls active at a single point in time — each individual call in that chain is a stack frame. Using a simplified example:

main()
 └── loadUsers()
      └── parseJson()
           └── StringBuilder.append()

The root frame is the entry point (main() here). The leaf frame is the innermost, most-recently-called method (StringBuilder.append() here) — it's the one that was actually executing, on the CPU, at the instant the sample was taken.

The leaf frame (index 0 in the JFR output) tells you what is burning CPU. The full stack tells you why — the call chain that led there. Both matter: the same leaf method can appear under very different stacks, and grouping by leaf-only can hide the fact that two unrelated code paths are converging on the same expensive call.

Patterns and What They Mean

Pattern A: ClassLoader.defineClass1 dominates the leaf samples

This means the JVM is spending a large share of its CPU defining classes — reading bytes from JAR files, parsing the classfile format, verifying bytecode, and linking into the runtime. On a cold startup of any JVM application with a non-trivial classpath, this can be a substantial fraction of samples, though the exact share varies widely by application size and JAR count.

Optimization: Use Class Data Sharing (CDS). CDS creates a shared archive containing preprocessed class metadata from a prior run. On later JVM starts, many of those classes can be mapped directly from the archive instead of being repeatedly read from JARs, parsed, verified, and linked from scratch — the expensive work happens once, when the archive is built, rather than on every single startup:

java -XX:ArchiveClassesAtExit=app-cds.jsa -jar my-app.jar
java -XX:SharedArchiveFile=app-cds.jsa -jar my-app.jar

This typically produces a meaningful reduction in class-loading CPU time. Treat any specific percentage as a rough expectation, not a guarantee — measure before/after on your own app (see Dimension 12).

Pattern B: Inflater.inflateBytes high in the stacks

This is JAR decompression overhead. Every JAR entry is typically compressed; reading a class means decompressing it first. The more JARs on your classpath, the more inflation CPU you pay.

Optimization: CDS helps here too — the shared archive stores classes in an already-processed form so they don't need to be decompressed and parsed again on every startup. Also consider trimming unused JARs from your classpath.

Pattern C: Classpath/annotation-scanning code appears prominently

If your application uses a dependency-injection or component-scanning framework (Spring, Micronaut, Quarkus/Jakarta CDI, Guice with classpath scanning, etc.), startup often involves walking the classpath looking for annotated types, reading .class files, and building object-graph or bean metadata from what it finds. In stack traces this typically shows up as framework-internal classpath-scanner or annotation-reader classes.

Optimization: Most DI frameworks offer a way to avoid runtime scanning — build-time indexing (e.g., Spring's context indexer generates a component index at compile time), ahead-of-time processing (Spring AOT, Micronaut and Quarkus both compute much of this at build time by design), or simply scoping the scan to a narrower set of packages instead of the whole classpath. Check your framework's documentation for its specific mechanism.

Pattern D: Constructor.newInstance or Method.invoke appear heavily

This is reflection-driven instantiation. DI containers, ORMs, and serialization libraries (Spring, Hibernate/JPA, Guice, Jackson, and others) commonly use reflection for object creation, proxy generation, and field/property access.

Optimization: Many frameworks offer reflection-reduced or reflection-free code paths for common cases (e.g., Spring AOT, Micronaut's compile-time DI, Quarkus's build-time processing). A more drastic option is GraalVM Native Image, which compiles your application ahead-of-time into a standalone native executable — this eliminates JIT compilation and most runtime class loading entirely, since nearly all of that work happens at build time instead of at every startup. The trade-off: build times are much longer, you generally give up the JIT's runtime, profile-guided optimizations (so peak steady-state throughput can be lower than a warmed-up JVM), and anything relying on dynamic behavior — reflection, dynamic proxies, resources loaded by name, JNI — needs to be explicitly declared in build-time configuration, since native image can't discover it at runtime the way the JVM can.

Pattern E: Deep framework call chains with the same root pattern across many leaf methods

Look at the full stack signatures, not just the leaf. If you see the same multi-frame prefix (e.g., DispatcherServlet → Interceptor → Controller → Service → Repository) with different leaves, the framework dispatch itself is a non-trivial, if normal, cost. Worth quantifying, rarely worth fighting.

Thread Distribution

Also check which threads accumulate samples:

# Group samples by thread name
jfr print --events jdk.ExecutionSample recording.jfr | \
  grep "sampledThread" | sort | uniq -c | sort -rn
  • main thread dominates → Startup analysis: the main thread is doing the heavy lifting (object graph construction, class loading, initialization).
  • http-nio-* (or equivalent) threads dominate → Steady-state analysis: your request-handling threads are the bottleneck.
  • C2 CompilerThread* appears heavily → JIT compilation is consuming significant CPU (see Dimension 8).

When NOT to Optimize

Not every hot method deserves your attention. Before spending time on something you found in a CPU profile, ask:

  • Is it on a path that actually matters? A method that's hot exclusively during a one-time startup sequence matters a lot less than one that's hot on every request.
  • Does it consume a meaningful share of samples? A method with a handful of samples out of tens of thousands is noise, not signal (see the sampling-bias note above).
  • Is it actually user-visible? Shaving milliseconds off something nobody waits on doesn't move any metric that matters.
  • Is it unavoidable framework overhead? Plenty of methods show up as "hot" simply because the framework you chose does real, necessary work on every call. Fighting that is often lower-leverage than fixing something in your own code, or accepting it as the cost of the framework.

It's easy to spend an afternoon optimizing the third-hottest method in a profile only to discover it accounted for 2% of total time. Chase the few things that materially move the numbers you actually care about — not everything the profiler happens to surface.


4. Dimension 2: Memory Allocation Hotspots

Data source: jdk.ObjectAllocationSample events (available from JDK 16 onward). The JVM samples object allocations, recording the type, an estimated size (weight), and the allocation-site stack trace. This is a statistical profile of allocation throughput, not a heap dump — it doesn't tell you what's still alive, only what's being created.

What to Extract

jfr print --events jdk.ObjectAllocationSample --json recording.jfr

The JSON output includes objectClass.name and weight (an estimate of bytes allocated) for each sample, plus stackTrace.frames showing the allocation site.

Patterns and What They Mean

Pattern A: byte[] is a large share of allocated bytes

This is extremely common in JVM applications. Almost everything allocates byte[]: reading class files from JARs, deserializing JSON/XML, reading files, building response buffers, processing request bodies. The question is not really whether byte[] shows up near the top, but from where.

To find the allocation sites:

# Filter allocation stacks to only byte[] allocations
jfr print --events jdk.ObjectAllocationSample --json recording.jfr | \
  python3 -c "
import json, sys
from collections import Counter
data = json.load(sys.stdin)
stacks = Counter()
for e in data['recording']['events']:
    v = e['values']
    cls = v.get('objectClass', {}).get('name', '')
    if 'byte' in str(cls):
        frames = v.get('stackTrace', {}).get('frames', [])
        sig = ' -> '.join(f['method']['type']['name'] + '.' + f['method']['name'] for f in frames[:4])
        stacks[sig] += v.get('weight', 0)
for sig, w in stacks.most_common(20):
    print(f'{w:>12,d}  {sig}')
"

Typical culprits and their fixes:

Allocation site Root cause Fix
Resource.getBytesClassLoader.defineClass Reading class bytes from JARs CDS (pre-loaded classes)
StreamDecoder.readBytesSocketChannel.read Network I/O buffers Defer or cache HTTP calls
ByteArrayOutputStream.write → Jackson/JSON Serialization Streaming/incremental serialization where feasible
ASM class-reading internals → ClassReader.accept Bytecode parsing Build-time/AOT processing where your framework supports it (reduces runtime parsing)

Pattern B: Framework internals dominate — annotation metadata, reflective Field/Method wrappers, proxy-related objects

These are your framework's internal representation of your object graph, annotations, and reflective access points, produced because the framework is doing runtime annotation processing and reflection to wire things up.

Optimization: Where available, build-time/AOT processing (offered in some form by most major DI frameworks) pre-computes much of this metadata instead of building it at runtime. CDS complements this by archiving the resulting classes and reducing re-parsing on subsequent runs.

Pattern C: Domain objects (your entities/DTOs) dominate

Your own data objects are the allocation leaders. This is application-level churn — consider:

  • Object pooling for frequently created/discarded objects (only where profiling justifies it — pooling has its own costs)
  • Reducing unnecessary DTO copies
  • Using primitive fields over boxed types where it matters
  • Caching instead of re-creating

The GC Connection

A quick clarification before going further, because it's a common source of confusion: allocation is not the same thing as memory usage. Allocation rate measures how much memory you create over time, regardless of how long any of it survives. Heap usage measures how much is currently retained. You can allocate 100MB of short-lived objects, immediately discard 99MB of it, and end up with only 1MB actually sitting in the heap — the allocation rate was 100MB, but the retained footprint was 1MB. Both numbers matter, but they answer different questions: allocation rate tells you how hard the GC has to work; heap usage tells you how large your working set actually is.

That connects directly to GC pressure: the amount of work the garbage collector has to do because the application keeps creating new objects. High allocation rates generally translate to high GC pressure — more frequent young-generation collections — even when the application's overall heap usage stays modest, because it's the rate of churn that drives collection frequency, not the retained size.

Putting the two together: allocation rate is a primary driver of young-generation GC frequency, so the more you allocate, the more often minor collections run. Reducing total allocation generally reduces GC pause time and frequency, though the relationship isn't perfectly linear — look at allocation hotspots first; GC improvements often follow.


5. Dimensions 3 & 4: GC / Heap Timeline and Pause Times

Data sources:

  • jdk.GCHeapSummary — periodic snapshots of heap usage
  • jdk.GCPhasePause — stop-the-world pause events, with phase-level detail

Not every GC-related event is enabled under every JFR configuration — the default recording is intentionally minimal. If these events come back empty, confirm you recorded with the profile settings template (or explicitly enabled the events you need), not the default one.

What to Extract

# Heap growth over time
jfr print --events jdk.GCHeapSummary --json recording.jfr

# GC pauses with phase breakdown
jfr print --events jdk.GCPhasePause --json recording.jfr

Reading the Heap Timeline

Plot heapUsed over startTime. The shape tells a story:

  • Rapid ramp-up then flat → Normal startup. The heap grows as beans, caches, and connection pools initialize, then stabilizes at steady-state.
  • Sawtooth pattern → Healthy GC. The heap grows between collections, then drops.
  • Ever-increasing with no drops → Possible memory leak or heap under sustained pressure. The heap grows because objects are outliving what you'd expect.
  • High peak usage relative to -Xmx → You're close to an OutOfMemoryError. Either increase the heap or reduce allocation.

Reading GC Pause Statistics

Aggregate then break down by phase:

Pause count:      45
Total pause time: 1,230 ms
Avg pause:        27.3 ms
Max pause:        180 ms
Pause % of recording: 4.1%

Rough rules of thumb (these vary by GC algorithm and workload, so treat as starting points, not thresholds): total pause time well under 5% of the recording, with only young-generation pauses, is generally healthy for most workloads. What's more concerning:

  • Total pause time climbing well past 5–10% → GC is struggling. Check allocation hotspots first (Dimension 2); reducing allocation is usually the cheapest way to reduce GC time.
  • Individual pauses well above your latency budget (e.g., over 100ms for a service targeting sub-200ms p95 latency) → At least one long stop-the-world event is impacting user-facing latency.
  • Any full/old-generation GC pause appears (naming depends on the collector — G1's Pause Full, or full/major cycles under other collectors) → The old generation is under pressure. This is typically the most expensive kind of collection. Investigate:
    • Long-lived objects that should be short-lived (caching too aggressively?)
    • Objects prematurely promoted to old gen (young gen too small relative to allocation rate?)
    • A genuine memory leak (unbounded cache, unclosed resources)

By-phase breakdown tells you which type of collection dominates:

Phase Indicates
Young-generation pauses dominate Heavy short-lived allocation churn (normal)
Full/old-gen pauses appear Old-gen pressure (investigate)
Remark/reference-processing phases are large Large heap + many references to scan

Optimization for young-gen dominance: Tune young generation sizing (e.g., -XX:NewRatio, or your collector's equivalent) so objects die young instead of being promoted, or reduce allocation (Dimension 2).

Optimization for old-gen/full GCs: Increase old generation headroom, tune promotion thresholds, or fix the underlying leak.

Note: the exact event and phase names above depend on which GC you're running (G1 is the default in modern JDKs; ZGC and Shenandoah expose different, mostly-concurrent phase events). Confirm the phase names in your own recording rather than assuming G1's naming.


6. Dimension 5: Class Loading

Data source: jdk.ClassLoadingStatistics — cumulative counters of loaded and unloaded classes.

What to Extract

jfr print --events jdk.ClassLoadingStatistics --json recording.jfr

The last event in the series gives the final tally:

Classes loaded (final):    18,423
Classes unloaded (final):      47

What's Typical

Class counts vary a lot by application, but as a rough sense of scale:

Application type Illustrative class count
Small app with a web server + a handful of libraries Low thousands to ~15,000
Typical app-server app with several framework integrations (web, DI, ORM, messaging, etc.) Higher, often 15,000–25,000+
Large monolith with many modules and dependencies Can exceed 25,000–30,000
Minimal, dependency-light app A few thousand

Every class you load costs:

  1. Startup time — parsing, verifying, and linking the bytecode
  2. Metaspace memory — class metadata is stored off-heap in Metaspace (typically on the order of a few hundred bytes to a few KB per class, depending on the class)
  3. JIT compilation — more classes means more code that could potentially be compiled

Treat any "X classes ≈ Y MB of Metaspace" estimate as a ballpark, not a precise conversion — actual metadata size per class varies with class complexity.

Patterns

High loaded count relative to app size → You may have more dependencies on the classpath than you need. Audit with:

# Count classes in each JAR
jar tf your-app.jar | grep '\.class$' | awk -F/ '{print $1}' | sort | uniq -c | sort -rn

Optimization: Trim unused dependencies. Convenience dependency bundles (framework "starters," fat client libraries, umbrella artifacts) often pull in far more transitive classes than you actually use. Depend on narrower artifacts where available, or exclude unused transitive dependencies explicitly. CDS also reduces the runtime cost of each class regardless of count.

Nonzero or growing unloaded count → In many applications a small number of unloaded classes is unremarkable. If the unloaded count keeps growing over the life of a long-running process, that can indicate a classloader leak — a classloader being created and discarded repeatedly, often from dynamic proxies, scripting engines, or redeployed contexts. Worth investigating if it trends upward over time rather than staying flat.


7. Dimension 6: Thread Creation

Data sources: jdk.ThreadStart and jdk.ThreadEnd events.

What to Extract

jfr print --events jdk.ThreadStart --json recording.jfr
jfr print --events jdk.ThreadEnd --json recording.jfr

Grouping Threads by Pool

Thread names typically follow conventions: http-nio-8080-exec-1, http-nio-8080-exec-2 are the same pool. Strip trailing numbers to group them:

Pool prefix (typical examples) What it represents
http-nio-*-exec, qtp* (Jetty), undertow-* Web-server request-handling threads
HikariPool-*, c3p0-*, or your driver/pool's naming Database connection pool threads
scheduling-*, quartz-* Scheduled/cron task executor threads
reactor-http-*, Netty event-loop names Reactive/async I/O event-loop threads
C2 CompilerThread JIT compiler threads (normal)
ForkJoinPool.commonPool-* Parallel stream / CompletableFuture workers

Exact naming depends on your web server, connection pool, and scheduler libraries — the table above shows common examples, but the grouping principle (strip trailing numbers, bucket by prefix) applies regardless of which ones you're running.

What's Normal vs. Concerning

  • A few dozen threads is normal for a typical server-side app (a web-server thread pool + a database connection pool + a handful of schedulers + GC/JIT threads).
  • Hundreds of threads → Check for thread leaks or an oversized pool configuration. Each platform thread carries stack-space and scheduling overhead. Look for executors that create threads but never shut down, or pools configured with an unnecessarily high maximumPoolSize. And the same caution applies in the other direction: if contention analysis (Dimension 7) points you toward increasing a pool size, verify the fix with a before/after measurement rather than just cranking the number up — an oversized pool can itself increase contention and context-switching overhead, trading one bottleneck for another.
  • One-off threads (names that don't group into pools) → Each one has creation overhead. Consider a shared executor if they're frequent.

A note on virtual threads (JDK 21+): everything above assumes platform threads — a platform thread maps one-to-one to an OS thread, which is why each one is relatively expensive to create and hold. A virtual thread is different: it's a lightweight, JVM-managed thread that gets multiplexed onto a much smaller pool of platform threads (called carrier threads), so the JVM can support enormous numbers of them cheaply.

If your application uses virtual threads, raw thread counts stop being a reliable signal on their own — seeing thousands of virtual threads is normal and by design, not a leak. The concerning pattern shifts from "how many threads exist" to pinning (a virtual thread blocking its carrier thread instead of yielding it, often due to a synchronized block or certain native calls) and carrier-thread starvation (too few carrier threads available for the work being scheduled onto them). Those show up differently in a JFR recording — via jdk.VirtualThreadPinned events and general scheduling delay — rather than through simple thread-count totals.


8. Dimension 7: Lock Contention

Data sources:

  • jdk.JavaMonitorEnter — recorded when a thread blocks waiting to enter a synchronized block/method (intrinsic lock contention)
  • jdk.ThreadPark — waits on java.util.concurrent primitives (ReentrantLock, CountDownLatch, Semaphore, etc.)

By default, JFR only records monitor-contention events above a threshold (commonly 20ms) — shorter contention won't show up unless you lower that threshold when starting the recording, at the cost of more overhead.

What to Extract

jfr print --events jdk.JavaMonitorEnter --json recording.jfr
jfr print --events jdk.ThreadPark --json recording.jfr

For each event, extract the contended/parked class name and the wait duration.

Worth keeping in mind: a jdk.ThreadPark event by itself is not automatically a problem. Parking is the normal, intended mechanism behind most java.util.concurrent waiting — a thread sitting idle in an executor waiting for work, or a scheduled task waiting for its next run, will generate park events constantly without indicating anything wrong. What makes park time worth investigating is the combination of where it's happening (the stack trace) and how much of it there is relative to the work being done — a thread parking while genuinely idle is fine; a thread parking while it's supposed to be actively serving requests is the pattern to chase.

Patterns

Monitor enter on java.lang.Class objects → Static synchronized methods or synchronized(MyClass.class) blocks. Class-level locks are particularly bad because they serialize all threads contending on that class, not just per-instance callers.

Monitor enter on Logger / logging internals → Synchronized logging. Consider an async logger (e.g., Log4j2's async loggers) if this shows up as significant contention.

Park on a connection-pool's internal lock (HikariCP, c3p0, or others) → Connection pool contention. If threads are parking waiting for a database connection, consider increasing the pool's max size (only after checking whether the DB and queries can actually support more concurrent connections) or investigate slow queries holding connections longer than necessary.

Park on CountDownLatch / CyclicBarrier with high counts → Thread coordination that's spending more time waiting than working. Check if the parallelism is appropriate for your hardware.

How to Read the Numbers

Monitor enter events:   847    total wait:   342.1 ms
Thread park events:   2,103    total park:  1,892.6 ms
  • Park time notably higher than monitor-enter time → Contention is more concentrated in java.util.concurrent structures than synchronized blocks — common in modern applications and frameworks that lean on the concurrency utilities rather than intrinsic locks.
  • Total wait a meaningful fraction of recording time (rough guide: worth a look above roughly 5%) → Contention is meaningful. Below 1–2%, it's usually not worth chasing — the wins will be marginal relative to the effort.

9. Dimension 8: JIT Compilation

Data source: jdk.Compilation events. One event per compiled method, recording the method signature, compile level, and compilation duration.

What to Extract

jfr print --events jdk.Compilation --json recording.jfr

Understanding Compile Levels

Modern JVMs don't compile everything the same way. Instead, tiered compilation runs code through progressively more sophisticated compilers as it proves itself "hot" enough to be worth the investment:

Interpreter  →  C1  →  C2
(slow to run,   (fast to compile,   (expensive to compile,
 no compile      moderately         most optimized,
 delay)          optimized)         best steady-state speed)

New code starts in the interpreter, which runs slowly but has zero compilation delay. Code that gets called often is compiled by C1, a quick compiler that produces reasonably fast code without spending much time compiling. Code that's still hot after that gets recompiled by C2, a much more thorough optimizing compiler that takes longer to run but produces the fastest possible code, using the profiling data gathered along the way. This staged approach is why a JVM's startup performance and its long-running, steady-state performance are genuinely different regimes — a short-lived process may never get past C1 for most of its code, while a long-running server eventually gets its hot paths onto C2.

Broken down into JFR's numeric compile levels:

Level Name Description
0 Interpreter No compilation — interpreted bytecode
1 C1, no profiling Simple C1 compile, fastest to produce
2 C1, limited profiling C1 with basic invocation/loop counters
3 C1, full profiling C1 with full profiling (gathers data used to decide on C2)
4 C2 Full optimizing compiler — most expensive to produce, highest steady-state performance

The Startup Compilation Trade-off

During startup, hot methods are typically compiled first at cheaper C1 levels, then re-compiled at level 4 once the JIT has gathered enough profiling data to justify it. This tiered approach favors faster ramp-up at the cost of some methods being compiled more than once.

Compilations:      2,847
Total compile time: 3,452 ms
Compile time % of recording: 11.5%
  • Few or no level-4 compilations → Code isn't reaching full optimization. Peak throughput may suffer if the process runs long enough to matter.
  • Heavy, sustained level-4 activity → The JIT is working hard. For startup-sensitive, short-lived, or CLI-style applications where peak throughput matters less than time-to-first-response, consider C1-only mode:
java -XX:TieredStopAtLevel=1 -jar my-app.jar

This disables C2 entirely. You give up C2's deeper optimizations (aggressive inlining, loop unrolling, escape analysis, etc.) in exchange for saving all C2 compilation CPU. Worth it for batch jobs, CLI tools, or anything where the process exits before C2 would have paid for itself — not a good default for a long-running, throughput-sensitive service.

Top compiled methods by time show which code paths trigger the most expensive compilations. Application methods appearing here (as opposed to framework code) are hot paths the JIT considers worth optimizing — probably worth a look regardless of your JIT tuning.


10. Dimension 9: Socket / File I/O

Data sources:

  • jdk.SocketRead / jdk.SocketWrite — network I/O with host, bytes, and duration
  • jdk.FileRead / jdk.FileWrite — file I/O with path, bytes, and duration

What to Extract

jfr print --events jdk.SocketRead,jdk.SocketWrite --json recording.jfr
jfr print --events jdk.FileRead,jdk.FileWrite --json recording.jfr

Reading I/O Patterns

Socket I/O during startup — every network call during ApplicationRunner / @PostConstruct delays readiness. Look for:

  • Database connection establishment (expected, but should happen once)
  • HTTP calls to external services (configuration fetching, health checks, service discovery)
  • Redis/Memcached connection setup (expected)
  • Unexpected HTTP calls (e.g., XML schema validation fetching remote DTDs)

Optimization: Defer non-critical network calls to after the application is ready (ApplicationReadyEvent). Cache responses that don't change between restarts. Use connection pooling to amortize handshake cost.

File I/O duration — the duration field on FileRead/FileWrite events captures the time the call took, including any blocking wait, not just CPU-side processing. High wait time on:

Path pattern Suggests
*.jar Classpath scanning — CDS can help
*.class Class loading from exploded directories — CDS or packaging as a jar can help
application*.yml / *.properties Config file loading — normal, but check for excessive includes
/etc/, /usr/ System-level file access — unusual, worth investigating
Database files If using an embedded DB (e.g., H2), the DB files are being read directly

11. Dimension 10: CPU Load Timeline

Data source: jdk.CPULoad — periodic snapshots of JVM user CPU, JVM system CPU, and total machine CPU.

What to Extract

jfr print --events jdk.CPULoad --json recording.jfr

Interpreting the Three Numbers

Metric Meaning
JVM user% CPU spent in JVM user-mode code (your app + framework + JIT)
JVM system% CPU spent in kernel on behalf of the JVM (I/O syscalls, thread scheduling, some GC activity)
Machine total% Total CPU utilization across all processes on the host

Patterns:

  • JVM user% + system% ≈ machine total% → The JVM is the dominant CPU consumer on the box. You're compute-bound — optimize CPU hotspots (Dimension 1).
  • High system% relative to user% → The JVM is spending an unusual amount of time in kernel space. Points toward heavy I/O (Dimension 9) or heavy thread context switching (Dimension 6).
  • Machine total% near 100% but JVM% is comparatively low → Something else on the host is consuming CPU. Your JVM isn't the bottleneck — look at co-located processes.
  • Low JVM% and low machine% → The JVM is waiting on something external (I/O, locks, or genuinely idle). Check thread idle time (Dimension 11).

12. Dimension 11: Thread Idle / Sleep Time

Data sources: jdk.ThreadSleep (explicit Thread.sleep() calls) and jdk.JavaMonitorWait (Object.wait()), alongside jdk.ThreadPark already covered in Dimension 7 for java.util.concurrent waits. Together these three event types cover most of the ways a thread can be voluntarily idle rather than blocked on I/O or a contended lock.

A note on terminology: some third-party profilers (notably async-profiler's wall-clock mode) offer a more general "wall clock" sampling approach that periodically samples every thread's state regardless of whether it's running, sleeping, or blocked — useful for a single unified idle-time view. Standard JFR doesn't have one combined event for this; you assemble the picture from the events above.

What to Extract

jfr print --events jdk.ThreadSleep,jdk.JavaMonitorWait,jdk.ThreadPark --json recording.jfr

Each event's duration field tells you how long the thread was idle, and its stack trace shows where the thread entered that state.

Patterns

main thread has high idle/sleep time during startup → The main thread is waiting on something. Check what the top frame is:

  • Socket.connect or a related network call → Waiting for a network connection
  • FileInputStream.read → Waiting for file I/O
  • CountDownLatch.await → Waiting for another thread to complete initialization
  • Thread.sleep → Explicit sleep (sometimes intentional — e.g., a documented retry backoff — sometimes worth questioning if it's on the startup critical path)

Thread-pool threads dominate idle time → Those threads are spending most of their time waiting for work. Normal during idle periods for request-handling pools; if you're profiling a busy period and pool threads are still mostly idle, the bottleneck is upstream — not enough requests arriving, or work isn't being distributed to them.

Longest individual idle events — look at the top 10–15 by duration. These are your "long poles" — single events that dominate the timeline. Each is a candidate for targeted optimization:

Duration  Thread    Top Frame
 8,234 ms  main      java.net.Socket.connect
 4,102 ms  main      java.io.FileInputStream.read
 1,892 ms  main      java.util.concurrent.CountDownLatch.await

In an example like this, the socket connect and file read alone could account for the majority of a short startup window — a strong signal that those two call sites are worth optimizing first.


13. Dimension 12: Before/After Comparison

The most rigorous way to validate an optimization is to compare two JFR recordings — one before the change, one after — captured under the same workload and conditions.

Methodology

  1. Record Before: Capture a JFR recording of the baseline.
  2. Make exactly one change: -XX:TieredStopAtLevel=1, or CDS, or AOT, or dependency trimming.
  3. Record After: Capture a second recording with the same JFR settings, same duration, same workload.
  4. Compare across all dimensions: Don't cherry-pick. Check every section — an optimization that improves CPU but worsens allocation may be a net negative depending on your priorities.

What to Compare (and What's Noise)

High-signal metrics (deltas worth taking seriously, roughly >10%):

Metric Why it matters
Total allocation (bytes) Directly drives GC pressure
GC pause count + total time Directly impacts responsiveness
Classes loaded Impacts startup time + Metaspace
JIT compile time Impacts startup CPU
Thread idle/sleep time Impacts latency / readiness

Medium-signal metrics (look for larger deltas, roughly >30%, before drawing conclusions):

Metric Why it matters
CPU sample count Rough proxy for total work done
byte[] allocation specifically Primary GC driver for many apps
Lock contention wait time Impacts throughput under concurrency
File I/O bytes + wait time Impacts startup latency
Socket I/O bytes + wait time Impacts network-dependent readiness

Low-signal metrics (small deltas here are often sampling noise — pay attention mainly when something appears or disappears entirely):

  • Small allocation-type deltas (on the order of a few MB or less, depending on total recording size)
  • Individual hot methods with only a handful of samples in either recording
  • Thread count changes of one or two

The Eliminated / New Pattern

In the comparison, flag items that were significant in the Before recording but are essentially absent in After, and vice versa. These are often the clearest wins — not "we reduced this by 15%," but "this cost is gone."

Type                         Before          After          Delta
byte[]                       48,123,456           0    -48,123,456 (-100%) ◀◀ ELIMINATED
TypeMappedAnnotations        12,456,789     892,123    -11,564,666 (-92.8%)

An eliminated byte[] allocation like that is consistent with what CDS does for class-loading-related allocations — the class bytes come from the shared archive instead of being read and inflated at runtime. A large drop in annotation-metadata allocation is consistent with AOT pre-computing that metadata instead of building it at runtime. (As always, correlation from a before/after diff is a strong hint, not proof — confirm against the change you actually made.)


14. Putting It All Together: A Systematic Analysis Protocol

Here's a workflow for approaching a .jfr file when the question is "why is this slow":

Step 1: Orient

Read the recording metadata — JVM version, JVM arguments, recording duration. A very short recording doesn't have enough samples to be reliable. A recording with a small -Xmx explains why GC is thrashing. Know the context first.

Step 2: Profile CPU

Extract ExecutionSample events (text mode for stack traces). Identify the top leaf methods and top stack signatures. If ClassLoader.defineClass dominates → CDS. If annotation scanning dominates → indexer/AOT. If application code dominates → profile deeper into those methods.

Step 3: Profile Allocation

Extract ObjectAllocationSample events (JSON mode for weights). Identify the top allocation types by bytes. For byte[], drill into allocation-site stacks. Map each major allocation site to a fix (CDS → class bytes, AOT → annotation metadata, caching → repeated object creation).

Step 4: Check GC Health

Extract GCHeapSummary + GCPhasePause. Plot the heap curve; look at pause statistics. If pause time is a large share of the recording, return to Step 3 — the fix is usually reducing allocation. If old-gen/full GCs appear, investigate separately.

Step 5: Audit the Rest

Work through class loading, threads, locks, JIT, I/O, and idle time. Each may reveal a secondary bottleneck. The longest individual idle events often point to "long pole" optimizations — single changes that remove seconds of waiting.

Step 6: Compare (if you have Before/After)

Run every section side-by-side. Focus on eliminated items and large-magnitude deltas. Verify that improvements in one dimension didn't regress another.


15. A Worked Example

To make the protocol concrete, here's an illustrative (simplified, representative-of-the-shape-not-exact-numbers) walkthrough of applying it to a slow-starting application.

Baseline: Startup takes 18 seconds.

Step 2 — CPU: ExecutionSample shows ClassLoader.defineClass1 accounting for roughly 45% of samples during the startup window — Pattern A from Dimension 1.

Step 3 — Allocation: ObjectAllocationSample shows byte[] at roughly 38% of allocated bytes, with the allocation-site stacks tracing back through class-file reading — consistent with the CPU finding.

Step 4 — GC: 17 young-generation collections over the recording, unremarkable pause times, no full GCs. GC isn't the bottleneck here — the allocation churn is a symptom of class loading, not a separate problem.

Diagnosis: Both the CPU profile and the allocation profile point at the same root cause — class loading and parsing dominates startup.

Change made: Enable CDS (-XX:ArchiveClassesAtExit / -XX:SharedArchiveFile), one change, nothing else.

After: Startup takes 11 seconds.

Metric                Before      After      Delta
Startup time           18.0 s     11.0 s     -39%
CPU: ClassLoader          45%         14%     -31 pts
Allocation: byte[]        38%          9%     -29 pts
Minor GCs                  17          8      -9

Step 6 — Verify: The CPU share, the allocation share, and the GC count all moved together in the direction the diagnosis predicted, and the change made (CDS) is a plausible mechanism for exactly that combination of effects — class-loading CPU down, class-byte allocation down, and fewer minor GCs because there's less churn to collect. That consistency across independent dimensions is what turns "we made a change and it got faster" into a reasonably confident causal story, rather than a coincidence.

Notice what this example does not do: it doesn't stop at "startup got faster," it checks that the mechanism (less class-loading work) is visible in more than one independent dimension of the data. A change that improves one number while an unrelated number gets worse is worth a second look before declaring victory.


16. Common JFR Mistakes

A few patterns that undermine an otherwise sound analysis:

  • Recording too long. A five-minute recording spanning startup, steady-state, and idle time averages all three together — none of the resulting numbers describe any single phase well. Record the specific window you care about.
  • Comparing different workloads. A before/after comparison is only meaningful if both recordings saw the same requests, the same data, and the same duration. Comparing a quiet-period recording to a load-test recording tells you about the workload difference, not your change.
  • Optimizing methods with only a handful of samples. Low sample counts are noise, not signal (see the sampling-bias note in Dimension 1) — chasing them wastes effort that could go toward something statistically meaningful.
  • Looking only at CPU. A CPU-only view misses allocation-driven GC pressure, I/O waits, and lock contention — all of which can dominate wall-clock time without showing up as CPU samples at all.
  • Ignoring allocation. Allocation rate drives GC pressure well before heap usage looks alarming; waiting for a heap problem to show up before checking allocation means you're diagnosing late.
  • Ignoring idle time. A method that's cheap on CPU can still be the reason startup is slow, if it's blocking on I/O or a latch. CPU profiling alone won't surface that — Dimension 11 will.
  • Comparing across different JVM versions or GC algorithms. Event names, phase names, and default behaviors can differ between JDK versions and collectors. A before/after comparison should hold the JVM version and GC algorithm constant unless that's specifically the change being tested.

17. Conclusion

A JFR recording is more than a performance trace — it's a map of how your JVM actually spends its time and resources, as opposed to how you assume it does. By systematically working through CPU, allocation, garbage collection, class loading, threads, synchronization, JIT compilation, and I/O, you replace guesswork with evidence.

The point isn't to chase every hotspot the profiler surfaces — plenty of what shows up is normal, unavoidable framework overhead or startup-only cost that will never matter in production. The point is to find the handful of bottlenecks that materially affect startup time, latency, or throughput; make one change at a time; and verify the result with another recording, checking that the numbers moved the way your diagnosis predicted before calling it done.

Building a Dual-Mode Monolith/Microservices System with Spring Boot - One Codebase, Two Topologies

How to build a single codebase that deploys as either a monolith or independent microservices — and why you might want to.


Monolith-to-microservices rewrites are expensive, and most teams don't actually know which topology they need until they're already committed to one. The usual fix is to guess, build, and rewrite later when the guess turns out wrong.

There's a third option: ship both from day one, from the same codebase, the same tests, and the same CI pipeline.

That's what the dual-architecture pattern does. Three Spring Boot services — Order, Inventory, and Shipping — live in one repo, share one set of interfaces, and deploy identically whether they're running as a single JAR or as independently scaled services. Switching topology is a single property: deployment.mode.

In this post, I'll walk through how it works, how to set it up, and how it compares to Spring Modulith — along with the trade-offs that matter once you're past the prototype stage.


The Problem It Solves

Scenario Monolith Microservices
Early-stage startup ✅ Fast to build, one deployable ❌ Operational overhead kills velocity
Enterprise with dedicated ops ❌ Can't scale teams independently ✅ Teams own their services end-to-end
On-premise customer ✅ Single JAR, simple install ❌ Requiring Kubernetes is a dealbreaker
Cloud-native customer ❌ Wastes resources scaling monolithically ✅ Scales inventory independently of ordering
Vendor selling to both Needs to support both at once, without maintaining two codebases

If you're building software that different customers deploy differently — or you're a growing team that wants to defer the monolith-vs-microservices decision — you need a codebase that supports both topologies without forking. That's what this architecture delivers.


How It Works: The Three Pillars

Pillar 1: A Shared Contract (the Client Interface)

Every cross-service interaction starts with a plain Java interface in a shared common-api module:

// common-api/src/main/java/com/monoservice/common/api/InventoryClient.java
public interface InventoryClient {
    boolean checkStock(String sku, int quantity);
    ReservationResult reserveStock(String sku, int quantity);
    void releaseStock(String sku, int quantity);
    int getAvailableQuantity(String sku);
}

Domain services never know whether the implementation lives in-process or across the network — they just inject the interface:

// order-service/.../OrderDomainService.java
@Service
@RequiredArgsConstructor
public class OrderDomainService {
    private final InventoryClient inventoryClient;  // ← just the interface
    private final OrderEventGateway eventGateway;

    @Transactional
    public OrderResult placeOrder(PlaceOrderRequest request) {
        if (!inventoryClient.checkStock(request.sku(), request.quantity())) {
            throw new InsufficientStockException(request.sku());
        }
        inventoryClient.reserveStock(request.sku(), request.quantity());
        // ... persist order, publish event
    }
}

No if (mode == MONOLITH) branches, no proxy magic — just polymorphism, wired by the container.

Pillar 2: Two Implementations, Conditionally Wired

For each Client interface, you write two implementations, and Spring picks the right one based on a property:

// Monolith mode — direct method call, same JVM, same transaction
@Component
@ConditionalOnProperty(name = "deployment.mode", havingValue = "monolith")
public class InventoryClientLocal implements InventoryClient {

    private final InventoryDomainService domainService;

    @Override
    public boolean checkStock(String sku, int quantity) {
        return domainService.checkStock(sku, quantity);
    }

    @Override
    public ReservationResult reserveStock(String sku, int quantity) {
        return domainService.reserveStock(sku, quantity);
    }
    // ...
}
// Microservices mode — HTTP call via Feign
@FeignClient(name = "inventory-service",
             url = "${services.inventory.url:http://inventory-service:8080}")
@ConditionalOnProperty(name = "deployment.mode", havingValue = "microservices")
public interface InventoryClientRemote extends InventoryClient {

    @Override
    @GetMapping("/api/inventory/check")
    boolean checkStock(@RequestParam("sku") String sku, @RequestParam("qty") int quantity);

    @Override
    @PostMapping("/api/inventory/reserve")
    ReservationResult reserveStock(@RequestParam("sku") String sku,
                                    @RequestParam("qty") int quantity);
    // ...
}

deployment.mode=monolith activates the local adapter; deployment.mode=microservices activates the Feign client. @ConditionalOnProperty handles the rest — no custom condition classes, no proxy factories, no bytecode generation. It's plain Spring you already know.

Pillar 3: Dual-Path Event Routing

Cross-service asynchronous communication follows the same pattern. Each service defines an event gateway that publishes identically in both modes:

@Component
public class OrderEventGateway {
    private final ApplicationEventPublisher eventPublisher;
    private final ObjectProvider<KafkaTemplate<String, Object>> kafka;

    public void publish(Object event) {
        eventPublisher.publishEvent(event);  // always — for in-process listeners

        kafka.ifAvailable(template ->        // only when Kafka is on the classpath
            template.send("order.events." + event.getClass().getSimpleName(), event)
        );
    }
}

On the receiving side, two listeners — only one active per mode — handle the same event, both implementing a shared OrderPlacedHandler interface to keep the contract explicit and testable:

// Monolith: synchronous, within the publishing transaction's lifecycle
@Component
@ConditionalOnProperty(name = "deployment.mode", havingValue = "monolith")
public class InventoryOrderPlacedListener implements OrderPlacedHandler {

    @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
    public void handle(OrderPlacedEvent event) {
        domainService.reserveStock(event.sku(), event.quantity());
    }
}

// Microservices: async, from Kafka
@Component
@ConditionalOnProperty(name = "deployment.mode", havingValue = "microservices")
public class InventoryOrderPlacedKafkaListener implements OrderPlacedHandler {

    @KafkaListener(topics = "order.events.OrderPlacedEvent",
                   groupId = "inventory-service")
    public void handle(OrderPlacedEvent event) {
        domainService.reserveStock(event.sku(), event.quantity());
    }
}

The Project Structure

Putting it together, the repo looks like this:

mono-service/
├── common-api/              # Shared interfaces, DTOs, events, Feign clients
├── order-service/           # Own @SpringBootApplication, port 8081
├── inventory-service/       # Own @SpringBootApplication, port 8082
├── shipping-service/        # Own @SpringBootApplication, port 8083
├── monolith-app/            # Aggregator — depends on all three, port 8080
├── docker/                  # Dockerfile + docker-compose (both modes)
├── k8s/                     # Kubernetes manifests — monolith/ and microservices/
└── e2e/                     # Mode-agnostic test suite (29 assertions)

Each service is a standalone Spring Boot application, with its own main method, its own configuration, and its own database migrations. monolith-app is just a fourth application that bundles all three as Gradle dependencies, scans the unified package namespace, and excludes the Kafka- and Feign-related autoconfiguration it doesn't need.

The Mode Switch in Configuration

Monolith (monolith-app/src/main/resources/application-monolith.yml):

deployment:
  mode: monolith

spring:
  autoconfigure:
    exclude:
      - org.springframework.boot.autoconfigure.kafka.KafkaAutoConfiguration
      - org.springframework.cloud.openfeign.FeignAutoConfiguration
  datasource:
    url: jdbc:postgresql://localhost:5432/shop
  liquibase:
    enabled: true          # One DB, three schemas
  flyway:
    enabled: false

Microservices (order-service/src/main/resources/application.yml):

deployment:
  mode: microservices

spring:
  kafka:
    bootstrap-servers: localhost:9092
  flyway:
    enabled: true           # Per-service database

services:
  inventory:
    url: http://inventory-service:8080
  shipping:
    url: http://shipping-service:8080

Database Strategy

This is where the pattern gets interesting — the dual architecture handles database evolution differently in each mode:

Aspect Monolith Microservices
Databases One PostgreSQL (shop) Three PostgreSQL (orders, inventory, shipping)
Schemas Three schemas in one DB One schema per DB
Migrations Liquibase (runs all SQL in order) Flyway (per-service, independent)
SQL files Same files, different tool Same files, different tool
Cross-schema FKs None — application-level integrity None — application-level integrity

The SQL migration files are authored once and consumed by both tools. Liquibase references them via a master changelog; Flyway picks them up natively from each service's classpath. This avoids the single worst failure mode of dual architectures: diverging schema definitions.

No cross-schema foreign keys means no mode-specific data integrity rules. If you wouldn't trust it across the network, don't trust it across schemas.


How Spring Modulith Compares

If you're thinking "this sounds like Spring Modulith," you're right — and the differences are instructive.

Spring Modulith verifies and enforces module boundaries within a modular monolith. It gives you:

  • Module structure verification — ArchUnit-style tests that catch illegal dependencies between modules at build time.
  • Event publication — an ApplicationModuleListener annotation that formalizes in-process event handling.
  • Documentation — auto-generated diagrams of your module topology.
  • Testing supportModuleTest for testing modules in isolation.

What Modulith doesn't do is provide a config-driven switch between local method calls and remote HTTP calls — it assumes you're a monolith. If you later split out a module into its own service, you're rewriting the integration points: replacing direct calls with HTTP, adding circuit breakers, dealing with distributed transactions.

The dual-architecture pattern instead writes the integration point once (the Client interface), implements both paths from the start, and lets a property choose. The cost is upfront: two implementations per interface, conditional beans, dual-tool database migrations. The payoff is that you never rewrite an integration.

Concern Spring Modulith Dual Architecture
Module boundary enforcement ✅ Compile-time verification ⚠️ By convention only
Deploy as monolith ✅ Natural ✅ Natural
Deploy as microservices ❌ Requires rewrite of integrations ✅ Built-in, same artifact
Event system @ApplicationModuleListener Spring Events + Kafka (conditional)
Database strategy Single DB recommended Single DB or separate DBs
Learning curve Low (annotations you already know) Medium (more moving parts)
Framework dependency Heavy (spring-modulith-starter) None (plain Spring Boot + Feign)
Good for Teams committed to monolith, wanting discipline Teams that need both topologies from day one

Neither is "better" — they solve different problems. If you're sure you'll be a monolith forever, Modulith's compile-time verification and lighter footprint win. If you might split, or you sell to customers who need different topologies, the dual architecture prevents a rewrite.


What Makes It Work (and What Doesn't)

What works well

  1. @ConditionalOnProperty is the right abstraction level. You don't need a custom framework — a single property, standard Spring annotations, and a discipline of always pairing local and remote implementations is enough.

  2. Controllers implementing the Client interfaces catches drift at compile time. If InventoryController implements InventoryClient, the compiler guarantees the REST endpoint signatures match the Feign client's expectations. No more "the URL changed but the client wasn't updated" bugs.

  3. The same E2E tests run in both modes. The test suite (run-tests.sh) is parameterized only by base URLs and asserts the same 29 behaviors against monolith and microservices deployments. If the tests pass in both modes, you haven't introduced a topology-dependent bug.

  4. The aggregator app is a separate module, not a profile. monolith-app is its own Gradle module that depends on all three services — it doesn't reconfigure them internally. This means each service can still be built, tested, and deployed independently; the monolith is a consumer of services, not a special mode of each one.

What's tricky

  1. The Feign clients live in common-api, which pulls spring-cloud-starter-openfeign into every module — including ones that never make an HTTP call in monolith mode. You end up excluding it manually in the aggregator (as shown above). A cleaner alternative is to move the Feign interfaces into the consumer module (e.g., InventoryClientRemote inside order-service), keeping common-api free of network dependencies entirely.

  2. Event semantics differ silently between modes. In monolith mode, events are synchronous and transactional (@TransactionalEventListener(AFTER_COMMIT)). In microservices mode, events are async and at-least-once (Kafka). Code that works in one mode can fail subtly in the other — duplicate processing, missing transactional rollback, reordering. The only real defense is idempotency keys on every event, plus running the full test suite in both modes on every change.

  3. There's no compile-time module boundary check. Unlike Modulith, nothing stops order-service from importing inventory-service's entity classes directly and bypassing the InventoryClient abstraction entirely. You need either discipline or an ArchUnit test that bans cross-module imports.


# Clone and build
./gradlew build -x test

# Run as monolith — all three services in one process
SPRING_PROFILES_ACTIVE=monolith ./gradlew :monolith-app:bootRun

# Or run as microservices — three independent processes
./gradlew :order-service:bootRun &
./gradlew :inventory-service:bootRun &
./gradlew :shipping-service:bootRun &

# Same E2E tests, both modes
MODE=monolith BASE_URL=http://localhost:8080 ./e2e/run-tests.sh
MODE=microservices \
  ORDER_URL=http://localhost:8081 \
  INVENTORY_URL=http://localhost:8082 \
  SHIPPING_URL=http://localhost:8083 \
  ./e2e/run-tests.sh

Key Takeaways

  • One codebase, two deployment topologies is a real, practical architecture — not an academic exercise.
  • Three pillars make it work: shared Client interfaces, conditionally-wired local/remote implementations, and dual-path event routing.
  • Spring Modulith and this pattern solve different problems. Modulith enforces monolith discipline; the dual architecture enables topology switching.
  • @ConditionalOnProperty is simpler and more transparent than a custom framework for this.
  • The biggest risk is mode-specific behavioral divergence — mitigate it with idempotency, identical E2E tests in both modes, and compile-time contract verification wherever you can get it.

For teams selling the same product into both monolith and microservices customers, this pattern eliminates the most expensive decision in backend architecture: the one you have to make before you know the answer.