Skip to content

ADR-032: Canonical Imports Design ​

Status: Final Date: 2025-11-14 References: ADR-007 (Export-with-Helpers Pattern), ADR-031 (Plugin-Adapter Architecture)


Context ​

As the plugin ecosystem expanded (@ws-kit/plugins, @ws-kit/pubsub, @ws-kit/rate-limit, @ws-kit/middleware), a critical question emerged:

Where should users import plugins and utilities from?

The Challenge ​

Three import patterns emerged in practice:

typescript
// Option 1: Direct from source
import { withPubSub } from "@ws-kit/pubsub";
import { rateLimit } from "@ws-kit/rate-limit";
import { useAuth } from "@ws-kit/middleware";

// Option 2: Via validator convenience re-exports (plugins only)
import { withPubSub } from "@ws-kit/zod";
import { withPubSub } from "@ws-kit/valibot";

// Option 3: Via core
import { withMessaging, withRpc } from "@ws-kit/core";

This inconsistency created confusion about:

  • Where is the canonical source? Does middleware come from validators?
  • What gets re-exported? Is everything available everywhere?
  • Developer friction: Importing from multiple sources for a single application
  • Future scaling: Adding new plugins requires changes in multiple packages

The Real Problem ​

The root issue wasn't package location but canonical import identity. For effective code generation, documentation, and library tooling, we needed clear rules about:

  1. Each plugin has one canonical home
  2. Convenience re-exports are optional, not authoritative
  3. Users know exactly where to import from
  4. Documentation never suggests non-canonical imports

Decision ​

Canonical Import Sources ​

Each feature type has one canonical import source. Users MUST always import from canonical sources.

Core Framework Plugins (No Validation) ​

These go in @ws-kit/plugins:

typescript
import { withMessaging } from "@ws-kit/plugins";
import { withRpc } from "@ws-kit/plugins";

Feature-Specific Plugins ​

These live in their own packages:

typescript
import { withPubSub, usePubSub } from "@ws-kit/pubsub";
import { withTelemetry, useTelemetry } from "@ws-kit/telemetry"; // Future
import { withCompression, useCompression } from "@ws-kit/compression"; // Future
import { withCaching, useCaching } from "@ws-kit/caching"; // Future

Middleware & Hooks ​

These live in their respective packages:

typescript
// Rate limiting (token bucket via middleware)
import { rateLimit, keyPerUser, keyPerUserPerType } from "@ws-kit/rate-limit";

// Authentication, logging, metrics, telemetry
import { useAuth, useLogging, useMetrics } from "@ws-kit/middleware";

Adapters ​

Backend implementations live in their adapter packages:

typescript
import { memoryPubSub, memoryRateLimiter } from "@ws-kit/memory";
import { redisPubSub, redisRateLimiter } from "@ws-kit/redis";
import { cloudflarePubSub, cloudflareRateLimiter } from "@ws-kit/cloudflare";

Validator-Provided Helpers ​

These export from @ws-kit/zod or @ws-kit/valibot (whichever you choose):

typescript
import { z, message, createRouter, withZod } from "@ws-kit/zod";
// or
import { v, message, createRouter, withValibot } from "@ws-kit/valibot";

Convenience Re-exports (Optional) ​

Validator packages MAY re-export common plugins and middleware for convenience, but only from canonical sources. Adapters are NEVER re-exported — they must always be imported from their adapter packages to ensure explicit intent (development vs production).

@ws-kit/zod re-exports:

typescript
// From @ws-kit/plugins
export { withMessaging, withRpc } from "@ws-kit/plugins";

// From @ws-kit/pubsub
export { withPubSub, usePubSub } from "@ws-kit/pubsub";

// Router factory (canonically from core)
export { createRouter } from "@ws-kit/core";

@ws-kit/valibot re-exports the same list (identical to Zod for consistency).

What Does NOT Get Re-exported ​

These have no convenience re-exports; users must import from canonical sources:

typescript
// ✗ NOT available from @ws-kit/zod or @ws-kit/valibot

