DEV Community

Cover image for Modern Java: Why Old Rules No Longer Apply
Nevena
Nevena

Posted on

Modern Java: Why Old Rules No Longer Apply

Is Java no longer relevant? Denis Tsyplakov, Solutions Architect at DataArt, doesn’t think so. Java's reputation issues stem less from the language itself and more from outdated practices. This article revisits long-standing Java dogmas, identifies shortcomings, and offers modern alternatives to improve our development workflows.

Note: Code review standards vary by company and project. Apply these ideas thoughtfully; abrupt changes to enterprise projects can disrupt teams.

Setters and Getters

One common complaint is that Java code gets cluttered with setters and getters. They were initially popularized in the 1990s due to the influence of The Gang of Four (GoF) Design Patterns, which promoted implicit behavior in classes. However, implicit behavior is now seen as bad practice: data and functions should be separate. Most times, setters and getters aren't required unless you're using specific libraries like JPA.

Alternatives:

  1. Class with Fields: Suitable when transporting 3-5 fields between methods.
  2. Records: A standard solution for storing data in Java, creating immutable data classes equipped with hashCode, equal and getters. However, they enforce a "stateless state" paradigm, which is fine in 95% of cases. Unless you have a 10 MB+ data structure requiring modification of individual data class fields in real-time, then use a DTO.
  3. Advanced Future Solution: Project Valhalla for lightweight data structures.

One Class, One File Rule

The "one class per file" rule has been a golden standard since Java's early days. While this structure made sense when IDEs were less advanced, it's become less relevant today. Modern development environments offer robust search and navigation capabilities, allowing for more flexible file organization.

Problems with the rule:

  1. Decreased Readability: Jumping between multiple files to follow a single logic flow.
  2. Broken Nesting Logic: Classes like DTOs or callbacks make sense only within the context of their parent class. However, the project structure does not show that relationship.
  3. Local Block Scope: Exposing classes outside the method spoils the class structure.
  4. Domain Leaks: Moving non-sharable classes outside their natural context introduces fragility.

Alternatives:

  • Place dependent classes/interfaces within their parent class where they belong.
  • Group tightly coupled DTO records under a single parent class. For example, serialize the main class BookingDTO to JSON and put nested classes InvoiceDTO, ItemDTO, etc, inside BookingDTO.
  • If you need a data structure inside the code block, declare the class and do not expose it.

In general, make sure your code does not make you jump between classes too often and does not make you scroll for too long. Your code will be easier to read if you have 20 files with 20 lines of code instead of 80 files with 5 lines.


`public record DBDataInfo( 
    int documentsCount, 

    int documentsUnProcessedCount, 

    String dbSize, 

    int docProcessingErrors, 

    int archivedDocCount, 

    List<SourceInfo> sourceInfo 
) { 
    public record SourceInfo( 

        String name, 

        int raw, 

        int digest 

    ) {} 
} `
Enter fullscreen mode Exit fullscreen mode

Custom Exceptions

The Java exception system is one of its strengths, and one of the reasons why I started using it over 20 years ago. The primary purpose of the exception mechanism is the ability to handle unique scenarios. In this paradigm, we usually have one exception for each scenario that can be compensated for.

However, in microservice architectures, compensation becomes less relevant and is often streamlined to the service level. It usually involves cognitive complexity that exceeds the average. For example, in a medium-sized system, you may compensate for 2-3 types and nothing more.

Java (and frameworks like Spring) offer many standard exception classes that cover 95% of cases, such as IllegalArgumentExceptions and IllegalState exceptions. Most often, developers are unaware of the standard exceptions and declare a class structure that's hardly digestible and can ruin your code.

Alternatives:

  • Consider scenarios that can be compensated and stick with standard exceptions otherwise.
  • Design an exception hierarchy around those cases.
  • In a microservice architecture, 80% of sync call exception handling cases fall under two patterns: retry and circuit breaker.

Remember: Creating a new class can add some cognitive load. Think about how it might affect different situations. If you don't plan to use it, it might be best to skip it.

Example:

@io.github.resilience4j.circuitbreaker.annotation.CircuitBreaker( 

    name = "weather",  

    fallbackMethod = "offlineWeather" 

) 

public WeatherDto current(String city) { 

    return restTemplate.getForObject( 

        "https://api.example.com/weather?city={}",  

        WeatherDto.class,  

        city 

    ); 

} 

  

