Skip to main content

Clean Nextjs Architecture

What is the best way to implement Clean (Hexagonal) Architecture with Nextjs in a NX Monorepo

Clean Architecture separates concerns into distinct layers, ensuring that business logic remains independent of frameworks, databases, and external services. Combining Clean Architecture with Next.js in an NX monorepo equips teams to build scalable, testable, and maintainable applications while tightly controlling development and hosting costs. The strict layering of concerns, DI patterns, NX boundary enforcement, and Vercel optimization strategies together minimize technical debt and empower engineers to rapidly seize new opportunities.

Context

Architectural Layers

Clean Architecture advocates a layered solution where each layer has a clear responsibility. Core business rules reside at the center, isolated from external concerns like UI frameworks or databases. This structure not only leads to a highly testable codebase but also minimizes technical debt and provides the flexibility to swap out external dependencies later on.

1. User Interface

(Frameworks & Drivers)

  • Responsibilities:
    • Acts as the entry point where requests are received and passed to controllers.
    • Hosts Next.js pages, API routes, server actions, and client components.
  • Best Practices:
    • Keep this layer thin by handling only validation, error formatting, and routing.

2. Interface Adapters

(Controllers & Presenters)

  • Controllers:
    • Validate inputs (e.g., via Zod).
    • Perform authentication checks.
    • Orchestrate one or more use cases.
  • Presenters:
    • Transform domain models into UI-safe DTOs (e.g., omit password hashes, format dates).
    • Prevent leaking internal types or shipping unnecessary libraries (e.g., date-formatting on server) to the client.

Example

// Example of a controller function using Zod validation
import { z } from 'zod';
import { createTodoUseCase } from '@/application/use-cases/createTodoUseCase';

const todoInputSchema = z.object({
content: z.string().min(1, "Content is required"),
});

export const createTodoController = async (input: unknown, sessionId: string) => {
// Validate input using Zod.
const { success, data, error } = todoInputSchema.safeParse(input);
if (!success) throw new Error("Invalid input");

// Authentication check (abstracted in a DI service)
const user = await authService.validateSession(sessionId);
if (!user) throw new Error("Unauthenticated");

// Execute use case and then pass through a presenter to shape the output.
const newTodo = await createTodoUseCase.execute({ content: data.content, userId: user.id });
return {
id: newTodo.id,
content: newTodo.content,
completed: newTodo.completed,
createdAt: newTodo.createdAt.toISOString(),
};
};

3. Application Layer

(Use Cases)

  • Responsibilities:
    • Contains business logic operations (e.g., “Create Todo”, “Sign In”).
    • Coordinates the workflow between controllers and lower layers without knowledge of external systems.
  • Key Characteristics:
    • Accepts pre-validated inputs.
    • Throws domain-specific errors for authorization failures or business rule violations.

4. Domain Layer

(Entities and Validation)

  • Responsibilities:
    • Houses core business models and rule validations.
    • Uses libraries such as Zod to both define types and validate enterprise business rules.
  • Implementation: Use Zod or similar to ensure both type inference and runtime validation.

Example:

import { z } from 'zod';

// Todo entity with validation rules
export const TodoSchema = z.object({
id: z.string().uuid(),
content: z.string().min(1, "Content cannot be empty"),
completed: z.boolean().default(false),
});

export type Todo = z.infer;

5. Infrastructure Layer

(Repositories, Services)

  • Responsibilities:
    • Contains concrete implementations for database operations, third-party services, authentication, and other integrations.
    • Provides implementations that obey interfaces defined in the Application layer.
  • Implementation Tip:
    • Use repository patterns to isolate SQL/NoSQL queries from business logic.
    • Convert framework-specific errors into domain-specific ones.

Implementation Patterns

  • Strong Typing: Use TypeScript throughout for static analysis. Define entity schemas, DTOs, and response types to prevent runtime errors.
  • JSDoc and Inline Documentation: Annotate functions and complex logic to improve clarity and IDE integration.
  • Functional and Declarative Patterns: Favor functional programming constructs (pure functions, immutability) over class-based designs where possible.

NX Monorepo Integration

  • Workspace Layout:
    • apps/ for Next.js applications.
    • libs/ for shared domain, application, infrastructure, and UI-adapter libraries.
  • Benefits:
    • Enforced boundaries via nx.json and tsconfig.base.json settings.
    • Code scaffolding with custom generators for Clean Architecture structure.
    • Scalable maintenance as teams grow—80/20 rule: most code in libs, minimal in apps.

