Clean Architecture: A practical approach
How you can apply Clean Architecture principles to a real-world project
Introduction
Clean Architecture is a powerful architectural paradigm that provides a clear and structured approach to building scalable, maintainable, and testable applications. It emphasizes the separation of concerns and the use of loosely coupled and independent components, allowing each component to be modified or replaced independently without affecting the rest of the system. While Clean Architecture has gained popularity among software developers, it’s not always easy or necessary to apply all of its principles to every real-world project.
In this article, we’ll focus on how to apply some of the core ideas of Clean Architecture to real-world projects. Moreover, we’ll provide a practical example using Express.js/Typescript, identifying the key benefits and challenges this approach can bring.
Clean Architecture
Clean Architecture was first introduced by American Software Engineer Robert C. Martin in his book titled Clean Architecture, to help developers create an easier-to-maintain code through separation of concerns and the use of loosely coupled and independent components. It basically leans on the concept of having your application divided into layers, in which a layer has no knowledge of any of the external ones. For instance, a CLI-based snake game would rely on text-based commands and display the game board in the terminal, while a GUI-based snake game would have visual components and interactive elements for user interaction. However, the core game mechanics and rules remain consistent across both versions. In this case, it could have a business logic and a presentation layer, in which the first one is not strictly attached to the second one, making it easier to build applications with distinct presentations. This can be achieved by following the Dependency Inversion Principle, one of the SOLID principles (also discussed in the Clean Architecture book), which says that high-level modules should depend on abstractions, not on low-level modules.
Applying Clean Architecture principles to a real-world project
Let’s create a RESTful API with a registration use case. First of all, we’ll divide our application into 3 layers:
- Domain: Models, Use Cases and Ports (interfaces to external dependencies);
- Infra: Implementations for the ports defined on the Domain Layer;
- Main: Application entry point, Use Cases assembly and presentation.
This is not a rule, it’s just an example of how to organize the code. So, let’s create our domain layer:
// @domain/models/user.ts
export type User = {
id: string;
email: string;
password: string;
};
// @domain/repositories/userRepository.ts
import type { User } from '@domain/models/user';
// UserRepository port
export type UserRepository = {
create(user: Omit<User, 'id'>): Promise<User>;
findByEmail(email: string): Promise<User | null>;
};
// @domain/validation/emailValidator.ts
// EmailValidator port
export type EmailValidator = {
validate(email: string): boolean;
};
// @domain/useCases/userRegistration.ts
import type { User } from '@domain/models/user';
import type { UserRepository } from '@domain/repositories/userRepository';
import type { EmailValidator } from '@domain/validation/emailValidator';
export class MissingParam extends Error {
constructor(param: string) {
super(`Missing parameter: ${param}`);
}
};
export class PasswordDoesNotMatchError extends Error {
constructor() {
super('Passwords does not match!');
}
};
export class InvalidEmailError extends Error {
constructor() {
super('Invalid email!');
}
};
export class UserAlreadyExistsError extends Error {
constructor() {
super('There is already a user with this email!');
}
};
type UserRegistrationInput = {
email?: string;
password?: string;
confirmPassword?: string;
};
// UserRegistration use case
export class UserRegistration {
constructor(
private userRepository: UserRepository, // UserRepository port
private emailValidator: EmailValidator,// EmailValidator port
) { }
// Use case execution method
async execute(input: UserRegistrationInput): Promise<User> {
const { email, password, confirmPassword } = input;
if (!email) throw new MissingParam('email');
if (!password) throw new MissingParam('password');
if (!confirmPassword) throw new MissingParam('confirmPassword');
if (password !== confirmPassword)
throw new PasswordDoesNotMatchError();
const isValid = this.emailValidator.validate(email);
if (!isValid)
throw new InvalidEmailError();
const user = await this.userRepository.findByEmail(email);
if (user)
throw new UserAlreadyExistsError();
const newUser = await this.userRepository.create({ email, password });
return newUser;
};
};
Our domain layer doesn’t know anything about how the email is being validated or how the user is being fetched/saved. This way we can focus only on a high-level business logic (the email and password will be validated and a user will be created if it doesn’t exist). Since it’s possible to create any implementation that conforms to the emailValidator and userRepository protocols, it’s easy to write unit tests by faking their outputs and see how the UserRegistration class behaves.
Now we’ll need an implementation for the emailValidator and for the userRepository, so let’s create our infra layer:
// @infra/validator/emailValidator/regexEmailValidator.ts
import type { EmailValidator } from '@domain/validation/emailValidator';
// An adapter for the EmailValidator using regex,
// but it could be any other implementation
export class RegexEmailValidator implements EmailValidator {
regex: RegExp = /^[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}$/;
validate(email: string): boolean {
return this.regex.test(email);
};
};
// @infra/repositories/userRepository/typeorm.ts
import type { User } from '@domain/models/user';
import type { UserRepository } from '@domain/repositories/userRepository';
import { Entity, PrimaryGeneratedColumn, Column, BaseEntity } from 'typeorm';
@Entity({ name: 'user' })
export class TypeormUser extends BaseEntity implements User {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column()
email: string;
@Column()
password: string;
};
// An adapter for the UserRepository using typeorm,
// but it could be using any other implementation
export class TypeormUserRepository implements UserRepository {
create(user: Omit<User, 'id'>): Promise<User> {
const userRepository = TypeormUser.getRepository();
const newUser = userRepository.create(user);
return userRepository.save(newUser);
};
async findByEmail(email: string): Promise<User | null> {
const userRepository = TypeormUser.getRepository();
return userRepository.findOne({ where: { email } });
};
};
Now we just need to create the entry point and assemble the use case with its adapters, so let’s create our main layer:
// @main/rest/endpoints/userRegistration.ts
import { Router, Request, Response } from 'express';
import {
UserRegistration,
PasswordDoesNotMatchError,
InvalidEmailError,
UserAlreadyExistsError,
} from '@domain/useCases/userRegistration';
import { RegexEmailValidator } from '@infra/validation/emailValidator/regexEmailValidator';
import { TypeormUserRepository } from '@infra/repositories/userRepository/typeorm';
// Instantiating the use case adapters
const regexEmailValidator = new RegexEmailValidator();
const typeormUserRepository = new TypeormUserRepository();
// Instantiating the use case itself, injecting its adapters
const userRegistration = new UserRegistration(typeormUserRepository, regexEmailValidator);
const router = Router();
// RESTful endpoint:
// Execute the business logic and handle its errors and success according to the RESTful conventions
router.post('/user-registration', async (req: Request, res: Response) => {
const { email, password, confirmPassword } = req.body;
try {
const user = await userRegistration.execute({ email, password, confirmPassword });
res.status(201).send(`User created for email: ${user.email}`);
} catch (err) {
if (
err instanceof PasswordDoesNotMatchError ||
err instanceof InvalidEmailError ||
err instanceof UserAlreadyExistsError
) {
res.status(400).send(err.message);
} else {
res.status(500).send('Internal server error');
}
}
});
export default router;
As you can see, our main layer is also playing a presentation role, since we tied our RESTful handling logic strictly to express.js and there’s no problem because we probably won’t have to change it to another framework. Also, this is where we manage the use case dependencies, by injecting the adapters we want for them.
If we strongly follow all the clean architecture principles, we’ll probably end up doing some overengineering. For example, when we choose a framework like Fastify, Spring boot or Flask, we’ll probably never want/need to change it to another one, so why we should create some ports and adapters for the RESTful presentation layer? Also, it could make the project really confusing if we create an abstraction for everything. On a real-world project, before we start creating interfaces for every single thing, it’s more interesting we first analyze what’s more likely to change or have more than one implementation and then define our layers and their boundaries.
A database is also something we probably won’t have to change and since the test libraries, such as jest, can fake module implementations, the userRepository could be the database API implementation instead of just a type. Same thing about the emailValidator, if you’re sure there’s not going to be another kind of email validation, it could already be the class implementation instead of a type. In this case, since we want to show how the Dependency Inversion Principle works we kept them as an “interface”.
However, there are cases in which we might want more than one implementation that does “the same thing”. For example, a use case in which we want to download some files from Google Drive, do some processing and upload them to Microsoft OneDrive. Both of them are file storage and you’d have to create a file handler to upload/download files, so you could make your use case use ports for a source and target file storage and inject a Google Drive and a Microsoft OneDrive adapter into it. Another example is, when you’re working on a very bureaucratic environment and you need a use case integrated with a certain external resource you can’t access locally, if you create a port/adapter for this resource, at least you can test your use case logic.
Conclusion
Clean Architecture offers an effective approach to building scalable, maintainable, and testable applications. The three core layers of Domain, Infra, and Main provide a clear separation of concerns, allowing for modular and flexible codebases. By encapsulating business logic in the Domain layer, implementing external dependencies in the Infra layer, and assembling use cases in the Main layer, developers can achieve a well-organized, easier-to-understand and maintainable architecture.
The use of ports and adapters brings additional benefits, enabling flexibility and testability in the code. Ports define the contract for external dependencies, allowing for multiple implementations, while adapters serve as the bridge between ports and their implementations. This decoupling of components facilitates unit testing by easily substituting adapters with fakes or mocks. However, it’s important to strike a balance and avoid unnecessary abstractions. Careful analysis of project requirements is crucial to determine which components truly require abstraction and multiple implementations, ensuring the architecture remains pragmatic and avoids unnecessary complexity. By following the core principles of Clean Architecture and adapting them judiciously, developers can create robust and adaptable applications that are easier to maintain and evolve over time.
You can take a better look at the example code in this repository, which also includes a GraphQL example.