How We Fixed Next.js at Scale: DI & Clean Architecture Secrets From Production

Table of Contents Di Architecture Table of Contents Overview Requirements Usecase Where we Use DI Feature layer DI Key: Interface-Based Binding Module Features di Usage Application layer vm key App module Provider Usage Conclusion Overview Dependency Injection (DI) is one of the most debated design patterns in software development. While opinions vary on whether it’s universally beneficial, one thing is certain: DI can be extremely useful if not treated as a "golden hammer." Overusing it, such as blindly injecting every dependency, can lead to unnecessary complexity rather than cleaner code. In this article, we’ll explore how DI can be effectively applied in Next.js, striking the right balance between maintainability and simplicity. Requirements This article builds on the architecture outlined in the Next.js Boilerplate repository. To fully understand the implementation, I recommend reviewing these two foundational documents first: Clean Architecture in Next.js MVVM Architecture in Next.js We've included direct file references to key implementations in the main repository for each example. To explore all concepts in depth and see a production-ready boilerplate following these best practices, visit: behnamrhp / Next-clean-boilerplate A Full featured nextjs boilerplate, based on clean architecture, mvvm and functional programming paradigm. Nextjs clean architecture boilerplate Table of content Overview Technologies Architecture Folder Structure Getting started Guildline Overview This project is a starting point for your medium to large scale projects with Nextjs, to make sure having a structured, maintainable and reusable base for your project based on best practices in clean architecture, DDD approach for business logics, MVVM for the frontend part, storybook and vitest for testing logics and ui part and also functional programming with error handling for business logics. Motivation Nextjs and many other new SSR tools provide a really good and new approach to handle frontend applications, with new tools to bring a new good experience for users. But as they're new and they just tried to bring new tools and features and also frontend community, didn't talk about software engineering and best practices approach for this tools. So in many cases we see many teams uses nextjs… View on GitHub Usecase In Next.js applications, we organize the project into two primary layers: Feature Layer – Contains all business logic. Application Layer – Handles communication with Next.js and React APIs. Where we Use DI We apply DI only between components that: Require minimal knowledge of each other (loose coupling). Can work independently but integrate seamlessly. We apply dependency Injection (DI) in different ways in each layer: Feature Layer Usage: DI connects UseCase and Repository via interfaces, following the Adapter Pattern. Purpose: Ensures business logic remains decoupled from data sources (e.g., databases, APIs). Application Layer Usage: DI bridges ViewModel (VM) and View, adhering to the Bridge Pattern. Special Case: When passing a VM from a Server Component to a Client Component, we use a unique VM key for serialization. Also global and other dependencies. ## Feature layer As mentioned earlier, we use Dependency Injection (DI) in the Feature Layer to connect the UseCase and Repository layers via interfaces. DI Key: Interface-Based Binding To link a UseCase with its corresponding Repository, we use a unique DI key tied to the repository interface. This ensures type-safe dependency resolution. Example: export default interface UserRepository { create(params: CreateUserParams): ApiTask; update(params: UpdateUserParams): ApiTask; delete(ids: string[]): ApiTask; } export const userRepoKey = "userRepoKey"; file: user.repository.interface.ts As you see in this file we have a userRepositoryKey Which we use to register a repository and also use it in usecase to use this repository inside of it. Module In Domain-Driven Design (DDD), a Module serves as a high-level organizational unit that groups related domain concepts. For example, all user-related domain logic (entities, repositories, services) would be organized within a User Module, where we also centralize its dependency injection (DI) registrations. Example: export default function userModule(di: DependencyContainer) { di.register(userRepoKey, UserRepositoryImpl); return di; } file: User module. Let's examine the key aspects of this file: *Architectural Layer Responsibility: This component has visibility into both domain repository interfaces (i-repo) and concrete repository implementations. According to Clean Architecture principles, this dual knowledge properly places it in t

May 2, 2025 - 16:23
 0
How We Fixed Next.js at Scale: DI & Clean Architecture Secrets From Production

Table of Contents

  • Di Architecture
    • Table of Contents
    • Overview
    • Requirements
    • Usecase
      • Where we Use DI
    • Feature layer
      • DI Key: Interface-Based Binding
      • Module
      • Features di
      • Usage
    • Application layer
      • vm key
      • App module
      • Provider
      • Usage
    • Conclusion

