🎨Node.js with Multiple Design Patterns

Tony Min
Bits and Pieces
Published in
7 min readMar 5, 2023

--

We use design patterns in Node.js because they provide a set of proven solutions to common problems or challenges in software development.

Here are some reasons why we use design patterns in Node.js:

  • Reusability: Design patterns allow us to encapsulate complex logic and functionality in a reusable and modular way, which can save us time and effort when building applications.
  • Scalability: Design patterns provide a structure and organization to our code that can make it easier to scale and maintain as our applications grow and evolve.
  • Maintainability: Design patterns help us to write more maintainable code by separating concerns and responsibilities, reducing code duplication, and promoting consistency.
  • Flexibility: Design patterns allow us to swap out or modify different components or modules in our applications without affecting the overall functionality, which can make our code more flexible and adaptable to changing requirements.
  • Code quality: Design patterns promote good coding practices and principles, such as abstraction, encapsulation, and separation of concerns, which can lead to higher quality and more reliable code.

Design patterns can help us to write better code that is more reusable, scalable, maintainable, flexible, and of higher quality, which can ultimately lead to more successful and robust applications.

đź’ˇ This is exactly the approach Bit enables. By adopting a composable, modularity-first design for your components, and independently storing, testing, and documenting components instead of entire apps at once, your app scales better, and is infinitely more maintainable. This guide will show you how.

From now on, let’s look at each design pattern with code in a “Login” service logic situation.

Singleton Pattern

We can use the Singleton pattern to ensure that only one instance of the authentication service is created and shared across the application. This can be useful for managing shared resources such as a JWT secret key or the user database connection.

class AuthenticationService {
constructor() {
this.jwtSecret = 'secret'; // initialize the JWT secret key
this.userDb = new DatabaseConnection('mongodb://localhost/users'); // initialize the user database connection
}

static getInstance() {
if (!AuthenticationService.instance) {
AuthenticationService.instance = new AuthenticationService();
}

return AuthenticationService.instance;
}

// authentication logic
async authenticate(username, password) {
const user = await this.userDb.findUserByUsername(username);
if (user && user.password === password) {
const token = jwt.sign({ username: user.username }, this.jwtSecret);
return { token };
} else {
throw new Error('Invalid username or password');
}
}
}

// Usage:
const authService = AuthenticationService.getInstance();

In this example, we create a Singleton class called AuthenticationService that manages the JWT secret key and the user database connection. The authenticate() method performs the authentication logic and returns a JWT token if the username and password are valid.

Factory Pattern

We can use the Factory pattern to create different types of authentication strategies based on the authentication type. For example, we can create different strategies for local authentication, social media authentication, or multi-factor authentication.

class AuthenticationStrategyFactory {
static createStrategy(type) {
if (type === 'local') {
return new LocalAuthenticationStrategy();
} else if (type === 'social') {
return new SocialMediaAuthenticationStrategy();
} else if (type === 'multi-factor') {
return new MultiFactorAuthenticationStrategy();
} else {
throw new Error(`Unsupported authentication type: ${type}`);
}
}
}

// Usage:
const strategy = AuthenticationStrategyFactory.createStrategy('local');

In this example, we create a Factory class called AuthenticationStrategyFactory that can create different types of authentication strategies based on the type. We create three different strategies, LocalAuthenticationStrategy, SocialMediaAuthenticationStrategy, and MultiFactorAuthenticationStrategy, and return the appropriate strategy based on the type.

Middleware Pattern

We can use the Middleware pattern to define a chain of handlers that can be used to process the authentication request in a modular way. This can be useful for implementing middleware-based frameworks, such as Express.js.

class AuthenticationMiddleware extends Middleware {
constructor(strategy) {
super();
this.strategy = strategy;
}

async _handle(req, res, next) {
try {
const result = await this.strategy.authenticate(req.body.username, req.body.password);
req.user = result;
next();
} catch (error) {
res.status(401).json({ error: error.message });
}
}
}

// Usage:
const strategy = AuthenticationStrategyFactory.createStrategy('local');
const middleware = new AuthenticationMiddleware(strategy);
app.post('/login', middleware.handle.bind(middleware), (req, res) => {
res.json({ token: req.user.token });
});

In this example, we create a Middleware class called AuthenticationMiddleware that handles the authentication logic using the specified strategy. The handle() method is used to process the authentication request through the middleware chain, and the next() function is used to pass the request to the next middleware in the chain. If the authentication fails, an error message is returned to the client.

Observer Pattern

We can use the Observer pattern to notify other objects or services when a user logs in or logs out. For example, we can notify the logging service or the analytics service when a user logs in or logs out.

