General clean code in Java
DRY
Duplicated logic means every fix must be found in several places. Extract it to a method, a shared class, or a base type — once you see it a third time and the duplicates truly say the same thing.
Duplication that is coincidental is not duplication. Two rules that happen to compute the same number today, for different reasons, should stay apart.
No magic values
// Bad
if (user.age() >= 18 && order.total() > 100) { applyDiscount(order, 0.15); }
// Good
private static final int LEGAL_ADULT_AGE = 18;
private static final Money FREE_SHIPPING_THRESHOLD = Money.euros(100);
private static final BigDecimal LOYALTY_DISCOUNT = new BigDecimal("0.15");
Money is BigDecimal or a dedicated type, never double — binary floating point cannot represent 0.10, and the rounding error becomes a support ticket.
Immutable by default
// Good — a record is final, its components final, equals and hashCode supplied
public record Money(BigDecimal amount, Currency currency) {
public Money {
Objects.requireNonNull(amount);
if (amount.scale() > 2) throw new IllegalArgumentException("max 2 decimals");
}
public Money plus(Money other) { return new Money(amount.add(other.amount), currency); }
}
The compact constructor is where invariants are enforced — an object that cannot be constructed invalid never needs validating again. Mark fields final unless they must change, return List.copyOf(...) rather than the internal list, and prefer a new instance over a mutating setter.
Sealed types and pattern matching over type checks
// Bad — a chain that must be edited in five places when a shape is added
if (shape instanceof Circle) { ... }
else if (shape instanceof Square) { ... }
// Good — the compiler enforces exhaustiveness
public sealed interface Shape permits Circle, Square, Rectangle {}
double area = switch (shape) {
case Circle c -> Math.PI * c.radius() * c.radius();
case Square s -> s.side() * s.side();
case Rectangle r -> r.width() * r.height();
};
Adding a permitted subtype breaks the build at every switch that must handle it — which is what you want. Record patterns destructure in place: case Circle(double radius) -> Math.PI * radius * radius.
Behaviour that belongs to the type itself still belongs on the type. Use polymorphism when each case is the type's own business; use a sealed switch when the operation belongs to the caller.
Tell, don't ask
// Bad — anaemic object, logic outside it
if (account.getBalance().compareTo(amount) >= 0) {
account.setBalance(account.getBalance().subtract(amount));
}
// Good — the object enforces its own rule
account.withdraw(amount);
A class that is only getters and setters is a struct with ceremony. Move the behaviour that operates on its data into it, and stop exposing the internals that behaviour needs.
Don't chain through objects
// Bad — knows the whole graph; any change breaks it
order.getCustomer().getAddress().getCountry().getCode();
// Good
order.shippingCountryCode();
Ask the object you have for what you need, not for its neighbours.
Streams where they clarify
// Good
var activeEmails = users.stream()
.filter(User::isActive)
.map(User::email)
.toList();
A stream that needs a comment to follow should be a loop. Do not use forEach for side effects on a collection you are building, do not nest streams three deep, and do not chase cleverness — toList() beats collect(Collectors.toList()), and a plain for beats a contorted pipeline.
Handle the boundaries
Null checks at the edges (Objects.requireNonNull in constructors), validation where data enters, empty collections rather than null, and Optional for absent return values. Inside a well-guarded core, stop re-checking.
Composition over inheritance
Inheritance is permanent coupling to a parent's internals. Use it only where the subtype is substitutable everywhere the parent is; for reuse, hold a field.
// Bad — extends to borrow send(), inherits the whole SMTP surface as its own API
class EmailNotifier extends SmtpClient {
void notify(User user, String message) { send(user.email(), message); }
}
// Good — holds what it needs, exposes only notify
final class EmailNotifier {
private final SmtpClient smtp;
EmailNotifier(SmtpClient smtp) { this.smtp = smtp; }
void notify(User user, String message) { smtp.send(user.email(), message); }
}
The composed version takes a fake SmtpClient in tests and can swap transports. Signals you
extended for the wrong reason: an override that throws UnsupportedOperationException, an override
ignoring parameters the base requires, or a three-deep hierarchy with the behaviour in the middle.
Where subclasses were only ever standing in for a fixed set of cases, a sealed interface plus
records says it better — and the compiler checks exhaustiveness. Use an interface to share a
contract, a field to share behaviour, and abstract class only for genuine shared state with an
invariant to protect.
Structure
One public class per file, named after the file. Fields private. Methods ordered so callers appear
above callees, so the file reads top-down (java-clean-functions covers the stepdown rule). Keep
classes focused — a class needing "and" to describe it is two classes. final on a class you do not
intend to be extended.