/**  

 * Fallback when circuit is OPEN or the call itself fails.  

 */ 

public WeatherDto offlineWeather(String city, Throwable ex) { 

    return new WeatherDto(city, "???", "service-unavailable (cached/default)"); 

} 
Enter fullscreen mode Exit fullscreen mode

Mapping

In a classic enterprise app structure, you map controller parameters to a DTO, which maps them to an entity. Then, the entity passes to a repository (DB or REST), and you receive another entity in exchange, return to the service, map it to a DTO, and return to the controller.

Real-life example: In a microservice, we receive a 1MB+ flight search XML reply from NDC, map it to a data structure, extract the airline code, add it to the response header, and pass the reply along. But parsing the reply and serializing it takes several seconds (10+ seconds in the worst case).

Solution: Read the reply as a byte stream, parse it with a NanoSAX parser, set the header, and return the bytes as the body. This would take less than 10 milliseconds.

However, a more critical problem is creating mappings for hundreds of fields when your code only needs a dozen. This makes simple microservices hard to read and digest.

Alternatives:

  • Avoid Unnecessary Mapping: If you only need a few fields from a JSON object with 100+ fields, read it as a JSONNode and extract fields to a clean DTO.
  • Use Declarative Transformers: Wherever it’s applicable, use XSLT(c) for XML, use libs like JSLT, Jolt, JSONata-for-Java, etc.
  • Minimize Hierarchies: If one is enough, avoid creating both entity and DTO layers. Just use a DTO, but track any data leakage.

Dependency Hell

Java engineers like mapping data structures and frequently create system-name-dto.jar, using it as a library in all services. All incoming data is mapped to one of the library DTOs and serialized back to JSON/XML when sending HTTP with RESTTemplate/Feign. This approach creates problems such as decreased performance and changes in the library when the DTO library is used in 30 different services deployed to prod 24/7.

There is no universal solution to this, but you can try a few of these guidelines:

  1. If more than two services use a big DTO, you probably need to rethink the service design.
  2. If you just pass through a big data structure, stick to that and don't mutate or inspect it.
  3. Make the mutated part a separate DTO if you need to mutate it.

Separation of Interface and Implementation

Theoretically, it seems like a good idea. You define the interface of a service or class and then create an implementation. If you need more, you make more. This practice results in writing more code than needed.

The typical explanation for this was:

  1. Enforce Class Contract: Even though Java classes have public contract methods.
  2. Unit Testing and Mocking: If your code needs to be changed to facilitate unit testing, you should probably rethink your test strategy.
  3. Prepare for Future Extension: Do it when needed, not in advance.
  4. Multiple Implementations: Nowadays, we usually don't have more than one implementation of an interface.

So, how to deal with it? Just don't do it, that's it. If you'd like to explore this topic in more detail, read this article.

Reactive Code

The main argument is that all reactive frameworks for Java are dead. There are several reasons for this:

  • Virtual Threads (Project Loom, GA in Java 21) make the classic "one-thread-per-request" model memory-right and massively scalable, eliminating the core performance reason for adopting reactive I/O.
  • Structured Concurrency and scoped values give you simple, imperative flow control and tracing without the cognitive load of reactive streams, but with equal or better throughput.
  • Mainstream HTTP Frameworks: Spring MVC, JAX-RS/Jakarta REST, Micronaut HTTP, Quarkus RESTEasy run unchanged on virtual threads and now match WebFlux/Vert.x-style stacks in latency and QPS, but with cleaner code and easier debugging.
  • Blocking JDBC Sudden Scales: A single JVM can spawn hundreds of thousands of virtual threads that wait on the same java.sql calls. So, you no longer need reactive drivers (R2DBC, jasync-sql) just to keep database I/O from becoming a bottleneck.
  • Reactive Frameworks bring complexity:

  • Non-linear control flow

  • Back-pressure plumbing

  • Tricky error handling: yet their performance edge has vanished

  • Copilot-like tools better digest the sequential blocking flow of operations. Post Java 21, the cost/benefit equation flips in favor of simple blocking APIs.

2Spring and Beyond

Another typical dogma is, "Java is not always Spring." While Spring is one of humanity's most advanced and remarkable frameworks, it still has some flaws; it requires you to write your application in a certain way and increases memory consumption.

