Clean Architecture vs. Feature-Sliced Design in Next.js Applications

Clean Architecture vs. Feature-Sliced Design in Next.js Applications

Introduction

In the ever-evolving landscape of web development, architectural patterns serve as crucial blueprints for organizing complex applications. Two architectural approaches have gained significant traction in recent years: Clean Architectureand Feature-Sliced Design (FSD). As a computer science researcher specializing in software architecture, I've implemented both patterns across numerous projects and observed their impact on development efficiency, code maintainability, and team collaboration.

This article explores the implementation of both patterns within Next.js applications—highlighting their differences, strengths, and practical applications. We'll examine how these architectures handle business logic, external resources, repositories, and cross-cutting concerns, with concrete examples demonstrating their implementation.

Clean Architecture in Next.js

The Core Principles

Clean Architecture, popularized by Robert C. Martin (Uncle Bob), organizes code into concentric circles, with dependencies pointing inward. The innermost layers represent business rules and domain logic, while the outermost layers handle infrastructure concerns like UI, databases, and external services.

The four fundamental layers in Clean Architecture are:

  1. Entities: Core business models and domain-specific logic
  2. Use Cases: Application-specific business rules (also called Interactors)
  3. Interface Adapters: Translate data between use cases and external frameworks
  4. Frameworks & Drivers: External frameworks, tools, and delivery mechanisms

Implementation in Next.js

Let's examine how Clean Architecture can be implemented in a Next.js application:

/src
  /core
    /entities
      User.ts
      Product.ts
    /use-cases
      /user
        GetUserUseCase.ts
        CreateUserUseCase.ts
      /product
        GetProductsUseCase.ts
    /repositories
      UserRepository.ts
      ProductRepository.ts
  /adapters
    /controllers
      UserController.ts
    /gateways
      UserAPIGateway.ts
    /presenters
      UserPresenter.ts
  /frameworks
    /database
      mongodb.ts
    /api
      userRoutes.ts
    /web
      /pages
        /users
          [id].tsx
          index.tsx
      /components
        UserList.tsx
        UserDetails.tsx

Business Logic Implementation

In Clean Architecture, business logic is encapsulated within entities and use cases. Let's look at a concrete example:

// core/entities/User.ts
export class User {
  id: string;
  name: string;
  email: string;
  role: 'admin' | 'user';

  constructor(props: Omit<User, 'isAdmin'>) {
    Object.assign(this, props);
  }

  isAdmin(): boolean {
    return this.role === 'admin';
  }
}

// core/repositories/UserRepository.ts
export interface UserRepository {
  findById(id: string): Promise<User | null>;
  findAll(): Promise<User[]>;
  save(user: User): Promise<User>;
}

// core/use-cases/user/GetUserUseCase.ts
export class GetUserUseCase {
  constructor(private userRepository: UserRepository) {}

  async execute(userId: string): Promise<User | null> {
    return this.userRepository.findById(userId);
  }
}

Repository Implementation

// adapters/gateways/UserAPIGateway.ts
import { User } from '@/core/entities/User';
import { UserRepository } from '@/core/repositories/UserRepository';
import { MongoClient } from '@/frameworks/database/mongodb';

export class UserMongoRepository implements UserRepository {
  constructor(private mongoClient: MongoClient) {}

  async findById(id: string): Promise<User | null> {
    const userData = await this.mongoClient.db().collection('users').findOne({ id });
    if (!userData) return null;
    return new User({
      id: userData.id,
      name: userData.name,
      email: userData.email,
      role: userData.role,
    });
  }

  async findAll(): Promise<User[]> {
    const usersData = await this.mongoClient.db().collection('users').find().toArray();
    return usersData.map(userData => new User({
      id: userData.id,
      name: userData.name,
      email: userData.email,
      role: userData.role,
    }));
  }

  async save(user: User): Promise<User> {
    await this.mongoClient.db().collection('users').updateOne(
      { id: user.id },
      { $set: { ...user } },
      { upsert: true }
    );
    return user;
  }
}

API and Controller Implementation

