Complete Layered Architecture Guide with Next.js
Introduction
Why layered architecture? Modern software systems demand maintainability, testability, and scalability. Without a clear separation of concerns, projects quickly become tangled, making changes risky and costly.
In this guide, we will demystify layered architecture, walk through a real‑world example using Next.js, and provide a Mermaid diagram that visualizes the flow between layers.
What You Will Learn
- The principles behind a layered architecture.
- How to organize a project into distinct layers using Next.js.
- A complete code example implementing the layers with Next.js and TypeScript.
- Best practices for dependency direction and testing in a Next.js project.
- How to visualize the architecture with Mermaid.
Understanding Layered Architecture
Core Principles
Separation of concerns is the cornerstone. Each layer has a single responsibility and communicates only with adjacent layers.
| Layer | Responsibility | Typical Technologies |
|---|---|---|
| Presentation | UI, API endpoints, request handling | Next.js, React |
| Application | Orchestrates use‑cases, transaction control | Services, Handlers |
| Domain | Business rules, entities, validation | TypeScript, POCOs |
| Infrastructure | Data access, external services, logging | Prisma, Repositories |
Dependency Rule
Higher layers must not depend on lower layers. Dependencies flow inward, from outer to inner.
Project Structure Example
src/
│
├── components/
│ └── ui/
│
├── services/
│ └── application/
│
├── domain/
│ └── entities/
│ └── interfaces/
│
└── infrastructure/
└── data/
└── repositories/
Mermaid Diagram
graph TD
UI[Presentation Layer] -->|Calls| App[Application Layer]
App -->|Uses| Domain[Domain Layer]
Domain -->|Accesses| Infra[Infrastructure Layer]
Infra -->|Persists| DB[(Database)]
Implementation Walkthrough (Next.js and TypeScript)
Domain Layer
// src/domain/entities/product.ts
export class Product {
public id: string;
public name: string;
public price: number;
constructor(id: string, name: string, price: number) {
this.id = id;
this.name = name;
this.price = price;
}
public applyDiscount(percentage: number) {
if (percentage <= 0 || percentage > 100)
throw new Error('Invalid discount percentage');
this.price -= this.price * (percentage / 100);
}
}
Application Layer
// src/services/application/productService.ts
import { Product } from '../domain/entities/product';
export class ProductService {
private readonly productRepository: any;
constructor(productRepository: any) {
this.productRepository = productRepository;
}
public async getProduct(id: string): Promise<Product> {
return await this.productRepository.getProduct(id);
}
public async createProduct(product: Product): Promise<void> {
await this.productRepository.createProduct(product);
}
}
Infrastructure Layer
// src/infrastructure/data/productRepository.ts
import { PrismaClient } from '@prisma/client';
const prisma = new PrismaClient();
export class ProductRepository {
public async getProduct(id: string): Promise<any> {
return await prisma.product.findUnique({ where: { id } });
}
public async createProduct(product: any): Promise<void> {
await prisma.product.create({ data: product });
}
}
Presentation Layer (Next.js Page)
// pages/api/products/[id].ts
import type { NextApiRequest, NextApiResponse } from 'next';
import { ProductService } from '../../services/application/productService';
import { ProductRepository } from '../../infrastructure/data/productRepository';
const productRepository = new ProductRepository();
const productService = new ProductService(productRepository);
export default async function handler(
req: NextApiRequest,
res: NextApiResponse
) {
if (req.method === 'GET') {
const id = req.query.id;
const product = await productService.getProduct(id as string);
return res.status(200).json(product);
}
}
Insight: Notice how the Presentation layer only knows about the Application service interface. It never touches the
Infrastructure or Domain implementations directly, preserving the dependency rule.
Testing Strategy
- Unit tests target the Domain and Application layers using mocks for repositories.
- Integration tests spin up an in‑memory database for the Infrastructure layer.
- End‑to‑end tests exercise the Presentation layer via HTTP calls.
Common Pitfalls & How to Avoid Them
| Pitfall | Remedy |
|---|---|
| Leaking infrastructure types into higher layers | Use interface abstractions and DI containers. |
| Over‑crowding a layer with unrelated responsibilities | Keep single responsibility; extract new layers if needed. |
| Circular dependencies | Enforce dependency direction via architecture reviews. |
Conclusion
Layered architecture provides a robust scaffold for building maintainable, testable, and scalable applications with Next.js. By adhering to the dependency rule, organizing code into clear folders, and visualizing relationships with tools like Mermaid, teams can reduce technical debt and accelerate delivery.
Ready to level up your Next.js codebase? Start refactoring a small module using the patterns in this guide and share your experience in the comments below!
Top comments (0)