Minimizing Cognitive Load for Better Code

AI Bot
By AI Bot ·

Loading the Text to Speech Audio Player...
Cognitive Load

Introduction

Buzzwords and best practices are plentiful, but what truly matters is a developer's cognitive load – the mental effort required to understand code. High cognitive load leads to confusion, costing time and money. Since we spend more time reading code than writing it, minimizing cognitive load is crucial. By optimizing cognitive load, developers can enhance code comprehension, reduce bugs, and improve overall maintainability.

Cognitive Load Explained

Cognitive load refers to the mental capacity a developer uses to complete a task. Our working memory can hold about four "chunks" of information (e.g., variable values, logic). Exceeding this limit hinders comprehension. Unfamiliar projects with complex architectures and trendy technologies increase cognitive load. Understanding and managing cognitive load can lead to more efficient coding practices and better software outcomes.

The Three Types of Cognitive Load

  1. Intrinsic Cognitive Load:

    • Definition: The inherent difficulty associated with a specific task.
    • Characteristics: Irreducible; depends on the complexity of the task itself.
    • Example: Understanding a complex algorithm or mastering a new framework like Next.js.
  2. Extraneous Cognitive Load:

    • Definition: The load imposed by the way information is presented or tasks are structured.
    • Characteristics: Reducible; can be minimized with better design and practices.
    • Example: Overcomplicated code structures or poor documentation that make understanding Next.js or Laravel projects harder.
  3. Germane Cognitive Load:

    • Definition: The mental effort dedicated to processing, constructing, and automating schemas.
    • Characteristics: Beneficial; supports learning and mastery.
    • Example: Actively learning best practices in Laravel to improve coding efficiency.

Types of Cognitive Load

  • Intrinsic: Inherent difficulty of a task (irreducible).
  • Extraneous: Caused by how information is presented (can be reduced).
  • Germane: Effort used to create and automate schemas (enhances learning).

Practical Examples of Extraneous Cognitive Load

Reducing extraneous cognitive load involves simplifying the way information is presented or structuring tasks to be more intuitive. Below are enhanced examples using Next.js and Laravel to illustrate common pitfalls and their solutions.

Complex Conditionals

Scenario: Handling multiple conditions in both Next.js API routes and Laravel controllers can lead to intricate and hard-to-read code.

// High cognitive load in Next.js API Route
export default function handler(req, res) {
  if (req.method === 'POST' && req.body.user && (req.body.user.isActive || req.body.user.isAdmin) && req.body.user.emailVerified) {
    // Process the request
  } else {
    res.status(400).json({ error: 'Invalid request' });
  }
}

Reduced cognitive load:

// Reduced cognitive load in Next.js API Route
export default function handler(req, res) {
  const { method, body } = req;
  const { user } = body;
 
  const isPost = method === 'POST';
  const hasUser = user !== undefined;
  const isActiveOrAdmin = user.isActive || user.isAdmin;
  const emailVerified = user.emailVerified;
 
  if (isPost && hasUser && isActiveOrAdmin && emailVerified) {
    // Process the request
  } else {
    res.status(400).json({ error: 'Invalid request' });
  }
}

Explanation: Breaking down complex conditionals into smaller, well-named variables enhances readability and reduces cognitive strain.

Nested Ifs

Scenario: Deep nesting in Laravel controllers can obscure the flow of logic.

// High cognitive load in Laravel Controller
public function update(Request $request, $id)
{
    if ($request->has('name')) {
        if ($request->has('email')) {
            if ($this->isValidEmail($request->email)) {
                // Update user
            } else {
                return response()->json(['error' => 'Invalid email'], 400);
            }
        } else {
            return response()->json(['error' => 'Email is required'], 400);
        }
    } else {
        return response()->json(['error' => 'Name is required'], 400);
    }
}

Reduced cognitive load:

// Reduced cognitive load in Laravel Controller
public function update(Request $request, $id)
{
    if (!$request->has('name')) {
        return response()->json(['error' => 'Name is required'], 400);
    }
 
    if (!$request->has('email')) {
        return response()->json(['error' => 'Email is required'], 400);
    }
 
    if (!$this->isValidEmail($request->email)) {
        return response()->json(['error' => 'Invalid email'], 400);
    }
 
    // Update user
}

Explanation: Using early returns simplifies the control flow, making the code easier to follow.

Inheritance Nightmare

Scenario: Overusing inheritance in Next.js and Laravel can create deep and hard-to-manage hierarchies.

// Deep inheritance in Next.js
class BaseHandler {
  handle(req, res) {
    // Common handling
  }
}
 
class AuthHandler extends BaseHandler {
  handle(req, res) {
    // Authentication logic
    super.handle(req, res);
  }
}
 
class AdminHandler extends AuthHandler {
  handle(req, res) {
    // Admin-specific logic
    super.handle(req, res);
  }
}

Reduced cognitive load using composition:

// Composition in Next.js
function withAuth(handler) {
  return (req, res) => {
    // Authentication logic
    return handler(req, res);
  };
}
 
