A refactor can look safe in a pull request while still hiding serious risk. The code compiles. The happy-path tests pass. The method names look a little better. Then the service fails under a payload nobody tested, leaks a resource after an exception, slows down under normal load, or lets untrusted input flow into a sensitive operation.
That is the situation this article addresses: you need to change a Java service that already works in production, but you do not fully trust the code around the change. Maybe the service has grown through years of urgent patches. Maybe there are duplicated branches, broad Object parameters, old exception handling, or performance issues that only appear at runtime. Before you reorganize the code, you need a safety pass that tells you what is fragile.
The practical answer is to combine static analysis and dynamic analysis. Static analysis inspects code without running it. Dynamic analysis observes the program while it runs. Static analysis helps you catch suspicious code patterns early. Dynamic analysis helps you understand behavior that only appears with real execution, such as memory use, call frequency, slow paths, and crashes caused by unusual input.
The goal is not to replace tests or refactoring discipline. The goal is to make the refactor smaller, safer, and easier to review.
The Problem
Imagine a Java shipping quote service. It accepts a quote request, calls carrier-specific logic, calculates a price, archives request and response payloads, and returns a quote to the caller.
Client
|
v
Quote API
|
v
QuoteService
|-- Carrier rules
|-- Price calculation
|-- Payload archive
|-- Audit notification
v
Quote response
The team wants to refactor the quote calculation because new carrier rules are becoming difficult to add. The risky parts are not only in the method being changed. The surrounding code may contain:
- Null dereferences that only happen on uncommon inputs
- Unsafe casts hidden behind generic parameters
- Conditions that make some code unreachable
- Repeated logic that causes inconsistent fixes
- Exception handling that hides the original failure
- High cyclomatic complexity, which means too many execution paths
- Runtime bottlenecks that are invisible from the source alone
- User-controlled input that reaches sensitive operations without enough validation
A good workflow checks these risks before the refactor lands in the main branch.
Step 1: Run Static Analysis Before Touching the Design
Static analysis tools examine source code or compiled code without executing the application. In a Java project, this can happen in the IDE while you type, in a local build, or in a continuous integration pipeline before code is merged.
Common Java tools in this space include SonarQube, SonarLint, Checkstyle, FindBugs, and PMD. They do not all look for the same things. Some focus on coding conventions, some on bug patterns, some on duplication, and some on security-related issues. Used together, they give the team a broader view of the code than a manual review alone.
Start with defects that can break execution or mislead future changes. For example, this small class contains two risks that are easy to miss inside a larger service:
class CarrierRuleReader {
boolean acceptsExpress(Object ruleCandidate) {
ExpressCarrierRule rule = (ExpressCarrierRule) ruleCandidate;
return rule.isExpressEnabled();
}
int retryDelaySeconds(RetryPolicy policy) {
if (policy == null) {
return policy.getDelaySeconds();
}
return policy.getDelaySeconds();
}
}
The cast can fail at runtime if the caller passes a different kind of rule. The null check is also wrong: the branch that handles null still dereferences policy. Both are simple examples, but real code often hides the same pattern behind longer methods and domain logic.
A safer version makes the assumptions explicit:
class CarrierRuleReader {
boolean acceptsExpress(Object ruleCandidate) {
if (!(ruleCandidate instanceof ExpressCarrierRule rule)) {
return false;
}
return rule.isExpressEnabled();
}
int retryDelaySeconds(RetryPolicy policy) {
if (policy == null) {
return 0;
}
return policy.getDelaySeconds();
}
}
This is not only about avoiding a crash. It also makes the code easier to refactor because the behavior is visible. The reader can see what happens when the input is not the expected type, and what default is used when no retry policy exists.
Step 2: Use Complexity Reports to Find Refactoring Hotspots
Cyclomatic complexity measures how many paths can pass through a piece of code. A method with many conditions, switches, and branches requires more tests to cover its behavior and more effort to understand safely.
You do not need to calculate the number manually every day, but it helps to understand the idea. A simple method with one condition has two possible paths. Every new decision point adds another route through the code.
START
|
v
request valid?
| true | false
v v
calculate quote reject request
| |
v v
END END
When a quote method grows nested conditions for carriers, destinations, package types, discounts, exceptions, and fallback behavior, it becomes harder to refactor safely. Static analysis can point to these methods so you do not choose refactoring targets only by instinct.
Here is a simplified example of code that will become painful as rules grow:
Money calculateQuote(QuoteRequest request) {
if (request != null) {
if (request.destination() != null) {
if (request.weightInKg() > 0) {
if (request.expedited()) {
return expressPrice(request);
} else {
return standardPrice(request);
}
}
}
}
return Money.zero();
}
A first cleanup step is not to redesign the whole pricing system. Start by making the flow readable:
Money calculateQuote(QuoteRequest request) {
if (request == null) {
return Money.zero();
}
if (request.destination() == null) {
return Money.zero();
}
if (request.weightInKg() <= 0) {
return Money.zero();
}
if (request.expedited()) {
return expressPrice(request);
}
return standardPrice(request);
}
This does not remove all complexity, but it changes the shape of the method. The exceptional cases are handled first, and the normal path is easier to see. After that, the team can decide whether pricing rules should move into separate classes or strategies.
Step 3: Inspect Exception Handling Before It Hides the Real Failure
Exception handling is a common source of production confusion. The code may technically handle an error while making diagnosis harder. Two especially dangerous patterns are throwing from a finally block and returning from a finally block.
The problem is that finally always runs after the try block. If it throws or returns, it can hide the original exception. That means the failure you see in logs might not be the failure that caused the problem.
Resource cleanup is another common trap. If multiple resources are closed in one cleanup block and the first close fails, later cleanup may never happen.
void archivePayload(Payload payload) {
PayloadArchive archive = archiveFactory.open();
AuditSink audit = auditFactory.open();
try {
archive.write(payload);
audit.write(payload);
} finally {
archive.close();
audit.close();
}
}
If archive.close() fails, audit.close() is skipped. A static analysis tool can flag this family of problems so the team handles cleanup deliberately.
A clearer version lets Java manage both resources as part of the operation:
void archivePayload(Payload payload) {
try (PayloadArchive archive = archiveFactory.open();
AuditSink audit = auditFactory.open()) {
archive.write(payload);
audit.write(payload);
}
}
The exact fix depends on the resource types in your codebase, but the review rule is simple: cleanup code must not hide the original problem, and one cleanup failure should not silently prevent other cleanup work.
Step 4: Add Dynamic Analysis for Behavior Static Tools Cannot See
Static analysis can warn you about code structure, but it cannot fully explain runtime behavior. For that, you need dynamic analysis.
Dynamic analysis means observing the application while it runs. The simplest form is debugging. More advanced forms include profiling, fuzzing, symbolic execution, and taint tracking.
Use dynamic analysis when the question depends on execution:
- Which method is called most often during quote calculation?
- Which path allocates the most memory?
- Which input causes the service to crash?
- Which branch is reached under a specific carrier response?
- Which untrusted value reaches a sensitive operation?
Profiling is especially useful when a refactor is motivated by slow behavior. A profiler can show CPU usage, memory usage, call frequency, method execution time, thread behavior, and resource usage. VisualVM is one common Java tool for profiling and diagnostics. It can help inspect running applications and capture memory-related information when a service slows down or behaves unexpectedly.
A useful workflow is:
1. Reproduce the slow or failing behavior.
2. Capture runtime data with a debugger, logs, or profiler.
3. Identify the method, branch, allocation, or resource causing the issue.
4. Refactor only the smallest area needed.
5. Run the same dynamic check again.
This keeps performance work separate from guesswork. You are not optimizing code because it looks suspicious. You are changing the part that runtime evidence points to.
Step 5: Use Fuzzing Around Public Boundaries
Fuzzing means testing a program with unusual, malformed, random, or partially valid inputs. It is useful near boundaries where your system accepts data from callers, files, network messages, or other services.
For the quote service, good fuzzing targets include request payloads, carrier response parsing, uploaded templates, and any input that crosses a trust boundary. A trust boundary is a point where data moves from a less trusted place into code that performs sensitive work.
A conceptual fuzz test might look like this:
class QuoteRequestFuzzTest {
void generatedRequestsShouldNotCrashService() {
for (QuoteRequest candidate : requestGenerator.unusualQuoteRequests()) {
try {
quoteService.calculate(candidate);
} catch (ExpectedValidationException expected) {
continue;
}
}
}
}
The point is not that every generated request must produce a successful quote. Many should be rejected. The important rule is that invalid input should fail in an expected, controlled way rather than causing an unexpected crash, resource leak, or inconsistent state.
Step 6: Track Untrusted Input Through Sensitive Code
Taint tracking follows data that comes from an untrusted source. The data is marked as tainted, then the analysis observes where it flows. This is helpful for finding places where user-controlled values reach sensitive operations without validation.
For example, this code accepts a label template name from a request and uses it to resolve a file path:
void renderShippingLabel(QuoteRequest request, Quote quote) {
String requestedTemplate = request.labelTemplateName();
Path templatePath = templateDirectory.resolve(requestedTemplate);
labelRenderer.render(templatePath, quote);
}
The risk is not visible from types alone. requestedTemplate is just a String, but it came from outside the trusted code. A safer design converts the raw input into a validated domain value before it can reach the renderer:
void renderShippingLabel(QuoteRequest request, Quote quote) {
TemplateName templateName = templateCatalog.validate(request.labelTemplateName());
Path templatePath = templateCatalog.resolve(templateName);
labelRenderer.render(templatePath, quote);
}
This makes the trust decision explicit. The renderer no longer receives an arbitrary string from the request. It receives a path resolved through a catalog that understands valid templates.
Taint tracking can produce false positives, and it can be expensive to investigate every warning. Still, it is valuable around security-sensitive workflows because manual review often misses long data paths across controllers, services, utilities, and adapters.
How to Put the Workflow Into a Pull Request
A practical review workflow for a risky Java refactor can look like this:
- Run static analysis before editing the code.
- Fix or document null dereferences, unsafe casts, unreachable code, duplicated logic, and exception mishandling near the change.
- Check complexity reports and choose the smallest method or class that needs restructuring.
- Reproduce runtime symptoms before optimizing or reorganizing performance-sensitive paths.
- Profile the behavior if the problem involves speed, memory, threads, or resource usage.
- Add boundary tests or fuzz-style generated cases for public inputs.
- Validate or wrap untrusted input before it reaches sensitive operations.
- Run the same static and dynamic checks after the refactor.
This workflow keeps the scope controlled. You are not trying to fix the entire service in one pull request. You are using analysis to prevent the refactor from making fragile code even harder to reason about.
Common Mistakes
Treating static analysis as a replacement for thinking
Tools can flag suspicious code, but they do not understand every business rule. A warning may be irrelevant, and clean reports do not prove the program is correct. Use tool output as evidence, not as the final decision.
Ignoring false positives until everyone ignores the tool
If a tool constantly reports issues the team never fixes, developers stop trusting it. Keep the rule set useful. Suppress or document warnings only when the team agrees they are not actionable.
Refactoring and optimizing at the same time
Refactoring changes structure without intending to change behavior. Optimization changes performance characteristics and may require different tradeoffs. If profiling shows a bottleneck, address it deliberately instead of mixing it into a broad cleanup.
Letting high complexity hide behind passing tests
A method can pass existing tests and still be dangerous to change. High cyclomatic complexity means there are many paths to understand and cover. Treat complexity reports as a signal to add focused tests or split the logic before large changes.
Waiting until production to analyze runtime behavior
Dynamic analysis is not only for emergencies. Debugging, profiling, fuzzing, and taint tracking are more useful when applied before release, while the code is still cheap to change.
Checklist
Before merging a Java refactor in a risky area, confirm that:
- Static analysis runs locally or in the pipeline.
- New warnings introduced by the change are reviewed.
- Null handling is explicit around optional data.
- Casts are avoided or guarded.
- Complex methods are made easier to read before deeper design changes.
- Exception handling does not hide the original failure.
- Runtime behavior has been checked when the issue involves speed, memory, or unusual input.
- Public boundaries reject invalid data predictably.
- Untrusted input is validated before reaching sensitive operations.
- The final diff is small enough for reviewers to understand.
Conclusion
Static and dynamic analysis give you two different views of the same Java service. Static analysis shows suspicious structure before the code runs. Dynamic analysis shows what actually happens during execution. Together, they help you choose safer refactoring targets, reduce hidden failure modes, and keep pull requests focused.
The best time to use these tools is before the risky change becomes urgent. Run them while the code is still easy to modify, fix the issues closest to your refactor, and use the results to guide a smaller, more reliable design change.