Skip to main content

Barrel Files (Domain Public API)

Understanding and using barrel files in our DDD architecture.

What are Barrel Files?

A barrel file is an index.ts file that re-exports selected items from other files, serving as a public API for a module or domain.

In Asset360 v3, every domain has a barrel file that defines its public interface:

worker/services/fund/
├── domain.ts # Internal: Types and entities
├── repository.ts # Internal: Data access
├── service.ts # Internal: Business logic
└── index.ts # Public: Barrel file (API) ⭐

Purpose in DDD

Barrel files enforce bounded context boundaries in Domain-Driven Design:

  1. Define Public API: Explicitly declare what's public vs. internal
  2. Enforce Encapsulation: Keep implementation details private
  3. Enable Refactoring: Change internals without affecting consumers
  4. Prevent Coupling: ESLint enforces barrel imports across domains

Example Barrel File

// worker/services/fund/index.ts

/**
* Fund Domain Public API
*
* This barrel file defines the public interface for the fund domain.
* External domains should only import from this file, not from internal implementation files.
*/

// Domain types
export type {
CreateFundRequest,
CreateFundSnapshotRequest,
EODState,
FundEntity,
FundFeesJson,
FundSnapshotEntity,
ManagementFeeTier,
RunEODRequest,
UpdateFundRequest,
} from "./domain";

// Service
export { FundService } from "./service";

// Repository
export { FundRepository } from "./repository";

How to Use

✅ Correct Usage

Import from the barrel file:

// In worker/services/bank-account/service.ts
import type { FundService, FundEntity } from "../fund";
import type { InvestorService } from "../investor";
import { AggregateAccountingService } from "../accounting";

class BankAccountService {
constructor(
private repository: BankAccountRepository,
private fundService?: FundService,
private accountingService?: AggregateAccountingService,
) {}
}

❌ Incorrect Usage

Direct imports from internal files (ESLint will error):

// ❌ This will fail ESLint
import { FundService } from "../fund/service";
import { FundEntity } from "../fund/domain";
import { FundRepository } from "../fund/repository";

ESLint Error:

Import from domain barrel (index.ts) instead of internal files.
Use "./fund" not "./fund/service" or "./fund/domain".

Internal Imports (Within Same Domain)

Within the same domain, direct imports are allowed:

// In worker/services/fund/service.ts
import type { FundEntity, CreateFundRequest } from "./domain";
import { FundRepository } from "./repository";

// ✅ This is fine - within the same domain

ESLint Enforcement

The project has an ESLint rule (no-restricted-imports) that enforces barrel imports:

// eslint.config.mjs
{
rules: {
'no-restricted-imports': ['error', {
patterns: [{
group: [
'**/services/fund/service',
'**/services/fund/domain',
'./fund/service',
'../fund/service',
// ... all domains
],
message: 'Import from domain barrel (index.ts) instead of internal files.'
}]
}]
}
}

Benefits

1. Clear Public API

Barrel files explicitly declare what's public:

// fund/index.ts - Only these are public
export { FundService } from "./service";
export type { FundEntity } from "./domain";

// fund/domain.ts - These are internal (not exported)
interface InternalFundCalculation {
/* ... */
}
function validateFundCode(code: string) {
/* ... */
}

2. Refactoring Safety

Move or rename internal files without breaking consumers:

// Before: fund/service.ts
// After: fund/fund-service.ts

// barrel file update:
export { FundService } from "./fund-service"; // Changed

// Consumers unchanged:
import { FundService } from "../fund"; // Still works!

3. Prevents Circular Dependencies

Barrel files make import cycles visible and preventable:

// Without barrels - hidden cycle:
// fund/service.ts -> investor/service.ts -> fund/service.ts

// With barrels - cycle is obvious:
// fund/index.ts -> investor/index.ts -> fund/index.ts

4. Better Testing

Mock entire domains at the barrel level:

// test/helpers/mock-services.ts
vi.mock("../worker/services/fund", () => ({
FundService: vi.fn(),
FundRepository: vi.fn(),
}));

5. Documentation

Barrel files serve as documentation of the domain's public API:

// Looking at fund/index.ts tells you:
// - What types are public
// - What services are available
// - What can be used from outside the domain

Trade-offs

Advantages ✅

  • Encapsulation: Clear public/private boundaries
  • Maintainability: Internal changes don't affect consumers
  • DDD Alignment: Enforces bounded contexts
  • Tooling Support: ESLint enforcement

Potential Concerns ⚠️

  • Bundle Size: Importing one item loads the entire barrel (mitigated by tree-shaking)
  • Development Speed: Requires barrel file updates when exposing new exports
  • Circular Dependencies: Can hide cycles if not careful (mitigated by explicit exports)

Our Decision

We use barrel files because:

  1. DDD Requires Boundaries: Bounded contexts need clear APIs
  2. ESLint Enforcement: Automated checking prevents violations
  3. Tree-Shaking Works: Modern bundlers optimize barrel imports
  4. Library Pattern: Matches industry standard (all npm packages use barrels)

Adding a New Domain

When creating a new domain:

  1. Create the domain structure:

    worker/services/new-domain/
    ├── domain.ts
    ├── repository.ts
    ├── service.ts
    └── index.ts ← Create this!
  2. Populate the barrel file:

    // worker/services/new-domain/index.ts
    export type { DomainEntity, CreateRequest } from "./domain";
    export { DomainService } from "./service";
    export { DomainRepository } from "./repository";
  3. Add to ESLint rule:

    // eslint.config.mjs - add patterns:
    '**/services/new-domain/service',
    '**/services/new-domain/domain',
    './new-domain/service',
    '../new-domain/service',
  4. Export from central barrel:

    // worker/services/index.ts
    export { DomainService } from "./new-domain";
    export type { DomainEntity } from "./new-domain";

Troubleshooting

Circular Dependency Error

// Error: Circular dependency detected
import { FundService } from "../fund"; // in investor/
import { InvestorService } from "../investor"; // in fund/

Solution: Remove one dependency or introduce a coordinator to orchestrate the interaction.

ESLint Error Not Showing

If ESLint doesn't catch a direct import:

  1. Check that the pattern is in eslint.config.mjs
  2. Run pnpm eslint worker/services/**/*.ts
  3. Restart your editor's TypeScript server

Build Failing After Barrel Update

If the build fails after updating a barrel:

  1. Verify all exports exist in the source files
  2. Check for typos in export names
  3. Run pnpm typecheck to see specific errors

See Also