Introduction
Earlier this year I discovered the book 97 Things Every Programmer Should Know. It's a book which has a bunch of amazing wisdom regarding software engineering. I didn't go one by one in order but jumped around in between the items, and could relate through most of them.
It reminded me of many times in my career where I didn't follow these concepts. There was a learning in those times and some items described in this book resonated with me immediately.
As software developers, sometimes we think that getting better as a developer and advancing in our careers involves technical expertise. So we get obsessed with learning that new framework, new technology, etc. But in my experience, while being technically proficient is important, the non-technical aspects are what allow a developer from going to "good" to "amazing" developer.
I recently gave a talk to my dev team and shared three concepts from the book. While I haven't read all the items, I shared the three that resonated with me the most so far. In this blog post I wanted to share them because I think they help developers improve.
1. What Would the User Do
By Giles Colborne
This concept highlights that we, as software developers, are not the users. Users of the software have different mental models and problem-solving approaches. Sometimes we create assumptions based on user's input or actions, or assume that the users will understand and use the software.
Observing users is really insightful as well. Using observability and analytics tools that help gather what users do when using the software is really helpful in understanding what are the pain points users face.
One example that I think it's easy to relate to is user input. Sometimes when dealing with user input we, as software developers, assume that the formatting would be as we expected and programmed. However, as mentioned in this concept, "we are not the users". That means we need to think about the different possibilities for the user input.
In the following example I present a function that's receiving a phone number from the user. The function assumes the format to be in a certain way. However, users may be able to send a phone number in different ways:
Without the concept
function validatePhoneNumber (phoneNumber) {
// Assume users will enter phone numbers in the '123-456-7890' format
const regex = /^\d{3}-\d{3}-\d{4}$/;
return regex.test(phoneNumber);
}
// Example usage
const userInput = '123-456-7890';
const isValid = validatePhoneNumber(userInput); // Worksfor the expected format only
In this example we see a simple function validatePhoneNumber
, which validates if a phone number is valid or not. The function expects the format 123-456-7890
for phone numbers. But if we ask the question ¿What would the user do?, the answer is that the user will probably use other types of inputs:
Applying the concept
function validatePhoneNumber (phoneNumber) {
const regex = /^\d{3}-\d{3}-\d{4}$/;
if (!regex.test(phoneNumber)) {
throw new Error('Invalid phone number format. Please use the format: 123-456-7890');
}
return true;
}
// Example usage
const input = '(123) 456-7890';
try {
validatePhoneNumber(input);
console.log('Phone number is valid');
} catch (error) {
console.error(error.message);
}
After applying the concept to this simple function, we can refactor our code to have better error handling to gracefully handle other formats for phone numbers that the user can input. Our code will probably result in a better user experience because the user can receive feedback about the expected format.
2. No "Magic Happens Here"
By Alan Griffiths
This is a concept that developers (myself included) break a lot. It's very easy to assume things "just work" without knowing how they work. This applies to many things: functions or classes, API calls, Async processes and even other people or other teams!
Having an understanding of critical dependencies ensures our own software components are interacting correctly with other processes. "Magic" components or software can fail. When we understand what happens under the hood, we can align better with error handling and ensure a better experience. This makes our software more robust
When building software that interacts with unfamiliar processes/components, take the time to understand it
This concept also applies to other people or teams. From a developer POV, it's easy to just code and be in a "bubble" where we just assume other people/teams just work. Product Managers, Business Analyst, QA Testers, etc. Imagine just how chaotic things will be without them
For the following example, we have a simple function that sends an email. The function uses an unfamiliar "magical" process that sends the message. Assuming it will just work, without understanding the underlying process, makes the function susceptible to unhandled exceptions, and potentially using the process in a wrong way:
Without the concept:
async function sendEmail (recipient, subject, message) {
const queueMessage = { recipient, subject, message, behavior: "send" };
// magically sends the email
await sendToSqsQueue(queueMessage);
}
// Example usage
const recipient = "test@test.com";
const subject = "Hello!";
const message = "This is a test email.";
await sendEmail(recipient, subject, message);
This function is sending an email. However It doesn't handle any potential errors that may occur during the process. Also, it sends the message with the behavior as send
. Without understanding the purpose of the behavior
parameter, it's hard to improve or maintain the code.
Applying the concept:
async function sendEmail (recipient, subject, message) {
try {
const queueMessage = { recipient, subject, message, behavior: "sendEmail" };
await sendToSqsQueue(queueMessage);
return true;
} catch (error) {
console.error("Failed to send email:", error);
return false;
}
}
// Example usage
const isEmailSent = await sendEmail(recipient, subject, message);
if (isEmailSent) {
console.log("Email sent successfully!");
}
else {
console.log("Failed to send email. Please try again.");
}
Now, after inspecting and understanding the unfamiliar sendToSqsQueue
process, error handling can be appropriately implemented. Also, the behavoir
parameter can be set to a better, more performant alternative
Notice that it's not necessary to memorize every small detail about an unfamiliar process - Sometimes just reading the documentation of a "magical" API or process can be enough!
3. The Boy Scout Rule
By Robert C. Martin (Uncle Bob)
This concept is about continuous improvement and refactoring. The boy scouts have a rule: "Always leave the campground cleaner than you found it". Transposed to the software world, it could be similar to "Always leave the code a little bit cleaner than you found it".
This rule is about being able to refactor code as you implement changes in a codebase. Little improvements over time can make a huge difference, specially in a large repository.
I think this is a great rule, as long as it doesn't involve huge refactoring. Ultimately, when working in a change we don't want to break what's already working! The level of refactoring comes to the level of comfort and familiarity with the process (see concept 2). Improvements that impact readability and maintainability are greatly appreciated
A high-performing software team can adopt this rule, which prevents gradual deterioration of the codebase. The larger a codebase becomes, the more difficult changes become if not following a maintainability and refactoring culture. This concept aims to improve code quality every time someone touches a module
For the code example, a function with several bad practices is shown. When a new feature gets implemented, also a small portion of the function is refactored to improve maintainability and readability:
Without the concept:
function calcOrder (odr) {
let x = 0;
// Sum up item prices
for (let i = 0; i < odr.length; i++) {
x += odr[i].prc * odr[i].qty;
}
// Apply discount if applicable
if (odr.length > 5) {
x = x - (x * 0.1);
}
// Special handling for specific product IDs
if (odr.some(e => e.id === 'ABC123')) {
x += 100;
}
return x;
}
This is a sample function that has some bad practices. Bad naming convention for variables, magic strings and numbers, not following the Single Responsibility Principle are among the most apparent.
Applying the concept:
function calcOrder (odr) {
let total = 0; // Improved variable name
for (let i = 0; i < odr.length; i++) {
total += odr[i].prc * odr[i].qty;
}
if (odr.length > 5) {
total = total - (total * 0.1);
}
if (odr.some(e => e.id === 'ABC123')) {
total += 100;
}
// New feature: discount for orders over $500
if (total > 500) {
total -= 20;
}
return total;
}
When implementing a new feature in this function, aside from implementing the required change, the developer also takes the effort to make a small readability improvement: changing the variable called x
to something more appropriate, like total
. Notice the function wasn't all refactored, only a small portion was. The idea is to refactor small portions of code at a time to avoid big refactors that can result in more harm than good
Summary
What Would the User Do? We’re building a digital product for the users. But we are not the users - Think how would users interact with the software and take appropriate action
Don't Rely on "Magic Happens Here": Don’t assume a process or component “just works” - have an understanding of critical dependencies. Also applies to other people/roles
The Boy Scout Rule: Continuous improvement - leave the codebase a bit better that what you found it. Make small refactors aimed towards maintainability and readability
Conclusion
By Following the concepts explained here, you can become a better software developer. These concepts are not purely technical, but I see them more on how to improve in the "Collaboration", "Teamwork" and "Purpose".
In my experience, awesome software developers have great non-technical skills, and the concepts presented here can help a software developer go from "good" to "great" or "awesome"!