Back to catalog

@bun-and-butter/factory

A small factory helper for lazily building and caching shared or isolated values with a consistent API for synchronous and asynchronous builders.

Tags
v1.0.0
Install

Install from Git

Bun & Butter packages are installed directly from the GitHub repository over SSH. This is the recommended install path for the current version. See the Bun docs on Git dependencies for the exact `bun add` behavior.

					bun add [email protected]:bun-and-butter/factory.git
				
Documentation

README.md

A small helper for lazily building and caching values behind a consistent async API.

@bun-and-butter/factory is useful when you want to create something exactly once per key and then reuse it afterwards. The builder can be synchronous or asynchronous, but the public API always returns a promise so consumers can use one consistent access pattern.

Typical examples are database connections, clients, prepared resources, configuration objects, adapters, or other application services that should be constructed on demand and then reused.

The package supports three styles:

  • factory A shared default instance for simple, module-level usage.
  • createFactory() Creates isolated factory instances with their own cache.
  • AbstractFactory A base class for class-based APIs that still want the same caching behavior.

Quick Start

import { factory } from "@bun-and-butter/factory";

let buildCount = 0;

const getConfig = () =>
	factory.get("app-config", async () => {
		buildCount += 1;

		return {
			environment: "development",
			featureFlags: ["onboarding-v2"],
		};
	});

const first = await getConfig();
const second = await getConfig();

console.log(first);
console.log(second);
console.log("build count:", buildCount); // 1

Quick Start With AbstractFactory

import { AbstractFactory } from "@bun-and-butter/factory";

class DatabaseFactory extends AbstractFactory {
	public getDatabase(name: string) {
		return this.get(`sqlite:${name}`, async () => {
			return connectToDatabase(name);
		});
	}
}

const databases = new DatabaseFactory();

const first = await databases.getDatabase("main");
const second = await databases.getDatabase("main");

console.log(first === second); // true

Why Use It

In many codebases, lazy initialization starts out as a small closure or a module variable:

  • a database connection should only be opened once
  • a client should only be configured once
  • a resource is expensive enough that it should be reused
  • the same setup logic appears in multiple places

That works for a while, but it often becomes inconsistent once synchronous and asynchronous initialization mix together or once multiple parts of the application need their own isolated caches.

This package keeps that flow explicit:

  • values are cached by key
  • sync and async builders use the same API
  • concurrent requests for the same key share the same in-flight build
  • failed builds are removed from the cache so later calls can retry

Core API

There are three main entry points:

  • factory A shared default instance that is convenient when one cache should be reused throughout the application.
  • createFactory() Returns a new isolated Factory instance with its own cache.
  • AbstractFactory Lets you expose domain-specific methods like getDatabase() or getClient() while delegating the caching behavior to the shared base implementation.

The central method is always get(key, build).

  • key Identifies the cached value.
  • build Creates the value when it is not cached yet.
  • return value Always a Promise<T>, regardless of whether build is sync or async.

Cache Behavior

The cache stores the in-flight promise for each key.

That has a few useful consequences:

  • the first call starts the build and stores the promise
  • later calls with the same key reuse that promise
  • concurrent callers do not trigger duplicate work
  • once the promise resolves, future calls still reuse the same resolved promise
  • if the build rejects, the failed promise is removed so the next call can try again

This is especially helpful for resources like SQL connections, API clients, or configuration loading where duplicate initialization is undesirable.

Usage Patterns

Shared Factory

Use the shared factory export when one cache should be reused by default:

import { factory } from "@bun-and-butter/factory";

export const getDatabase = () =>
	factory.get("sqlite:main", async () => {
		return connectToDatabase();
	});

This is the smallest and most convenient option, but it also means the cache is shared across every module that imports that same factory instance.

Isolated Factories

Use createFactory() when different parts of the application need separate caches:

import { createFactory } from "@bun-and-butter/factory";

const europeFactory = createFactory();
const usFactory = createFactory();

const europeDb = await europeFactory.get("sqlite:tenant", () => connect("eu"));
const usDb = await usFactory.get("sqlite:tenant", () => connect("us"));

Both factories may use the same key while still building different values, because each factory instance owns its own cache.

