Clean Architecture: A Craftsman's Guide to Software Structure
When I first picked up Clean Architecture by Robert C. Martin, I wasn't entirely sure how its traditional enterprise concepts would apply to modern frontend development with React and Next.js. However, the foundational principles of separating concerns and maintaining independence between layers remain indispensable for building scalable web applications today.
The Core Concept: The Dependency Rule
The defining characteristic of Clean Architecture is the Dependency Rule: source code dependencies must point only inward, toward higher-level policies.
Uncle Bob visualizes this as a series of concentric circles:
- Entities (Enterprise Business Rules): The core business objects and rules.
- Use Cases (Application Business Rules): Orchestrates the flow of data to and from the entities.
- Interface Adapters: Converts data from the format most convenient for the use cases to the format most convenient for external agencies (like the web or a database).
- Frameworks and Drivers: The outermost layer, where the database, web frameworks (like Next.js), and UI live.
The most critical takeaway is that your core business logic (Entities and Use Cases) should know absolutely nothing about the database you are using, the UI framework rendering the screen, or the API routing mechanism.
Applying Clean Architecture in Next.js
It is easy to conflate organizational patterns with architectural boundaries. For example, Next.js Route Groups (like (main) and (cms)) are excellent for organizing layouts and UI concerns, but they do not constitute Clean Architecture. They are simply framework-level routing mechanisms.
To truly implement Clean Architecture in a Next.js application, we must isolate our business logic from Next.js itself.
1. Abstracting the Framework
Instead of writing database queries directly inside a React Server Component or a Next.js Route Handler, we abstract them into Use Cases and Repository interfaces.
// /core/use-cases/PublishPostUseCase.ts
// This use case knows NOTHING about Next.js, HTTP requests, or Vercel.
export class PublishPostUseCase {
constructor(private postRepository: IPostRepository) {}
async execute(postData: PostDTO) {
// Validate business rules
if (!postData.title || postData.content.length < 100) {
throw new Error("Post does not meet publishing criteria.");
}
return this.postRepository.save(postData);
}
}2. Interface Adapters (Controllers)
Next.js Server Actions and Route Handlers should simply act as Interface Adapters (Controllers). They receive the HTTP request, pass the data to the Use Case, and format the response.
// /app/api/posts/route.ts
// This is the Framework layer. It simply calls the Use Case.
import { PublishPostUseCase } from '@/core/use-cases/PublishPostUseCase';
import { SupabasePostRepository } from '@/infrastructure/SupabasePostRepository';
export async function POST(request: Request) {
const data = await request.json();
// Dependency Injection
const repository = new SupabasePostRepository();
const useCase = new PublishPostUseCase(repository);
try {
const result = await useCase.execute(data);
return Response.json({ success: true, data: result });
} catch (error) {
return Response.json({ success: false, error: error.message }, { status: 400 });
}
}3. Decoupling the UI
React components should ideally act purely as the presentation layer. While Server Components blur the line by allowing direct data access, maintaining a boundary by calling a Use Case or a distinct data-fetching service ensures that if you ever migrate away from Next.js, your core logic remains completely untouched.
Final Thoughts
Clean Architecture is an absolute must-read for any developer transitioning to a senior role. While applying it strictly to a frontend-heavy Next.js app can sometimes feel like over-engineering, understanding why the boundaries exist allows you to make informed trade-offs. It teaches you to think of Next.js as just a "detail"—a delivery mechanism that plugs into your application, rather than the application itself.