5 (and a Half) Java Puzzles That Keep Developers Awake at Night

Java is stable, reliable, and mature — and for many of us, it’s the language we trust to build systems that just work. It’s the foundation of everything from enterprise software to microservices to that one legacy monolith we secretly admire (or fear).
But working with Java on real projects isn’t always smooth sailing. For every elegant stream operation or perfectly mapped DTO, there’s a cryptic stack trace, a missing null check, or a mysterious behavior that seems to defy logic.
This isn’t a rant. It’s a collection of real-life Java quirks that developers like me encounter across projects and companies — and the lessons we’ve learned while navigating them.
Some of these have clear solutions. Others? Well, they’re more about learning to recognize the pattern, manage the impact, and move forward with a little more wisdom (and logging).
So here are five (and a half) Java puzzles that might be keeping you up at night — and how we deal with them like professionals.
1. NullPointerException in 2025 — Really?
Yes, it’s still here. And it still manages to crash applications at the worst possible time.
Despite numerous improvements in the Java ecosystem — from Optional and IDE-level inspections to annotations like @NotNull and @Nullable — the infamous NullPointerException remains a persistent guest in many Java projects.
The issue isn’t necessarily with the language. It often comes down to how we, as developers, deal with uncertainty in data. Nulls tend to sneak in through third-party integrations, loosely structured JSON, incomplete object mapping, or simply forgotten checks in complex flows.
A few familiar scenarios:
- A third-party API returns null for a missing field, and your mapper crashes without warning.
- An object built using @Builder silently skips an uninitialized required field.
- You call list.get(0) assuming something’s there… but it never was.
How to reduce the pain:
- Use Optional appropriately — for return values where the absence of data is a valid outcome. Avoid using it for method parameters or fields.
- Validate inputs early, especially in constructors and service methods.
- Annotate expectations clearly using @NonNull and @Nullable, and treat them as contracts.
- Leverage static analysis tools like SpotBugs, Error Prone, or SonarQube to catch missing null checks before runtime.
- Invest in meaningful logs to ensure that if a NullPointerException does occur, the next person (or your future self) knows where and why.
As frustrating as it is, the NullPointerException isn’t going away. But with a bit of discipline and the right tooling, we can at least make it less surprising — and far easier to trace.
“In God we trust. All others must bring null checks.”
2. Hibernate: Lazy vs. Eager – The Eternal Struggle
Choosing the right fetch strategy in Hibernate is one of those decisions that seems simple — until it isn’t. On paper, you pick between FetchType.LAZY and FetchType.EAGER, and move on. In practice, this choice can affect your application’s performance, stability, and even debuggability in surprising ways.
Lazy loading (FetchType.LAZY):
This is the default for most collection associations, and it often makes sense. With lazy loading, related entities are only fetched when they’re accessed, which keeps the initial query light and fast.
However, it’s not without its traps. Accessing a lazy-loaded property outside an active persistence context (like in a REST controller) results in the dreaded LazyInitializationException. Even worse, lazy loading inside loops can trigger the classic N+1 query problem — where one query becomes dozens or hundreds without you realizing it.
Eager loading (FetchType.EAGER):
At first glance, eager fetching looks safer: you get everything up front, no surprises. It’s commonly used for @ManyToOne or @OneToOne associations where the related entity is always needed.
But eager loading can backfire, especially when dealing with complex or deeply nested relationships. You might unknowingly trigger a massive join that slows down the entire system — or brings back far more data than you expected.
So which one is better?
Truthfully, neither is objectively better — it depends entirely on your use case.
- Use LAZY by default, especially for collections (@OneToMany, @ManyToMany), and load what you need explicitly using JOIN FETCH or EntityGraph.
- Be cautious with EAGER, especially when your model grows over time. What makes sense now might introduce performance issues later.
- Always test your queries against realistic datasets, not just a local H2 with three rows.
Hibernate is powerful — but with power comes responsibility. Understanding your fetch strategies, keeping an eye on query logs, and setting clear boundaries between entities and DTOs will help you stay on the safe side of ORM.
“You don’t load what you don’t need. And if you do — know exactly why.”
3. Date vs. LocalDateTime: The DTO Dilemma
Working with dates in Java is a puzzle of its own. Even if you’ve avoided the quirks of java.util.Date for years, the moment you start exposing your entities through a REST API, old problems come knocking.
Let’s say your service returns a field called createdAt. If it’s a java.util.Date, most JSON serializers (like Jackson) will convert it to a numeric timestamp — a long value representing milliseconds since the epoch. If you use LocalDateTime, the same field may show up as an array like [2025, 5, 28, 15, 30]. Both are technically correct… but neither is particularly frontend-friendly.
This mismatch between Java-side clarity and JSON output often leads to confusion — especially when different teams or services expect different formats.
So which type should you use?
For internal logic and database interaction, LocalDateTime is typically the better choice:
- It’s immutable and thread-safe.
- It doesn’t rely on system timezones.
- It has a more complete API for manipulating and formatting dates.
However, for external interfaces like REST APIs, you need to be deliberate:
- Consider using @JsonFormat to control how your LocalDateTime or Date fields are serialized.
- Align with frontend expectations: ISO 8601 strings (e.g. “2025-05-28T15:30:00”) are often preferred.
- If possible, agree on a shared date format across all services and clients to avoid unnecessary conversion logic.
In short:
Use LocalDateTime in your core logic.
Use clear, predictable formatting when sending that logic to the outside world.
“Dates aren’t hard. Timezones are. And JSON makes everything just a little worse.”
4. Dependency Hell: The AWS SDK Edition
Adding a new library to your project shouldn’t break things. And yet, we’ve all been there: you pull in a new AWS SDK module — maybe aws-java-sdk-s3 or aws-java-sdk-ses — and suddenly, the application either fails to start or behaves… oddly.
What’s going on?
The AWS Java SDK (v1 especially) is modular in name, but not always in practice. Adding one component can quietly bring in others — each with their own transitive dependencies and versions. Even if your pom.xml or build.gradle looks clean, what’s happening at runtime may be a different story.
A few common surprises:
- Conflicting versions of Jackson can lead to serialization bugs that are hard to trace.
- Some modules rely on older HTTP clients or outdated Apache libs, which may clash with your own dependencies.
- Your Spring Boot app starts, but AWS-specific functionality silently fails — no compile errors, just unexpected behavior.
How to stay sane:
- Use a BOM (Bill of Materials) like aws-java-sdk-bom to control versions across AWS modules.
- Inspect your dependency tree regularly with mvn dependency:tree or gradle dependencies.
- If you’re using Spring Cloud AWS or other wrappers, check which AWS SDK version they pull in — it might not match yours.
- Isolate AWS interactions into their own module or service where possible. This helps reduce risk and keeps SDK-related issues contained.
Dependency conflicts don’t always show up during build. Sometimes, they wait until runtime — or worse, production. So when adding anything that starts with aws-, take an extra minute to see what’s coming along for the ride.
“It’s not the dependency you added — it’s the five you didn’t know came with it.”
5. “It Works on My Machine” Syndrome
Every developer has said it at least once — sometimes proudly, sometimes defensively:
“But… it works on my machine.”
And it probably does. But the moment your code hits staging, CI/CD, or someone else’s laptop, things start breaking in all the strange ways. Configs don’t load. Tests fail randomly. Something that compiled yesterday refuses to even start today.
Common causes behind this classic headache:
- Environment-specific configuration:
Spring Boot profiles, different property values, or missing .env files can completely change how an application behaves in each environment. - JDK version mismatches:
Maybe your local machine runs Java 17, but the CI server is still on Java 11. Or worse — a legacy service on Java 8. Subtle syntax or API differences can make or break your build. - Gradle or Maven quirks:
Different wrapper versions, caching issues, or corrupted local repositories can lead to inconsistent behavior between developers and environments. - Missing or mocked dependencies:
You have Redis running locally, but it’s not set up in the test environment. Your machine has a seed database; the test container doesn’t. - Docker discrepancies:
Maybe you’re not using Docker locally, but production is — and now your app behaves differently because of timezone, locale, or OS-level differences.
How to fight the syndrome (constructively):
- Standardize your environments:
Use Docker Compose, dev containers, or virtualization to mirror production as closely as possible. - Lock down versions:
Use java { toolchain } in Gradle or .sdkmanrc / .tool-versions to define consistent JDKs across machines. - Validate config early:
Add startup checks for critical config values. Fail fast with meaningful errors if something is missing or misconfigured. - Use CI as your source of truth:
If it works locally but not in CI, fix your local setup — not the CI. Reproducibility beats convenience.
“It works on my machine” is a good starting point — but reproducibility is what takes your team forward.
5½. The Java Version Gap
Switching between Java versions across projects sounds simple — until you forget which features belong to which version.
You’re working on a modern application using Java 17, enjoying helpful features like pattern matching, enhanced switch, and Stream.toList(). Then suddenly, you’re asked to fix something in a legacy codebase still stuck on Java 8… and half your code won’t even compile.
Even small jumps — from Java 8 to 11, or 11 to 17 — can cause unexpected friction. APIs evolve. Standard library methods are added or deprecated. And unless your brain keeps an internal compatibility matrix (or you’re secretly a compiler), these transitions can be jarring.
Sound familiar?
- You confidently write var for a local variable, only to find the compiler doesn’t recognize it.
- You use List.copyOf() or Optional.orElseThrow() — and realize they were added in Java 10.
- You refactor with Stream.toList()… and the build explodes with “method not found.”
Can it be avoided? Not always. But it can be managed.
- Know the minimum Java version of each project, and set it explicitly in your build tool.
- Use IDE settings or version checkers (like Gradle’s toolchain feature) to prevent accidental usage of unsupported features.
- Have empathy for your future self when context-switching — leave notes, README comments, or Git hooks as reminders.
Working with mixed Java versions is one of those things you can’t always control — especially in teams, monorepos, or long-lived systems. The trick is not to avoid it, but to be aware of it, and treat it as part of the job — not a blocker.
“Modern Java is great. But so is knowing which parts of it you’re allowed to use today.”
Final Thoughts
None of these puzzles are unique to any one team or project — they’re part of writing real-world Java, where clean design meets legacy code, deadlines, and ever-evolving tools.
Some of them have clear best practices. Others, like fetch strategies or versioning quirks, require judgment and context. But what helps across all of them is awareness: knowing what to watch out for, when to pause before you code, and how to spot the patterns early.
As Java developers, we don’t just write code — we debug, adapt, and design for change. And every puzzle we solve makes the next one a bit easier to approach.
If even one of these felt familiar, you’re not alone — and you’re probably doing better than you think.
Read more articles from the same author @YuliiaKopytko here.