As Java developers, we spend a lot of time writing code. But writing code that works is only half the job.
The real challenge is writing code that another developer can understand, modify, and maintain six months from now — including you.
Over time, I've found that small coding habits can make a significant difference in the quality of a Java application.
Here are five habits worth building.
1. Write Methods That Do One Thing
A method that validates input, saves data, sends an email, and writes logs is doing too much.
Consider this example:
public void registerUser(User user) {
validateUser(user);
userRepository.save(user);
emailService.sendWelcomeEmail(user);
logger.info("User registered: {}", user.getEmail());
}
This may work, but the method coordinates several responsibilities.
A cleaner approach is to extract the workflow into a clearly named method and keep individual responsibilities separate:
public void registerUser(User user) {
validateUser(user);
saveUser(user);
sendWelcomeEmail(user);
}
The benefit isn't fewer lines of code. It's making each operation easier to understand, test, and change.
2. Stop Returning null Without a Good Reason
Returning null can force every caller to remember to check for it.
For methods that represent a potentially missing result, Optional can make the contract clearer.
Instead of:
public User findUser(Long id) {
return userRepository.findById(id)
.orElse(null);
}
Consider:
public Optional<User> findUser(Long id) {
return userRepository.findById(id);
}
The caller can then handle the missing value explicitly:
User user = findUser(userId)
.orElseThrow(() ->
new UserNotFoundException(userId));
Important: Optional is primarily useful as a return type for potentially absent values. It shouldn't automatically replace every nullable field, method parameter, or collection.
3. Prefer Constructor Injection
If you're using Spring, constructor injection makes dependencies explicit and simplifies unit testing.
Instead of field injection:
@Service
public class OrderService {
@Autowired
private OrderRepository orderRepository;
}
Use constructor injection:
@Service
public class OrderService {
private final OrderRepository orderRepository;
public OrderService(OrderRepository orderRepository) {
this.orderRepository = orderRepository;
}
}
With a single constructor, Spring can inject the dependency without requiring @Autowired.
This approach also allows the dependency to be final, making the class easier to reason about and instantiate in tests.
4. Make Illegal States Harder to Represent
Primitive values are convenient, but they don't always express what your application expects.
For example:
public void transferMoney(
Long fromAccount,
Long toAccount,
BigDecimal amount) {
// Transfer logic
}
What happens if the amount is zero or negative? What if both account IDs are identical?
Validate your business rules before performing the operation:
public void validateTransfer(
Long fromAccount,
Long toAccount,
BigDecimal amount) {
if (fromAccount == null || toAccount == null) {
throw new IllegalArgumentException(
"Account IDs are required");
}
if (fromAccount.equals(toAccount)) {
throw new IllegalArgumentException(
"Source and destination must differ");
}
if (amount == null
|| amount.compareTo(BigDecimal.ZERO) <= 0) {
throw new IllegalArgumentException(
"Amount must be greater than zero");
}
}
In a real application, you might use a dedicated value object, domain-specific exceptions, and validation at the appropriate boundaries.
The goal is to prevent invalid data from silently travelling through your system.
5. Write Tests for Behaviour, Not Implementation Details
A test shouldn't break every time you rename a private method or refactor an internal implementation.
Focus on observable behaviour.
For example:
@Test
void shouldThrowExceptionWhenUserDoesNotExist() {
when(userRepository.findById(42L))
.thenReturn(Optional.empty());
assertThrows(
UserNotFoundException.class,
() -> userService.getUser(42L)
);
}
This test describes an expected outcome: requesting a missing user should produce a specific exception.
Good tests give you the confidence to refactor without being afraid of every change.
Bonus: Use Meaningful Names
Compare these two examples:
int d = 30;
int sessionTimeoutInMinutes = 30;
The second version communicates intent without requiring a comment.
Good naming is one of the simplest ways to reduce the mental effort required to understand a codebase.
Final Thoughts
You don't need a complicated architecture or a new framework to improve your Java code.
Start with small, consistent habits:
- Keep methods focused.
- Make missing values explicit.
- Inject dependencies through constructors.
- Validate business rules early.
- Test observable behaviour.
- Choose names that communicate intent.
These practices won't solve every software design problem, but they make everyday development more predictable and maintenance less painful.
A question for fellow Java developers:
What's one coding habit that has made the biggest difference in your projects? I'd love to hear your experiences in the comments.
Top comments (0)