TypeScript Module Boundaries: Barrel Files vs Clean Architecture

Barrel files look clean but create hidden coupling. Here's how to organize TypeScript modules for real maintainability.

•9 min read
typescriptarchitecturemodulesdependency management

TypeScript's module system is powerful, but teams often organize imports in ways that look clean initially and become painful as the codebase grows.

If you've worked in a large TypeScript project, you've probably seen imports like this:

import { UserService, ProductService, OrderService } from '@/modules';

At first glance this looks elegant. One clean import path. Everything neatly organized.

Six months later things start to feel different:

  • circular dependencies appear
  • bundle builds get slower
  • refactoring becomes risky
  • no one is entirely sure which modules actually depend on which

The pattern behind many of these problems is barrel files.

Barrels aren't inherently bad. They solve real problems in small codebases. But as systems grow, they often obscure the architecture rather than clarify it.

The core issue isn't just barrel files themselves. It's something deeper:

Your imports describe your architecture.

Every import represents a dependency between parts of the system. When import paths hide those relationships, the architecture becomes harder to reason about.

There are three common approaches teams use for organizing TypeScript modules:

  • Barrel files – re-exporting everything through index.ts
  • Direct imports – importing files explicitly
  • Architectural boundaries – exporting controlled module APIs

The first two are extremely common. The third tends to scale much better.

Let's look at why.

The Barrel File Temptation

Barrel files promise cleaner imports.

Without barrels:

import { UserService } from '@/modules/user/services/UserService';
import { UserRepository } from '@/modules/user/repositories/UserRepository';
import { UserDTO } from '@/modules/user/dto/UserDTO';

With a barrel:

import { UserService, UserRepository, UserDTO } from '@/modules/user';

The second version looks much cleaner. Teams adopt this pattern quickly because it reduces import noise.

In small projects this works perfectly well.

But as modules grow, barrel files often introduce a set of architectural issues.

Problem 1: Circular Dependencies Become Easier

Barrel files make circular dependencies surprisingly easy to create.

Example:

// modules/user/index.ts
export * from './UserService';
export * from './UserRepository';
// modules/user/UserService.ts
import { UserRepository } from './index';
// modules/user/UserRepository.ts
import { UserService } from './index';

This produces a dependency cycle that isn't immediately obvious. Note that these are intra-module cycles — within a single module — rather than the cross-module cycles that first come to mind:

index.ts → UserService.ts → index.ts
index.ts → UserRepository.ts → index.ts

From the perspective of the dependency graph, this creates a strongly connected component.

In practice, cycles like this usually appear gradually:

  • A service imports something from the barrel.
  • Another module imports something else from the barrel.
  • Eventually the barrel ends up importing files that also import the barrel.

The architecture silently becomes cyclic — and once you visualize the full dependency graph, you'll often find cycles you didn't know existed.

Problem 2: Barrel Files Hide Dependency Structure

Barrel imports obscure the actual dependencies of a module.

Consider this import:

import { UserDTO, ProductDTO, PaymentDTO } from '@/modules';

From the import statement alone, it's unclear:

  • which modules are actually required
  • which parts of the system are coupled
  • whether a module depends on domain logic or infrastructure

Explicit imports tell a clearer story:

import { UserDTO } from '@/modules/user/dto/UserDTO';
import { ProductDTO } from '@/modules/product/dto/ProductDTO';

Now the architectural relationship is visible:

  • OrderService → User module
  • OrderService → Product module

This difference becomes important when refactoring modules, reasoning about architecture, or visualizing dependency graphs.

Barrel files compress architectural information into a single import path.

Problem 3: API Drift From export *

Another subtle issue is accidental API expansion.

Consider this barrel:

// modules/user/index.ts
export * from './UserService';
export * from './UserRepository';
export * from './internal/userValidation';

If userValidation was meant to be internal, it has now become part of the module's public API.

Consumers can now write:

import { validateUserEmail } from '@/modules/user';

At that point, the internal helper function has effectively become a public contract.

Later refactoring becomes risky because you don't know whether external modules depend on that internal implementation.

Over time this creates API drift — modules unintentionally expose far more surface area than intended.

Problem 4: Nested Barrel Chains

Large repositories often develop layers of barrels.

Example:

modules/index.ts
modules/user/index.ts
modules/user/services/index.ts
modules/user/services/internal/index.ts

Each barrel re-exports another set of files.

Imports then pass through multiple layers:

OrderService → modules/index.ts
             → modules/user/index.ts
             → modules/user/services/index.ts
             → UserService.ts

This creates barrel chains.

Even when a file imports a single symbol, the module resolver may need to traverse multiple re-export layers to determine where that symbol originates.

This doesn't always affect final bundle size, but it can noticeably affect:

  • cold build time
  • HMR update performance
  • module graph complexity

Why Barrel Files Became Popular

It's worth acknowledging that barrel files solve real problems.

They became common because they provide:

Shorter imports

import { UserService } from '@/modules/user';

instead of:

import { UserService } from '@/modules/user/services/UserService';

Easier module discovery

Developers can browse a single index.ts file to see what a module provides.

Faster early development

When architecture is still evolving, exporting everything is convenient.

Reduced path churn during refactors

If internal files move, the public import path can stay stable.

These benefits are real, especially in small or early-stage projects.

The problems tend to appear later when modules grow large and teams start refactoring architecture.

The Direct Import Alternative

If barrel files create problems, the obvious alternative is direct imports:

import { UserService } from '@/modules/user/services/UserService';
import { validateEmail } from '@/modules/user/validators/emailValidator';
import { UserCreatedEvent } from '@/modules/user/events/UserCreatedEvent';

