Skip to main content

factory-function-composition

Factory function patterns to compose clients and services. Use when wrapping resources with domain methods, refactoring mixed client/service/method options, or documenting returned factory methods.

معلومات المصدر

المستودع
EpicenterHQ/epicenter
آخر نشاط في المصدر
٥ أكتوبر ٢٠٢٦ في ٠٥:٢٦
لغة SKILL.md المكتشفة
الإنجليزية
النجوم
٤٬٨٢٣
التفرعات
٣٨٥

خيارات التثبيت

يُحدَّد Prompt الذي يراجع المصدر أولًا بشكل افتراضي. يمكنك التبديل إلى أمر مباشر أو تنزيل نسخة محلية.

مراجعة ملفات المصدر

اقرأ SKILL.md وأي ملفات مرافقة يعرضها SkillsMP قبل أن تقرر التثبيت.

مستكشف الملفات
4 ملفات

عرض SKILL.md

SKILL.md
تعليمات المصدر · معاينة للقراءة فقط
name
factory-function-composition
description
Factory function patterns to compose clients and services. Use when wrapping resources with domain methods, refactoring mixed client/service/method options, or documenting returned factory methods.
metadata
{"author":"epicenter","version":"1.0"}
# Factory Function Composition This skill helps you apply factory function patterns for clean dependency injection and function composition in TypeScript. > **Related Skills**: See [refactoring methods](../collapse-pass/references/refactoring-methods.md) for caller counting and inlining single-use extractions. ## The Universal Signature **Every factory function follows this signature:** ```typescript function createSomething(dependencies, options?) { return { /* methods */ }; } ``` - **First argument**: Always the resource(s). Either a single client or a destructured object of multiple dependencies. - **Second argument**: Optional configuration specific to this factory. Never client config; that belongs at client creation. Two arguments max. First is resources, second is config. No exceptions. ## The Core Pattern ```typescript // Single dependency function createService(client, options = {}) { return { method(methodOptions) { // Uses client, options, and methodOptions }, }; } // Multiple dependencies function createService({ db, cache }, options = {}) { return { method(methodOptions) { // Uses db, cache, options, and methodOptions }, }; } // Usage const client = createClient(clientOptions); const service = createService(client, serviceOptions); service.method(methodOptions); ``` ## Key Principles 1. **Client configuration belongs at client creation time**: don't pipe clientOptions through your factory 2. **Each layer has its own options**: client, service, and method options stay separate 3. **Dependencies come first**: factory functions take dependencies as the first argument 4. **Return objects with methods**: not standalone functions that need the resource passed in ## Recognizing the Anti-Patterns ### Anti-Pattern 1: Function takes client as first argument ```typescript // Bad function doSomething(client, options) { ... } doSomething(client, options); // Good const service = createService(client); service.doSomething(options); ``` ### Anti-Pattern 2: Client creation hidden inside ```typescript // Bad function doSomething(clientOptions, methodOptions) { const client = createClient(clientOptions); // Hidden! // ... } // Good const client = createClient(clientOptions); const service = createService(client); service.doSomething(methodOptions); ``` ### Anti-Pattern 3: Mixed options blob ```typescript // Bad doSomething({ timeout: 5000, // Client option retries: 3, // Client option endpoint: '/users', // Method option payload: data, // Method option }); // Good const client = createClient({ timeout: 5000, retries: 3 }); const service = createService(client); service.doSomething({ endpoint: '/users', payload: data }); ``` ### Anti-Pattern 4: Multiple layers hidden ```typescript // Bad function doSomething(clientOptions, serviceOptions, methodOptions) { const client = createClient(clientOptions); const service = createService(client, serviceOptions); return service.method(methodOptions); } // Good: each layer visible and configurable const client = createClient(clientOptions); const service = createService(client, serviceOptions); service.method(methodOptions); ``` ## Multiple Dependencies When your service needs multiple clients: ```typescript function createService( { db, cache, http }, // Dependencies as destructured object options = {}, // Service options ) { return { method(methodOptions) { // Uses db, cache, http }, }; } // Usage const db = createDbConnection(dbOptions); const cache = createCacheClient(cacheOptions); const http = createHttpClient(httpOptions); const service = createService({ db, cache, http }, serviceOptions); service.method(methodOptions); ``` ## The Canonical Internal Shape The previous sections cover the external signature: `(deps, options?) → return { methods }`. This section covers what goes *inside* the function body. Every factory function follows a four-zone ordering: ```typescript // Option A: destructure in the signature (preferred for small dep lists) function createSomething({ db, cache }: Deps, options?) { const maxRetries = options?.maxRetries ?? 3; // ... } // Option B: destructure in zone 1 (fine when you also need the deps object itself) function createSomething(deps: Deps, options?) { const { db, cache } = deps; const maxRetries = options?.maxRetries ?? 3; // ... } ``` Both are valid. The point is that by the time you reach zone 2, all dependencies and config are bound to `const` names. The four zones: ```typescript function createSomething({ db, cache }, options?) { // Zone 1: Immutable state (const from deps/options) const maxRetries = options?.maxRetries ?? 3; // Zone 2: Mutable state (let declarations) let connectionCount = 0; let lastError: Error | null = null; // Zone 3: Private helpers function resetState() { connectionCount = 0; lastError = null; } // Zone 4: Public API (always last) return { connect() { ... }, disconnect() { ... }, get errorCount() { return connectionCount; }, }; } ``` Zones 1 and 2 can merge when there's little state. Zone 3 is empty for small factories. But the return object is always last: it's the complete public API. ### Public Return Types Derive From Zone 4 When the exported type is just the handle returned by one factory, derive the type from the factory instead of annotating the factory with the type. ```typescript export type RemoteClient = ReturnType<typeof createRemoteClient>; export function createRemoteClient(options: RemoteClientOptions) { return { actions<T>(peerId: string): RemoteActionProxy<T> { // ... }, describe(peerId: string): Promise<ActionManifest> { // ... }, }; } ``` Zone 4 is already the public API. Duplicating it in a manual return type creates a second source of truth and changes editor navigation: Go to Definition tends to jump to the alias instead of the returned member. Keep method parameter and return annotations inside zone 4 when they make the public surface clearer. This is one face of a broader principle: when organizing types and exports, always consider Go-to-Definition. Adapter / proxy / wrapper factories with no behavior change are another regression in the same family: Go-to-Def lands on the wrapper instead of the source of truth. The "collapsed adapter" rule below is its concrete remedy. See `typescript` "Go-to-Definition Awareness" for the full set of regressions to watch for. Do not use this for shared service contracts that several factories implement. Those contracts are vocabulary. Use `satisfies` at the return object when a factory needs to prove it matches an external contract while preserving the concrete returned shape. ### The `this` Decision Rule Inside the return object, public methods sometimes need to call other public methods. Use `this.method()` when the method is called on the returned object; method shorthand receives that object as its `this`. If callers extract the method, it loses that receiver. See [method documentation and sibling calls](references/method-documentation-and-sibling-calls.md) for the documentation and extraction tradeoffs. If a function is called both by return-object methods *and* by pre-return initialization logic, it belongs in zone 3 (private helpers). Call it directly by name; no `this` needed. | Where the function lives | How to call it | |---|---| | Return object (zone 4) | `this.method()` from sibling methods | | Private helper (zone 3) | Direct call by name: `helperFn()` | | Both zones need it | Keep in zone 3, call by name everywhere | See [Closures Are Better Privacy Than Keywords](../../../docs/articles/closures-are-better-privacy-than-keywords.md) for the full rationale and real codebase examples. ## Structural Contracts: Factories That Satisfy External Interfaces A factory's return object can be designed to **structurally satisfy** an external contract, so it can be passed directly to a platform-agnostic core without an adapter. The canonical example is `createPersistedState` / `createStorageState`, whose return shape structurally satisfies the `SessionStore` contract that `@epicenter/auth`'s `createAuth` consumes: ```ts // The contract (in a platform-agnostic core): type SessionStore = { get(): T | null; set(value: T | null): void; watch(fn: (next: T | null) => void): () => void; }; // The factory exposes BOTH a reactive accessor AND the contract methods: function createPersistedState(opts) { let value = $state(readFromStorage()); // ... return { get current() { return value }, // reactive read for templates set current(v) { setAndPersist(v) }, get(): T { return value }, // contract: sync read set: setAndPersist, // contract: fire-and-forget write watch(fn) { /* ... */ }, // contract: change notification }; } // Consumer: pass the factory result directly, no adapter: const session = createPersistedState({ key, schema, defaultValue }); const auth = createAuth({ baseURL, session }); ``` ### The "collapsed adapter" rule If you find yourself writing a `fromX` translator that only renames or re-projects fields, **delete it and widen the factory's return shape instead**. The adapter is pure ceremony; the factory already holds the state, so just expose both surfaces. Signs an adapter should be collapsed: - It's one-to-one with the factory (every caller wraps the factory result). - It only renames methods or adds a thin passthrough. - The factory and the contract disagree on shape but not on semantics (`.current` vs `.get()`: same value, different API). Signs an adapter should stay: - It does real work at the seam (e.g., sync-read-vs-async-get reconciliation, local-write fan-out because the underlying `watch` only fires on external change). - Multiple consumers with different contracts wrap the same factory. When in doubt: start without the adapter. Add one only when the seam actually earns its keep. ### Why this works TypeScript's structural typing means the factory doesn't have to `implements SessionStore` or import the contract type. As long as the return shape matches, it's assignable. This keeps the factory package free of the consumer's dependencies: `createPersistedState` lives in `@epicenter/svelte` and has zero knowledge of `@epicenter/auth`. ## The Mental Model Think of it as a chain where each link: - Receives a resource from the previous link - Adds its own configuration - Produces something for the next link ``` createClient(...) → createService(client, ...) → service.method(...) ↑ ↑ ↑ clientOptions serviceOptions methodOptions ``` ## Benefits - **Testability**: Inject mock clients easily - **Reusability**: Share clients across multiple services - **Flexibility**: Configure each layer independently - **Clarity**: Clear ownership of configuration at each level ## References See the full articles for more details: - [The Universal Factory Function Signature](../../../docs/articles/universal-factory-signature.md): signature explained in depth - [Stop Passing Clients as Arguments](../../../docs/articles/stop-passing-clients-as-arguments.md): practical guide - [The Factory Function Pattern](../../../docs/articles/factory-function-pattern.md): detailed explanation - [Factory Method Patterns](../../../docs/articles/factory-method-patterns.md): separating options and method patterns - [Closures Are Better Privacy Than Keywords](../../../docs/articles/closures-are-better-privacy-than-keywords.md): internal anatomy and why closures beat class keywords Load on demand: - [references/single-or-array-pattern.md](references/single-or-array-pattern.md): when a factory or CRUD entry point should accept either a single item or an array. - [references/sync-construction-render-gate.md](references/sync-construction-render-gate.md): when a client's synchronous methods depend on async-initialized state and the UI must gate render on readiness. - [references/method-documentation-and-sibling-calls.md](references/method-documentation-and-sibling-calls.md): where JSDoc belongs on returned methods, and when `this` is safe for sibling calls.
عرض على GitHub