Stop writing express controllers!

If you are reading this you probably wrote server-side code at least once. Did you ever have a hard time adding a small feature to the system? Does it take more and more time to introduce new features or fix bugs? You might have laid the foundatations wrong. Let me show you a battle-tested way of laying down backend code — a one that makes it easier to modify and extend your system later. You might be wondering what that has to do with the title — the point is to shift attention from web frameworks to modeling the reality of the business you are writing code for. I’m going to show you a mix of what I extracted & found really useful from some methodologies, most notably DDD., TDD and Robert C. Martin’s “Clean Architecture”. The purpose of this is to present an approach that mixes these with developer experience in typescript & nodejs.

Note: I’m assuming you are building a middle-sized application. This approach doesn’t make sense if you are creating just a handful of endpoints, and if you are building something really big — you should probably go with something more disciplined.

1. Laying the foundations

  • Domain layer

In this layer the code should be a model of reality. That’s it. The domain layer should have no knowledge about databases, MVC frameworks etc.

  • Application layer

This layer contains use-cases of our system — e.g. a user logs in or a user changes password - long story short, it coordinates objects from domain and infrastructure layer.

  • Infrastructure layer

Here goes our database connection, database schemas etc. — also very generic stuff like logging, hashing, 3rd party APIs etc.

  • Driver layer

This layer is glue between our system and outside world — that’s where our Express Controllers, websocket listeners and other such things are.

2. Project files

__tests__
src -/driver
-/application
-/domain
-/infrastructure
package.json
tsconfig.json

When it comes to tsconfig.json, make sure to crank it up to be as strict as possible (well, almost) - turn on the following flags:

"noImplicitAny": true ,
"strictNullChecks": true,
"strictFunctionTypes": true ,
"strictPropertyInitialization": true,
"noImplicitThis": true,
"alwaysStrict": true ,

3. Our needs

Since the domain is pretty straightforward, let’s jump right into defining the requirements:

  • A user registers
  • A user logs in
  • A user wants to get his/her details

These are examples of a system’s use-cases. Let’s begin with user registration.

We’ll surely need something that represents a User. Let's write a test for a class doing just that:

// __tests__/User.ts
import { User } from "../src/domain/User";
describe("User", () => {
describe("User creation", async () => {
it("Should allow you to create a user", () => {
const user = await User.create({
password: "12345678",
email: "email@example.com",
});
expect(user).toBeTruthy();
});
it(`Should NOT allow you to create a user with invalid data`, () => {
expect(
User.create({
password: "12345678",
email: "This is not a valid email",
})
).rejects.toThrowErrorMatchingInlineSnapshot();
});
});
});

There should be more tests — but let’s skip them for now.

Now, when we know what we’re gonna need from the User class, let's write the class itself:

export default class User {
private constructor(
private readonly props: {
email: string;
password: string;
}
) {}
}

You might wonder why we’re using a private constructor - that's done in order to prevent future programmers (code users) from bypassing data validation. We'll only allow to create a User instance with valid data.

Let’s finally add that creation mechanism:

export default class User {
private constructor(
private readonly props: {
email: string;
password: string;
}
) {};
static async create(userInit: {
email: string;
password: string;
}) {
if(!userInitSchema.isValid(userInit)) throw new Error("ERR_INVALID_USER_INIT");
return new User({
email: userInit.email,
password: userInit.password
})
}
}

As for the userInitSchema part - there's many ways to validate data - personally I recommend Zod

You can probably already see that something is wrong here. We are not hashing the password. Let’s fix that:

We can change the return statement to be:

return new User({
email: userInit.email,
password: await bcryptjs.hash(userInit.password)
})

Later on, when we validate the password we’ll just call bcryptjs.compare(candidate, this.props.password). But that's not the way to go - we are making the User class depend on a 3rd party library - and it shouldn't care about how hashing is done. Let's instead isolate the Password to another object that handles it for us.

import { v4 } from "uuid";export default class User {
private constructor(
private readonly props: {
id: string;
email: string;
password: HashedPassword;
}
) {}
static async create(userInit: { email: string; password: string }) {
if (!userInitSchema.isValid(userInit))
throw new Error("ERR_INVALID_USER_INIT");
return new User({
id: v4(),
email: userInit.email,
password: await HashedPassword.create(userInit.password),
});
}
}
class HashedPassword {
constructor(private readonly hashValue: string) {}
static async create(plaintext: string) {
return new HashedPassword(await bcryptjs.hash(plaintext));
}
async isEqual(candidate: string): Promise<boolean> {
return await bcryptjs.areEqual(this.hashValue, candidate);
}
}

Much better. We could go one step further and isolate bcryptjs to the Infrastructure layer, but for now it's enough.

