Enforce Module Imports in FSD (using eslint-plugin-import)


Have you ever refactored a project and realised every file had a different import style? This article dives deeper into managing code structure by enforcing rules with ESLint.




Table of Contents




TL;DR

If you already know what you’re doing, check out the example repo: eslint-fsd-example. It follows the FSD (Feature Sliced Design) and serves as a practical showcase.




Motivation

As a lead developer, one of my responsibilities is to ensure that the codebase stays coherent and manageable while complexity grows. When new features and fixes are introduced by a team of developers, it’s inevitable that their code looks different. That’s fine — each one brings a unique vision and perspective. Sometimes, the team even adopts those practices at scale.

But coding style and structure are another story. Everyone has their own vision, and that’s great… until you want to refactor 😑.

Imagine different parts of your code doing things their own way:

import ... from "shared/ui/Comment"; // baseUrl + direct file
import ... from "../../ui/Comment";  // relative path
import ... from "@/shared/ui";       // alias + index file
import ... from "../../ui/index.js"; // relative path to index
// and so on
Enter fullscreen mode

Exit fullscreen mode

This happens in real projects and leads to a zoo of approaches scattered everywhere. Problems include circular imports, and broken refactors (auto-import often fails to replace paths correctly).




Unforeseen Consequences

Circular imports often appear when you import from an index file that re-exports the same module:

// src/shared/ui/index.js
export * from './Comment';
export * from './Like';
export * from './ViewsCounter';

// src/shared/ui/Comment
import ... from 'shared/ui'; // <-- 🔥 causes a cycle
Enter fullscreen mode

Exit fullscreen mode

Or even without an index file:

// src/shared/ui/Comment
import ... from './Like';
// src/shared/ui/Like
import ... from './ViewsCounter';
// src/shared/ui/ViewsCounter
import ... from './Comment';
Enter fullscreen mode

Exit fullscreen mode

💡 This can even lead to runtime errors that are very hard to debug (ask me how i know it).




Enforcing Code Structure

FSD conventionally enforces strict import/export principles (they’re even described as mandatory in the docs):

  • Any module may only be imported via its public API (index file) using an absolute path
  • Inside a module, all imports must use relative paths (avoids cycles)

But what stops developers from breaking this rule? Nothing 🤔 — unless you enforce it with ESLint.

FSD provides a dedicated feature-sliced/eslint-config with just three rules:

  • public-api – uses eslint-plugin-import, adds glob expression to match patterns in your import statements (internally then converts glob expressions to regexp via minimatch )
  • layers-slices – uses eslint-plugin-boundaries, analyses relations between the dependent element (your module) and the dependency (what’s being imported) and checks if it’s allowed or not (see fsd docs)
  • import-order – uses eslint-plugin-import, sorts and groups imports alphabetically

Let’s take a look at a Next.js example project that tries to follows FSD (Feature Sliced Design) methodology. Boilerplate comes from create-next-app using the with-eslint example.

npx create-next-app \
eslint-fsd-example \ # project name
-e https://github.com/vercel/next.js/tree/canary \ # example name or url
--example-path examples/with-eslint # if example url has a branch name in it and then path to the example, it has to be specified separately
Enter fullscreen mode

Exit fullscreen mode




Customising the Rule

Project structure (simplified):

src/
├── app/
   ├── ...
   ├── page.tsx
      └── Home page
   └── signin/
       └── page.tsx
           └── Signin Page
├── _pages/
   └── signin/
       └── ui/
           ├── index.ts
           ├── views/
              ├── Signin.tsx
                 └── Signin main container
              └── index.ts
           └── components/
               ├── index.ts
               └── Signin/
                   ├── Signin.tsx
                      └── Signin actual content
                   └── index.ts
├── _features/
   └── sigin/
       ├── types/
          ├── index.ts
          └── signinFormValues.ts
       └── lib/
           ├── index.ts
           └── useSigninForm.ts
Enter fullscreen mode

Exit fullscreen mode

The _pages and _features folders start with underscores to avoid naming conflicts with Next.js reserved folders. Other FSD layers use the same convention for consistency.

We want to enforce imports only from public APIs (index files). Here’s a simplified ESLint config:

const FS_LAYERS = [ 'entities', 'features', 'widgets', 'app', 'processes', 'pages', 'shared' ];

const FS_SEGMENTS = [ 'ui', 'types', 'model', 'lib', 'api', 'config', 'assets' ];

const nextjsConfigRules: Linter.Config['rules'] = {
  'import/no-internal-modules': [
    'error',
    {
      allow: [
        /**
         * Allow not segments import from slices
         * @example
         * 'entities/user/ui' // Pass
         * 'entities/user' // Also Pass
         */
        `**/*(${FS_SLICED_LAYERS_REG})/!(${FS_SEGMENTS_REG})/*(${FS_SEGMENTS_REG})`,

        /**
         * Allow slices with structure grouping
         * @example
         * 'features/auth/form' // Pass
         */
        `**/*(${FS_SLICED_LAYERS_REG})/!(${FS_SEGMENTS_REG})/!(${FS_SEGMENTS_REG})`,
        ...
      ],
    }
  ]
};
Enter fullscreen mode

Exit fullscreen mode

💡 For the shared directory different rules apply since it’s somewhat special.
Look in the source code for details.

Reaching past src/_features/signin/lib/index.ts is prohibited — exactly what we want ✅.

Here’s the proof:
Proof that the rule works

The FSD guidelines say you should only import from the slice root. Our team, however, prefers to allow imports from specific segments (like ui, lib, etc.).

To achieve this, we had to adjust eslint-plugin-import directly.

If you want to follow the official FSD recommendations, you can just use the feature-sliced/eslint-config public-api rule as is.




Future Exploration

While this setup covers the basics of enforcing FSD imports, there are still some open questions worth exploring:

  • Cross-imports with @x in FSD – Cross-imports are part of the official FSD methodology. They allow integration between slices or layers in a controlled way. But how do we enforce them properly with ESLint, and what are the best practices for keeping them consistent?
  • Dynamic imports – Sometimes you need to use import() directly for code-splitting. But what happens when you want to dynamically import something from a public API instead of a deep file? Is that possible, or do you always end up importing the raw file?
  • Testing strategies – Should tests respect the same public API import rules, or is it acceptable for them to reach into private modules?

I don’t have all the answers yet (still experimenting 😅), but these are areas I’m actively digging into.

👉 In a follow-up post, I’ll share experiments and possible solutions for:

  • Making dynamic imports work with public APIs
  • Handling test imports without breaking conventions
  • Enforcing and scaling cross-imports without losing clarity

So if that sounds interesting — stay tuned, because Part 2 is coming!


👇 Share your experience in the comments — I’d love to hear how you handle these challenges!



Source link

Leave a Reply

Your email address will not be published. Required fields are marked *