You make a good point and I agree. However, code also doesn't exist in a vacuum; when it's first written it's not perfect. Systems are built iteratively as the problem becomes gradually more understood. The wonderful thing about exceptions is they fit well into this evolution process. I can wrap the top level application processing step in a try/catch block, and I can be confident that we have a plausible starting point. In the very least, I can be confident that I will detect all reasonable failures, and I can be confident they won't spill over into anything else the application is doing. It's very difficult to crash a Java application that's designed to be robust.

If any of my low-level exceptions bubble up into the top-level catch block, when we're expecting to handle all exceptions at lower levels, then I have the ability to do something reasonable in my catch block: abort the task, emit metrics, and log the full stack trace as part of my "unexpected exception" routine. That's really useful: it's one way to discover exceptions that the system ought to handle but isn't handling yet. You might write one of these catch blocks with the expectation that it "should never happen", but if it does we'll detect it and supply a reasonable default behavior.

For example, if my application is a microservice, then I will wrap each microservice operation in a try/catch block, and in the catch block I will include logic to convert the exception into an appropriate failure return code from the operation. Or even better, my service framework will handle this for me -- that's an example of the decoupling that exceptions provide as an abstraction. If any microservice operation throws an undeclared exception, then the framework can surface it as the equivalent of HTTP 503. We might set the expectation that all exceptions should be declared exceptions, but if we ever miss one and have an undeclared exception, then the framework can return an appropriate error response.

The right thing happens by default: we don't need detect-and-propagate logic in each call frame. Some of those layers could also catch and retry the exception, or catch it and refine the exception type, or just catch and log it and emit task-specific log entries or metrics. For example, perhaps I have a try/catch block around some logic that calls S3. A catch block around the S3 calls can emit metrics describing the failure rate of calls to S3, which are useful to alarm on, and to examine distinct from the overall failure rate of my microservice operation which might call other services too. This is an example where a simple catch-observe-propagate-or-rethrow adds meaningful value.

At the same time, those failures could happen for many different reasons. Perhaps my credentials have expired, and so I'll see a 100% failure rate with that step: emergency. Perhaps I'm using a presigned URL, and it's past the expiration time. Perhaps there is a random transient failure. Perhaps the file I'm uploading is too big, or perhaps its checksum doesn't match, and so on. We can start with a basic naive catch block around S3 and refine it over time to handle these cases if we observe that it's relevant to do so.

Exceptions are an excellent foundation for confidently evolving system behavior in these sorts of ways. Plus, I can implement this evolution in the most convenient place for me. I don't have to go to a lot of trouble propagate all down the call stack to where it's thrown; that code can pass it on without knowing what it is. Yes, any of these exception handling blocks is coupled to the exceptions it catches, but higher level blocks aren't, and code in between can pass them on without understanding them -- that's valuable.