// Rate-limiting (middleware, independent of validation choice)
import { rateLimit, keyPerUserPerType } from "@ws-kit/rate-limit"; // ✓ Must import from @ws-kit/rate-limit

// Other middleware (independent of validation choice)
import { useAuth } from "@ws-kit/middleware"; // ✓ Must import from @ws-kit/middleware
import { useLogging } from "@ws-kit/middleware"; // ✓ Must import from @ws-kit/middleware

// Feature plugin middleware (separate from plugins)
import { usePubSub } from "@ws-kit/pubsub"; // ✓ Must import from @ws-kit/pubsub

// Adapters (always explicit to clarify dev vs prod)
import { memoryPubSub } from "@ws-kit/memory"; // ✓ Must import from @ws-kit/memory
import { redisPubSub } from "@ws-kit/redis"; // ✓ Must import from @ws-kit/redis
import { durableObjectsPubSub } from "@ws-kit/cloudflare"; // ✓ Must import from @ws-kit/cloudflare

Rationale for adapter exclusion: Adapters implement backend strategies (in-memory, Redis, Cloudflare Durable Objects). Explicitly importing from their packages makes architectural intent clear. Convenience re-exports would obscure this decision, encouraging ambiguous imports like import { memoryPubSub } from "@ws-kit/zod" instead of the clear import { memoryPubSub } from "@ws-kit/memory".

Why This Design? ​

  1. Single Source of Truth: Each feature has one canonical home, reducing confusion
  2. Documentation Clarity: Specs always show canonical imports, no ambiguity
  3. Future-Proof: New plugins don't require changes to validator packages
  4. Tooling-Friendly: Code generators and IDE extensions know exactly where to find things
  5. Backwards Compatible: Convenience re-exports don't break existing code
  6. Scalability: Middleware, telemetry, compression, and future features have clear homes

Implementation ​

Documentation Standards ​

All specifications and examples MUST follow these rules:

  1. Always show canonical imports first

    typescript
    // ✓ Always canonical
    import { withPubSub } from "@ws-kit/pubsub";
    import { withRateLimit } from "@ws-kit/rate-limit";
  2. Note convenience re-exports as alternatives (if relevant)

    typescript
    // ✓ Convenience re-export (same as canonical above)
    import { withPubSub } from "@ws-kit/zod";
  3. Document adapter imports clearly

    typescript
    // For development (in-memory)
    import { memoryPubSub } from "@ws-kit/memory";
    
    // For production (distributed)
    import { redisPubSub } from "@ws-kit/redis";
  4. Never suggest non-canonical imports except when noting re-exports

Package Structure ​

Each canonical package exports clearly:

@ws-kit/pubsub/package.json exports field:

json
{
  "exports": {
    ".": "./dist/index.js",
    "./adapters": "./dist/adapters.js"
  }
}

@ws-kit/pubsub/src/index.ts:

typescript
export { withPubSub, usePubSub } from "./plugin.js";
export type { PubSubConfig, PubSubAdapter, PublishResult } from "./types.js";

Rationale ​

Why Separate Packages for Feature Plugins? ​

  • Clarity: Users know exactly where a feature lives
  • Discoverability: Feature packages appear in package managers under their names
  • Zero Dependency: Apps not using pub/sub don't install @ws-kit/pubsub
  • Evolution: Features can evolve independently

Why Convenience Re-exports in Validators? ​

  • DX: New developers often start with validators; lower barrier to entry
  • Backward Compat: Existing code importing from @ws-kit/zod keeps working
  • Consistency: Both validators re-export the same set (no surprises)
  • Composition: Plugins are library composition concerns; safe to re-export

Why No Middleware Re-exports? ​

  • Separate Concern: Middleware (including rate-limiting) is independent of validation strategy
  • Clarity: rateLimit, useAuth, useLogging are not validation concepts
  • Future: New middleware can be added without validator changes
  • Consistency: Rate-limiting joins other middleware in canonical packages, not validators