Dependency Injection

  • Why: Decouple core logic from concrete implementations, making tests and environment-specific bindings trivial.
  • How:
    • Configure a DI container to bind interfaces to implementations.
    • InversifyJS for projects needing advanced features—note that edge runtimes may lack the reflection API required by some DI libraries.
  • Benefits:
    • Easier testing via mocks.
    • Environment-specific implementations without touching business logic.
// DI container configuration
import { container } from 'tsyringe';
import { IUserRepository } from '@/application/repositories/IUserRepository';
import { UserRepository } from '@/infrastructure/repositories/UserRepository';

container.register('UserRepository', {
useFactory: () => new UserRepository(/* database client instance */),
});

Testing Strategy

  1. Unit Tests (Use Cases & Controllers):
    • Mock infrastructure bindings via DI container overrides.
    • Assert happy paths and error conditions (e.g., input validation, authentication failure).
  2. E2E/API Tests:
    • Use Cypress or Playwright against a staging deployment.
    • Cover critical flows like signup, login, todo creation.
  3. Coverage:
    • Aim for ≥90%, ideally hitting 100% on core logic modules.
    • Integrate with CI (e.g., GitHub Actions) and Codecov reporting.

Example Test Case:

// Using Jest to test the CreateTodo use case
describe('CreateTodoUseCase', () => {
it('creates a todo with valid input', async () => {
const input = { content: 'Test Todo', userId: 'user-123' };
const result = await createTodoUseCase.execute(input);
expect(result).toMatchObject({
content: 'Test Todo',
completed: false,
});
});

it('throws an error for empty content', async () => {
const input = { content: '', userId: 'user-123' };
await expect(createTodoUseCase.execute(input)).rejects.toThrowError('Content cannot be empty');
});
});

Performance vs Cost

Balancing performance with cost efficiency is key.

  • Edge Middleware & Hybrid Rendering:
    • Offload lightweight auth/redirect logic to the edge, cutting origin hits by ~45%.
    • Use Next.js edge middleware for lightweight authentication checks.
    • Leverage Static Site Generation (SSG) and Incremental Static Regeneration (ISR) to reduce compute function invocations.
  • Dynamic Imports and Code Splitting:
    • Load heavy modules dynamically to minimize the size of serverless functions and improve Time to Interactive (TTI).
    • Dynamically import heavy libs (charts, editors) to shrink serverless function bundles.
  • Smart Caching Strategy:
    • Use stale-while-revalidate headers and Redis layer for hot data to reduce database load and lower hosting costs.
  • SSG & ISR:
    • Pre-render static pages and employ revalidate for dynamic content to reduce function invocations by up to 80%.
    • Leverage Vercel’s ISR improvements (TTFB gains, 65% cost savings) for cache updates.

Cost Optimization Matrix:

StrategyImplementation DescriptionImpact
Edge MiddlewarePerform auth checks and redirects at the edge45-60% cost reduction
Hybrid RenderingUse SSG for static pages and ISR for dynamic content80% fewer serverless invocations
Smart CachingCache frequent reads and utilize stale-while-revalidate70% cache hit rate
Bundle OptimizationUse dynamic imports to reduce bundle size40% smaller lambdas
Cold Start PreventionKeep serverless functions lightweight (< 50MB)Faster cold start times

Developer Experience

Developer Productivity and Ecosystem Enhancements

  • Absolute Import Paths: Use concise and clear import paths (e.g., @/components/Button) to improve code readability.
  • Shared Schemas: Share Zod schemas between front-end and back-end to avoid duplication and ensure type consistency.
  • Auto-Generated Docs: Auto-generate API docs with tools like next-swagger-doc to streamline integration and onboard new developers quickly.
  • Monorepo & Modularization: Use tools like Lerna or Nx to manage larger projects, keeping code modular and easier to maintain.
  • Real-world Monitoring: Integrate with performance-tracking tools (e.g., Sentry, Vercel Analytics) to measure API response times, error rates, and user experience metrics.
  • Linting & Formatting: Enforce Husky pre-commit hooks, ESLint, and Prettier.
  • Architecture Decision Records (ADR): Document major design choices to onboard new team members.

Technical Debt Prevention

Maximize Productivity

  • Layer Isolation: Unit test individual layers by mocking external dependencies via DI.
  • Comprehensive Coverage: Aim for 100% test coverage using Jest or Vitest along with E2E tests (e.g., Cypress) for critical flows.
  • Error Handling: Use custom error types to represent common issues such as validation failures (e.g., ValidationError) and authentication errors.