Static vs Instance Methods
Decide when a method belongs to the class and when to an object, and why back-end services are usually instances rather than static utilities.
static ReturnType helper(Type arg) { } // ClassName.helper(arg)
ReturnType action(Type arg) { } // object.action(arg), can use thisAn instance method runs on a particular object. It can read and change that object's fields through this, and you call it with an object: service.apply(price). A static method belongs to the class itself. It has no this, cannot see instance fields, and you call it with the class name: Math.max(a, b).
Which to choose
Make a method static when its result depends only on its parameters: Integer.parseInt, String.join, a slugify helper. Static factory methods such as List.of, Duration.ofSeconds and your own Money.ofPounds are another good fit, because they can have descriptive names, which constructors cannot.
Make a method an instance method when it needs state or collaborators: a discount rate, a repository, an HTTP client, a clock. In back-end code most logic lives in services with dependencies, so most methods are instance methods. This is what makes them testable: a test can create the service with a fake repository or a fixed clock. A static method that reaches out to a global database connection cannot be swapped out like that. Frameworks such as Spring create service objects and pass their dependencies in through the constructor for exactly this reason.
The compile error everyone meets
In a classic class, public static void main is static. Calling an instance method from it directly fails with non-static method ... cannot be referenced from a static context, because there is no object to call it on. Either create an object first (new Main().run()) or make the helper static.
Compact source files work differently. There, void main() is an instance method of the implicit class, and the methods you declare next to it are instance methods too, so main can call them directly.
Static gotchas
- Static methods are not overridden. A subclass can declare a static method with the same signature, but it only hides the parent's version. Which one runs depends on the declared type, not the object. Call static methods through the class name to avoid the confusion.
- Static mutable state is shared by everything. A
staticcounter or cache is one value for the whole JVM, so every request thread reads and writes it. It needs thread-safe types such asAtomicLongorConcurrentHashMap, and it leaks state between tests. Prefer fields on a service object that you create once.
Example
When to use it
- Keep pure helpers such as slug and email normalisation as static methods in a small utility class.
- Write an OrderService as an instance with a repository passed to its constructor so tests can supply a fake one.
- Add a static factory like Money.ofPounds that makes call sites in pricing code read clearly.
More examples
A static factory next to instance methods
Money.ofPounds says what the number means, which new Money(12, ...) would not. The plus method needs the current value, so it is an instance method.
record Money(long pence, String currency) {
// Static factories: descriptive names a constructor cannot have.
static Money ofPounds(long pounds) {
return new Money(pounds * 100, "GBP");
}
static Money zero() {
return new Money(0, "GBP");
}
// Instance method: works on this value.
Money plus(Money other) {
if (!currency.equals(other.currency)) {
throw new IllegalArgumentException("currency mismatch");
}
return new Money(pence + other.pence, currency);
}
}
void main() {
Money total = Money.zero().plus(Money.ofPounds(12)).plus(new Money(99, "GBP"));
IO.println(total);
try {
total.plus(new Money(100, "EUR"));
} catch (IllegalArgumentException e) {
IO.println("rejected: " + e.getMessage());
}
}
// Output:
// Money[pence=1299, currency=GBP]
// rejected: currency mismatchStatic methods are hidden, not overridden
Instance methods are dispatched on the real object, static methods on the declared type, so p.kind() runs Parent's version. Adding @Override to the static method would be a compile error, which is a quick way to see the difference.
class Parent {
static String kind() { return "parent static"; }
String name() { return "parent instance"; }
}
class Child extends Parent {
static String kind() { return "child static"; } // hides Parent.kind
@Override
String name() { return "child instance"; } // overrides Parent.name
}
void main() {
Parent p = new Child();
IO.println(p.name()); // decided by the object at run time
IO.println(p.kind()); // decided by the declared type Parent
IO.println(Child.kind());
}
// Output:
// child instance
// parent static
// child staticStatic state is shared by every instance
The static field is shared by both counters, which is what you want for a global total but is also why it carries over between tests. In a real server many threads would call handle at once, so the shared count would need an AtomicInteger.
class RequestCounter {
static int totalAcrossAll = 0; // one value for the whole JVM
private int handledHere = 0; // one value per object
void handle() {
totalAcrossAll++;
handledHere++;
}
int handledHere() {
return handledHere;
}
}
void main() {
RequestCounter users = new RequestCounter();
RequestCounter orders = new RequestCounter();
users.handle();
users.handle();
orders.handle();
IO.println("users: " + users.handledHere());
IO.println("orders: " + orders.handledHere());
IO.println("total: " + RequestCounter.totalAcrossAll);
}
// Output:
// users: 2
// orders: 1
// total: 3
Discussion