Class-Based API

Use AbstractFactory when you want a domain-specific class interface:

import { AbstractFactory } from "@bun-and-butter/factory";

class DatabaseFactory extends AbstractFactory {
	public getDatabase(name: string) {
		return this.get(`sqlite:${name}`, () => connect(name));
	}
}

This is useful when you want factory behavior plus a clear object-oriented API for your application domain.

Error Handling

If build() throws or returns a rejected promise, that failed entry is removed from the cache before the error is rethrown.

That means this works as expected:

  1. the first call fails
  2. the cache entry is cleared
  3. the second call retries the build instead of reusing a rejected result

This makes the helper safe for transient initialization failures.

When To Use Which API

  • use factory when one shared cache is the simplest and correct choice
  • use createFactory() when isolation matters
  • use AbstractFactory when you want domain-specific methods instead of exposing raw keys directly

If you are unsure, start with createFactory() or AbstractFactory() and only move to the shared factory export when shared process-wide state is actually desirable.

Examples

The repository currently includes four examples:

Design Notes

  • the package is intentionally small and focused on one job
  • caching happens by explicit string key
  • the public API stays consistent for sync and async builders
  • concurrency is handled by reusing the same in-flight promise
  • isolated and shared usage are both first-class parts of the API

For most applications:

  1. choose clear, stable keys that describe the resource being cached
  2. keep builder functions small and focused on initialization
  3. use createFactory() or AbstractFactory when isolation matters
  4. use the shared factory export only when one shared cache is genuinely the intended behavior
  5. close or dispose cached resources explicitly when your application shuts down, if those resources require cleanup
Usage Examples

Examples

examples/abstractFactory.ts
import { SQL } from "bun";
import { AbstractFactory } from "../src/factory";

class DatabaseFactory extends AbstractFactory {
	public readonly buildCount = { value: 0 };

	public getDatabase(name: string) {
		return this.get(`sqlite:${name}`, async () => {
			this.buildCount.value += 1;

			const sql = new SQL(":memory:");
			await sql`
				CREATE TABLE connections (
					name TEXT NOT NULL PRIMARY KEY
				)
			`;
			await sql`
				INSERT INTO connections (name)
				VALUES (${name})
			`;

			return sql;
		});
	}
}

// `AbstractFactory` lets you wrap the generic caching behavior in a
// domain-specific class API such as `getDatabase(name)`.

const databases = new DatabaseFactory();

const first = await databases.getDatabase("main");
const second = await databases.getDatabase("main");

const rows = await second<{ name: string }[]>`SELECT name FROM connections`;

console.log("same connection:", first === second);
console.log("rows:", rows);
console.log("build count:", databases.buildCount.value);

await first.close();
examples/createFactory.ts
import { SQL } from "bun";
import { createFactory } from "../src/factory";

let buildCount = 0;

const createTenantDatabase = async (tenant: string) => {
	buildCount += 1;

	const sql = new SQL(":memory:");
	await sql`
		CREATE TABLE tenant_config (
			key TEXT NOT NULL PRIMARY KEY,
			value TEXT NOT NULL
		)
	`;
	await sql`
		INSERT INTO tenant_config (key, value)
		VALUES ('tenant', ${tenant})
	`;

	return sql;
};

const europeFactory = createFactory();
const usFactory = createFactory();

// Every call to `createFactory()` creates an isolated cache.
// Both factories use the same key, but each one builds its own
// SQLite in-memory connection.

const europe = await europeFactory.get("sqlite:tenant", () =>
	createTenantDatabase("eu"),
);
const us = await usFactory.get("sqlite:tenant", () =>
	createTenantDatabase("us"),
);

const europeRows = await europe<
	{ key: string; value: string }[]
>`SELECT key, value FROM tenant_config`;
const usRows = await us<
	{ key: string; value: string }[]
>`SELECT key, value FROM tenant_config`;

console.log("same connection:", europe === us);
console.log("europe rows:", europeRows);
console.log("us rows:", usRows);
console.log("build count:", buildCount);

await europe.close();
await us.close();
examples/demo.ts
import { SQL } from "bun";
import { AbstractFactory, createFactory, factory } from "../src/factory";

