factory-function-composition
Standardizes factory functions for better testability and dependency management.
Install
mkdir -p .claude/skills/factory-function-composition && curl -L -o skill.zip "https://agentskills.codes/api/skills/download/4171" && unzip -o skill.zip -d .claude/skills/factory-function-composition && rm skill.zipInstalls to .claude/skills/factory-function-composition
Activation
This is the description your AI agent reads to decide when to run this skill — the better it matches your request, the more reliably it fires.
Factory function patterns to compose clients and services. Use when wrapping resources with domain methods or refactoring mixed client/service/method options.Key capabilities
- →Enforces two-argument factory signature
- →Separates resource injection from config
- →Standardizes return object structures
- →Decouples client creation from logic
How it works
Applies a specific structural refactoring rule that extracts client/service configuration to the top level and standardizes factory arguments.
Inputs & outputs
When to use factory-function-composition
- →Refactoring service creation
- →Implementing dependency injection
- →Wrapping external clients
About this skill
Factory Function Composition
This skill helps you apply factory function patterns for clean dependency injection and function composition in TypeScript.
Related Skills: See
method-shorthand-jsdocfor when to move helpers into the return object. Seerefactoringfor caller counting and inlining single-use extractions.
The Universal Signature
Every factory function follows this signature:
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
// 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
- Client configuration belongs at client creation time: don't pipe clientOptions through your factory
- Each layer has its own options: client, service, and method options stay separate
- Dependencies come first: factory functions take dependencies as the first argument
- 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
// Bad
function doSomething(client, options) { ... }
doSomething(client, options);
// Good
const service = createService(client);
service.doSomething(options);
Anti-Pattern 2: Client creation hidden inside
// 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
// 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
// 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:
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:
// 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:
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.
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, and method-shorthand-jsdoc for the JSDoc sibling of this navigation concern.
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() for that; method shorthand gives proper this binding.
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 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:
// 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 (
.currentvs.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
watchonly 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,
Content truncated.
When not to use it
- →Small scripts requiring quick prototypes
- →Functions with zero dependencies
Limitations
- →Requires refactoring existing function signatures
- →Strictness may feel verbose for simple functions
How it compares
It dictates a strict API architecture standard rather than just optimizing code style.
Compared to similar skills
factory-function-composition side by side with the closest alternatives in the catalog.
| Skill | Installs | Updated | Safety | Difficulty |
|---|---|---|---|---|
| factory-function-composition (this skill) | 1 | 2mo | No flags | Intermediate |
| project-principles | 1 | 5mo | No flags | Intermediate |
| tldr-code | 1 | 7mo | Review | Advanced |
| dry | 0 | 2mo | Review | Intermediate |
Try saying
Example prompts that trigger this skill in your AI assistant.
More by EpicenterHQ
View all by EpicenterHQ →You might also like
project-principles
vm0-ai
Core architectural and code quality principles that guide all development decisions in the vm0 project
tldr-code
parcadei
Token-efficient code analysis via 5-layer stack (AST, Call Graph, CFG, DFG, PDG). 95% token savings.
dry
chrisreddington
DRY — **Don't Repeat Yourself** — is one of the most misquoted principles in software engineering. It is *not* a rule about avoiding repeated lines of code. It is a rule about avoiding repeated **knowledge**.
solid
chrisreddington
SOLID is Robert C. Martin's five object-oriented design principles. In a TypeScript codebase like Flight School — which mixes a Next.js API surface, a Copilot SDK adapter layer, GitHub data fetching, and a streaming UI — SOLID is mostly a tool for keeping modules **swappable, tes
hexagonal-architecture
jssmy
Design, implement, and refactor Ports & Adapters systems with clear domain boundaries, dependency inversion, and testable use-case orchestration across TypeScript, Java, Kotlin, and Go services. Use for: new features needing long-term maintainability, decoupling domain logic from frameworks/DB/HTTP,
mcp-builder
anthropics
Guide for creating high-quality MCP (Model Context Protocol) servers that enable LLMs to interact with external services through well-designed tools. Use when building MCP servers to integrate external APIs or services, whether in Python (FastMCP) or Node/TypeScript (MCP SDK).