Published On

Using Clean Architecture with Next.js

Using Clean Architecture with Next.js

I recently came across a fascinating post clean architecture on different architectures and it got me thinking. I realized I don't follow all of the guidelines of clean architecture in many of my Next.js apps. The biggest drawback to this is it makes something more annoying to refactor and test. For example:

  1. Tight Coupling: In some of my projects, the business logic is tightly coupled with the user interface, making it hard to make changes to the UI without affecting the underlying logic. This violates the principle of separation of concerns, leading to a more brittle codebase.
  2. Lack of Dependency Inversion: I've often directly injected dependencies into my components instead of using dependency injection techniques. This makes it difficult to swap out components or services for testing purposes or when requirements change.
  3. Insufficient Abstractions: Sometimes, I don't create enough abstraction layers. This leads to a situation where a single change can cascade through the entire application, causing unexpected issues and making the codebase harder to maintain.
  4. Poor Module Boundaries: I tend to blur the lines between different parts of the application, leading to poor module boundaries. This makes it challenging to isolate and test individual components or services.
  5. Inconsistent Testing: Due to the lack of a clean architecture, setting up tests can be more complex and time-consuming. This inconsistency often results in inadequate test coverage, making the application more prone to bugs.

Recognizing these shortcomings has motivated me to start refactoring my apps to align more closely with clean architecture principles. Doing so should make my projects more modular, easier to test, and more maintainable in the long run.

Presentation Layer

The presentation layer is the entry point of our Next.js application, responsible for handling user interactions and displaying data. In a clean architecture, this layer should only focus on presentation logic and delegate any business logic to the appropriate services.

Key Point: For Next.js, this includes pages/routing and actions that take input, get the user, and pass it to the service layer.


Business Layer

The business layer contains the application's business logic. This is where services, custom collections, and other domain-specific operations reside. By isolating business logic in this layer, we can ensure that changes in business rules do not affect the presentation or persistence layers.

Key Points:

  • Services
  • Custom Collections
  • Links Services
  • Auth Services

Example: Check user authentication and pass the data to the model.


Persistence Layer

The persistence layer is responsible for data storage and retrieval. This layer interacts with databases and other storage mechanisms to read and write data. Keeping this logic separate from the business and presentation layers ensures a clear separation of concerns and makes it easier to switch databases or storage methods if needed.

Key Points:

  • Models
  • Collections Repository
  • Links Repository

Example: Validate input and read/write to the database.


Summary

Implementing clean architecture in your Next.js applications can greatly enhance the modularity, testability, and maintainability of your projects. By separating concerns and adhering to solid architectural principles, you can build robust and scalable applications.

Check out more about clean architecture here: The Clean Architecture.

services/authService.ts
// Example of an authentication service in the business layer
import { getUser } from '../repositories/userRepository';

export const authenticateUser = async (email: string, password: string) => {
  const user = await getUser(email);
  if (user && user.password === password) {
    return user;
  }
  throw new Error('Authentication failed');
};
api/login.ts
// Example of an API route in the presentation layer
import { authenticateUser } from '../../services/authService';

export default async function handler(req, res) {
  const { email, password } = req.body;
  try {
    const user = await authenticateUser(email, password);
    res.status(200).json({ user });
  } catch (error) {
    res.status(401).json({ message: 'Authentication failed' });
  }
}
repositories/userRepository.ts
// Example of a repository in the persistence layer
import db from '../db';

export const getUser = async (email) => {
  return await db.collection('users').findOne({ email });
};
Comments