type ConnectionInfo = {
	origin: "shared" | "isolated" | "class";
	name: string;
};

const buildConnection = async (
	info: ConnectionInfo,
	buildCount: { value: number },
) => {
	buildCount.value += 1;

	const sql = new SQL(":memory:");
	await sql`
		CREATE TABLE connection_info (
			origin TEXT NOT NULL,
			name TEXT NOT NULL
		)
	`;
	await sql`
		INSERT INTO connection_info (origin, name)
		VALUES (${info.origin}, ${info.name})
	`;

	return sql;
};

class DatabaseFactory extends AbstractFactory {
	public readonly buildCount = { value: 0 };

	public getDatabase(name: string) {
		return this.get(`sqlite:${name}`, () =>
			buildConnection({ origin: "class", name }, this.buildCount),
		);
	}
}

// This demo shows the three main ways to use the package with a Bun SQL
// SQLite in-memory connection:
//
// 1. `factory`
//    One shared default cache for the whole application.
//
// 2. `createFactory()`
//    New isolated caches for independent parts of the application.
//
// 3. `AbstractFactory`
//    A class-based API with the same caching behavior under the hood.

const sharedBuildCount = { value: 0 };
const isolatedBuildCount = { value: 0 };

const sharedConnection = await factory.get("sqlite:main", () =>
	buildConnection({ origin: "shared", name: "main" }, sharedBuildCount),
);
const sharedConnectionAgain = await factory.get("sqlite:main", () =>
	buildConnection({ origin: "shared", name: "main" }, sharedBuildCount),
);

const sharedRows = await sharedConnectionAgain<
	{ origin: string; name: string }[]
>`SELECT origin, name FROM connection_info`;

console.log(
	"shared same connection:",
	sharedConnection === sharedConnectionAgain,
);
console.log("shared rows:", sharedRows);
console.log("shared build count:", sharedBuildCount.value);

const europeFactory = createFactory();
const usFactory = createFactory();

const europeConnection = await europeFactory.get("sqlite:tenant", () =>
	buildConnection({ origin: "isolated", name: "eu" }, isolatedBuildCount),
);
const usConnection = await usFactory.get("sqlite:tenant", () =>
	buildConnection({ origin: "isolated", name: "us" }, isolatedBuildCount),
);

const europeRows = await europeConnection<
	{ origin: string; name: string }[]
>`SELECT origin, name FROM connection_info`;
const usRows = await usConnection<
	{ origin: string; name: string }[]
>`SELECT origin, name FROM connection_info`;

console.log("isolated same connection:", europeConnection === usConnection);
console.log("europe rows:", europeRows);
console.log("us rows:", usRows);
console.log("isolated build count:", isolatedBuildCount.value);

const databases = new DatabaseFactory();

const classConnection = await databases.getDatabase("analytics");
const classConnectionAgain = await databases.getDatabase("analytics");

const classRows = await classConnectionAgain<
	{ origin: string; name: string }[]
>`SELECT origin, name FROM connection_info`;

console.log("class same connection:", classConnection === classConnectionAgain);
console.log("class rows:", classRows);
console.log("class build count:", databases.buildCount.value);

await sharedConnection.close();
await europeConnection.close();
await usConnection.close();
await classConnection.close();
examples/shared.ts
import { SQL } from "bun";
import { factory } from "../src/factory";

let buildCount = 0;

const getDatabase = () =>
	factory.get("sqlite:main", async () => {
		buildCount += 1;

		const sql = new SQL(":memory:");
		await sql`
			CREATE TABLE app_config (
				key TEXT NOT NULL PRIMARY KEY,
				value TEXT NOT NULL
			)
		`;
		await sql`
			INSERT INTO app_config (key, value)
			VALUES ('environment', 'development')
		`;

		return sql;
	});

// `factory` is the shared default instance exported by the package.
// Because the same cache key is used twice, both reads return the same
// SQLite in-memory connection.

const first = await getDatabase();
const second = await getDatabase();

const rows = await second<
	{ key: string; value: string }[]
>`SELECT key, value FROM app_config`;

console.log("same connection:", first === second);
console.log("rows:", rows);
console.log("build count:", buildCount);

await first.close();