Java has changed a lot since I first started using it. The language I write today looks different—cleaner, safer, and more direct. This isn’t about learning a whole new way of thinking, but about using simple tools that do a lot of work for you. These tools let you focus on what your program should do, not on writing the same repetitive code over and over. Here are some of the most useful features that help me write better, clearer code.
Let’s talk about data. How often have you written a class just to hold a few values? You create fields, a constructor, getter methods, and those equals, hashCode, and toString methods. It’s a lot of typing, and it’s easy to make a mistake. Now, you can use a record. A record is a simple promise: this is a bundle of data.
You declare what’s in the bundle, and Java handles everything else. It creates all the standard methods for you. This is perfect for things like results from a database query, configuration settings, or keys for a map. The code becomes a clear statement of what you intend, without the clutter.
public record DeliveryAddress(String street, String city, String postalCode) {}
Using it is straightforward.
DeliveryAddress address = new DeliveryAddress("123 Main St", "Springfield", "12345");
System.out.println(address.city()); // Prints "Springfield"
The record is final and its data is immutable. This promotes safer code, as you can pass these objects around without worrying about something else changing their contents.
Sometimes, you design a class hierarchy where you want control. You define a base type, but you don’t want just any class to extend it. You want a specific, known set of subclasses. This is where sealed classes come in. You can explicitly state which classes are allowed to extend or implement your class or interface.
This might sound abstract, but it’s practical. It makes your program’s structure a clear, documented part of the code. The compiler knows all the possibilities, which helps it help you.
public sealed interface PaymentMethod permits CreditCard, PayPal, BankTransfer {}
public final class CreditCard implements PaymentMethod {
private String cardNumber;
// ...
}
public final class PayPal implements PaymentMethod {
private String email;
// ...
}
public record BankTransfer(String reference) implements PaymentMethod {}
Because the compiler knows every possible PaymentMethod, you can write logic that is guaranteed to cover every case. We’ll see how this works well with another feature later. This design prevents a teammate from accidentally creating a new PaymentMethod type somewhere else in the codebase, which could break assumptions.
For a long time, the switch statement was a source of minor bugs. You’d forget a break and the code would “fall through” to the next case. A new form, the switch expression, fixes this. It’s an expression that produces a value, and each case points directly to that value.
This change makes your intent clearer. You are asking for a value based on a choice, not just executing different blocks. The syntax is cleaner and less error-prone.
String description = switch (priority) {
case HIGH -> "Immediate attention required";
case MEDIUM -> "Process during normal cycle";
case LOW -> "Background task";
};
You can also use it for more complex logic by using a block.
int cost = switch (serviceTier) {
case BASIC -> 10;
case PRO -> {
int base = 25;
int regionalFee = 5;
yield base + regionalFee; // 'yield' provides the value for this case
}
case ENTERPRISE -> 100;
};
This feels more like writing regular Java, not a special construct with its own rules.
A very common pattern is checking an object’s type and then casting it. The old way is verbose.
if (shape instanceof Circle) {
Circle c = (Circle) shape;
double area = 3.14 * c.radius() * c.radius();
}
Pattern matching for instanceof streamlines this. You check the type and declare a new variable of that type in one step.
if (shape instanceof Circle c) {
double area = 3.14 * c.radius() * c.radius();
}
The variable c is only available inside that if block. It’s a small change, but when you do it dozens of times a day, it removes a significant amount of visual noise and potential for error. This becomes even more powerful when combined with sealed classes. Because the compiler knows all possible subclasses, it can warn you if your if or switch statements don’t cover all possibilities.
Writing strings that span multiple lines, like SQL queries or JSON, used to be messy. You’d concatenate lines with plus signs or use backslash escapes. Text blocks solve this. You use triple quotes to open and close the block, and you can write the text exactly as you want it to appear.
String htmlSnippet = """
<html>
<body>
<p>Hello, %s</p>
</body>
</html>
""".formatted(userName);
String sql = """
SELECT employee.id, employee.name, department.name
FROM employee
JOIN department ON employee.dept_id = department.id
WHERE employee.status = 'ACTIVE'
""";
The formatter strips away the incidental whitespace on the left, so your code can be neatly indented without adding extra spaces to the string itself. It makes embedding data or templates in your code much more pleasant.
Repeating long type names on the left and right side of an assignment can make lines very long. The var keyword lets you declare a local variable and let the compiler figure out the type from the value you’re assigning.
// Instead of:
Map<String, List<CustomerReport>> reportMap = new HashMap<String, List<CustomerReport>>();
// You can write:
var reportMap = new HashMap<String, List<CustomerReport>>();
It’s important to understand that var doesn’t make Java dynamically typed. The variable reportMap is still strongly typed as a HashMap<String, List<CustomerReport>>. The type is inferred at compile time and fixed. Use this when the type is obvious from the right-hand side, like with constructors. If the type isn’t clear, like with a method returning a complex interface, spelling it out might be better for readability.
This feature is about reducing repetition, not hiding information. I find it most useful with long generic declarations and with the results of chained method calls where the intermediate type name is long.
One of the biggest shifts in modern Java is the ease of using functional styles. Lambdas let you pass behavior as an argument. A method reference is a shorthand for a lambda that just calls an existing method.
These are essential for working with the Streams API, which lets you process collections of data in a declarative way. You say what you want to do, not how to do it step-by-step with loops.
List<String> adminEmails = userAccounts.stream()
.filter(account -> account.getRole().equals("ADMIN")) // Lambda
.map(UserAccount::getEmailAddress) // Method reference
.sorted()
.collect(Collectors.toList());
Here, the filter method needs a piece of logic that takes an account and returns true or false. The lambda account -> account.getRole().equals("ADMIN") provides that logic. The map method needs a function to transform an account into an email string. The method reference UserAccount::getEmailAddress says “just call the getEmailAddress method on the account”.
This style often leads to code that is easier to read and reason about, as it chains operations together in a logical flow.
NullPointerException is a common problem. A method returns null to indicate “no result,” and the caller forgets to check. The Optional class is a container that forces you to acknowledge the possibility of an empty result. It can either hold a value or be empty.
You should use it primarily as a return type for methods that might not find something.
public Optional<Customer> findCustomerById(String id) {
// ...lookup logic...
if (customerFound) {
return Optional.of(customer);
} else {
return Optional.empty(); // Explicitly states "not found"
}
}
Now, the calling code must decide what to do if the customer isn’t present. It can’t just call a method on the result without thinking.
String name = findCustomerById("123")
.map(Customer::getFullName) // This runs only if a customer is present
.orElse("Customer Not Found"); // Provide a default value
// Or handle it another way
findCustomerById("456").ifPresent(cust -> {
System.out.println("Sending invoice to " + cust.getEmail());
});
This makes the contract of your method explicit and guides users of your code to handle both success and absence. I avoid using Optional for class fields or method parameters, as it adds unnecessary wrapping; null is usually fine for those with proper documentation.
Java’s standard collections have gained many helpful methods. They reduce the need for verbose utility code. For instance, creating small, unmodifiable lists and maps is now trivial.
List<String> primaryColors = List.of("Red", "Green", "Blue");
Set<Integer> luckyNumbers = Set.of(7, 21, 42);
Map<String, Integer> initialScores = Map.of("Alice", 10, "Bob", 15);
These collections are immutable. They’re perfect for constants or for safely returning internal data without risk of the caller modifying it.
The Map interface has some particularly useful new methods. The computeIfAbsent method is something I use often. It looks up a key; if the value is missing, it calculates it using a function and stores it.
Map<String, List<String>> departmentMembers = new HashMap<>();
// This will get the list for "Engineering", or create a new ArrayList if it doesn't exist,
// then add the new member to that list.
departmentMembers.computeIfAbsent("Engineering", k -> new ArrayList<>())
.add("Jane Doe");
This replaces several lines of checking get, checking for null, creating a new list, and calling put. These small helpers make everyday code more concise and expressive.
Managing resources like files, network connections, or database sessions is critical. They must be closed, even if an error occurs. The old way required a try/catch/finally block where you had to remember to close the resource in the finally block. It was easy to get wrong.
The try-with-resources statement simplifies this. You declare the resources in parentheses after the try keyword. When the block ends, Java automatically closes them for you, in the correct reverse order.
// This is safe and clean
try (BufferedReader reader = new BufferedReader(new FileReader("data.txt"));
BufferedWriter writer = new BufferedWriter(new FileWriter("output.txt"))) {
String line;
while ((line = reader.readLine()) != null) {
writer.write(processLine(line));
writer.newLine();
}
} // Both reader and writer are closed automatically here, even if an exception is thrown
Any class that implements the AutoCloseable interface can be used this way. This construct has eliminated an entire category of resource leak bugs from my code. It’s a definitive improvement.
Using these features, my code has become shorter, more intention-revealing, and less prone to certain classes of bugs. The compiler does more work, catching errors that I would have had to find through testing. Writing in this style feels more like describing the solution and less like performing the mechanical steps to get there. It’s a gradual shift, and you don’t need to use everything at once. Start with one, like using records for your data classes or try-with-resources for file handling. You’ll quickly appreciate the clarity they bring.