You might wonder why we didn’t allow User to directly depend on bcrypt, but we did allow it to depend on uuid. The reason is - generating random strings is almost as generic as it gets. You don't even need a library to do that. Hashing on the other hand makes you need to use the same hashing library in multiple places - and that makes you much more vulnerable.

We should also take into account that Users need to be stored in the database and hydrated later. For that let's create another static method on the user class:

static async hydrate(userDTO: {
email: string;
id: string;
passwordHash: string;
}) {
if (!userDTOSchema.isValid(userDTO))
throw new Error("ERR_INVALID_USER_DTO_HYDRATION");
return new User({
email: userDTO.email,
password: new HashedPassword(userDTO.passwordHash),
id: userDTO.id
});
}

Notice that the argument is different — we require id, and password in a hashed form.

I’ll leave out the details of this — Khalil Stemmler did a great job in describing this here: https://khalilstemmler.com/articles/typescript-domain-driven-design/repository-dto-mapper/

From now on I’m assuming we have access to global UserRepository object.

Now we’ll write the glue — an application-layer function that registers the user.

Lets create /src/application/UserManager.ts:

import { User } from "../domain/User";
import { UserRepository } from "../infrastructure/repositories";
import { userToDTO } from "../infrastructure/mappers";
export const UserManager = {
async register(userInit: Parameters<typeof User["create"]>[0]) {
const user = await User.create(userInit);

await UserRepository.save(user);

const userDto = userToDTO(user);

return {
email: userDto.email,
id: userDto.id,
}
}
}

You might notice the userToDTO function - we'll need it to translate our User to the outside world. Let's see how we can handle this:

export function userToDTO(user: User) {
const props = user["props"];

return {
id: props.id,
email: props.email,
passwordHash: props.password.hashValue
}
}

Wait, what? Didn’t we define user.props as private? Then why can we access them here? It's because of typescript's ["key"] syntax (square bracket notation) - it's an escape hatch for this kind of situations.

Look out not to return passwordHash - never return the whole DTO directly.

Only now we get to write http-reliant code Notice how we didn’t even take this into account before. Let’s write the simplest express code possible:

const app = express();
import { UserManager } from "../application/UserManager";
app.post(`/api/user`, async (req, res) => {
const result = await UserManager.register(req.body);

return res.status(202).json({
success: true,
data: { user: result }
});
});
app.use((err: any, req: ExpressRequest, res: ExpressResponse, next: NextFunction) => {
console.error(err);
if(isUserError(err)) { // Determining this is not scope of this article
return res.status(400).json({ success: false, error: err });
} else {
return res.status(500).end();
}
});app.listen(2000);

I’m pretty sure you now understand the point of the title.

Let’s add another feature with the same flow: logging in. We need to define our needs in a test. It’d be cool to have a isPasswordValid method on a User:

import { User } from "../src/domain/User";describe("User", () => {
...

describe("Authentication", () => {
const VALID_PASSWORD = "12345678";
const user = await User.create({
email: "email@example.com",
password: VALID_PASSWORD
});

it("Should indicate password correctness when it's in fact correct, and incorrectness if it's not", async () => {
expect(user.isPasswordValid(VALID_PASSWORD)).resolves.toEqual(true);
expect(user.isPasswordValid(VALID_PASSWORD+"e")).resolves.toEqual(false);
});
})
});

Great. Now let’s write the implementation:

User.tsexport default class User {
[....]

async isPasswordValid(candidatePassword: string): Promise<boolean> {
return await this.props.password.isEqual(candidatePassword);
}
}

This should make the test pass. Now we’ll need to write a use-case:

export const UserManager = {
...
async login(credentials: {
email: string;
password: string;
}) {
const targetUser = await UserRepository.findByEmail(credentials.email);

if(!targetUser) {
throw new Error("ERR_USER_NOT_FOUND");
}

const isPasswordValid = await targetUser.isPasswordValid(credentials.password);

if(!isPasswordValid) {
throw new Error("ERR_INVALID_CREDENTIALS");
}

const { email, id } = userToDTO(targetUser);

return targetUser;
}
}

That looks good. Finally, let’s write the controller:

/src/driver/server.ts[...]app.post(`/api/user/login`, async (req, res) => {
const user = await UserManager.login(req.body);

//express-session or similar mechanism
req.session.user = user;

return res.status(200).json({
success: true
});
});

And we’re done! I think that’s enough for you to get the idea. Here’s some room for improvement of what we made:

  • We could make an AppError class extending Error for easy differentiating between operational & programmer error. See this great article
  • When our UserManager grows we might want to extract login method to an UserAuthService or similar - the rule of thumb is methods should be grouped by their purpose. (That's the naming for now - when your app grows you'll deal with subdomains and bounded contexts, but that's not the topic of this article)
  • You should write tests for your use-cases. Maybe even before writing tests for the domain-layer objects. It depends on personal taste and specific case.