There were some attempts to create something better. Quarkus, for example, was quite popular a few years ago, but is not a buzzword now. There is no comparative framework to replace Spring, except for some niche tasks.

So, the main points here are:

  • Java has frameworks for everything.
  • Don't avoid Spring; use it if you can.
  • If you have a specialized task, do your research: most likely, there is a Java framework/library for it.

Enterprise-class app hierarchy

The classic Java class hierarchy goes like this: Controller>DTO>Service>Entity>Repository

The main goal of a standard class hierarchy is to make the class layout predictable. It's useful for applications with over 200,000 lines of code, enabling developers to quickly find a class with specific business logic.

But if you have a small microservice, you don't need to follow the same pattern. You will probably have just 5 controllers with 10+ methods.

Ask yourself:

  • Can a controller go directly to the repository?
  • Do you need to remap an entity to a DTO?
  • If a service needs one SQL query, can it be done directly inline?

Granularity matters, but oversplitting small services can create unnecessary bloat. Sometimes fewer layers mean clearer, more maintainable code.

Let's test the limits and see how far we can distance ourselves from the dogma.

@PostMapping(🌐“/api/booking”) 

public void saveBooking (@RequestBody BookingDTO bookingDTO) { 

record BookingReply( 

UUID uuid, 

String transactionId) { 

{ 

jdbcTpl.update(sql… 

update booking set transaction_id = :transactionId where uuid = :uuid 

…, Map.of( 

k1: "uuid", bookingDTO.uuid, 

k2: "transactionId", restTpl.postForObject( 

url: http://booking.api/booking , 

bookingDTO 

BookingReply.class).transactionId) 

); 

} 
Enter fullscreen mode Exit fullscreen mode

This code is functional, with 17 LOC that shows all the logic end-to-end. Nonfunctional aspects, such as error handling and validations, are handled at the service level. Would this be fine as a part of a 200 LOC microservice? I'm not brave enough to try.

So, one controller that does the rest call and saves the results to the database in just a few lines of code. The reply is in line with everything. Is this legal? Probably not. If I see this in production code, I will probably ask to separate work with the database and HTTP into two different classes due to a mix of concerns.

This would be acceptable on other platforms and languages. Imagine writing 17 lines of code in one file or 5 times more lines for the same logic. The latter sounds better. But don't take this as advice; we were only testing the limits here.

Performance Myths

“Java is Slow”

Many people say Java is slow, but that's not really the case! This perception comes from needing to switch to other frameworks or languages to make things faster. If you check out some performance tests, you'll find that Java actually performs well. Indeed, it may not be as fast as C or Rust, but it still holds its own.

If you're looking to speed things up, consider using the GraalVM Compiler! It's simple to set up with Spring—just add one line to your configuration, and you'll see a boost in performance. You can test it by checking out these links:

  1. GraalVM official website
  2. GraalVM Native Images documentation

“Java Slow Startup”

25 years ago, it might have taken a few seconds to start. But nowadays, numerous ways of speeding things up are essential when designing a microservice architecture in a Kubernetes cluster. A good solution for this is called the Coordinated Restore Checkpoint. It allows you to capture the state of an already started container and deploy it into production. If you follow this link, you'll see how to speed up your application's startup 100 times.

Why Change How We Write Java

There are several reasons for this:

  • Code Assistants: Copilot and similar assistants work best with linear text. You can have one file with data objects; Copilot will know which to include. So, you should write your code to be more readable for assistants since they are our future.
  • Language Evolution (Java and Spring have changed significantly): Design patterns from 20 years ago simply don't work anymore.
  • New Generation Pressure: You should adapt and embrace new approaches. Others don't see a reason to follow old practices.

Conclusion

To keep Java relevant, we must rethink old conventions.

Action points:

  • Stay updated with Java 11, 17, and 21.
  • Study the latest Spring Framework documentation.
  • Explore the broader Spring ecosystem: Spring Data JDBC, Spring Cloud, etc.
  • Question current best practices: Are they relevant today?
  • Experiment with TypeScript or other languages to broaden your programming perspective.

Java isn’t outdated; it’s evolving. The real challenge is ensuring our coding habits evolve with it.

*Originally published on DataArt Team blog.

Top comments (0)