Clean Node.js Architecture | Enterprise Node.js + TypeScript
Have you ever heard of the "clean architecture"?
Maybe you've heard it by a different name...
Clean Architecture, the Onion Architecture, Ports & Adapters, Hexagonal Architecture, the Layered Architecture, DCI (Data, Context and Interaction), etc.
They're all a little bit different in implementation, but for our understanding: they all mean the same thing.
Separation of concerns at the architectural level
I first discovered the term when I read "Clean Architecture" by Robert C. Martin (Uncle Bob) (which, despite some negative reviews is actually an incredible read and I highly recommend you check it out).
After reading his book and spending some time learning the SOLID principles, not only did I enjoy the fact that the flexibilty and testability of my code improved, but I became way more confident tackling complex software development problems with TypeScript and Node.js.
In this article, I'll cover:
- How the clean architecture separates the concerns of your code
- How it enables you to write testable code
- How it also enables you to write flexible code
Understanding the Clean Architecture
Policy vs. Detail
When we're writing code, at any given time, we're either writing Policy or Detail.
Policy is when we're specifying what should happen, and when.
Policy is mostly concerned with the business-logic, rules and abstractions that exist in the domain that we're coding in.
Detail is when we specify how the policy happens.
Details actually enforce the policy. Details are implementations of the policy.
An easy way to figure out if the code you're writing is detail or policy is to ask yourself:
- does this code enforce a rule about how something should work in my domain?
- or does this code simply make something work
For that reason: frameworks (like Nest.js and Express.js), npm libraries (like lodash, RxJs or Redux) are details.
Again,
The ultimate goal of the Clean Architecture is to separate Policy vs. Detail at the architectural level.
So let's see what that looks like:
Layered Architecture
Those small half-circles are meant to signify writing interfaces (at the policy level) to be implemented by the detail level.
This diagram is a sort of simplification of all of the other diagrams I found. There's more than just these two layers (read "Organizing App Logic with the Clean Architecture" for a more detailed depiction). But for our understanding of the concept, its much easier to think about a clean architecture like this.
So what does this mean?
In one layer (domain) we have all of the important stuff: the entities, business logic, rules and events. This is the irreplaceable stuff in our software that we can't just swap out for another library or framework.
The other layer (infra) contains everything that actually spins up the code in the domain layer to execute.
You'll recall that this is the biggest challenge in MVC, figuring out what the "M" should do and how it does it. Well, this is it. The "M" = that Domain Layer.
Here's an illustration how a RESTful HTTP call might cause code to be executed across our entire architecture.
There's a pattern here with respect to the direction of dependencies.
The Dependency Rule
In Uncle Bob's book, he describes the dependency rule.
That rule specifies that something declared in an outer circle must not be mentioned in the code by an inner circle.
In other diagrams, there are many more layers. The rule still applies.
That means that code can only point inwards.
Domain Layer code can't depend on Infrastructure Layer code.
But Infrastructure Layer Code can depend on Domain Layer code (because it goes inwards).
When we follow this rule, we're essentially following the Dependency Inversion rule from the SOLID Principles.
Ports and Adapters way of thinking about Clean Architecture
The ports and adapters approach to thinking about this is that the interfaces and abstract classes are the Ports and the concrete classes (ie: the implementations) are the adapters.
Let's visualize it.
Let's say I was to design an IEmailService
interface. It specifies all of the things that an Email Service can do. But it doesn't actually implement any of those things specifically.
export interface IEmailService {
sendMail(mail: IMail): Promise<IMailTransmissionResult>;
}
Here's my little Port.
And let's say I'm just wiring up some code that relies on an IEmailService
.
class EmailNotificationsService implements IHandle<AccountVerifiedEvent> {
private emailService: IEmailService;
constructor (emailService: IEmailService) {
DomainEvents.register(this.onAccountVerifiedEvent, AccountVerifiedEvent.name)
}
private onAccountVerifiedEvent (event: AccountVerifiedEvent): void {
emailService.sendMail({
to: event.user.email,
from: 'me@khalilstemmler.com',
message: "You're in, my dude"
})
}
}
Because I'm referring to policy, all that's left to do is to create the implementation (the details).
// We can define several valid implementations.
// This infra-layer code relies on the Domain layer email service.
class MailchimpEmailService implements IEmailService {
sendMail(mail: IMail): Promise<IMailTransmissionResult> {
// alg
}
}
class SendGridEmailService implements IEmailService {
sendMail(mail: IMail): Promise<IMailTransmissionResult> {
// alg
}
}
class MailgunEmailService implements IEmailService {
sendMail(mail: IMail): Promise<IMailTransmissionResult> {
// alg
}
}
When I go to hook this thing up, I have several options available now.
// index.js
import { EmailNotificationsService } from './services/EmailNotificationsService'
import { MailchimpEmailService } from './infra/services/MailchimpEmailService'
import { SendGridEmailService } from './infra/services/SendGridEmailService'
import { MailgunEmailService } from './infra/services/MailgunEmailService'
const mailChimpService = new MailchimpEmailService();
const sendGridService = new SendGridEmailService();
const mailgunService = new MailgunEmailService();
// I can pass in any of these since they are all valid IEmailServices
new EmailNotificationsService(mailChimpService)
Look! The port fits the adapter ❤️.
Hopefully we're starting to see how this can make our code more testable and flexible.
Code is testable
If you follow the dependency rule, domain layer code has 0 dependencies.
You know what that means?
We can actually test it
Next time you're writing code, think about it like this...
Before you get too far along working on some classes, ask yourself: "can I mock what I just wrote?"
If you were following SOLID and referring to interfaces or abstract classes, the answer will be yes.
If you've been referring to concretions, it'll be considerably more challenging to write tests for it. This is caused by coupling.
Code is flexible
When we've separated the concerns between Policy and Detail, we create an explicit relationship that we know how to deal with.
If we change the policy, we might end up affecting the detail (since detail depends on policy).
But if we change the detail, it should never affect the policy because policy doesn't depend on detail.
This separation of concerns, combined with adhering to the SOLID principles makes changing code a lot easier.
Tests?
The only way we can be certain about changing code is to have written tests for it.
Domain code is incredibly easy to test (since it has 0 dependencies) and refers to abstractions. We can really easily create mocks for things by implementing the interface with our mock classes.
Infrastructure layer code is a little bit more challenging (and slower) to test because it has dependencies (web servers, caches, key-value object stores like Redis, etc).
Too clean?
The more complex the software you're building, and the more robust it needs to be, the more you need to build into it, the mechanisms for flexibility.
For example: if you're writing a quick Node.js script to scrape a particular web page periodically so that you can automate something, don't spend too much time trying to make your code SOLID.
But, if you're building a web scraper that needs to know how to scrape all of the 100 most popular job boards in the world, then you might want to consider coding it for flexibility.
// Define the abstraction to implement the algorithms
abstract class BaseScraper {
constructor () {
this.puppeteer = new Puppeteer();
}
abstract getNumberPages (): Promise<number>;
abstract getJobTitle (): Promise<HTML>;
abstract getJobDescription(): Promise<HTML>;
abstract getJobPaymentDetails(): Promise<HMTL>;
}
// Implement the algorithms
class LinkedInScraper extends BaseScraper {
constructor () {
super();
}
getNumberPages (): Promise<number> {
// implement algorithm
this.puppeteer...
}
getJobTitle (): Promise<HTML> {
// implement algorithm
}
getJobDescription(): Promise<HTML> {
// implement algorithm
}
getJobPaymentDetails(): Promise<HMTL> {
// implement algorithm
}
}
class GlassdoorScraper extends BaseScraper {
constructor () {
super();
}
getNumberPages (): Promise<number> {
// implement algorithm
this.puppeteer...
}
getJobTitle (): Promise<HTML> {
// implement algorithm
}
getJobDescription(): Promise<HTML> {
// implement algorithm
}
getJobPaymentDetails(): Promise<HMTL> {
// implement algorithm
}
}
Stay in touch!
Join 20000+ value-creating Software Essentialists getting actionable advice on how to master what matters each week. 🖖
View more in Enterprise Node + TypeScript