// frameworks/api/userRoutes.ts
import { NextApiRequest, NextApiResponse } from 'next';
import { UserController } from '@/adapters/controllers/UserController';
import { GetUserUseCase } from '@/core/use-cases/user/GetUserUseCase';
import { UserMongoRepository } from '@/adapters/gateways/UserAPIGateway';
import { mongoClient } from '@/frameworks/database/mongodb';

const userRepository = new UserMongoRepository(mongoClient);
const getUserUseCase = new GetUserUseCase(userRepository);
const userController = new UserController(getUserUseCase);

export default async function handler(req: NextApiRequest, res: NextApiResponse) {
  if (req.method === 'GET') {
    const userId = req.query.id as string;
    await userController.getUser(userId, res);
  }
}

// adapters/controllers/UserController.ts
import { NextApiResponse } from 'next';
import { GetUserUseCase } from '@/core/use-cases/user/GetUserUseCase';
import { UserPresenter } from '@/adapters/presenters/UserPresenter';

export class UserController {
  constructor(private getUserUseCase: GetUserUseCase) {}

  async getUser(userId: string, res: NextApiResponse) {
    try {
      const user = await this.getUserUseCase.execute(userId);
      if (!user) {
        return res.status(404).json({ error: 'User not found' });
      }
      
      const presenter = new UserPresenter();
      const presentedUser = presenter.present(user);
      
      return res.status(200).json(presentedUser);
    } catch (error) {
      return res.status(500).json({ error: 'Internal server error' });
    }
  }
}

UI Implementation

// pages/users/[id].tsx
import { GetServerSideProps } from 'next';
import { UserDetails } from '@/frameworks/web/components/UserDetails';
import { GetUserUseCase } from '@/core/use-cases/user/GetUserUseCase';
import { UserMongoRepository } from '@/adapters/gateways/UserAPIGateway';
import { mongoClient } from '@/frameworks/database/mongodb';
import { User } from '@/core/entities/User';
import { UserPresenter } from '@/adapters/presenters/UserPresenter';

interface Props {
  user: ReturnType<UserPresenter['present']> | null;
}

export default function UserPage({ user }: Props) {
  if (!user) {
    return <div>User not found</div>;
  }
  
  return <UserDetails user={user} />;
}

export const getServerSideProps: GetServerSideProps<Props> = async (context) => {
  const userId = context.params?.id as string;
  
  const userRepository = new UserMongoRepository(mongoClient);
  const getUserUseCase = new GetUserUseCase(userRepository);
  
  const user = await getUserUseCase.execute(userId);
  
  if (!user) {
    return { props: { user: null } };
  }
  
  const presenter = new UserPresenter();
  const presentedUser = presenter.present(user);
  
  return {
    props: {
      user: presentedUser,
    },
  };
};

Feature-Sliced Design in Next.js

The Core Principles

Feature-Sliced Design (FSD) is a methodology that organizes code by features rather than technical layers. It was developed to address some of the challenges faced by large teams working on complex applications. FSD structures the application into slices (features), where each slice contains all the code necessary for a specific feature to function.

The key layers in FSD are:

  1. App: Global application settings, providers, and styles
  2. Processes: Complex flows involving multiple features
  3. Pages: Compositional layer connecting features to specific routes
  4. Widgets: Composite UI blocks combining multiple entities/features
  5. Features: User scenarios, business capabilities
  6. Entities: Business objects and their logic
  7. Shared: Reusable utilities, UI kit, API clients, and types

Implementation in Next.js

Here's how a Feature-Sliced Design structure might look in a Next.js application:

/src
  /app
    /providers
      with-auth.tsx
      with-theme.tsx
    /styles
      globals.css
  /processes
    /auth
      /model
        auth-process.ts
  /pages
    /users
      [id].tsx
      index.tsx
    /products
      [id].tsx
      index.tsx
  /widgets
    /user-profile
      /ui
        user-profile.tsx
      /model
        types.ts
  /features
    /user
      /create-user
        /ui
          create-user-form.tsx
        /model
          create-user.ts
          validation.ts
        /api
          create-user.ts
      /edit-user
        /ui
          edit-user-form.tsx
        /model
          edit-user.ts
        /api
          edit-user.ts
  /entities
    /user
      /ui
        user-card.tsx
        user-avatar.tsx
      /model
        user.ts
        user-schema.ts
      /api
        user-api.ts
  /shared
    /ui
      button.tsx
      input.tsx
    /api
      base-api.ts
    /lib
      hooks.ts
    /config
      constants.ts