function withAdmin(handler) {
  return (req, res) => {
    // Admin-specific logic
    return handler(req, res);
  };
}
 
export default withAdmin(withAuth((req, res) => {
  // Final handler logic
}));

Explanation: Composition allows for more flexible and readable code structures compared to deep inheritance chains.

Too Many Small Modules

Scenario: In Laravel, creating too many small service classes can fragment the codebase.

// Excessive small modules in Laravel
class UserService {
    public function createUser($data) { /* ... */ }
}
 
class EmailService {
    public function sendEmail($user, $message) { /* ... */ }
}
 
class NotificationService {
    public function notify($user, $notification) { /* ... */ }
}
 
// And so on...

Reduced cognitive load with consolidated modules:

// Consolidated service in Laravel
class UserManagementService {
    public function createUser($data) { /* ... */ }
 
    public function sendWelcomeEmail($user) { /* ... */ }
 
    public function notifyUser($user, $notification) { /* ... */ }
}

Explanation: Consolidating related functionalities into fewer modules reduces the number of files developers need to navigate, easing cognitive load.

Feature-Rich Languages

Scenario: Both Next.js (JavaScript/TypeScript) and Laravel (PHP) offer numerous language features that can be overused, complicating the code.

Overuse example in TypeScript (Next.js):

// Excessive use of generics and advanced types
type Response<T> = {
  status: number;
  data: T;
  error?: string;
};
 
function fetchData<T>(url: string): Promise<Response<T>> {
  // ...
}

Reduced cognitive load with simpler types:

// Simpler type definitions in Next.js
type ApiResponse = {
  status: number;
  data: any;
  error?: string;
};
 
function fetchData(url: string): Promise<ApiResponse> {
  // ...
}

Explanation: While advanced types can be powerful, overusing them can make the code harder to understand. Using simpler, more straightforward types can enhance readability.

Business Logic and HTTP Status Codes

Scenario: Mixing business logic with HTTP status codes in both Next.js API routes and Laravel controllers can obscure the intent.

// Next.js API Route with mixed logic
export default function handler(req, res) {
  if (!req.body.token) {
    return res.status(401).json({ error: 'Unauthorized' });
  }
  // Business logic...
}
// Laravel Controller with mixed logic
public function store(Request $request)
{
    if (!$request->has('api_key')) {
        return response()->json(['error' => 'Unauthorized'], 401);
    }
    // Business logic...
}

Reduced cognitive load with separation of concerns:

// Next.js API Route with separated concerns
export default function handler(req, res) {
  try {
    authenticate(req);
    // Business logic...
    res.status(200).json({ success: true });
  } catch (error) {
    res.status(error.status).json({ error: error.message });
  }
}
 
function authenticate(req) {
  if (!req.body.token) {
    throw { status: 401, message: 'Unauthorized' };
  }
}
// Laravel Controller with separated concerns
public function store(Request $request)
{
    try {
        $this->authenticate($request);
        // Business logic...
        return response()->json(['success' => true], 200);
    } catch (\Exception $e) {
        return response()->json(['error' => $e->getMessage()], $e->getStatusCode());
    }
}
 
private function authenticate(Request $request)
{
    if (!$request->has('api_key')) {
        throw new \Exception('Unauthorized', 401);
    }
}

Explanation: Separating authentication from business logic clarifies the flow and reduces the intertwining of concerns, making the code easier to understand and maintain.

Abusing DRY Principle

