We make references to “writing code the right way”, but that is secondary to getting the correct answer. After all, how can you get a good grade if it doesn’t work?
In software engineering, everything needs to work, but doing it the right way is equally important. Why?
Because you are on a team, and someone else may have to understand and edit your code. Including your future self. We call this understandability.
Poorly-implemented solutions are more difficult to change without introducing bugs. We call this maintainability.
Poorly-implemented solutions may work with small data, but become intolerable with millions of records. We call this efficiency.
Overly-specific solutions that make assumptions about the data will break when encountering “the real world”. Avoiding this is called robustness.
The Rules
These characteristics are the result of your code design. The labs in these sections will go through code-level design principles that you, the developer, are responsible for when writing code.
Write these down! We will explore them in-depth in turn. We will start by creating a simple game, then applying design rules to it.
Click below to get started.
1 - pygame setup
Getting started with a game.
Example event-driven program using pygame
We’ll have some fun by creating a very simple game using the pygame library. Our example program comes from a very excellent YouTube tutorial called “The Ultimate introduction to Pygame” by Clear Code. I highly recommend his channel as his tutorials are clear and to the point.
We will implement the code as in his tutorial, but we will re-design the code by applying the rules above. His code works just fine, but our re-design will help improve the understandability, maintainability, efficiency, and robustness of the software.
Setup
Open a Terminal and use cd to get into your seng-201 directory.
Run the command git clone https://github.com/UNCW-SENG/pygame-design. This will create a subdirectory named pygame-design.
Open PyCharm. Go through the menus: File -> Open. Find and open the pygame-design/ folder, then hit the Open button. You should see the following structure:It is essential that the root folder is pygame-design/
Click in the bottom right of your PyCharm window where it either says Add interpreter... or Python 3.x (something).
Then select Add Interpreter -> Add Local Interpreter. You should see something similar to the following:
Make sure Generate New is selected. The pre-populated location should be fine. Then hit OK.
Open the Integrated Terminal in PyCharm. Type the command pip install pygame to download the pygame library.
Open runner.py and run it. A black screen should pop-up and you should see Hello from the pygame community in the integrated Terminal.
You should now be good to go.
Class recording
Code at the end
You must have cloned the project from the setup section. Here is the code at the end of class:
A magic literal is a raw value (number, string, None, etc.) that appears in code without a name explaining its meaning or origin. They harm readability, hide intent, and make changes risky—because the same value might be duplicated in many places.
Rule of thumb: If a value has domain meaning (tax rate, role name, error code, feature flag, file path, regex, etc.), name it once and reuse that name everywhere.
Benefits
Clear intent (self-documenting)
Single source of truth (change in one place)
Fewer bugs during refactors
Easier testing & configuration
Example 1 - numeric literal
Problematic code
deffinal_price(subtotal):# Why 0.085? City tax? Promo? Future me has no idea.returnsubtotal*(1+0.085)
Problem: Where does the value 0.085 come from? Why is it there? Not knowing this harms maintainability.
Fixed with a constant that conveys intent
Constants are variables that don’t vary. They are set once and not changed. In Python, the convention is to name Python constants as ALL_UPPERCASE_AND_UNDERSCORES.
CITY_SALES_TAX_RATE=0.085# 8.5% city sales taxdeffinal_price(subtotal:float)->float:returnsubtotal*(1+CITY_SALES_TAX_RATE)
Why this is better: The constant gives the number meaning, centralizes the value, and invites documentation and tests around that concept. Keep constants close to where they’re used (module-level), or in a dedicated constants.py if shared broadly.
Again, the meaning of each string is hidden. Typos (“vetran”) will silently break the logic. And, finally, if category labels change, you must update multiple places.
The constants clearly express intent and centralize both string values and their corresponding numeric meanings. If a new category or discount rate is added, it only needs to be defined once.
For larger systems, consider moving these constants to a separate constants.py module to avoid duplication across files.
When a literal is not magic
Sentinel/obvious values: 0, 1, -1, True, False, "" used in generic math or indexing (e.g., arr[-1]) are usually fine.
Short-lived throwaway code/tests: Inline values in extremely small, clear scopes can be acceptable.
Data structure examples: Literals inside illustrative examples or test fixtures are usually okay unless they are likely to change.
Knowledge Check
Question: Why are magic literals risky in larger code bases?AnswerBecause you need to update the literal values everywhere they appear in code if the value needs to change.
Question: Which of the following is least likely to be a magic literal?
"admin"
0.075
arr[-1]
"https://api.example.com/v1"
Answer3 — Sentinel and language-specific values like 0, 1, True, False, and -1 are not considered magic literals. In Python, arr[-1] is a shortcut to get the last element of a list.
AnswerThe "admin" and "guest" strings are magic literals. The messages themselves are probably not magic literals since they are likely used only once. However, if your app had internationalization where it supports multiple languages, you would replace those messages with variables.
Question: True or False: It’s acceptable to use a literal directly in code when its meaning is obvious and universally understood, such as 0 in range(0, 10) or True in a simple condition.AnswerThis is True, similar to the answer of Question 2.
The Single Responsibility Principle is that functions should have a single responsibility—i.e., they should be cohesive. Group together into a function statements and logic that have a single, simple goal.
The Single Responsibility Principle is often stated as “each function has one clear reason to change.”
Benefits
Each function has a clear purpose and answers one “what does this do?” question.
Narrow behaviors are easier to unit test.
A change to that single purpose only affects one function rather than requiring changes across unrelated code.
Small, focused functions are easier to compose to solve bigger problems.
Red flags (violations):
The function name needs “and” to describe it.
It touches multiple domains (I/O, parsing, business rules, UI) at once.
It has many parameters or returns mixed/compound results that signal different concerns.
It has multiple try/except blocks for different activities.
It both decides and acts (e.g., computes a result and prints/saves/sends it).
Example 1 - Decision logic mixed with formatting
Problematic code
defdescribe_temperature(celsius:float)->str:ifcelsius<0:color="blue"label="Freezing"elifcelsius<20:color="green"label="Cool"else:color="red"label="Hot"# Mixing presentation logic herereturnf"{label} ({celsius}°C) shown in {color.upper()}"
Problem: This function both categorizes a temperature and formats a human-readable message. If we change color conventions or output format, unrelated logic breaks.
Fixed to separate out unrelated purposes
defclassify_temperature(celsius:float)->str:ifcelsius<0:return"Freezing"elifcelsius<20:return"Cool"return"Hot"defcolor_for_temperature(label:str)->str:return{"Freezing":"blue","Cool":"green","Hot":"red"}[label]defformat_temperature_message(label:str,celsius:float,color:str)->str:returnf"{label} ({celsius}°C) shown in {color.upper()}"defdescribe_temperature(celsius:float)->str:label=classify_temperature(celsius)color=color_for_temperature(label)returnformat_temperature_message(label,celsius,color)
Why this is better: Each helper has a single reason to change — classification rules, color mapping, or formatting.
Example 2 - Data validation mixed with transformation
Each function has one clear reason to change — validation rules vs. formatting rules.
How to refactor toward SRP
Name first. Write a function name that states a single outcome; split if you need “and.”
Separate concerns. Isolate I/O, parsing, validation, business rules, formatting, and presentation.
Extract functions. Pull distinct blocks into helpers with clear inputs/outputs.
Push side effects outward. Keep core logic pure; print/save at the edges.
Knowledge Check
Which function best follows SRP?
process_and_save_and_print_order()
compute_total(items, tax_rate)
read_validate_compute()
do_everything()
Answer2 — the names of the others all imply that they have multiple responsibilities.
A common SRP smell is:
One return statement
Short parameter list
A function that both validates input and writes files
A pure function with docstring
Answer3 — Validating input (from a user, from a file) and writing data to a file are distinct responsibilities within a program.
A good SRP-based refactor typically involves:
Extracting cohesive operations into new functions.
Reducing the number of function calls.
Merging similar code into a single larger function.
Avoiding helper functions.
Answer1 — refactoring an SRP problem almost always will result in more functions in the program.
The SRP is violated when a function changes for more than one reason. “Reason” here refers to:
Multiple developers editing the same code.
Multiple sources of change tied to distinct responsibilities.
The number of commits per week.
The number of test cases.
Answer2 — thinking of "responsibility" as "one task the program performs", if something about that task changes (e.g., getting input, validating input is correct, writing output) it should ideally only affect one function in the code that is separate from the other tasks.
Rule #3: DRY — Don’t Repeat Yourself (and the Rule of Three)
Don’t Repeat Yourself! Commonly called the DRY rule, it simply means don’t write the same code in multiple places.
Why not? Because when you must fix or update the logic, you have to do it everywhere it’s copied—this multiplies effort and risk of bugs.
The Rule of Three is helpful to identify when DRY is being violated. If the same (or nearly the same) code shows up in three or more places, extract it into a function (or module). Two copies feels suspicious, but three is definitely an indicator to refactor.
If the copies differ slightly, find the part that varies and control that via a parameter.
Benefits
One place to fix or improve.
Fewer missed patches and inconsistent behaviors.
Less surface area to understand/review.
Better-named functions document intent.
Example 1 — Obvious repetition → function
Problematic code
# apply discounts in three placestotal_a=subtotal_a-(subtotal_a*0.10)# 10% offtaxed_a=total_a*1.07total_b=subtotal_b-(subtotal_b*0.10)# 10% offtaxed_b=total_b*1.07total_c=subtotal_c-(subtotal_c*0.10)# 10% offtaxed_c=total_c*1.07
The only thing that changes here is the variable acted upon. This is a clear call for a function. There are also magic literals here.
Better code (extract once)
# Note the use of default parameters below. These should DEFAULT_DISCOUNT=0.10DEFAULT_TAX_RATE=0.07defapply_discount_and_tax(subtotal,discount=DEFAULT_DISCOUNT,tax_rate=DEFAULT_TAX_RATE):discounted=subtotal*(1-discount)returndiscounted*(1+tax_rate)taxed_a=apply_discount_and_tax(subtotal_a)taxed_b=apply_discount_and_tax(subtotal_b)taxed_c=apply_discount_and_tax(subtotal_c)
Now we have one function and the repeated code is gone. Even better, that function is now flexible by taking the discount amount and tax rate as parameters. We also cleaned up magic literals!
Example 2 - Hidden duplication (structure, not lines)
Duplication isn’t always copy-paste; sometimes two blocks share a shape. Do you see the similarities and differences? We should refactor the common elements into a reusable helper function, and then create additional functions to provide the specifics to that helper (this will help with SRP too)!
We extracted the common logic of sending the SMTP mail but parameterized the message. Now the send_welcome_email and send_password_reset_email use the helper.
Common Pitfalls
Parameter bloat. Too many knobs can make the function unclear. If it grows unwieldy, split into cohesive variants or use a small strategy object.
Premature abstraction. Don’t over-abstract on the first duplication. Two copies are a smell, the third justifies the refactor.
Duplicating data transformations. Push conversions (e.g., parsing, formatting) to the boundaries. Write functions that work with one canonical representation internally, and make other functions for dealing with particular formats (like in Example 2).
Knowledge Check
What’s the best trigger to refactor for DRY?
The very first time code is written.
When you see two copies anywhere.
When substantially the same code appears three or more times.
Only when performance suffers.AnswerC — Use the **Rule of Three** as a practical trigger (two is a smell, three is a must).
You find three similar blocks differing only in a constant (e.g., a rate). What’s the cleanest DRY fix?
Copy the block and change the constant.
Extract a function and make the constant a parameter.
Add three separate functions.
Inline everything into one giant function.AnswerB — Extract and parameterize the variation.
Two blocks share setup/teardown but build different messages. Best approach?
Leave as is; they’re “not identical.”
Extract just the setup/teardown into a helper and pass the message (or a small builder) as the parameter.
Merge both into one if/else ladder in-place.
Use copy-paste with a TODO.AnswerB — Extract the shared structure; parameterize the varying message.
Your new helper now takes 7 parameters and is hard to read. What next?
Add more parameters.
Revert to duplication.
Split the helper along cohesive responsibilities.
Ignore it.AnswerC — Avoid parameter bloat by grouping or splitting to maintain cohesion.
Handle errors where they can be meaningfully addressed; otherwise, re-raise them.
Example and class note
The class recording below uses the project bank-accounts.zip for an example. The beginning of the recording applies Design Rules 1-3 to the project, including multiple code updates. The discussion of design rules 4-5 begins around 45:30.
Handle Errors at the Lowest Sensible Level
The rule Handle errors at the lowest sensible level, and re-raise/re-throw them otherwise means that you should catch and handle exceptions where you can meaningfully address them, and let them propagate upward when you cannot.
What is sensible? Do not gobble up errors just to hide problems. Catch and fix them if you can, otherwise, raise the error and let the calling function deal with it.
What does it mean to meaningfully address or fix an error? A function can meaningfully address an error when it has the context and capability to either resolve the issue or convert it into a recoverable state. For example, a function that reads user input can handle a ValueError by prompting for valid input again, or a network function can retry a failed connection. However, if a function encounters an error it cannot resolve (like a missing configuration file that the function doesn’t have permission to create, or a badly-formatted input file in a function that only processes data), it should re-raise the exception so a higher-level function with more context can handle it appropriately. The key is: if you can fix it or work around it meaningfully at your level, do so; otherwise, let it propagate.
Benefits
Functions become more robust and clearly defined: “I handle these situations, but not these.”
Error-handling logic is simplified because you only handle what you can fix.
Errors are not hidden; they propagate to where they can be properly addressed.
The user interface layer is responsible for displaying error messages, keeping business logic separate from presentation.
Red flags (violations):
Functions that catch all exceptions and silently return None or default values, hiding real problems.
Catching exceptions at too high a level when they could be handled more specifically at a lower level.
Swallowing exceptions with empty except: blocks or except: pass.
Functions that catch exceptions only to re-raise them without adding context or handling.
Mixing error handling with business logic instead of handling errors where they occur.
Displaying error messages or logging from deep within business logic functions.
Example 1 - Swallowing errors to hide problems
Problematic code
defread_config_file(filename:str)->dict:try:config={}withopen(filename,'r')asf:forlineinf:if'='inline:key,value=line.strip().split('=',1)config[key]=valuereturnconfigexcept:return{}# Silently fails, caller doesn't know what went wrongdefprocess_user_data(config:dict)->list:users=[]foruser_idinconfig.get("user_ids",[]):try:user=fetch_user_from_database(user_id)users.append(user)except:pass# Silently skips users, no indication of failurereturnusers
Problem: These functions swallow errors, hiding real problems. The caller has no way to know if the config file was missing, corrupted, or if the database call failed. Errors are hidden rather than being handled or propagated.
Fixed to handle or re-raise appropriately
defread_config_file(filename:str)->dict:try:config={}withopen(filename,'r')asf:forline_num,lineinenumerate(f,1):line=line.strip()ifnotlineorline.startswith('#'):continueif'='notinline:raiseValueError(f"Invalid format in {filename} at line {line_num}: missing '='")key,value=line.split('=',1)config[key.strip()]=value.strip()returnconfigexceptFileNotFoundError:# Can't handle this at this level - file missing is a real problemraiseexceptValueErrorase:# Could provide more context, but still re-raiseraiseValueError(f"Invalid config format in {filename}: {e}")fromedefprocess_user_data(config:dict)->list:users=[]failed_ids=[]foruser_idinconfig.get("user_ids",[]):try:user=fetch_user_from_database(user_id)users.append(user)exceptConnectionError:# Network error - can't fix here, but we can track itfailed_ids.append(user_id)exceptValueErrorase:# Invalid user ID format - can't fix hereraiseValueError(f"Invalid user_id {user_id}: {e}")fromeiffailed_ids:# Re-raise with context about what failedraiseConnectionError(f"Failed to fetch users: {failed_ids}")returnusers
Why this is better: Errors are either handled meaningfully (with context added) or re-raised so callers can decide how to respond. No errors are silently swallowed.
Example 2 - Handling errors at too high a level
Problematic code
defprocess_order(order_data:dict)->bool:try:# All errors handled at top levelvalidate_order(order_data)calculate_total(order_data)charge_card(order_data)send_confirmation(order_data)returnTrueexceptExceptionase:print(f"Error: {e}")# UI concern in business logic!returnFalse
Problem: All errors are caught at the top level, mixing UI concerns (printing) with business logic. The function can’t distinguish between different types of errors, and the caller gets no information about what went wrong. Different errors might need different handling.
Fixed by handling at appropriate levels
defvalidate_order(order_data:dict)->None:if"items"notinorder_dataorlen(order_data["items"])==0:raiseValueError("Order must contain at least one item")if"card_number"notinorder_data:raiseValueError("Card number is required")# Validation errors are handled here, but they're fixable at input leveldefcalculate_total(order_data:dict)->float:total=0.0foriteminorder_data["items"]:if"price"notinitemor"quantity"notinitem:raiseValueError(f"Invalid item data: {item}")total+=item["price"]*item["quantity"]returntotal# Calculation errors handled here - data problems are fixabledefcharge_card(order_data:dict,amount:float)->None:try:# Payment gateway callpayment_api.charge(order_data["card_number"],amount)exceptpayment_api.InsufficientFundsErrorase:# Can't fix this here, but we communicate what happenedraiseValueError("Insufficient funds to complete the transaction")fromeexceptpayment_api.InvalidCardErrorase:# Can't fix this here eitherraiseValueError(f"Payment failed: {e}")frome# Network errors, etc. - let them propagatedefprocess_order(order_data:dict)->None:# There are no try-except blocks in this function, so it re-raises errors by default. Let the caller handle them. validate_order(order_data)total=calculate_total(order_data)charge_card(order_data,total)# Only send confirmation if everything succeededsend_confirmation(order_data)
Why this is better: Each function handles errors it can meaningfully address (validation, calculation) and re-raises errors it cannot fix (payment failures, network issues). The UI layer can then catch these and display appropriate messages to the user.
How to apply this rule
Handle what you can fix. If you can meaningfully recover from an error at a specific level, handle it there.
Re-raise what you can’t. If you can’t fix the problem, re-raise the exception (possibly with added context) so a caller can handle it.
Don’t swallow errors. Never use bare except: or except: pass unless you’re at the absolute top level (like a main event loop).
Add context when re-raising. Use exception chaining (raise ... from e) to preserve the original error while adding useful context.
Keep UI concerns separate. Displaying error messages or re-prompting the user to enter “good” input are the UI layer’s responsibilities, not the business logic layer’s.
Handle at the lowest level. If a low-level function can fix a specific error (e.g., retry a network call), handle it there rather than letting it bubble up unnecessarily.
Knowledge Check
A function that reads user input encounters a ValueError when parsing a number. The function can prompt the user to re-enter valid input. What should this function do?
Catch the exception and return None to indicate failure
Catch the exception, prompt the user for valid input, and retry the operation
Let the exception propagate to the caller without handling it
Catch the exception and print an error message to the console
Answer2 — since the function can meaningfully address the error by prompting for valid input, it should handle it at this level rather than propagating it upward.
You’re writing a function that processes data from a configuration file. The function encounters a FileNotFoundError but doesn’t have permission to create files. What should it do?
Catch the exception and return an empty dictionary as a default
Catch the exception and print “File not found” to the console
Re-raise the exception (possibly with added context) so a higher-level function can handle it
Use except: pass to silently ignore the error
Answer3 — since the function cannot meaningfully fix this error (it can't create the missing file), it should re-raise the exception so a caller with more context (like the UI layer) can handle it appropriately.
When re-raising an exception with added context, what is the best practice?
Use raise ValueError("New message") to replace the original exception completely
Use raise ValueError("New message") from e to preserve the original exception chain
Only re-raise the original exception without any modifications
Catch and log the exception, then return None
Answer2 — using `raise ... from e` preserves the original exception chain, which helps with debugging by showing both the original error and the added context.
Which of the following is a red flag that violates the “lowest sensible level” principle?
A validation function that raises ValueError when input is invalid
A payment processing function that catches InsufficientFundsError and re-raises it as ValueError with a user-friendly message
A data processing function that catches all exceptions and returns an empty list
A function that lets network exceptions propagate to the caller when it can’t retry the connection
Answer3 — catching all exceptions and returning a default value (like an empty list) hides errors and prevents callers from knowing what went wrong. This violates the principle by swallowing errors that should be handled or propagated.
6 - Raise Specific Errors and Define Your Own If Needed
Use precise exception types to indicate error causes clearly; create custom exceptions when built-in ones don’t fit.
Example and class note
The class recording below uses the project bank-accounts.zip for an example. The beginning of the recording applies Design Rules 1-3 to the project, including multiple code updates. The discussion of design rules 4-5 begins around 45:30.
Raise Specific Errors and Define Your Own If Needed
The rule Raise specific errors and define your own if needed means that you should use the most appropriate exception type for each error situation, choosing from built-in exceptions when they fit, and creating custom exception classes when they don’t.
Why specific errors? Specific exceptions precisely indicate what went wrong, making code more maintainable. When you catch a ValueError, you know the problem is with the value of the data. When you catch a FileNotFoundError, you know a file is missing. Generic exceptions like Exception or bare except: clauses hide the actual problem, making debugging and error handling much more difficult.
Built-in exceptions to use: Python provides many specific exception types. Choose the most appropriate one:
ValueError - often the most appropriate when called with “bad” data (wrong value, invalid format)
TypeError - for unsupported types of data (wrong type passed to function)
FileNotFoundError - when a file or directory cannot be found
PermissionError - when an operation is not permitted due to insufficient permissions
KeyError - when a dictionary key is missing
IndexError - when a sequence index is out of range
AttributeError - when an attribute (variable or function) doesn’t exist on the object, e.g., calling x.append('Bob') but x is a dictionary. Dictionaries don’t understand how to append() in Python.
And many more specific exceptions for different scenarios
When to create custom exceptions: When built-in exceptions don’t accurately represent your domain-specific errors, create your own exception classes. Custom exceptions make it clear that an error is specific to your application’s domain, not a general programming error. For example, if you’re building a payment system, a PaymentProcessingError or InsufficientFundsError is more meaningful than a generic ValueError.
Benefits
Precise error identification: callers can catch specific exceptions and handle them appropriately
Better maintainability: developers can quickly understand what went wrong
Improved debugging: specific error types make it easier to locate and fix issues
Clearer code intent: the exception type itself documents what can go wrong
Enables selective error handling: callers can catch only the exceptions they know how to handle
Red flags (violations):
Raising generic Exception instead of specific exception types
Using ValueError for everything, even when TypeError or other exceptions are more appropriate
Catching all exceptions with bare except: or except Exception: without distinguishing types
Using string error messages instead of exceptions when an exception is more appropriate
Creating custom exceptions that don’t add meaningful information beyond built-in exceptions
Example 1 - Using generic Exception instead of specific exceptions
Problematic code
defvalidate_age(age):ifage<0:raiseException("Age cannot be negative")ifnotisinstance(age,int):raiseException("Age must be an integer")returnagedefprocess_user_data(user_id):try:user=fetch_user_from_database(user_id)returnuserexceptException:returnNone# Caller doesn't know what went wrong
Problem: These functions use generic Exception instead of specific exceptions. Callers can’t distinguish between different error types, making it impossible to handle specific errors appropriately. For example, a caller can’t tell if read_config_file failed because the file was missing (FileNotFoundError) or because of a permission issue (PermissionError), so they can’t respond appropriately.
Fixed using specific exceptions
defvalidate_age(age):ifnotisinstance(age,int):raiseTypeError(f"Age must be an integer, got {type(age).__name__}")ifage<0:raiseValueError(f"Age cannot be negative, got {age}")returnagedefprocess_user_data(user_id):try:user=fetch_user_from_database(user_id)returnuserexceptConnectionErrorase:# Network issue - caller might want to retryraiseConnectionError("Could not connect to database.")fromeexceptValueErrorase:# Invalid user ID format - different from network errorraiseValueError(f"Invalid user_id format: {user_id}")frome
Why this is better: Specific exceptions allow callers to handle different error types appropriately. For example, a caller can catch FileNotFoundError to prompt for a different file, or catch PermissionError to display a permission-related message. The exception type itself communicates what went wrong.
Example 2 - Creating custom exceptions for domain-specific errors
Problematic code
defprocess_payment(card_number:str,amount:float)->bool:ifnotcard_numberorlen(card_number)<13:raiseValueError("Invalid card number")ifamount<=0:raiseValueError("Amount must be positive")ifamount>10000:raiseValueError("Amount exceeds daily limit")# Check if card is expiredifis_card_expired(card_number):raiseValueError("Card is expired")# Check if insufficient fundsbalance=get_account_balance(card_number)ifbalance<amount:raiseValueError("Insufficient funds")# Process paymentreturnTrue
Problem: All errors raise ValueError, even though they represent fundamentally different problems. A caller can’t distinguish between “invalid card format”, “card expired”, “insufficient funds”, and “amount exceeds limit” - all are treated as generic value errors. This makes it difficult to handle different payment errors appropriately (e.g., retry for insufficient funds vs. reject for expired card). You could inspect the error message, but that would be using a magic literal.
Fixed by creating custom exceptions
# Define custom exceptions for payment domainclassPaymentError(Exception):"""Base exception for payment-related errors"""passclassInvalidCardError(PaymentError):"""Raised when card number format is invalid"""passclassCardExpiredError(PaymentError):"""Raised when card has expired"""passclassInsufficientFundsError(PaymentError):"""Raised when account has insufficient funds"""passclassAmountExceedsLimitError(PaymentError):"""Raised when payment amount exceeds allowed limit"""passdefprocess_payment(card_number:str,amount:float)->bool:ifnotcard_numberorlen(card_number)<13:raiseInvalidCardError(f"Invalid card number format: {card_number}")ifamount<=0:raiseValueError("Amount must be positive")# Still ValueError - general validationifamount>10000:raiseAmountExceedsLimitError(f"Amount {amount} exceeds daily limit of 10000")# Check if card is expiredifis_card_expired(card_number):raiseCardExpiredError("Card has expired")# Check if insufficient fundsbalance=get_account_balance(card_number)ifbalance<amount:raiseInsufficientFundsError(f"Insufficient funds: balance {balance}, required {amount}")# Process paymentreturnTrue# Caller can now handle specific errors appropriatelydefhandle_payment_request(card_number:str,amount:float):try:process_payment(card_number,amount)print("Payment successful!")exceptCardExpiredError:print("Your card has expired. Please use a different card.")exceptInsufficientFundsError:print("Insufficient funds. Please try a smaller amount.")exceptAmountExceedsLimitError:print("Payment amount exceeds daily limit. Please contact support.")exceptInvalidCardError:print("Invalid card number. Please check and try again.")exceptPaymentError:# Catch any other payment-related errorsprint("Payment processing failed. Please try again later.")
Why this is better: Custom exceptions clearly communicate domain-specific errors. Callers can catch specific exceptions (InsufficientFundsError, CardExpiredError) and handle them appropriately, or catch the base PaymentError to handle any payment-related error. The exception hierarchy also allows for selective handling: catch PaymentError for all payment issues, or catch specific subclasses for granular control.
How to apply this rule
Choose the most appropriate built-in exception. When raising an error, use the most specific built-in exception that accurately describes the problem:
Use ValueError for invalid values or data formats
Use TypeError for wrong types
Use FileNotFoundError for missing files
Use PermissionError for permission issues
Use KeyError for missing dictionary keys
Use IndexError for out-of-range indices
And so on…
Create custom exceptions when built-in ones don’t fit. When your error is domain-specific and doesn’t match any built-in exception, create your own:
Don’t use generic Exception. Avoid raising Exception directly - it’s too generic and doesn’t help callers handle errors appropriately.
Don’t misuse ValueError for everything. While ValueError is common, don’t use it when TypeError, FileNotFoundError, or other exceptions are more appropriate.
Catch specific exceptions when possible. When catching exceptions, catch specific types rather than generic Exception:
try:process_data()exceptFileNotFoundError:# Handle missing fileexceptValueError:# Handle invalid data
Use exception hierarchies for domain errors. Create a base exception class for your domain, then subclass it for specific cases. This allows callers to catch either specific errors or all domain errors:
try:process_payment()exceptInsufficientFundsError:# Handle specific caseexceptPaymentError:# Handle any payment error
Knowledge Check
You’re writing a function that validates user input. The function receives a string when it expects an integer. What exception should you raise?
Exception("Expected integer")
ValueError("Expected integer")
TypeError("Expected integer")
AttributeError("Expected integer")
Answer3 — TypeError` is the most appropriate exception for when the wrong type is passed to a function. ValueError would be for an integer with an invalid value (like a negative age), not for the wrong type entirely.
Your function reads a configuration file, but the file doesn’t exist. What exception should you raise?
ValueError("File not found")
FileNotFoundError("config.txt")
Exception("File missing")
KeyError("config.txt")
Answer2 — FileNotFoundError is the specific built-in exception for missing files. It's more precise than `ValueError` or generic `Exception`, and allows callers to handle file-not-found errors specifically.
You’re building a payment processing system and need to indicate when a payment fails due to insufficient funds. The built-in exceptions don’t accurately represent this domain-specific error. What should you do?
Raise ValueError("Insufficient funds") since it’s a value problem
Create a custom exception class like class InsufficientFundsError(Exception)
Raise Exception("Payment failed") to be generic
Return False instead of raising an exception
Answer2 — when built-in exceptions don't accurately represent your domain-specific errors, create custom exception classes. This makes the error type clear and allows callers to catch and handle InsufficientFundsError specifically, which is more meaningful than a generic ValueError.
Which of the following is a red flag that violates the “raise specific errors” principle?
Using TypeError when a function receives the wrong type
Creating a custom DataProcessError exception for data processing failures
Raising Exception for all errors instead of specific exception types
Using FileNotFoundError when a file is missing
Answer3 — raising generic Exception for all errors violates the principle because it doesn't help callers distinguish between different error types. Specific exceptions like ValueError, TypeError, FileNotFoundError, or custom exceptions allow for precise error handling.