Why No Adapter Re-exports? ​

  • Explicit Intent: Importing from adapter packages (@ws-kit/memory, @ws-kit/redis) makes dev vs prod decision visible
  • Clarity: Code that says import { redisPubSub } from "@ws-kit/redis" is self-documenting
  • Anti-pattern Avoidance: Prevents confusing imports like import { redisPubSub } from "@ws-kit/zod"
  • Architecture: Adapters are backend implementation details, separate from composition (plugins/middleware)

Examples ​

Canonical Imports (Always Correct) ​

typescript
// Core + validators (always from these packages)
import { z, message, createRouter, withZod } from "@ws-kit/zod";

// Core plugins
import { withMessaging, withRpc } from "@ws-kit/plugins";

// Feature plugins
import { withPubSub } from "@ws-kit/pubsub";

// Middleware (rate-limit is middleware, not plugin)
import { rateLimit, keyPerUserPerType } from "@ws-kit/rate-limit";
import { useAuth } from "@ws-kit/middleware";

// Adapters (always explicit: dev vs prod)
import { memoryPubSub } from "@ws-kit/memory";
import { redisPubSub } from "@ws-kit/redis";

Convenience Re-exports (Valid but Secondary) ​

typescript
// Plugins available via validator, but middleware and adapters are always canonical
import { withMessaging, withRpc, withPubSub, createRouter } from "@ws-kit/zod";

// Middleware MUST be imported from their packages (not re-exported)
import { rateLimit, keyPerUserPerType } from "@ws-kit/rate-limit";

// Adapters MUST be imported from their packages (not re-exported)
import { memoryPubSub } from "@ws-kit/memory";
import { redisPubSub } from "@ws-kit/redis";

What Breaks ​

typescript
// ✗ Rate-limiting NOT re-exported from validators
import { rateLimit } from "@ws-kit/zod"; // ERROR
import { keyPerUserPerType } from "@ws-kit/valibot"; // ERROR

// ✗ Other middleware NOT re-exported from validators
import { useAuth } from "@ws-kit/zod"; // ERROR

// ✗ Adapters NEVER re-exported from validators or plugins
import { memoryPubSub } from "@ws-kit/zod"; // ERROR
import { redisPubSub } from "@ws-kit/plugins"; // ERROR
import { redisPubSub } from "@ws-kit/zod"; // ERROR

Documentation Updates ​

Affected Files ​

  1. CLAUDE.md — Quick Start example uses correct imports
  2. docs/specs/schema.md — Canonical imports section references this ADR
  3. docs/specs/README.md — Import quick reference updated
  4. docs/specs/plugins.md — All examples use canonical sources
  5. docs/specs/adapters.md — Adapter imports clearly documented
  6. All examples/ — Example code follows canonical patterns

Documentation Format ​

Each spec that shows imports includes a reference:

markdown
## Canonical Imports

See [ADR-032](../adr/032-canonical-imports-design.md) for complete rules.

Always import plugins from their canonical sources:

- Core plugins: `@ws-kit/plugins`
- Feature plugins: Their feature packages (`@ws-kit/pubsub`, etc.)
- Adapters: Adapter packages (`@ws-kit/memory`, `@ws-kit/redis`, etc.)

Migration Path ​

For Existing Code ​

No changes required. Convenience re-exports remain stable:

typescript
// Old code still works
import { withPubSub } from "@ws-kit/zod";

// New code uses canonical
import { withPubSub } from "@ws-kit/pubsub";

// Both import the same thing, so both are valid

For New Documentation ​

All new examples and specs use canonical imports exclusively.

For Linting (Future) ​

Tools can enforce canonical imports via:

typescript
// ESLint rule (future)
rules: {
  "@ws-kit/canonical-imports": "warn"
}

Consequences ​

Positive ​

  1. ✓ Clear, single import source per feature
  2. ✓ Documentation is unambiguous
  3. ✓ Better DX through consistency
  4. ✓ Easier for tooling and code generation
  5. ✓ Future features have clear homes

Neutral ​

  1. ~ Convenience re-exports remain (optional, not required)
  2. ~ Existing code continues to work unchanged
  3. ~ No runtime impact (purely organizational)

Minimal ​

    • Documentation needs updates (one-time cost)
    • Future contributors need to know canonical sources (documented in CLAUDE.md)

References ​