Dependencies are explicit. Architectural relationships are visible.

But direct imports have tradeoffs.

Verbose imports

In some modules, import blocks become large:

import { UserService } from '@/modules/user/services/UserService';
import { UserRepository } from '@/modules/user/repositories/UserRepository';
import { UserMapper } from '@/modules/user/mappers/UserMapper';
import { UserValidator } from '@/modules/user/validators/UserValidator';
import { CreateUserDTO } from '@/modules/user/dto/CreateUserDTO';
import { UpdateUserDTO } from '@/modules/user/dto/UpdateUserDTO';

Refactoring blast radius

Moving files requires updating multiple imports. Modern IDEs and codemods reduce this friction, but it still introduces overhead.

Direct imports expose architectural relationships clearly, but they don't enforce module boundaries.

The Real Solution: Architectural Module Boundaries

The deeper issue isn't barrels versus direct imports. It's module boundaries.

The key distinction is the difference between a barrel file and a public API facade.

Barrel fileAPI facade
ExportsEverything (export *)Only intentional public API
Internal filesVisible externallyHidden as implementation
Coupling riskHighControlled
Refactoring safetyLowHigh

Example barrel:

// modules/user/index.ts
export * from './UserService';
export * from './UserRepository';
export * from './UserValidator';
export * from './UserTransformer';

Example API facade:

// modules/user/index.ts
export { UserService } from './services/UserService';
export type { UserDTO } from './dto/UserDTO';
export type { CreateUserInput } from './dto/CreateUserInput';

Internal modules remain private.

Consumers still get clean imports:

import { UserService } from '@/modules/user';

But the module now controls its public surface.

Enforcing Dependency Direction

Large systems benefit from explicit dependency rules.

A common layered structure is:

presentation → application → domain
                                ↑
                         infrastructure

Example layout:

src/
  domain/
  application/
  infrastructure/
  presentation/

Each layer has a specific responsibility:

  • Domain — business rules and entities
  • Application — use cases and orchestration
  • Infrastructure — external systems
  • Presentation — HTTP handlers or UI

Clear layering prevents architectural drift.

Enforcing Boundaries With Tooling

TypeScript alone doesn't enforce architecture rules. Linting tools help make violations visible early.

Two useful tools are:

eslint-plugin-import

Detects circular dependencies.

{
  "rules": {
    "import/no-cycle": "error"
  }
}

eslint-plugin-boundaries

Defines allowed import directions.

{
  "rules": {
    "boundaries/element-types": ["error", {
      "default": "disallow",
      "rules": [
        { "from": "presentation", "allow": ["application"] },
        { "from": "application", "allow": ["domain"] },
        { "from": "infrastructure", "allow": ["domain"] }
      ]
    }]
  }
}

These rules help catch architectural violations immediately rather than months later.

Feature Modules as an Alternative

Another approach is feature-based architecture.

Instead of organizing by technical layer:

domain/
application/
infrastructure/

Code is grouped by business domain:

features/
  user/
  order/
  product/

Each feature can contain its own internal layers.

features/
  user/
    domain/
    application/
    infrastructure/

Many large systems combine feature modules with internal layering.

When Barrel Files Are Actually Fine

Barrel files aren't always problematic.

They can work well in:

Small libraries

Packages intended for external consumption often expose a single barrel as their public API.

Leaf modules

Modules with no internal layering may not benefit from strict facades.

Test utilities

Test helpers frequently re-export shared helpers through a barrel.

The key distinction is whether the barrel is an intentional public API or a convenience export of everything.

Visualizing the Architecture

One useful way to understand module boundaries is by visualizing the dependency graph.

Barrel-heavy structures tend to produce dense graphs because re-exports make many modules appear interconnected.

Layered architectures tend to produce clearer graphs:

presentation
     ↓
application
     ↓
domain
     ↑
infrastructure

When the graph becomes easier to read, architectural issues become easier to detect.

Dependency visualization often reveals:

  • circular dependencies
  • unexpected coupling between features
  • modules that have become architectural hubs

Migrating Away From Barrel Files

Refactoring an existing codebase doesn't require a full rewrite.

A gradual process works well.

Step 1: Map the dependency graph

Start by understanding the current module relationships.

Many teams are surprised by what they find: circular dependencies, unexpectedly central modules, hidden cross-layer imports.

Tools like PViz can generate a structural graph of a TypeScript project, making these relationships visible before restructuring begins.

Step 2: Define boundaries

Choose whether your architecture will be layered, feature-based, or hybrid.

Document the rules explicitly.

Step 3: Introduce public API facades

Replace large export * barrels with controlled exports.

Start with the most widely imported modules.

Step 4: Enforce rules with tooling

Add lint rules to prevent architectural drift.

The Key Principle

Your imports describe your architecture.

Every import represents a dependency between modules.

When imports clearly express module relationships:

  • dependencies become visible
  • refactoring becomes safer
  • cycles are easier to detect
  • architecture stays easier to reason about

Barrel files can make imports look cleaner, but they often hide those relationships.

The goal isn't fewer import lines.

The goal is a module structure where the code's shape reflects the system's design.

If you're unsure how your modules are actually connected, visualizing the dependency graph is often the fastest way to find out. PViz can generate a structural graph of a TypeScript project, making module coupling and circular dependencies visible before they become difficult to untangle.

Try PViz on your own codebase

Get dependency graphs, coupling signals, and a compressed bundle ready for your LLM — for any GitHub repository, in minutes.