Scenario: Over-applying the DRY (Don't Repeat Yourself) principle in both Next.js and Laravel can lead to tightly coupled and less flexible code.

Overuse example in Laravel:

// Excessive abstraction in Laravel
abstract class BaseController extends Controller {
    protected function respond($data, $status = 200) {
        return response()->json($data, $status);
    }
}
 
class UserController extends BaseController {
    public function show($id) {
        $user = User::find($id);
        return $this->respond($user);
    }
}
 
class ProductController extends BaseController {
    public function show($id) {
        $product = Product::find($id);
        return $this->respond($product);
    }
}

Balanced approach:

// Balanced approach in Laravel
class UserController extends Controller {
    public function show($id) {
        $user = User::find($id);
        return response()->json($user);
    }
}
 
class ProductController extends Controller {
    public function show($id) {
        $product = Product::find($id);
        return response()->json($product);
    }
}

Explanation: While DRY is valuable, over-abstraction can make the code harder to navigate and understand. Striking a balance ensures code remains maintainable without unnecessary complexity.

Tight Coupling with a Framework

Scenario: Deep integration with Next.js or Laravel features can make the codebase less flexible and harder to test.

// Next.js tightly coupled with specific framework features
import { getSession } from 'next-auth/client';
 
export default function handler(req, res) {
  const session = getSession({ req });
  if (!session) {
    return res.status(401).json({ error: 'Unauthorized' });
  }
  // Business logic...
}
// Laravel tightly coupled with framework features
public function index()
{
    $user = Auth::user();
    if (!$user) {
        return response()->json(['error' => 'Unauthorized'], 401);
    }
    // Business logic...
}

Reduced cognitive load with framework-agnostic code:

// Next.js framework-agnostic handler
export default function handler(req, res, authenticate) {
  try {
    const user = authenticate(req);
    // Business logic...
    res.status(200).json({ success: true });
  } catch (error) {
    res.status(error.status).json({ error: error.message });
  }
}

Explanation: By abstracting authentication logic, the handler becomes more flexible and easier to test independently of Next.js-specific features.

// Laravel framework-agnostic controller method
public function index()
{
    try {
        $user = $this->authenticate(request());
        // Business logic...
        return response()->json(['success' => true], 200);
    } catch (\Exception $e) {
        return response()->json(['error' => $e->getMessage()], $e->getStatusCode());
    }
}
 
private function authenticate($request)
{
    // Custom authentication logic
}

Explanation: Decoupling authentication from Laravel's built-in mechanisms allows for greater flexibility and easier testing.

Layered Architecture

Scenario: Implementing excessive layers in Next.js and Laravel projects can introduce unnecessary complexity.

Excessive layering example in Laravel:

// Multiple layers in Laravel
// Controller -> Service -> Repository -> Model
 
class UserController extends Controller {
    protected $userService;
    public function __construct(UserService $userService) {
        $this->userService = $userService;
    }
    public function show($id) {
        return $this->userService->getUserById($id);
    }
}
 
class UserService {
    protected $userRepository;
    public function __construct(UserRepository $userRepository) {
        $this->userRepository = $userRepository;
    }
    public function getUserById($id) {
        return $this->userRepository->find($id);
    }
}
 
class UserRepository {
    public function find($id) {
        return User::find($id);
    }
}

Simplified approach:

// Simplified controller in Laravel
class UserController extends Controller {
    public function show($id) {
        $user = User::find($id);
        return response()->json($user);
    }
}

Explanation: Removing unnecessary layers can make the codebase more straightforward, reducing the cognitive load required to navigate and understand the code.

Cognitive Load in Familiar Projects

Familiarity can mask complexity. In both Next.js and Laravel projects, developers might overlook underlying complexities because they have become accustomed to the frameworks' paradigms. Regularly reviewing code for simplification ensures that cognitive load remains manageable, even in well-known projects.

Example in Next.js:

A developer familiar with Next.js might use advanced features like dynamic imports and API routes extensively. While these features are powerful, overusing them without proper documentation can lead to confusion.

Solution: Periodic code reviews and refactoring sessions can help identify areas where simplification is possible, such as consolidating API routes or optimizing dynamic imports for better performance and readability.

Example in Laravel:

A Laravel project might have grown organically, accumulating various service providers, middleware, and custom traits. Familiarity with these components can lead to assumptions about their behavior.

Solution: Implementing consistent naming conventions, thorough documentation, and automated tests can help maintain clarity and reduce hidden cognitive load.

Conclusion

Minimizing cognitive load beyond what's intrinsic to the task is essential for maintaining high-quality, maintainable code. By applying principles such as simplifying conditionals, reducing deep inheritance, consolidating modules, balancing DRY, decoupling from frameworks, and avoiding excessive layering, developers can create more comprehensible and efficient codebases. Additionally, actively seeking feedback from junior developers and conducting regular code reviews can help identify and address complex areas, fostering an environment of continuous improvement and collaboration.

Acknowledgements

A special thanks to Artem Zakirullin for the original insights and inspiration behind this article on cognitive load in software development. Artem's in-depth analysis and practical examples provided a foundational understanding that significantly shaped the perspectives discussed here. Additionally, gratitude goes to the wider developer community whose continuous discussions and shared experiences contribute to the collective effort in creating more maintainable and comprehensible codebases.

References

  1. Original Blog Post by Artem Zakirullin: Understanding Cognitive Load in Software Development
  2. Sweller, J. (1988). Cognitive Load During Problem Solving: Effects on Learning. Cognitive Science, 12(2), 257–285. Link
  3. McConnell, S. (2004). Code Complete: A Practical Handbook of Software Construction. Microsoft Press.
  4. Frey, B. (2014). Cognitive Load Theory: Advances in Research and Practice. Springer.
  5. Martin, R. C. (2008). Clean Code: A Handbook of Agile Software Craftsmanship. Prentice Hall.
  6. Gamma, E., Helm, R., Johnson, R., & Vlissides, J. (1994). Design Patterns: Elements of Reusable Object-Oriented Software. Addison-Wesley.
  7. Next.js Documentation: https://nextjs.org/docs
  8. Laravel Documentation: https://laravel.com/docs

These references provide additional depth on cognitive load theory, software design principles, and best practices for writing maintainable code. They offer valuable insights for developers looking to further understand and apply strategies to reduce cognitive load in their projects.


Want to read more blog posts? Check out our latest blog post on Our Project Management Process.

Discuss Your Project with Us

We're here to help with your web development needs. Schedule a call to discuss your project and how we can assist you.

Let's find the best solutions for your needs.