Error handling is hard. When writing throwable code, and requirements are unmet, developers generally glance over error handling and let the script crash. However, when writing production-ready code, error handling oftentimes makes up the bulk of the source code. This has a non-trivial impact on readability, maintainability, and debugging.
In this article, we'll explore two very simple tricks that will help make error-handling a breathe.
A first example
Consider the following update_item
function:
VALID_STATUS = { "ON", "OFF" }
def update_item(tenant: User, item_id: str, new_status: str) -> Item
"""Update an item owned by a tenant with a new status"""
if tenant.is_authorized():
try:
item = db.fetch_item(item_id)
if !item.is_locked():
if new_status in VALID_STATUS:
if item.owner.id == tenant.id:
item.status = new_status
item = db.persist_item(item)
return item
else:
raise Exception("Tenant does not own item")
else:
raise ValueError(f"Invalid status '{new_status}'")
else:
raise Exception("Cannot update a locked item")
except UnknownItemException as e:
raise Exception(f"Cannot find item '{item_id}'") from e
except ConnectionError as e:
raise Exception(f"Error connecting to the database") from e
else:
raise Exception(f"Owner is not authorized to update statuses")
This code looks complicated, however, it does something simple: given a set of business rules and user inputs, it fetches an item
, check that the input is valid and the conditions are met, and then update and persist the item.
To help refactor this code, we'll introduce a set of rules:
- Rule 1: Hold your left
- Rule 2: Happy path last comes last
Let's jump to them and explore some examples.
Rule 1: Hold your left
This rule boils down to: when entering a conditional block, you should write as few code as possible in the nested block, and try to bail out of the block as soon as possible.
For example, instead of writing:
def main():
if business_rule:
execute_routine_1()
execute_routine_2()
execute_routine_3()
execute_routine_4()
execute_routine_5()
Write:
def main():
if !business_rule:
return
execute_routine_1()
execute_routine_2()
execute_routine_3()
execute_routine_4()
execute_routine_5()
Here the bulk of the code is located outside of the nested block. Making the average line length shorter.
Following this rule will help remove nested blocks that are hard to follow. Each nested block is a new state to keep in mind, which makes reasoning about the code more complicated.
Having most of the code outside of a nested block will help the maintainer or reviewer to understand what the code is actually about.
This rule alone is not enough to make our code more maintainable, we need to combine it with rule 2.
2. Happy path comes last
This rule is two-fold: following it will help implement code that respects rule one. But more importantly, this rule improves code quality by making the developer stop and think about edge cases first. The assumption being: you are very unlikely to forget to implement the happy path, this is what stakeholders will look at first, however, edge cases could be forgotten if their implementation is pushed at the bottom of a function.
def push_ball(ball: Ball, speed: number):
if speed > 0:
ball.speed += speed
ball.notify()
else:
raise Exception("Speed must be positive")
Following rule 1, we could refactor this code:
def push_ball(ball: Ball, speed: number):
if speed > 0:
ball.speed += speed
ball.notify()
+ return
+
~ # Unecessary else, hold your left!
~ raise Exception("Speed must be positive")
With rule 1 & 2 in mind, we can then invert the if condition, to push the happy path at the bottom of our code:
def push_ball(ball: Ball, speed: number):
# Check for the edge case first:
if speed <= 0:
raise Exception("Speed must be positive")
ball.speed += speed
ball.notify()
With the happy path on the left-most column, it becomes obvious where to look for the main action of a function: bottom-left of any code block. This can seem counter-intuitive since the visual cortex generally scans text from top-left to bottom-right, but you'll notice that functions following rules 1 and 2 won't have a lot of nested code: the happy path will always be easy to find when you know where to look for it.
Let's rewrite our first example
With those rules in mind, let's rewrite our original function:
VALID_STATUS = { "ON", "OFF" }
def update_item(tenant: User, item_id: str, new_status: str) -> Item:
"""Update an item owned by a tenant with a new status"""
# Execute validation linearly:
if !tenant.is_authorized():
raise Exception(f"Owner is not authorized to update statuses")
item: Item = None # Declare item in the outerscope
try:
item = db.fetch_item(item_id)
except UnknownItemException as e:
raise Exception(f"Cannot find item '{item_id}'") from e
except ConnectionError as e:
raise Exception(f"Error connecting to the database") from e
if item.is_locked():
raise Exception("Cannot update a locked item")
if new_status not in VALID_STATUS:
raise ValueError(f"Invalid status '{new_status}'")
if item.owner.id != tenant.id:
raise Exception(f"Tenant does not own item '{item_id}'")
# Finally run happy path:
item.status = new_status
item = db.persist_item(item)
return item
You'll notice that:
- The code is vertically longer, but horizontally narrower
- Even if the code is longer, it is easier to follow: no nested conditions, can be read in a linear fashion
- The happy path is literally the last lines of the block, at the bottom of function
- Conditions that are related are visually grouped using vertical white space management
A mental model: fence oriented programming
One way to mentally visualize this coding pattern is to view each if
statement as a fence (or guard) that needs to be passed before continuing execution. Each fence insure that a requirement is met before executing the happy path. Adding or removing a requirement is simply adding or removing a new fence.
FENCE FENCE
| | / || / || / \
| start | -> | condition A || -- yes -> | condition B || -- yes -> | Execute action |
| | \ met? || \ met? || \ /
| |
-- no -> | abort | -- no -> | abort |
This mental model is similar to Railway Oriented Programming in functional programming languages.
By following the two above rules, reading a piece of code the feels just like reading a checklist of conditions to met before executing the happy path.
update_item
checklist
To learn fence oriented programming, one way is to write our code in a form of a checklist:
- ☑️ Is
tenant
authorized to update statuses? - ☑️ Is the
item
present in the database? - ☑️ Was reading from the database successful?
- ☑️ Is the item unlocked?
- ☑️ Is the input status a valid status?
- ☑️ Is the tenant the owner of the item?
Writing the business logic as a checklist makes writing the code trivial then, just a set of instructions to follow!
Bonus benefit: trivial conditional reordering
Some of you might have noticed, there is a minor bug in the code example presented: status
validation is executed after item retrieval, even if the item is not needed to check the validity of the input. Depending on the database technology and the service architecture, this could become a potential attack vector against our service. For example, an attacker could execute a large number of queries against our service with invalid statuses, hammering the database with unnecessary queries.
Ignoring timing-based side-channel attacks, a simple fix would be to reorder the checks by first doing the status validation, then fetch the item in the database.
Using the inverted condition pattern, this reordering is as simple as moving lines around in our function. while with our first example, doing such would be a dance of moving the correct if/else
condition and correctly matching indentation...
VALID_STATUS = { "ON", "OFF" }
def update_item(tenant: User, item_id: str, new_status: str) -> Item
"""Update an item owned by a tenant with a new status"""
+ # Executing input validation before authorization and consistency checks:
+ if new_status not in VALID_STATUS:
+ raise ValueError(f"Invalid status '{new_status}'")
# Execute validation linearly:
if !tenant.is_authorized():
raise Exception(f"Owner is not authorized to update statuses")
item: Item = None # Declare item in the outerscope
try:
item = db.fetch_item(item_id)
except UnknownItemException as e:
raise Exception(f"Cannot find item '{item_id}'") from e
except ConnectionError as e:
raise Exception(f"Error connecting to the database") from e
if item.is_locked():
raise Exception("Cannot update a locked item")
-
if item.owner.id != tenant.id:
raise Exception(f"Tenant does not own item '{item_id}'")
# Finally run happy path:
item.status = new_status
item = db.persist_item(item)
return item