Junior to Senior Developer - The One Thing You Need To Learn

Junior to Senior Developer - The One Thing You Need To Learn

When I decided to learn programming many years ago, I could find great tutorials on almost everything. I took fundamental computer science classes from Harvard and Stanford, I took a degree program in full-stack development at Udacity, and I learned about specific coding languages and frameworks on Udemy (you can read more about resources when getting started on programming here).

However, as I became fluent at writing code I faced challenges I had not encountered before. What happens when your codebase grows large? Where should you put your code? Which folder structure makes sense? And on a more fundamental level: how should you architect your code?

You will hear from other coders that planning for architecture is a waste of time. There are apps that work as they should and completely ignore software architecture.

Peter Levels, an online entrepreneur known for websites like NomadList and RemoteOK, took this thinking to the extreme. RemoteOK, a job board for remote work, consists of one PHP file and no frameworks except for jQuery.

Even though you should focus on the business value of your code, you have a second job as a developer. And that job is to keep your code maintainable. If you have been working on larger applications, especially with many developers involved, you know how painful it can be to implement new features in a poorly structured codebase.

Designing a software architecture that remains manageable, while the codebase and developer team is growing, is a highly sought after skill. Engineering teams are one of the largest costs for tech companies. Keeping the time it takes for features to be implemented low is a real business advantage.

You will find other tutorials online that are filled with buzzwords like event-driven architecture, domain-driven design or microservices. I will cover many of the principles behind those trends without you requiring previous knowledge — or a dictionary. I will also give you practical examples on how to implement these principles in your projects today.

The code examples are in Typescript, but you can apply them to any other programming language. The important part is not the syntax, but the principles.

We will use a to-do app as an example to follow along. I know. You had enough of to-do examples in your coder career. I picked a to-do app because the concepts in this application are clear to everyone.

Decoupling is the magic word

The principles and techniques that follow come down to one objective: keep your code decoupled from the rest of the codebase. It should be clear what to expect back from any given function or object when you hand it inputs.

Consider the following code example:

import { db, TodoDBModel } from './db'

interface User {
	id: string;
	roles: [
		'Viewer',
		'Editor',
		'Creator'
	];
	email: string;
}

interface todoProps {
	id: string;
	title: string;
	description?: string;
	createdBy: string;
	done: boolean;
}

export function async createTodo(user, todoProps): TodoDBModel {
	const canCreate = user.roles.includes('Creator');
	if (!canCreate) {
		throw new Error('User does not have permission to create todo')
	}
	
	const todo = await db.todo.create(todoProps);

	return todo;
}

We are creating a to-do item. Before we can create it we need to check whether the user that submitted it is allowed to do so. What’s wrong with this code?

The first problem is with the user validation. Even though our function that checks if the user can create a to-do is small, it’s in the wrong place. The function should do what it says. We will need to repeat the code for checking whether the user is allowed to create to-dos where we need it. We will see how to fix this shortly.

The second problem is this line:

throw new Error('User does not have permission to create todo')

Imagine that you are new to this to-do app project and you need to create a to-do as part of a new feature. You find the function called createTodo and you are delighted.

You use it in your code and suddenly things start breaking. Painstakingly you debug your code only to realize that the createTodo function was lying to you! It promised us a TodoDBUser but forgot to mention that it might throw errors as well. The function is small, but imagine large code files. You would have to study every single line to understand how to use the code. We will also learn how to fix this issue soon.

One more thing that stands out in this code is await db.todo.create(todoProps);. Where did the database suddenly come from? How would I test this function without the database? The logic for creating a to-do should probably be separate from the actual creation in the database. Now that we have seen a bad example, let’s see how we can prevent issues like these with better architecture.

Modules first, function second

When you structure any application structure by modules first, and function second. A module is a logical grouping of your apps functionality. Typically, you have one core module and several supporting ones.

Let’s think about our glorious to-do app for a moment. What features should our app include? Users should be able to sign up and sign in. They should also be able to create, edit and check off to-dos. If their to-dos have a reminder set, our app should email them at the right time. Finally, once they tried the app for two weeks they should be paying us.