Business Logic Implementation in FSD

In Feature-Sliced Design, business logic is distributed across the entities and features layers:

// entities/user/model/user.ts
export interface User {
  id: string;
  name: string;
  email: string;
  role: 'admin' | 'user';
}

export const isAdmin = (user: User): boolean => {
  return user.role === 'admin';
};

// entities/user/api/user-api.ts
import { User } from '../model/user';
import { baseApi } from '@/shared/api/base-api';

export const userApi = {
  getById: async (id: string): Promise<User | null> => {
    try {
      const response = await baseApi.get<User>(`/users/${id}`);
      return response.data;
    } catch (error) {
      console.error('Failed to fetch user:', error);
      return null;
    }
  },
  
  getAll: async (): Promise<User[]> => {
    try {
      const response = await baseApi.get<User[]>('/users');
      return response.data;
    } catch (error) {
      console.error('Failed to fetch users:', error);
      return [];
    }
  }
};

// features/user/create-user/model/create-user.ts
import { User } from '@/entities/user/model/user';
import { userApi } from '@/entities/user/api/user-api';

export interface CreateUserForm {
  name: string;
  email: string;
  role: 'admin' | 'user';
}

export const createUser = async (userData: CreateUserForm): Promise<User | null> => {
  try {
    const response = await userApi.create(userData);
    return response;
  } catch (error) {
    console.error('Failed to create user:', error);
    return null;
  }
};

UI Implementation in FSD

// entities/user/ui/user-card.tsx
import React from 'react';
import { User, isAdmin } from '../model/user';
import { Button } from '@/shared/ui/button';

interface UserCardProps {
  user: User;
  onEdit?: () => void;
}

export const UserCard: React.FC<UserCardProps> = ({ user, onEdit }) => {
  return (
    <div className="p-4 border rounded-md">
      <h3 className="text-lg font-bold">{user.name}</h3>
      <p>{user.email}</p>
      {isAdmin(user) && <span className="text-sm bg-blue-100 px-2 py-1 rounded">Admin</span>}
      {onEdit && <Button onClick={onEdit}>Edit</Button>}
    </div>
  );
};

// features/user/edit-user/ui/edit-user-form.tsx
import React, { useState } from 'react';
import { User } from '@/entities/user/model/user';
import { Input } from '@/shared/ui/input';
import { Button } from '@/shared/ui/button';
import { updateUser } from '../model/edit-user';

interface EditUserFormProps {
  user: User;
  onSuccess: (updatedUser: User) => void;
}

export const EditUserForm: React.FC<EditUserFormProps> = ({ user, onSuccess }) => {
  const [formData, setFormData] = useState({
    name: user.name,
    email: user.email,
    role: user.role,
  });
  
  const handleSubmit = async (e: React.FormEvent) => {
    e.preventDefault();
    const updatedUser = await updateUser(user.id, formData);
    if (updatedUser) {
      onSuccess(updatedUser);
    }
  };
  
  return (
    <form onSubmit={handleSubmit}>
      <Input
        label="Name"
        value={formData.name}
        onChange={(e) => setFormData({ ...formData, name: e.target.value })}
      />
      <Input
        label="Email"
        value={formData.email}
        onChange={(e) => setFormData({ ...formData, email: e.target.value })}
      />
      <select
        value={formData.role}
        onChange={(e) => setFormData({ 
          ...formData, 
          role: e.target.value as 'admin' | 'user' 
        })}
      >
        <option value="user">User</option>
        <option value="admin">Admin</option>
      </select>
      <Button type="submit">Save Changes</Button>
    </form>
  );
};

Next.js Page Implementation in FSD

// pages/users/[id].tsx
import { GetServerSideProps } from 'next';
import { User } from '@/entities/user/model/user';
import { userApi } from '@/entities/user/api/user-api';
import { UserProfile } from '@/widgets/user-profile/ui/user-profile';
import { withAuth } from '@/app/providers/with-auth';