class LoginService {
constructor() {
this.observers = [];
}

addObserver(observer) {
this.observers.push(observer);
}

removeObserver(observer) {
this.observers = this.observers.filter((o) => o !== observer);
}

async login(username, password) {
const authService = AuthenticationService.getInstance();
const result = await authService.authenticate(username, password);

// notify the observers
this.notifyObservers({ username });

return result;
}

async logout(token) {
// revoke the token
const authService = AuthenticationService.getInstance();
const user = jwt.verify(token, authService.jwtSecret);
const tokenRevoked = await this.revokeToken(user.username, token);

// notify the observers
this.notifyObservers({ username: user.username });

return tokenRevoked;
}

notifyObservers(data) {
this.observers.forEach((observer) => observer.update(data));
}
}

// Usage:
const loginService = new LoginService();
loginService.addObserver(new LoggingService());
loginService.addObserver(new AnalyticsService());

In this example, we create a LoginService class that can be observed by other objects or services. The login() method performs the authentication logic and returns a JWT token if the username and password are valid. The logout() method revokes the token and notifies the observers when the user logs out. The addObserver() and removeObserver() methods can be used to manage the observers, and the notifyObservers() method is used to notify all observers when a user logs in or logs out.

Strategy Pattern

We can use the Strategy pattern to define different authentication strategies for different types of users or scenarios. For example, we can create different strategies for admin users, premium users, or API users.

class AdminAuthenticationStrategy extends LocalAuthenticationStrategy {
async authenticate(username, password) {
const user = await this.authService.userDb.findUserByUsername(username);
if (user && user.password === password && user.role === 'admin') {
const token = jwt.sign({ username: user.username }, this.authService.jwtSecret);
return { token };
} else {
throw new Error('Invalid username or password');
}
}
}

class PremiumAuthenticationStrategy extends LocalAuthenticationStrategy {
async authenticate(username, password) {
const user = await this.authService.userDb.findUserByUsername(username);
if (user && user.password === password && user.subscription === 'premium') {
const token = jwt.sign({ username: user.username }, this.authService.jwtSecret);
return { token };
} else {
throw new Error('Invalid username or password');
}
}
}

// Usage:
const authService = AuthenticationService.getInstance();
const localStrategy = new LocalAuthenticationStrategy(authService);
const adminStrategy = new AdminAuthenticationStrategy(authService);
const premiumStrategy = new PremiumAuthenticationStrategy(authService);

In this example, we create different authentication strategies for admin users and premium users that inherit from the LocalAuthenticationStrategy. The authenticate() method in each strategy checks the user's role or subscription before returning a JWT token.

Comparison

Singleton Pattern

  • Elements: Singleton class, constructor, static getInstance() method, shared instance variable, private constructor.
  • Purpose: Ensure that only one instance of a class is created and shared across the application.
  • Benefits: Avoids creating multiple instances of the same object, ensures consistency and centralized control of shared resources.

Factory Pattern

  • Elements: Factory class, create method(s), product class(es), product creation logic, abstract product class (optional).
  • Purpose: Create objects without exposing the creation logic to the client, allow for flexibility and customization of object creation.
  • Benefits: Encapsulates the object creation process, reduces coupling between client code and object creation logic, simplifies object instantiation.

Middleware Pattern

  • Elements: Middleware class, handle() method, next() function or middleware chain, request and response objects.
  • Purpose: Define a chain of handlers that can be used to process requests or data in a modular way.
  • Benefits: Promotes modularity and scalability, reduces code duplication, simplifies implementation of complex processing logic, allows for flexibility and customization of processing logic.

Observer Pattern

  • Elements: Subject class, observer class(es), registerObserver() and removeObserver() methods, notifyObservers() method, update() method in observers.
  • Purpose: Define a one-to-many dependency between objects, so that when one object changes state, all its dependents are notified and updated automatically.
  • Benefits: Promotes decoupling between objects, improves scalability and modularity, allows for dynamic composition of objects and behavior.

Strategy Pattern

  • Elements: Context class, strategy interface or class, concrete strategy classes, setStrategy() and executeStrategy() methods.
  • Purpose: Define a family of algorithms, encapsulate each one, and make them interchangeable, depending on the context or environment.
  • Benefits: Encapsulates the algorithm or behavior logic, promotes flexibility and customization of behavior, simplifies code maintenance and testing.

Conclusion

Each design pattern has its own unique set of elements, purposes, and benefits. By understanding and applying these patterns, we can improve the quality, scalability, maintainability, and flexibility of their code, and ultimately create more successful and robust applications.

Build Apps with reusable components, just like Lego

Bit’s open-source tool help 250,000+ devs to build apps with components.

Turn any UI, feature, or page into a reusable component — and share it across your applications. It’s easier to collaborate and build faster.

→ Learn more

Split apps into components to make app development easier, and enjoy the best experience for the workflows you want:

→ Micro-Frontends

→ Design System

→ Code-Sharing and reuse

→ Monorepo

--

--