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
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:
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:
- Feature Layer
- Usage: DI connects
UseCase
andRepository
via interfaces, following theAdapter Pattern
.
- Usage: DI connects
- Purpose: Ensures business logic remains decoupled from data sources (e.g., databases, APIs).
- Application Layer
- Usage: DI bridges
ViewModel (VM)
andView
, adhering to theBridge Pattern
.
- Usage: DI bridges
- Special Case: When passing a VM from a
Server Component
to aClient Component
, we use a uniqueVM 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
andRepository
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:
-
*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
. -
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