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.
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 moduleOrderService→ 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 file | API facade | |
|---|---|---|
| Exports | Everything (export *) | Only intentional public API |
| Internal files | Visible externally | Hidden as implementation |
| Coupling risk | High | Controlled |
| Refactoring safety | Low | High |
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.