interface Props {
  user: User | null;
}

function UserPage({ user }: Props) {
  if (!user) {
    return <div>User not found</div>;
  }
  
  return <UserProfile user={user} />;
}

export const getServerSideProps: GetServerSideProps<Props> = async (context) => {
  const userId = context.params?.id as string;
  const user = await userApi.getById(userId);
  
  return {
    props: {
      user,
    },
  };
};

export default withAuth(UserPage);

Key Differences Between Clean Architecture and FSD

1. Organizational Philosophy

Clean Architecture:

  • Organizes code by technical responsibility
  • Enforces strict dependency rules (dependencies point inward)
  • Emphasizes isolation of business logic from infrastructure
  • Has concentric layers rather than vertical slices

Feature-Sliced Design:

  • Organizes code by business features or user capabilities
  • Groups related code together regardless of technical role
  • Uses horizontal layers but within vertical slices (features)
  • Focuses on business domains rather than technical concerns

2. Handling Business Logic

Clean Architecture:

  • Centralizes business logic in entities and use cases
  • Abstracts away infrastructure through repositories and interfaces
  • Business rules are isolated and independent of UI/frameworks
  • Requires extensive use of interfaces and dependency injection

Feature-Sliced Design:

  • Distributes business logic across entities and features
  • Couples business logic more closely with its UI representation
  • Uses model folders within features to handle specific business operations
  • Less emphasis on abstraction, more on practical organization

3. Adaptability and Team Collaboration

Clean Architecture:

  • Highly adaptable to changing infrastructure (databases, frameworks)
  • Requires more upfront design and boilerplate
  • Can be challenging for new team members to understand
  • Better suited for long-lived projects with complex business rules

Feature-Sliced Design:

  • Highly adaptable to changing requirements and features
  • Facilitates team collaboration through feature ownership
  • Easier for new developers to understand and contribute to specific features
  • Better suited for rapid development and feature-based teams

4. Data Flow

Clean Architecture:

UI → Controller → Use Case → Entities → Repository → Data Source

Feature-Sliced Design:

Pages → Widgets → Features → Entities → API Clients → External Services

When to Choose Each Architecture

Choose Clean Architecture When:

  • Your application has complex business rules that rarely change
  • You need maximum flexibility to swap out infrastructure components
  • You want to ensure your core business logic is thoroughly testable
  • You're building a long-lived system that will undergo multiple infrastructure changes
  • Your team is experienced with design patterns and dependency injection

Choose Feature-Sliced Design When:

  • Your application is feature-rich with moderate business complexity
  • You need to scale development across multiple teams
  • Features are more likely to change than core business rules
  • You want to minimize the learning curve for new developers
  • You're building a user-facing application with many UI components

Hybrid Approach: Combining the Best of Both Worlds

In practice, many Next.js applications benefit from a hybrid approach:

/src
  /core                          # Clean Architecture core
    /entities
    /use-cases
    /repositories
  /features                      # Feature-Sliced organization
    /users
      /create-user
      /edit-user
    /products
      /product-list
      /product-details
  /widgets                       # Shared UI compositions
  /pages                         # Next.js pages
  /shared                        # Shared utilities and components
    /ui
    /lib
    /api

This hybrid approach:

  1. Maintains clean separation of core business logic
  2. Organizes UI and user interactions by feature
  3. Leverages Next.js's page-based routing
  4. Preserves shared components and utilities

Conclusion

Both Clean Architecture and Feature-Sliced Design offer powerful paradigms for structuring Next.js applications. Clean Architecture excels at preserving business rules and maintaining flexibility, while Feature-Sliced Design optimizes for developer experience and team collaboration.

The choice between these architectures should be guided by your specific project requirements, team composition, and long-term maintenance needs. For many projects, a hybrid approach that leverages the strengths of both patterns can provide the optimal balance between maintainability, scalability, and developer productivity.

As web applications continue to grow in complexity, thoughtful architectural decisions become increasingly crucial. By understanding these patterns and their implementations in Next.js, you can make informed choices that set your projects up for long-term success.