We can already identify a couple of modules there. Sign up and sign in could be grouped into an authentication module. Creating, editing and checking off to-dos is the core of our application. We call this module todos. Setting up reminders is part of our to-dos, but sending the email could be grouped in a module called notifications. We might want to add in-app or text message notifications later on. The two weeks trial and payments are part of a billing module.

Probably we also need some shared logic across these different domains, so setting up a folder for shared logic is always a good idea. Right now, a folder structure of our app might look like this:

- modules
	- authentication
	- todos
	- notifications
	- billing
- shared

Now that we have grouped our basic features into modules, let’s think about the functions that our app needs to perform. For this example, let’s pick a simple web api. We need to be able to receive http requests on endpoints. We should also be able to store data in a database for retrieval later. Most importantly, we need a place where to store our business logic — the part that makes our app unique. You can call your business logic layer whatever you want, but you might often hear it mentioned as domain or core.

If you were to create a frontend application with a framework like ReactJS, you would also use layers:

  • Retrieving from and sending data to an API
  • the UI or interface layer created with simple components
  • an interaction layer with hooks, higher-order components and more that connects the two

Think about each of your modules and what layers they need. A notifications module might not need a database layer, but instead connect to external APIs like Sendgrid (email), Twilio (text message) or Slack. Once you have listed all the requirements your folder structure might look something like this:

- modules
	- authentication
		- http
		- core
		- db
	- todos
		- http
		- core
		- db
	- notifications
		- core
		- services
	- billing
		- http
		- core
		- services
- shared
	- core
	- http
	- db

Now your code base is neatly structured into modules based on your main features. You can now develop inside one module without fear of breaking all other functionality of the app. But you might wonder what happens if we need to access the notifications module when we sign up a user? Don’t we cross module boundaries all the time? Often we don’t have to if we leverage events — and that is what we are going to talk about next.

Leverage events to keep modules separated

Let’s look at a fairly common example of a signup method in a web-app:

import { cryptoService } from './crypto-service';
import { notificationsService } from '@modules/notifications/notifications-service';
import { analyticsService } from '@modules/analytics/analytics-service';
import { billingService } from '@modules/billing/billing-service';

async function signup(firstName: string, lastName: string, email: string, password: string): User {
	const hashedPassword = cryptoService.hash(password)
	const newUser = await db.user.create(firstName, lastName, email, hashedPassword)

	await notificationsService.sendWelcomeEmail(newUser.email, newUser.firstName)
	await analyticsService.register(newUser.id)
	await billingService.registerCustomer(newUser.id, newUser.email)
	return newUser
} 

In one function we had to import three different modules. If any of the implementations of these modules changes we might break our signup method — a catastrophe for any app.

How can we process our different service calls without the signup method depending on them? The solution is events. Instead of importing each service and explicitly calling it we can create an event called UserSignedUp and listen to this event in other modules.

You want a central hub that registers and publishes your events across modules — an event bus. An event bus can be simple or advanced depending on your app’s requirements.

A simple event bus could be a simple observer class. If you want to leverage more robust events across services that are individually deployed (microservices), you might want to look into solutions like NATS, Kafka and RabbitMQ.

They key to internalize here is this: whenever you are writing code in one of your modules that does not quite seem to belong there, consider emitting an event and listening to it in other modules instead.

The two things that make your app tick: objects and behavior

On the most fundamental level, your software architecture will be made up of objects (or resources, or domain objects) and behavior (or features or use cases).

The golden rule here is to keep the behavior of objects as close as possible. Let’s explore this concept with a concrete example. If you are not used to object-oriented programming, don’t freak out. I am going to use classes but the same principle can be applied in a functional codebase.

Let’s consider a basic to-do class:

class Todo {
constructor(
	public readonly id: string, 
	public title: string, 
	public description: string,
	public subTodos: Todo[],
	public done: boolean, 
	public readonly creatorId: string) {}
}

Now we want to handle the case when a to-do item is updated by our client app. A first draft of this http handler might look like this:

