DEV Community

Amit Verma
Amit Verma

Posted on Originally published at tayloramitverma.com on

JavaScript Module Boundaries That Scale: Feature Folders, Public APIs and Lint-Enforced Imports

A couple of summers ago I spent a weekend untangling an analytics bundle because one small helper imported the entire dashboard layer. The homepage was shipping chart libraries it never rendered. That refactor taught me something I still repeat to every team: modules are only useful when their boundaries make sense to the humans on call.

The language gives you import and export. It doesn't give you architecture. This post is the set of conventions — and the tooling that enforces them — that I use to keep a growing JavaScript or TypeScript codebase from turning into one big ball of imports.

Three questions that define a boundary

Before creating a folder, I answer three questions. If the team can't answer them in a minute, the boundary is muddy.

  1. What does this module produce? One sentence. "Formats and sends invoices" is a boundary. "Utils" is not.
  2. What does it depend on? List the other modules it's allowed to import.
  3. How would we replace it? If swapping the email provider means touching 40 files, the provider isn't behind a boundary.

Organise by feature, not by file type

The classic layout groups by kind — components/, hooks/, services/, utils/. It looks tidy and scales badly: every feature is smeared across every folder, and nothing stops components/Chart from importing services/billing.

Group by feature instead, with a small shared layer underneath:

src/
  features/
    billing/
      api.ts # server calls
      components/
      hooks/
      index.ts # the public API of this feature
    analytics/
      ...
      index.ts
  shared/ # no feature imports allowed in here
    ui/
    lib/
  app/ # routes/pages — composes features
Enter fullscreen mode Exit fullscreen mode

The dependency direction is one-way: app → features → shared. Features don't import each other's internals, and shared never imports a feature.

Give every feature a public API

Each feature exposes an index.ts with named exports for what other code may use. Everything else is private by convention:

// features/billing/index.ts
export { InvoiceTable } from './components/InvoiceTable'
export { useInvoices } from './hooks/useInvoices'
export type { Invoice, InvoiceStatus } from './types'
// formatLineItem, the API client, internal hooks: not exported
Enter fullscreen mode Exit fullscreen mode

Other features import from @/features/billing, never from @/features/billing/components/InvoiceTable. When you refactor billing's internals, only billing changes.

I use named exports for these public APIs. They rename safely across the codebase, autocomplete well, and make "what does this module offer?" a one-file read.

Enforce it with lint, not code review

Conventions that rely on reviewers catching them don't survive deadlines. ESLint's built-in no-restricted-imports can block deep imports into another feature:

// eslint.config.mjs
export default [
  {
    files: ['src/**/*.{ts,tsx}'],
    rules: {
      'no-restricted-imports': [
        'error',
        {
          patterns: [
            {
              group: ['@/features/*/*'],
              message: 'Import from the feature’s public API (e.g. @/features/billing), not its internals.',
            },
          ],
        },
      ],
    },
  },
  {
    files: ['src/shared/**/*.{ts,tsx}'],
    rules: {
      'no-restricted-imports': [
        'error',
        { patterns: [{ group: ['@/features/*', '@/app/*'], message: 'shared/ must not depend on features or app.' }] },
      ],
    },
  },
]
Enter fullscreen mode Exit fullscreen mode

The first rule stops deep imports between features. The second keeps the shared layer truly shared. For more complex rules — "billing may import from auth, but not the other way around" — dependency-cruiser lets you describe allowed edges between folders and fails CI when a new import breaks them.

Catch circular dependencies early

Cycles are the most common way boundaries rot. They also cause real bugs: with ES modules, a cycle can mean one module sees another's exports as undefined at load time.

madge finds them in seconds:

npx madge --circular --extensions ts,tsx src/
Enter fullscreen mode Exit fullscreen mode

Run it in CI. A new cycle should block the merge just like a failing test.

Libraries: let package.json enforce the boundary

If the module is a package — in a monorepo or on npm — the exports field makes the public API a hard boundary. Anything not listed simply can't be imported:

{
  "name": "@acme/billing",
  "type": "module",
  "exports": {
    ".": "./dist/index.js",
    "./react": "./dist/react/index.js"
  }
}
Enter fullscreen mode Exit fullscreen mode

import { x } from '@acme/billing/dist/internal/x' now fails with ERR_PACKAGE_PATH_NOT_EXPORTED. No lint rule needed.

Pass dependencies in, don't import them

Hidden imports make modules hard to test and hard to replace. Compare:

// Hidden dependencies: hard to test, tied to real time and the real store
import { fetchReport } from './analytics'
import { store } from './store'

export const buildSummary = () => store.persist(fetchReport(new Date()))

// Explicit dependencies: the boundary is visible in the signature
type Deps = {
  clock: () => Date
  fetchReport: (at: Date) => Promise<Report>
  store: { persist: (r: Report) => Promise<void> }
}

export const buildSummary = async ({ clock, fetchReport, store }: Deps) => {
  const report = await fetchReport(clock())
  await store.persist(report)
  return report
}
Enter fullscreen mode Exit fullscreen mode

Tests pass a fake clock and an in-memory store. Production wiring happens once, at the edge of the app. You don't need a dependency-injection framework for this — a function argument is enough.

The barrel file trap

Public index.ts files are good. Barrel files that re-export everything are not. A shared/index.ts that does export * from './ui'; export * from './lib'; … causes two problems:

  • Bundle size. Depending on your bundler and how "side-effect free" the code is, importing one icon through a giant barrel can pull in far more than you asked for.
  • Slow dev servers and tests. Every import of the barrel loads every module behind it.

Keep public APIs small and intentional. For third-party barrels you can't change, Next.js has experimental.optimizePackageImports to rewrite them into direct imports.

Check the result, not just the rules

Before shipping a structural change I open the bundle analyser (@next/bundle-analyzer, or vite-bundle-visualizer) and confirm that lazy-loaded chunks stayed small. Then I write the boundary decision in the pull request description, so future me remembers why billing can't import from analytics.

Checklist

  • Each module answers: what it produces, what it depends on, how to replace it.
  • Code is grouped by feature, with a one-way dependency direction.
  • Every feature has a small index.ts public API with named exports.
  • no-restricted-imports (or dependency-cruiser) blocks deep and upward imports.
  • madge --circular runs in CI.
  • Packages use package.json exports.
  • Dependencies are passed in, not imported, at important boundaries.
  • No catch-all barrel files.

The analytics bundle that started all this stopped shipping chart code to the homepage after the refactor. More importantly, the problem hasn't come back, because the rules now live in CI instead of in my memory.


Originally published at www.tayloramitverma.com.

Top comments (0)