Set Package Boundaries in TypeScript

Learn how to set clear package boundaries in TypeScript projects using folder conventions, ESLint rules, and dependency direction to keep a large codebase maintainable.

8 min read

A package boundary is a rule that says "this folder can import from that folder, but not the other way around." In a small project, every file can import from every other file and things still work. In a large project, unrestricted imports create a tangled dependency graph where changing one utility function requires retesting half the application.

TypeScript does not enforce package boundaries on its own. The compiler checks types, not architecture. You need folder conventions and lint rules to define boundaries.

Once boundaries are in place, they prevent the most common cause of large-codebase decay: accidental coupling between unrelated modules.

Define Your Layers

Every codebase has natural layers. A typical TypeScript application has at least four, each with a specific responsibility and a strict dependency direction:

Package layer dependency direction

The arrows show the only allowed import direction. Shared code is imported by everyone. Domain imports from shared.

Features import from domain and shared. Infrastructure imports from shared only, and is connected to features through dependency injection rather than direct imports.

A concrete folder structure reflects these layers directly:

texttext
src/
  shared/         -- types, utilities, constants (no business logic)
  domain/         -- models, service interfaces, business rules
  features/       -- use cases, controllers, route handlers
  infrastructure/ -- database clients, HTTP servers, config loaders

The critical rule is simple: no folder imports from a folder above it. Domain cannot import from features, and shared cannot import from anyone.

This rule alone prevents the most damaging kind of coupling in a growing codebase.

Enforce Boundaries with ESLint

Folder structure is a suggestion. ESLint rules make it a requirement that runs in CI and blocks pull requests. The import/no-restricted-paths rule defines zones that certain directories cannot import from:

jsonjson
{
  "rules": {
    "import/no-restricted-paths": [
      "error",
      {
        "zones": [
          {
            "target": "./src/domain",
            "from": "./src/features",
            "message": "Domain cannot import from features. Use dependency inversion."
          }
        ]
      }
    ]
  }
}

When someone tries to import a feature module into domain code, the linter blocks the pull request with a clear error message that explains why it is blocked and what to do instead. This rule becomes part of your CI pipeline and runs on every commit.

You can tighten boundaries further for specific feature folders. Preventing sibling feature folders from importing each other directly forces shared logic through the proper channels. Here is how you would add a zone to block imports between checkout and dashboard:

jsonjson
{
  "target": "./src/features/checkout",
  "from": "./src/features/dashboard",
  "message": "Features must not import from sibling features. Use shared services or events."
}

Each zone rule is self-documenting. The message tells the developer exactly what pattern is forbidden and what the correct alternative is.

Design a Public API for Each Package

A package should expose a deliberate set of exports. Internal implementation details stay hidden behind an index.ts barrel file that acts as the package's public API:

texttext
checkout/
  index.ts              -- public API: exports only what consumers need
  checkout.service.ts
  checkout.types.ts
  pricing-calculator.ts -- internal, not exported from index.ts
  __tests__/

The barrel file exports exactly the public surface and nothing more, keeping internal implementation details hidden from all external consumers of the package:

typescripttypescript
// checkout/index.ts
export { createCheckout } from "./checkout.service";
export type { CheckoutRequest, CheckoutResult } from "./checkout.types";

Consumers outside the checkout package import only from the public API. They never reach into internal files directly, which means you can refactor the internals freely:

typescripttypescript
import { createCheckout } from "@/features/checkout";

This means you can refactor the internal pricing calculator freely. As long as the exported function keeps its signature, no consumer breaks. The public API is the contract between the package and the rest of the codebase.

Handle Cross-Cutting Concerns

Some types and utilities are genuinely needed everywhere. A Result type or a formatting function belongs in shared. But be strict about what qualifies: if only two features use a type, it does not belong in shared.

When a concern crosses layer boundaries, use dependency inversion. A domain service that needs to send email should not import an email client directly. Instead, define an interface in the domain layer and implement it in infrastructure:

typescripttypescript
// domain/notifications.ts
type EmailService = {
  send: (to: string, subject: string, body: string) => Promise<void>;
};

The domain service depends on this interface, not on any specific email provider. The infrastructure layer provides the actual implementation:

typescripttypescript
// infrastructure/mailgun-email-service.ts
const mailgunEmailService: EmailService = {
  send: async (to, subject, body) => {
    await fetch("https://api.mailgun.net/v3/...", { /* config */ });
  },
};

The domain defines what it needs. Infrastructure provides it, and neither layer knows about the other's implementation details.

For more on this pattern, see create service layers with TypeScript.

Name Packages by Responsibility

Package names signal what belongs inside. A package named after a technology tells you it contains integration code. A package named after a responsibility tells you it contains business logic that today happens to use that technology.

Avoid (named by technology)Prefer (named by responsibility)
stripe/billing/
sendgrid/notifications/
postgres/persistence/
redis/cache/

The technology-specific code lives inside the responsibility-named package. If billing moves from Stripe to Paddle, you replace one implementation file. The public API stays the same.

When to Split a Package

A package that does too many things becomes a dumping ground. Signs that a package should be split include: the barrel file exports more than 15 symbols, the tests set up unrelated mocks, changing one feature forces changes to seemingly unrelated files, or the package name contains "and" or "common."

When splitting, move files to the new package and update the old barrel to re-export from the new location temporarily. This keeps consumers compiling during the migration. Remove the re-exports once all consumers have moved.

Common Mistakes

Using export star in barrel files. This leaks every internal type and makes the public API impossible to audit. Always use named exports so the public surface is explicit and intentional.

Putting all shared code in a single utils folder. Utils is where code goes to be forgotten. If a function is shared, give it a meaningful home like formatting, validation, or datetime.

Allowing feature-to-feature imports. When one feature imports from another feature directly, both become harder to change independently. Route shared logic through the shared layer or lift it into a domain service.

Enforcing boundaries only through code review. Humans miss things. ESLint rules in CI catch every violation, every time. The rules are the architecture documentation.

Creating a package for everything. Boundaries have a cost: more files, more barrel files, more indirection. Start with the four-layer structure and add boundaries only when a specific coupling problem appears.

For more on keeping imports clean, see avoid circular imports in TypeScript.

Rune AI

Rune AI

Key Insights

  • Package boundaries define which modules can import from which, enforced by folder structure and lint rules.
  • Dependency direction flows one way: shared code is imported by features, never the reverse.
  • Each package exposes a public API through an index.ts barrel; internal files are never imported directly from outside.
  • ESLint rules like import/no-restricted-paths enforce boundaries in CI.
  • A package should be named after its responsibility, not its technology: billing, not stripe.
RunePowered by Rune AI

Frequently Asked Questions

What is a package boundary in TypeScript?

A package boundary is a rule that defines which modules can import from which other modules. It prevents lower-level modules from depending on higher-level ones, and stops feature modules from importing each other directly. Boundaries are enforced by folder conventions and lint rules.

How do I enforce package boundaries beyond folder structure?

Use ESLint with the import/no-restricted-paths rule to forbid specific import patterns. These rules run in CI and prevent boundary violations from merging. For monorepos, tools like Nx offer additional module boundary enforcement.

Conclusion

Package boundaries are the walls that keep a large TypeScript codebase from collapsing into a ball of imports. By defining clear layers, enforcing dependency direction with lint rules, and exposing only intentional public APIs from each package, you create a codebase where changes in one area do not silently break code in another.