Overview

Dependency Injection (DI) is one of the most debated design patterns in software development. While opinions vary on whether it’s universally beneficial, one thing is certain: DI can be extremely useful if not treated as a "golden hammer." Overusing it, such as blindly injecting every dependency, can lead to unnecessary complexity rather than cleaner code.

In this article, we’ll explore how DI can be effectively applied in Next.js, striking the right balance between maintainability and simplicity.

Requirements

This article builds on the architecture outlined in the Next.js Boilerplate repository. To fully understand the implementation, I recommend reviewing these two foundational documents first:

We've included direct file references to key implementations in the main repository for each example.

To explore all concepts in depth and see a production-ready boilerplate following these best practices, visit:

GitHub logo behnamrhp / Next-clean-boilerplate

A Full featured nextjs boilerplate, based on clean architecture, mvvm and functional programming paradigm.

Nextjs clean architecture boilerplate

Table of content

  • Overview
  • Technologies
  • Architecture
  • Folder Structure
  • Getting started
  • Guildline

Overview

This project is a starting point for your medium to large scale projects with Nextjs, to make sure having a structured, maintainable and reusable base for your project based on best practices in clean architecture, DDD approach for business logics, MVVM for the frontend part, storybook and vitest for testing logics and ui part and also functional programming with error handling for business logics.

Motivation

Nextjs and many other new SSR tools provide a really good and new approach to handle frontend applications, with new tools to bring a new good experience for users. But as they're new and they just tried to bring new tools and features and also frontend community, didn't talk about software engineering and best practices approach for this tools.

So in many cases we see many teams uses nextjs…

Usecase

In Next.js applications, we organize the project into two primary layers:

  • Feature Layer – Contains all business logic.
  • Application Layer – Handles communication with Next.js and React APIs.

Where we Use DI

We apply DI only between components that:

  • Require minimal knowledge of each other (loose coupling).
  • Can work independently but integrate seamlessly.

We apply dependency Injection (DI) in different ways in each layer:

  1. Feature Layer
    • Usage: DI connects UseCase and Repository via interfaces, following the Adapter Pattern.
  • Purpose: Ensures business logic remains decoupled from data sources (e.g., databases, APIs).
  1. Application Layer
    • Usage: DI bridges ViewModel (VM) and View, adhering to the Bridge Pattern.
  • Special Case: When passing a VM from a Server Component to a Client Component, we use a unique VM key for serialization.
  1. Also global and other dependencies. ## Feature layer As mentioned earlier, we use Dependency Injection (DI) in the Feature Layer to connect the UseCase and Repository layers via interfaces.

DI Key: Interface-Based Binding

To link a UseCase with its corresponding Repository, we use a unique DI key tied to the repository interface. This ensures type-safe dependency resolution.

Example:

export default interface UserRepository {
  create(params: CreateUserParams): ApiTask<true>;
  update(params: UpdateUserParams): ApiTask<true>;
  delete(ids: string[]): ApiTask<true>;
}

export const userRepoKey = "userRepoKey";

file: user.repository.interface.ts

As you see in this file we have a userRepositoryKey Which we use to register a repository and also use it in usecase to use this repository inside of it.

Module

In Domain-Driven Design (DDD), a Module serves as a high-level organizational unit that groups related domain concepts. For example, all user-related domain logic (entities, repositories, services) would be organized within a User Module, where we also centralize its dependency injection (DI) registrations.

Example:

export default function userModule(di: DependencyContainer) {
  di.register(userRepoKey, UserRepositoryImpl);

  return di;
}

file: User module.

Let's examine the key aspects of this file:

  1. *Architectural Layer Responsibility:

    This component has visibility into both domain repository interfaces (i-repo) and concrete repository implementations. According to Clean Architecture principles, this dual knowledge properly places it in the Data Layer.

  2. Dependency Injection Setup:

    We get a child DI container that registers all domain repositories and dependencies implementation.

Features di

To effectively organize, manage, and register all domain modules, we require a centralized component to handle these responsibilities:

Example:

/**
 * On adding new domain module, just add it to this list
 */
