IllegalStateException: Fix Wrong-Time Calls
Fix IllegalStateException fast: model object lifecycles, guard call order explicitly, and split state from argument errors...
20+ years shipping production Java in banking & fintech. Lessons pulled from things that broke in production.
- ✓Basic Java classes and methods
- ✓Stream and Scanner basics
- ✓A JDK to compile examples
- IllegalStateException means right call at the wrong time: reading a closed Scanner, removing before next, or reusing a consumed stream
- Think in states: list what must happen first, guard each method with an explicit state check and message
- Split it from IllegalArgumentException: illegal value ever versus legal call in the wrong order
- Document call order with @throws javadoc and lock transitions with assertThrows tests
Imagine trying to withdraw cash before opening a bank account — the amount is fine, the account doesn't exist yet. That's IllegalStateException: nothing wrong with your values, everything wrong with your timing. Java objects have life stages — unopened, open, closed — and each method only works in its stage. The fix is respecting the order: open first, use second, and never touch anything after close.
The trace says IllegalStateException: Scanner closed, pointing at a read call with perfectly good arguments. Nothing about the values is wrong — the scanner is just closed, and closed scanners don't read. Somewhere upstream, a close ran early, or a method ran before its setup, or a one-shot object got reused. The bug is choreography, not data.
This exception rewards a different mental model than most bugs. Stop asking what value broke and start asking what order broke: which call should have run first, which state the object is actually in, and who moved it there early. Objects are state machines wearing trench coats, and this exception is the machine rejecting an illegal transition.
This guide builds that model. You'll learn the classic shapes — closed resources, consumed streams, premature removes, committed responses — plus the crisp split from IllegalArgumentException, documentation habits that prevent order bugs, and tests that lock legal transitions. By the end, wrong-time calls get caught by guards with messages instead of found in production logs.
State Machines in Trench Coats: the Core Model
Every object with lifecycle methods is a state machine: it starts somewhere, certain calls move it, and some calls are legal only in some states. A connection is UNOPENED, OPEN, or CLOSED; send() works only in OPEN. A builder is COLLECTING or BUILT; setters work only in COLLECTING. IllegalStateException is the machine saying that transition doesn't exist from here. Debugging means drawing the machine — states as boxes, calls as arrows — and finding the arrow you tried to walk that isn't drawn.
Write your own classes this way on purpose. Keep a state field, check it at the top of each lifecycle method, and throw IllegalStateException with a message naming the required state and the actual one: send() requires OPEN, current is CLOSED. That message compresses the whole investigation into one line. Without it, callers guess; with it, they read.
The Order snippet below is the template: explicit states, guarded transitions, messages that teach. Copy it for every lifecycle class you write — connections, sessions, builders, jobs — and wrong-time calls become self-diagnosing. Reviewers should demand the state field the way they demand null checks. Reviewers should demand the state field the way they demand null checks, because transitions deserve the same visibility. Copy the template for every lifecycle class you write and wrong-time calls become self-diagnosing.
ship() never checked state — the illegal transition silently succeeded instead of throwing. Rule: guards that throw beat silent wrong transitions; the exception is the feature.Closed Resources: Scanner, Streams, and Connections
Closed means done: a closed Scanner rejects reads, a closed stream rejects I/O, a closed connection rejects sends. The throw is correct — the bug is whatever closed early or read late. Try-with-resources is the usual closer, and its scope decides legality: anything reading the resource must live inside the try block. Returns of the resource from inside the block, or background threads still reading after the block exits, throw on schedule.
Double-close is safe but reading after close is not — memorize the asymmetry. Scanner.close() twice is harmless; Scanner.nextLine() after close() throws. This means defensive extra closes are fine while defensive extra reads are bugs. When ownership is unclear, document who closes: the creator closes, borrowers never do. Shared-resource confusion causes most early-close bugs.
The snippet shows both the trap and the shape: returning a scanner from a try block dooms its callers, while keeping consumption inside the block stays legal. For resources shared across phases, don't share the resource — share a supplier that opens fresh ones per phase. When ownership is unclear, document who closes: the creator closes and borrowers never do, since shared-resource confusion causes most early closes. For resources shared across phases, share a supplier instead.
One-Shot Objects: Streams, Iterators, Builders
Some objects are single-use by design. Streams accept exactly one terminal operation — count, collect, forEach — then close permanently; a second terminal throws IllegalStateException. Iterators are consumed forward-only; remove() is legal only after next() and once per element. Builders produce their object at build() and shouldn't sprout setters afterward. Each is a two-state machine with a one-way door, and the exception guards the door.
The stream case from this article's incident deserves memorization: storing a Stream in a field and touching it from two phases guarantees an order bomb. Streams are pipelines, not collections — hold the List, build the stream per use. The same applies to iterator.remove(): call it before next() or twice per element and it throws, because the machine has no transition for that sequence.
The snippet shows the incident shape and its cure side by side. The supplier version builds a fresh stream per phase from the same list, making order irrelevant. Whenever you see a Stream field in review, flag it — fields should hold sources, methods should build streams. Whenever you see a Stream field in review, flag it at once: fields should hold sources while methods build streams. The same single-use discipline applies to iterators and builders with their one-way doors.
next() first, once per element.IllegalState vs IllegalArgument: the One Question
Ask whether any timing makes the call fine. new PortConfig(-1) is never fine — argument error, always. send() before connect() with perfect arguments is fine after connect() — state error. The question cleanly sorts nearly every case you'll meet, and it tells you where the fix lives: argument errors fix the caller value, state errors fix the call order.
Put the checks where they belong. Constructors and setters validate values with IllegalArgumentException because they run before any state exists. Lifecycle methods check order with IllegalStateException because values were already accepted. A class mixing them — argument checks in lifecycle methods, state checks in constructors — confuses every future debugger about which half broke.
The RateLimiter snippet shows both guards cooperating: the constructor rejects bad values, acquire() rejects bad timing after close. Tests then split naturally too — argument tests pass illegal values to construction, state tests call legal methods in illegal order. Teach this split to every junior and your exception taxonomy stays clean for years. Teach this split to every junior early and your exception taxonomy stays clean for years of feature work. Code reviewers should demand the split because it tells the next debugger which half broke.
send() when the socket was closed — the team audited arguments for a day before checking state. Rule: mislabeled state errors cost a day each; get the split right at write time.Documenting Order With @throws Javadoc
Lifecycle order lives in developers' heads until someone writes it down — usually after the incident. Javadoc @throws IllegalStateException is the right pen: @throws IllegalStateException if the scanner is closed states the precondition where every caller reads it. Pair it with @throws IllegalArgumentException for value rules and the method's contract is complete: what to pass, when to call.
Document the full sequence, not just single methods. The class-level doc for a connection should read: open, then any number of sends, then close; sends after close throw. Five words of sequence save five hours of archaeology. Builders should state when build() freezes setters; pools should state borrow-use-return order. If the sequence needs a diagram, the class is too complex — simplify the lifecycle first.
Enforce docs in review for every new lifecycle method. An undocumented order constraint is a bug report from the future, and the reporter will be you at 2 AM. Documented transitions get honored by callers and checked by reviewers; secret ones get violated by everyone equally. If the sequence needs a diagram, the class is too complex, so simplify the lifecycle before documenting it. An undocumented order constraint is a bug report from the future, and the reporter will be you at 2 AM.
Testing Transitions: Both Orders, Every Time
State tests come in pairs: the legal order passes, the illegal order throws. testSendAfterOpen plus testSendBeforeOpenThrows, testReuseAfterTerminalThrows plus testFreshStreamPerPhase. The illegal-order test uses assertThrows(IllegalStateException.class, ...) and asserts the message names the required state. Without the pair, refactors silently reorder calls and the suite stays green while production breaks.
Test both phase orders wherever phases share anything — validation-then-report and report-then-validation, open-use-close and close-attempted-use. The incident in this article would have been caught by a both-orders test the day the stream was hoisted to a field. Order tests are cheap; order incidents are not.
Run them in CI with everything else: mvn -q -Dtest=LifecycleTest test or gradle test --tests 'LifecycleTest'. The snippet shows the full pair pattern on the Order machine. Copy it per lifecycle class and illegal transitions become compile-adjacent failures instead of production pages. Order tests are cheap while order incidents are not, so copy the pair pattern for every lifecycle class you ship. Cover both phase orders wherever phases share state or the next refactor reintroduces the bomb.
Reused Stream Killed Nightly Reports for 4 Days
count() on it, a terminal operation that closes the stream, and reporting later called filter() on the same instance — an illegal transition the JDK rejects. The old code built a fresh stream per phase from the underlying list, so order never mattered. The new order plus the shared field made every run fail deterministically.- Streams are single-use; sharing one across phases is an order bomb. Supply fresh streams from the source collection per phase.
- Refactors that hoist shared mutable state must test both orders. The bug hid until the job order changed, weeks after the refactor.
- Don't blame the runtime for deterministic failures. Rolling back the JDK cost a night; reading the message would have cost minutes.
close() ran early; stream operated upon means a terminal op already ran. Find the state-changing call with grep -rn '\.close()\|\.count()\|\.collect(' src/main/java around the throwing usage. Fix the order, not the call.| File | Command / Code | Purpose |
|---|---|---|
| io | public final class OrderLifecycle { | State Machines in Trench Coats |
| io | public final class ResourceScope { | Closed Resources |
| io | public final class StreamSupply { | One-Shot Objects |
| io | public final class RateLimiter { | IllegalState vs IllegalArgument |
| io | class OrderLifecycleTest { | Testing Transitions |
Key takeaways
Common mistakes to avoid
6 patternsReturning block-owned resources to callers
Storing Streams in fields
Labeling state errors as argument errors
Sharing one-shot objects across threads or phases
Skipping order docs on lifecycle classes
Testing only the happy order
Interview Questions on This Topic
What does IllegalStateException mean?
Frequently Asked Questions
20+ years shipping production Java in banking & fintech. Lessons pulled from things that broke in production.
That's Exception Handling. Mark it forged?
5 min read · try the examples if you haven't