export async function updateTodo(req: Request, res: Response) {
	const updateProps = req.body;
	const currentTodo = await db.todos.getOne(req.param.id)
	currentTodo.title = updateProps.title;
	currentTodo.description = updateProps.description
	currentTodo.subTodos = updateProps.subTodos
	currentTodo.done = updateProps.done

	const updatedTodo = await db.todos.save(currentTodo)
	res.send(updatedTodo)
}

Right now this Todo just initiates itself with some variables. Let’s ignore all the obvious issues with this function that we covered before like handling http requests and database queries in the same function.

We decide that a to-do that has the done field set to true cannot have any subTodos. Easy enough — we do the following:

export async function updateTodo(req: Request, res: Response) {
	const updateProps = req.body;
	const currentTodo = await db.todos.getOne(req.param.id)
	
	if (updateProps.done && updateProps.subTodos?.length > 0) {
		throw new Error('A todo that is done cannot contain subTodos')
	}

	currentTodo.title = updateProps.title;
	currentTodo.description = updateProps.description
	currentTodo.subTodos = updateProps.subTodos
	currentTodo.done = updateProps.done

	const updatedTodo = await db.todos.save(currentTodo)
	res.send(updatedTodo)
}

That implements our business rule. But now your team points out that this validation should also be applied to the createTodo function. Before you know it, you are adding the same validation all over the place. A solution is to extract the validation logic into its own function that you can import. But an even better solution is to validate as close to the object you are trying to change — the to-do itself.

Let’s slightly rewrite our Todo model class to implement setters. Instead of setting a value directly on the class we can run a function inside the class to handle any additional logic there:

class Todo {
constructor(
	public readonly id: string, 
	private title: string, 
	private description: string,
	private subTodos?: Todo[],
	private done: boolean, 
	public readonly creatorId: string) {}

	public set subTodos(newSubTodos?: Todo[]) {
		if (newSubTodos?.length > 0 && this.done) {
			throw new Error('A todo that is done cannot contain subTodos')
		}
		this.subTodos = newSubTodos;
	}

	public set done(newDone?: Todo[]) {
			if (this.subTodos?.length > 0 && newDone) {
				throw new Error('A todo that is done cannot contain subTodos')
			}
			this.done = newDone;
	}
}

Now it won’t matter where we are setting the done or subTodos values. We can rely on that our Todo is always in a consistent state. Imagine a large codebase with many pathways to updating a to-do. With our solution, we are keeping the business logic close to the object that is changed.

Keep your business logic separated from everything else

No matter what application you are building, it will include unique logic. This is important to remember. You will find many frameworks and tools in the developer community that make it easy to create, update and delete objects in a database. But they can make it very hard for you to implement custom business logic. If you think that all your application is doing is to update values in a database, it’s probably not worth working on at all.

Even simple apps will grow in business logic over time. A prototype of a to-do app will create, update and delete to-dos. But what if you later on want to enable sub-todos? Should a to-do complete when all its sub-todos are completed? The answer depends on what your app is trying to achieve.

This type of logic should be kept separate from everything else in your app. If you think of your dependencies of your app as a pyramid, your business logic should be the top. It does not need any other library or framework to function. It should be written only in your language of choice without any side effects.

Whenever you are wondering where to put code, try to think about how unique this code is to your app. Let’s take validation as one example. Imagine that you are building an API endpoint that copies a to-do. A user can copy a to-do manually in the app, or they can set a to-do to recurring. A recurring to-do is automatically copied at a set time interval.

You need to make sure that the name of the to-do is a valid string that is not longer than 140 characters. Additionally, when a user manually copies a to-do the done state should be copied as well. A recurring to-do’s done state should be reset every time. The first rule is generic. You can use a validation library for the generic validation, but you will need to write custom logic for the second. This is your business logic.

—

If you incorporate these principles in your coding, you will take a huge step towards becoming a great programmer. Architecture isn’t easy at first. But with discipline and attention, it will make your code functional, more secure and maintainable.