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:
// 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:
- Each plugin has one canonical home
- Convenience re-exports are optional, not authoritative
- Users know exactly where to import from
- 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:
import { withMessaging } from "@ws-kit/plugins";
import { withRpc } from "@ws-kit/plugins";Feature-Specific Plugins
These live in their own packages:
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"; // FutureMiddleware & Hooks
These live in their respective packages:
// 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:
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):
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:
// 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:
// ✗ 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/cloudflareRationale 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?
- Single Source of Truth: Each feature has one canonical home, reducing confusion
- Documentation Clarity: Specs always show canonical imports, no ambiguity
- Future-Proof: New plugins don't require changes to validator packages
- Tooling-Friendly: Code generators and IDE extensions know exactly where to find things
- Backwards Compatible: Convenience re-exports don't break existing code
- Scalability: Middleware, telemetry, compression, and future features have clear homes
Implementation
Documentation Standards
All specifications and examples MUST follow these rules:
Always show canonical imports first
typescript// ✓ Always canonical import { withPubSub } from "@ws-kit/pubsub"; import { withRateLimit } from "@ws-kit/rate-limit";Note convenience re-exports as alternatives (if relevant)
typescript// ✓ Convenience re-export (same as canonical above) import { withPubSub } from "@ws-kit/zod";Document adapter imports clearly
typescript// For development (in-memory) import { memoryPubSub } from "@ws-kit/memory"; // For production (distributed) import { redisPubSub } from "@ws-kit/redis";Never suggest non-canonical imports except when noting re-exports
Package Structure
Each canonical package exports clearly:
@ws-kit/pubsub/package.json exports field:
{
"exports": {
".": "./dist/index.js",
"./adapters": "./dist/adapters.js"
}
}@ws-kit/pubsub/src/index.ts:
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/zodkeeps 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,useLoggingare 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)
// 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)
// 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
// ✗ 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"; // ERRORDocumentation Updates
Affected Files
- CLAUDE.md — Quick Start example uses correct imports
- docs/specs/schema.md — Canonical imports section references this ADR
- docs/specs/README.md — Import quick reference updated
- docs/specs/plugins.md — All examples use canonical sources
- docs/specs/adapters.md — Adapter imports clearly documented
- All examples/ — Example code follows canonical patterns
Documentation Format
Each spec that shows imports includes a reference:
## 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:
// 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 validFor New Documentation
All new examples and specs use canonical imports exclusively.
For Linting (Future)
Tools can enforce canonical imports via:
// ESLint rule (future)
rules: {
"@ws-kit/canonical-imports": "warn"
}Consequences
Positive
- ✓ Clear, single import source per feature
- ✓ Documentation is unambiguous
- ✓ Better DX through consistency
- ✓ Easier for tooling and code generation
- ✓ Future features have clear homes
Neutral
- ~ Convenience re-exports remain (optional, not required)
- ~ Existing code continues to work unchanged
- ~ No runtime impact (purely organizational)
Minimal
- Documentation needs updates (one-time cost)
- Future contributors need to know canonical sources (documented in CLAUDE.md)
References
- ADR-007 — Export-with-Helpers Pattern (validator + helpers from one source)
- ADR-028 — Plugin Architecture
- ADR-031 — Plugin-Adapter Split
- docs/specs/plugins.md — Plugin system reference
- docs/specs/adapters.md — Adapter documentation