Error Handling Done Right
Pushing Complexity to the Edges
Error handling is one of those things that gets bolted onto code as an afterthought, and that’s exactly how it ends up feeling—bolted on.
A lot of teams approach it the same way:
- Smear
try/catch
blocks across the codebase like peanut butter. - Clutter every function with defensive checks until the actual logic is buried.
- Build unnecessary abstraction layers to “handle errors properly,” only to introduce more failure points.
None of this makes a system more robust—it just makes it harder to read, maintain, and debug.
Here’s the better approach: push error handling to the edges.
Instead of trying to catch every possible issue deep inside the code, let failures happen where they make sense, handle them where they need to be handled, and keep the core logic clean. The goal is simple:
- Keep business logic focused on what it should do, not how to recover from every possible failure.
- Make sure errors are caught and handled as close to the source as possible—at API boundaries, user input points, and external integrations.
- Avoid over-engineering. Earn your complexity.
When done right, error handling isn’t something you constantly wrestle with—it just works.
Next, let’s talk about where errors belong and how keeping them out of your core logic improves your system.
The Right Place for Error Handling
Error handling isn’t about scattering try/catch
everywhere and hoping for the best. It’s about knowing where failures should be caught and dealt with. And that place is at the edges of your system, not deep inside your core logic.
Consider it like this: your core logic should assume things are fine. If something breaks, it shouldn’t be the job of every single function to panic and try to fix it. Instead, failures should be intercepted and handled at clear, well-defined boundaries—the parts of your system that interact with the outside world.
So, where do errors belong?
- External interfaces – API endpoints, file parsers, database queries. If something goes wrong here, handle it at this level, don’t let it leak further in.
- User input points – Forms, request handlers, CLI arguments. Validate input early so the rest of the system doesn’t have to deal with junk data.
- Integration boundaries – Third-party services, network calls, message queues. Expect failure here and decide what should happen when things go wrong before the issue propagates.
If you do this right, your core code remains focused and clean. There are no unnecessary null checks, no try/catch
mazes, and no defensive programming cluttering up logic that has nothing to do with handling errors.
Instead of coding as if failure might happen anywhere, code with the assumption that failure will occur at the boundaries, and deal with it there.
Keeping Core Code Clean: Isolating Error Handling
Once you push error handling to the edges, something extraordinary happens—your core code stops caring about failures.
This is where most teams go wrong. Instead of isolating error handling, they try to “be safe” by sprinkling try/catch
everywhere or littering functions with defensive checks. The result? A mess of logic that’s hard to read, harder to maintain, and impossible to debug.
Here’s the better approach:
- Write functions that assume valid input – Your core logic shouldn’t be in the business of checking for null values or malformed data. That should be handled at the edges, before it even reaches this point.
- Keep failure-prone operations separate – If a function interacts with a file, a database, or an API, don’t mix that logic with business rules. Wrap it in a dedicated function so failures can be handled cleanly.
- Fail fast – If bad data or an invalid state is detected at the boundary, reject it immediately instead of letting it sneak deeper into the system.
Let’s make this concrete.
Bad Example: Mixing Concerns
Here’s what happens when error handling isn’t isolated:
public void processData(String filePath) {
if (filePath == null || filePath.isEmpty()) {
throw new IllegalArgumentException("Invalid file path");
}
try {
String data = readFile(filePath);
if (data == null) {
throw new IOException("Empty file");
}
process(data);
} catch (IOException e) {
logError(e);
}
}
This function is doing too much—validating input, handling file operations, and processing data all in one place. The core logic (process(data)
) is buried under error-handling distractions.
Good Example: Isolating Error Handling
Instead, keep concerns separate:
public void processData(String data) {
process(data); // Assume valid input
}
public String readFile(String filePath) throws IOException {
return Files.readString(Paths.get(filePath));
}
Now, processData
only does what it’s supposed to do. No defensive programming, no unnecessary error handling—just business logic. The function readFile
deals with the possibility of failure, and if something goes wrong, the failure stays at the boundary.
Why This Matters
When error handling is pushed out of core logic, your code becomes:
- Easier to read – You focus on what the function actually does, not all the ways it can fail.
- Easier to test – Business logic can be tested independently without worrying about exceptions.
- More predictable – Failures happen where they should, and they don’t leak into parts of the system that don’t need to deal with them.
Next, let’s talk about testing error handling the right way—and why most teams only do half the job.
4. Earn Your Complexity: Avoid Over-Engineering Error Handling
There’s a common trap in error handling: trying to be too clever.
Developers see a failure and immediately start layering on abstraction—error-handling frameworks, retry managers, and custom exception hierarchies. Before you know it, a simple problem has turned into a stack of complexity that nobody asked for.
Not every failure needs an elaborate solution. Some problems just need a log.error()
and a clean exit.
When to Keep It Simple
Before adding complexity, ask:
- Is this failure common enough to justify an abstraction? If not, a simple
try/catch
or logging mechanism might be all you need. - Does this solution make debugging easier or harder? If error handling introduces more layers than it removes, it’s a problem.
- Would this still make sense in six months? If it’s not apparent why an abstraction exists, it’s going to waste time later.
Error handling should be as simple as the problem requires, not as complex as it could be.
Example of Over-Engineered Error Handling
Some teams love wrapping exceptions in layers of indirection.
public class FileReadException extends RuntimeException {
public FileReadException(String message, Throwable cause) {
super(message, cause);
}
}
public class FileService {
public String readFile(String path) {
try {
return Files.readString(Paths.get(path));
} catch (IOException e) {
throw new FileReadException("Failed to read file: " + path, e);
}
}
}
What does this extra exception do? Nothing useful. It just wraps an existing exception without improving clarity, making debugging harder instead of easier.
A Simpler, Better Approach
public String readFile(String path) throws IOException {
return Files.readString(Paths.get(path)); // Let the caller handle the exception
}
Now, the failure stays where it belongs—at the edge of the system. If a file isn’t found, the caller can handle it however it needs to, without unnecessary wrapping.
When Complexity is Justified
There are times when the extra structure is worth it:
- When failures need to be categorized – If different types of errors require different handling (e.g., user errors vs. system failures), some structured exceptions can help.
- When retry logic needs consistency – A dedicated retry mechanism makes sense if network calls need automatic retries.
- When errors need to be logged centrally – Rather than scattering logging across the codebase, structured logging ensures that failures are traceable.
The key is to earn your complexity. If an abstraction doesn’t solve a real problem, it’s just adding noise.
Final Thoughts
Good error handling isn’t about catching everything everywhere. It’s about:
- Pushing failures to the edges so the core stays clean.
- Isolating error handling so business logic isn’t polluted with defensive programming.
- Testing for failure so you know how the system breaks before it happens in production.
- Keeping it simple unless complexity is necessary.
Most of all, error handling should work with the system, not against it. If failure is predictable and contained, the system becomes easier to build, test, and maintain.
How does your team approach error handling? Are you keeping it simple, or has it spiraled out of control?
Get In Touch
We'd love to hear from you! Whether you have a question about our services, need a consultation, or just want to connect, our team is here to help. Reach out to us through the form, or contact us directly via social media.