Constants with final
Use final for constants, required fields and locals that never change, and know that final freezes the variable, not the object it points to.
static final int MAX_PAGE_SIZE = 100;
private final Repository repository; // set once in the constructor
final String region; // blank final: assign once later
static final List<String> SORTS = List.of("name", "createdAt");final on a variable means it can be assigned exactly once. Any second assignment is a compile error. It is the simplest way to say "this does not change" and have the compiler hold you to it.
Constants
A class-level constant is static final and named in UPPER_SNAKE_CASE: static final int MAX_PAGE_SIZE = 100;. Use constants for limits, header names, default values and API version strings, so the same magic number is not typed in five places and changed in four.
A static final primitive or String initialised with a constant expression is a compile-time constant. The compiler copies its value into every class that uses it. If you change it in a library and do not recompile the code that uses it, that code keeps the old value. Values that differ per environment (URLs, timeouts, credentials) are configuration, not constants: read them from environment variables or config files at startup.
final does not mean immutable
This is the misunderstanding that causes real bugs. final List<String> names = new ArrayList<>(); stops you pointing names at another list, but names.add(...) works fine. A public static final mutable list is shared, writable global state. For a constant collection use List.of, Set.of or Map.of, which reject changes with UnsupportedOperationException. They also reject null: List.of("name").contains(null) throws NullPointerException, so check for a missing query parameter before looking it up.
final fields
A final field must be assigned once, either where it is declared or in every constructor. That makes it the natural way to write services and value objects whose dependencies and data are set at construction and never swapped: a repository, an HTTP client, a configuration object. Final fields also get a guarantee from the Java memory model: once the constructor finishes, other threads see their values without extra synchronisation, provided the object did not escape during construction. Records make all their fields final for you.
final and effectively final locals
A local can be declared final and assigned later (a "blank final"), as long as every path assigns it exactly once. A local that is never reassigned is effectively final even without the keyword, and lambdas and anonymous classes may only capture effectively final locals. If the compiler complains that a variable used in a lambda must be final or effectively final, look for the line that reassigns it.
final on a class stops subclassing and on a method stops overriding; those uses belong with inheritance.
Example
When to use it
- Define MAX_PAGE_SIZE once and clamp every ?size= parameter in a list endpoint against it.
- Inject a repository and HTTP client into a service through final fields set in the constructor.
- Publish a fixed set of allowed sort fields as List.of so no request handler can modify it.
More examples
A final reference to a mutable object
Any code that can see BLOCKED_IPS can change it, which is rarely what a constant should allow. final only stops the variable from being pointed at another object.
static final List<String> BLOCKED_IPS = new ArrayList<>(); // final, but not constant
void main() {
BLOCKED_IPS.add("10.0.0.7"); // allowed: the list itself is mutable
// BLOCKED_IPS = new ArrayList<>(); // compile error: the reference is final
IO.println(BLOCKED_IPS);
final StringBuilder log = new StringBuilder("start");
log.append(" > done");
IO.println(log);
}
// Output:
// [10.0.0.7]
// start > donefinal fields set by the constructor and a blank final
If a constructor forgot to set baseUrl the class would not compile, so a half-built client is impossible. The blank final region is assigned on every path exactly once.
class ApiClient {
private final String baseUrl;
private final int timeoutSeconds;
ApiClient(String baseUrl, int timeoutSeconds) {
this.baseUrl = baseUrl; // each final field assigned exactly once
this.timeoutSeconds = timeoutSeconds;
}
String describe() {
return baseUrl + " (timeout " + timeoutSeconds + "s)";
}
}
void main() {
String env = "prod";
final String region; // blank final
if (env.equals("prod")) {
region = "eu-west-1";
} else {
region = "local";
}
var client = new ApiClient("https://api.example.com", 5);
IO.println(region + " " + client.describe());
}
// Output:
// eu-west-1 https://api.example.com (timeout 5s)Effectively final locals in lambdas
A lambda captures the value of a local, so Java requires that value never to change. Copying the finished sum into a new final variable is the usual fix.
void main() {
int threshold = 3; // never reassigned: effectively final
List<Integer> scores = List.of(1, 4, 2, 5);
List<Integer> high = scores.stream().filter(n -> n > threshold).toList();
IO.println(high);
int sum = 0;
for (int s : scores) {
sum += s; // reassigned, so not effectively final
}
// scores.stream().map(n -> n * 100 / sum); // compile error: must be final or effectively final
final int total = sum; // copy into a final for the lambda
IO.println(scores.stream().map(n -> n * 100 / total + "%").toList());
}
// Output:
// [4, 5]
// [8%, 33%, 16%, 41%]
Discussion