const moduleKeyToDi: Record<
  string,
  (di: DependencyContainer) => DependencyContainer
> = {
  [authModuleKey]: authModule,
  [userModuleKey]: userModule,
};

const memoizedDis: Record<string, DependencyContainer> = {};

export default function featuresDi(module: string): DependencyContainer {
  if (memoizedDis[module]) return memoizedDis[module];
  const moduleDiHandler = moduleKeyToDi[module];
  if (!moduleDiHandler)
    throw new Error(`Server Di didn't found for module: ${module}`);

  const moduleDi = moduleDiHandler(di.createChildContainer());
  globalModule(moduleDi);
  memoizedDis[module] = moduleDi;
  return moduleDi;
}

export function diResolve<T = unknown>(module: string, key: InjectionToken): T {
  return featuresDi(module).resolve<T>(key);
}

file: Feature di.

This implementation includes a method for retrieving domain-specific dependency injection (DI) configurations using unique module keys. Each domain is assigned a distinct identifier to ensure proper isolation and organization.
For reference, see the user domain domain module key in the user-module-key file.

Also in feature di file by adding new module for new domains we can add new domain module to the list of moduleKeyToDi object.

Usage

At the end, we can get the repository in usecase layer like this:

Example:

export default async function createUserUseCase(
  params: CreateUserParams,
): Promise<ApiEither<true>> {
  const repo = diResolve<UserRepository>(userModuleKey, userRepoKey);

  return repo.create(params)();
}

file: createUserUseCase

Application layer

Unlike the feature layer, the application layer follows a different architectural approach. Here we implement the MVVM (Model-View-ViewModel) pattern, where Dependency Injection plays a crucial role in passing ViewModels from server components to client-side views.

vm key

For a vm we can have a unique key to register the vm by a single unique string

App module

In each page or route we can define a module to register all vms and any other parts to our di like this:

Example:

/**
 * Each page can have its own di to connect all vms, usecases or controllers
 */
export default function dashboardAppModule() {
  const dashboardDi = di.createChildContainer();

  dashboardDi.register(
    createRandomInvoiceButtonVMKey,
    CreateRandomInvoiceButtonVM,
  );

  return dashboardDi;
}

file: Dashboard app module

Provider

We can distribute our dependency injection container throughout the component tree by implementing a provider pattern. This enables any component to access registered ViewModels while maintaining proper dependency isolation.

Example:


export default function Layout({ children }: { children: React.ReactNode }) {
  const di = useRef(dashboardAppModule());
  return (
    <ReactVVMDiProvider diContainer={di.current}>
      <div className="flex h-screen flex-col md:flex-row md:overflow-hidden">
        <div className="w-full flex-none md:w-64">
          <SideNav />
        </div>
        <div className="flex-grow p-6 md:overflow-y-auto md:p-12">
          {children}
        </div>
      </div>
    </ReactVVMDiProvider>
  );
}

file: Dashboard layout

In this example as we're using ReactVVM package, we passed the di to this library's provider so it can connect View to our vm, automatically based on passing Vmkey to the view component.

Usage

For a concrete implementation example, let's examine how to pass a ViewModel for random invoice generation to a button component.

Example:

export default async function LatestInvoices() {
  const latestInvoices = await latestInvoicesController();

  return (
    <div className="flex w-full flex-col md:col-span-4">
      ...
        <Button vmKey={createRandomInvoiceButtonVMKey} />
      ...
    div>
  );
}

file: Latest Invoice

As you see we passed the vm key to the view by vmKey prop to the Button component.

Conclusion

In this article, we explored a Clean Architecture approach using Dependency Injection (DI) to decouple business logic, applying DDD principles to connect UseCase and Repository layers via interfaces for maintainability and testability.

For the Application Layer, we implemented an MVVM pattern, using unique VM keys to bridge server and client components while ensuring reusability and single responsibility via the Bridge Pattern. This keeps UI logic clean and scalable across Next.js applications.

By structuring DI registration around domain modules and centralized providers, we achieve a flexible, maintainable architecture that works seamlessly in both server and client contexts.

If you found this article helpful, I’d be truly grateful if you could:
⭐ Star the repository to support its visibility