Habits of the Good Developer
We shape our
buildingssoftware, and thereafter they shape us.-
Winston ChurchillArvid De Meyer
The principle of two creations
In this lesson we'll first look at "the principle of two creations". Applying it is the single most important habit of any good developer.
To explain the principle of two creations, let's start by a citation of S. R. Covey on the subject:
All things are created twice. There's a mental or first creation, and a physical or second creation, to all things. Take the construction of a home, for example. You create it in every detail before you ever hammer the first nail into place. You try to get a very clear sense of what kind of house you want. You work with ideas. You work with your mind until you get a clear image of what you want to build. Then you reduce it to blueprint and develop construction plans. All of this is done before the earth is touched. If not, then in the second creation, the physical creation, you will have to make expensive changes that may double the cost of your home.
- S. R. Covey
In my opinion, this is equally true for software as it is for houses. And if this is true of software, why do we not make our first (mental) creation explicit in the form of documentation? That's killing two birds with one stone.
- Poor/angry bird #1: writing the first creation down, makes us think longer about it, causing the first creation, and thus the second creation, to be more correct.
- Poor/angry bird #2: writing the first creation down gives us perfect documentation.
RULE: The Good Developer writes down his "first creation" in the form of comments when writing code.
An example
As an example, consider the implementation of a login procedure; which takes email address and password and which "logs in" the new user by returning a token.
During the first creation, we work this out in more detail: what are the inputs, what are the preconditions on those inputs (which are the input requirements for the success scenario to happen), what other errors can occur, how do we proceed in the success scenario, ... Writing down the first creation explicitly, results in the following code snippet. We call this "beauty".
RULE: The Good Developer checks preconditions, and aborts early when those aren't met.
/**
* Log in with the given email and password. Results in an auth token.
*
* @param email - The user's email address.
* @param password - The user's password.
*
* @returns An auth token
* @throws {ServiceError} The error that occurred. Contains a type, which is one of:
* - INVALID_CREDENTIALS: invalid email or password provided.
* - MAX_LOGIN_ATTEMPTS: the user has tried too log in too many times in a too small
* time interval, and as a result his account is temporarily locked.
* - USER_LOCKED: the user can not log in as he is currently locked.
* - VALIDATION_REQUIRED: the user can no longer use his account, he must first verify
* his email address.
*/
export async function login(email: string, password: string) {
// Get user with given email.
// Check lastBadLoginAttempt and badLoginAttempts: if the user is
// temporarily locked, don't even bother to check the password.
// Do we have a permanent user lock?
// If the user has no associated password:
// Verify the given password, fail if invalid.
// We've successfully logged in, reset the number of login attempts.
// If this fails the request should still be served.
// Should the account be activated?
// Return a new token.
};
After the second creation, this results in the complete code fragment below. Note how easy it is to read and understand the code and get a rough image of what it does. Also note how easy it is to reason about a specific scenario.
I have a question for you: what will happen if you log in to a locked (banned) user with the wrong password?. Try to answer that question and remark how easy this is for you, being only partially familiar with the rather complex proces of logging in in a more serious application, to answer the question.
/**
* Log in with the given email and password. Results in an auth token.
*
* @param email - The user's email address.
* @param password - The user's password.
*
* @returns An auth token
* @throws {ServiceError} The error that occurred. Contains a code, which is one of:
* - INVALID_CREDENTIALS: invalid email or password provided.
* - MAX_LOGIN_ATTEMPTS: the user has tried too log in too many times in a too small
* time interval, and as a result his account is temporarily locked.
* - USER_LOCKED: the user can not log in as he is currently locked.
* - VALIDATION_REQUIRED: the user can no longer use his account, he must first verify
* his email address.
*/
export async function login(
email: string,
password: string,
): Promise<string> {
// Get user with given email.
const user = await models.user.findOne({
where: { email },
});
if (!user) {
throw options.newServiceError(
'INVALID_CREDENTIALS',
'Invalid email or password.',
);
}
// Check lastBadLoginAttempt and badLoginAttempts: if the user is
// temporarily locked, don't even bother to check the password.
if (
user.badLoginAttempts >= ACCOUNT_LOGIN_MAX_ATTEMPTS &&
(user.lastBadLoginAttempt === null ||
Date.now() - user.lastBadLoginAttempt.getTime() <=
ACCOUNT_LOGIN_ATTEMPTS_LOCK_TIME)
) {
throw options.newServiceError(
'MAX_LOGIN_ATTEMPTS',
'Too many login attempts. The user account is temporarily locked.',
);
}
// Do we have a permanent user lock?
if (user.locked) {
throw options.newServiceError(
'USER_LOCKED',
'This user account is locked.',
);
}
// If the user has no associated password:
// Verify the given password, fail if invalid.
if (!(await passwordVerify(password, user.passwordHash))) {
// Increase number of login attempts. Disregarding of whether this succeeded
// or not, don't swallow the original error of validatePassword.
try {
await models.user.update(
{
badLoginAttempts: user.badLoginAttempts + 1,
lastBadLoginAttempt: new Date(),
},
{
where: { id: user.id },
fields: ['badLoginAttempts', 'lastBadLoginAttempt'],
},
);
} catch (error) {
// Purposefully swallow error
}
// Fail our login.
throw options.newServiceError(
'INVALID_CREDENTIALS',
'Invalid email or password.',
);
}
// We've successfully logged in, reset the number of login attempts.
// If this fails the request should still be served.
try {
await models.user.update(
{ badLoginAttempts: 0 },
{ where: { id: user.id }, fields: ['badLoginAttempts']},
);
} catch (error) {
// Purposefully swallow error.
}
// Should the account be activated?
if (
!user.activatedAt &&
Date.now() - user.createdAt.getTime() >
ACCOUNT_VALIDATION_REQUIRED_AFTER_NUM_DAYS * 24 * 3600 * 1000
) {
throw options.newServiceError(
'VALIDATION_REQUIRED',
'This account is locked until successful activation.',
);
}
// Return a new token.
return await makeAuthToken(user.id, user.roles ?? []);
}
To conclude, the principle of two creations, when applied consistently, improves code quality, reduces bugs, improves readability and provides documentation. Are these things you want in your own code or not? We would already like to thank you for the respect for our customers and your colleagues!
But...
There is a small change that reading the above recommendation of applying the principle of two creations makes you think "but...", for example:
- But so much work :(
- But I am not used to this, I prefer cutting many corners, doing a lot of tickets badly instead of doing a few tickets good.
- But this principle was not used in the other code of my project.
- But... deadlines.
Unfortunately not applying those principles result in low quality code that has the tendency to quickly accumulate into an unmaintainable mess in which you will be bug fixing for 3 months.
Now that we have discussed the principle of two creations, we know that its application is the single most important habit of any good developer. However, good developers have some other good habits. Let's discuss some of them.
Set Terminology
Clean Code chapter 2, which I recommend as a good read, is entitled "Meaningful Names". One prerequisite to come to good naming is defining your terminology.
In one of our projects, Visitr, we failed to do so. For those not yet introduced to Visitr: Visitr is a platform counting and visualizing passengers by capturing WiFi-devices in the neighbourhood. When we started Visitr, we did not define terms, and as a result, "readings", "visits" and "passengers" we're used interchangeably. We then set terminology right:
- A (Measurement Device): a Raspberry Pi (or another device owned by Codifly) detecting other devices in the neighbourhood (e.g., WiFi devices)
- Passenger: a physical person "recognized"/"captured" by the system.
- Visit: a visit of a Passenger at a specific location detected by Visitr; in other words: a physical person detected by a Measurement Device.
- User: a person that can use the online Platform.
From then on, Passengers and Users were completely different things; and so were Passengers and Visits. It made code much more consistent.
Note: defining terminology is one thing, but without sharing the terminology in the team, misunderstandings and inconsistencies will still occur.
RULE: The Good Developer defines terms and shares terminology.
Correct (and Learn from) Your Mistakes
In line with our core values, we won't blame you for your mistakes. However, take responsibility. Correct them.
If you can't solve your problem yourself, or if it would take an unacceptable amount of time to do so, that is okay, just get help.
But don't tell "Here this is what's wrong, can you fix it?" to then let some magical cleaning fairy solve your shit while you do something else. When you created a mess, you and only you are the one in charge to make sure it gets solved.
You can ask someone else to solve the problem, but it should stay on your plate until fully resolved. Actively follow up changes. Ask it again a second (or third, or fourth) time until that person fixes the problem. That is just what Good Developers do.
RULE The Good Developer takes responsibility. He corrects the slightest mistake and corrects every mistake.
About Taking Shortcuts
Taking shortcuts almost always leads to bugs and crappy code, and thus an increase in development time.
When you really, really, really have to take shortcuts (for example, due to some third party not being ready yet or due to limited time or budget), document them in the code with an @TODO prefix. Clearly mention which shortcut was taken and what the implications of the shortcut are.
RULE: The Good Developer knows that avoiding shortcuts is better than having to document them with @TODO. He also knows that not documenting shortcuts is beyond bad.
Use static code analysis and formatter.
This one is a no-brainer. We advocate the use of TypeScript and a proper linter config to prevent common errors as well as a formatter for consistency in style. This is already set up in the seed project, so all you need to do is follow the rules and never commit linter errors (our pipeline checks will also prevent those from being merged in the main branch). If a linter rule or type error is not clear, check with your colleagues and/or mentor before using a disable/ignore comment.
RULE: When a console warning or error, a linter warning or error or a typing error appears, you are taking a shortcut. When you write a block of code without the appropriate documentation, you are taking a shortcut. The Good Developer does not tolerate this and fixes the issue, even if it is not critical or not visible to the user.
Document close to the source
Our most important principle is to put documentation as close to the source code where it is related to as possible.
This is, we prefer a /docs directory over external Swaggerfiles, Gitlab Wikis, Confluence pages or Apple Pages documents on some Dropbox account. We similarly prefer in-code comments (such as ESDoc, APIDoc) over a /docs directories.
RULE: The Good Developer puts documentation as close to the source as possible.
RULE: The Good Developer avoids separate (Swagger)files for API Documentation. He uses tools for inline documentation instead.
Learn Markdown
Many people start writing Markdown without first learning the basic Markdown syntax. Markdown is relatively forgiving, so it will work, but the result will leave a bit to be desired. In short: don't write random things, learn Markdown. You and your documentation will look a lot wiser when you do.
Fortunately, the Markdown syntax is easy and modern editors have the ability to preview Markdown. In Visual Studio Code, for example, type CMD + SHIFT + P and run Markdown: open preview to the side. However, note that this markdown preview is more forgiving than most other markdown renderers. I propose using one of the many online markdown preview websites, for example this one.
To facilitate fast learning of the correct syntax, hang this cheatsheet above your bed.
RULE: The Good Developer writes only correct markdown files and previews the result for verification.
Conclusion
Tell me, what kind of developer do you want to be? Do you want to be the developer generating linter errors and bugs while writing undocumented low-quality code or do you want to be a Good Developer who applies the principle of two creations, who writes high-quality documented code without lint errors and with a minimal amount of bugs. Being a Good Developer is a choice, but unfortunately the default value when not making the choice is "NO".
Exercise
PLEASE SUBMIT ANSWERS AND PROVIDE FEEDBACK USING THE FOLLOWING FORM: https://forms.gle/QNKFkDwpe4PftavN9.
Exercise 1: While working on a code base, you have to make a few changes to the function below. You immediately see that the principle of two creations and the concept of checking preconditions and aborting early were not followed. How would you change the code?
/**
* Refresh the given auth token.
*
* @param {string} authToken - The auth token to refresh.
* @param {CommonServiceOptions} [options] - Service options (like transaction and logger)
*
* @returns {string} A new auth token
* @throws {ServiceError} The error that occurred. Contains a code, which is one of:
* - LOGIN_REQUIRED: automatic refresh of the token is not possible, the user should log in again.
*/
export async function refresh(
authToken: string,
options: CommonServiceOptions = {},
) {
let decodedToken: string;
try {
decodedToken = await verifyAuthToken(authToken);
} catch (err) {
throw new ServiceError('LOGIN_REQUIRED', 'Login required.', err);
}
const user = await data.user.findByPk(decodedToken.userId);
if (user) {
if (!user.locked) {
if (
!user.activatedAt &&
Date.now() - user.createdAt.getTime() >
ACCOUNT_VALIDATION_REQUIRED_AFTER_NUM_DAYS * 24 * 3600 * 1000
) {
throw new ServiceError('LOGIN_REQUIRED', 'Login required.');
}
if (
decodedToken.issuedAt * 1000 + 1000 >=
user.requireLoginForTokensIssuedBefore.getTime()
) {
return await makeAuthToken(user.id, user.roles ?? []);
} else {
throw new ServiceError('LOGIN_REQUIRED', 'Login required.');
}
} else {
throw new ServiceError('LOGIN_REQUIRED', 'Login required.');
}
} else {
throw new ServiceError('LOGIN_REQUIRED', 'Login required.');
}
}
Exercise 2: Correct the following Markdown fragment.
Hello, this is a list:
1. Blub
2. Blab
3. Blob
Very cool!
This is also a list:
- List item 1
- List subitem
- List item 2