Software Engineering Best Practices
Software Engineering Best Practices
In this article, I want to capture some best practices for software engineering that I have found to be quite useful over the years. While I think there are many ways to think about what makes software "good" (be that performance, corrrectness, readability, etc.) and in turn just as many ways to phrase best practices, I want to focus on a few ground truths that I think are universally applicable.
KISS: Keep It Simple, Stupid
As (passionate) developers, we often think about software as a certain art. This often leads us to come up with a intricate and "beautiful" design that is intricate and complex. While sometimes this is quite necessary to tackle the complexity of growing systems (and we will see one such example in a later section), we should always remind ourselves to only introduce complexity when it is necessary.
Take the following as an example - You need to calculate the square root of a number. Now, you could prove your engineering prowess by implementing the Newton's method for calculating square roots and it would certainly look impressive.
def sqrt(x: float) -> float:
"""Calculate the square root of a number using Newton's method."""
if x < 0:
raise ValueError("Cannot calculate square root of a negative number.")
elif x == 0:
return 0
else:
guess = x / 2
while abs(guess * guess - x) >= 1e-10:
guess = (guess + x / guess) / 2
return guess
sqrt(25) # 5.0
However a new developer will have a much easier time understanding the well known built in math.sqrt function and you will loose less time debugging your own implementation.
import math
math.sqrt(25) # 5.0
YAGNI: You Ain't Gonna Need It
While at a first glance this sentence may also apply to the example above, it means something very different. YAGNI is a principle that states that you should not implement functionality until it is actually needed. It reminds us to resist our own temptation to anticipate future needs.
To take one of the most common examples, lets say you are modeling a user and you create the following class:
import "time"
type User struct {
Name string
DateOfBirth time.Time
Address string
}
func NewUser(name string, dateOfBirth time.Time) *User {
return &User{
Name: name,
DateOfBirth: dateOfBirth,
}
}
func (u *User) GetAge() int {
now := time.Now()
age := now.Year() - u.DateOfBirth.Year()
if now.YearDay() < u.DateOfBirth.YearDay() {
age--
}
return age
}
Now this example may be a little controversial, as a user most commonly has a name, but at least from the context given here, we can see that name has no actual downstream use. Even though it is not used the developer will keep it in mind regardless, adding to mental overhead. In this case, we should avoid adding it to the struct until we actually need it.
Both KISS and YAGNI are principles that pay towards simplicity in your design! I can not stress enough how important and often beautiful simplicity is in software engineering. Ideally, the following applies to everything you write: "Often the complexity of a problem is hidden in the simplicity of the solution." or if you prefer a more sophisticated quote by Antoine de Saint-Exupéry:
Perfection is achieved, not when there is nothing more to add, but when there is nothing left to take away.
DRY: Don't Repeat Yourself
Don't repear yourself is one of the most trivial principles, but also one of the most important ones. It helps control complexity in your codebase by making sure logic is not duplicated across your codebase. This not only avoids needing to write the same code multiple times, but also gives a guarantee that the place where the logic needs to be changes is the only one. This particularly helps reduce mental overhead when developing.
Take these two classes as an example - it may not be obvious at first glance but pieces of their logic are duplicated across both of them:
class BaseModel:
pass
class User(BaseModel):
def __init__(self, name: str, date_of_birth: datetime):
self.name = name
class Company(BaseModel):
def __init__(self, name: str):
self.name = name
class UserRepository:
def __init__(self, db: Database, table_name: str):
self.db = db
self.table_name = table_name
def create_user(self, u: User) -> User:
self.db.save(obj=u, table=self.table_name)
return u
class CompanyRepository:
def __init__(self, db: Database, table_name: str):
self.db = db
self.table_name = table_name
def create_company(self, c: Company) -> Company:
self.db.save(obj=c, table=self.table_name)
return c
if __name__ == "__main__":
db = Database()
user_repo = UserRepository(db=db, table_name="users")
company_repo = CompanyRepository(db=db, table_name="companies")
You can already see that there is some duplication across the classes here, but I want to differentiate.
The duplication across the User and Company classes is something I would keep in place as it gives us a clear separation of concerns and allows us to easily add more fields to either of the classes without affecting the other one.
However, the duplication across the UserRepository and CompanyRepository is something I would want to avoid and the concern is actually the same, i.e. "take the object and save it to the database".
By using a simple generic repository, we can avoid this duplication.
class Repository[T_Object: TypeVar]:
def __init__(self, db: Database, table_name: str):
self.db = db
self.table_name = table_name
def create(self, obj: T_Object) -> T_Object:
self.db.save(obj=obj, table=self.table_name)
return obj
if __name__ == "__main__":
db = Database()
user_repo = Repository[User](db=db, table_name="users")
company_repo = Repository[Company](db=db, table_name="companies")
Fail Fast
While fallbacks for errors or missing values can be tempting at times, they ultimately mask mistakes and hide errors in your application. I agree with a catch-all at the top level of your application (especially if you are managing a customer facing application), but you should make sure that those errors are properly logged and monitored. Otherwise, you should always fail fast and let the error propagate up the stack.
Separate implementation from interface
This lets you do many wonderful things, such as swapping out implementations without changing the interface, or even just making it easier to test your code by mocking out the implementation.
Imagine a structure like the following
"Avoid dependencies like the plague"
I put this title in quotes as I think it is a bit of an exaggeration. However, It is vital to ask yourself whether that extra library you are importing or the additional service you are using is actually necessary.
PRs are the most important tool when collaborating with other developers
Each developer has a mental model of the codebase, whether they like it or not. Keeping this model aligned is a challenge. PRs are the bridge between models and serve as an update to the mental model of each participant. Don't be lazy when writing PR descriptions. These descriptions not only motivate other developers to properly engage with the PR, but ultimately are what people see of your work. This is not to say that you should write an essay (again simplicity is king!), but at the same time all necessary information should be included.
Conclusion
In this article, I have shared some of the best practices that I have found to be quite useful in my software engineering journey. While there are many more best practices out there, I think these are some of the most fundamental ones that can be applied to any codebase and any programming language. I hope you found this article useful and found yourself nodding along to some of the points I made. If you have any other best practices that you think are worth sharing, please feel free to reach out to me!