@classytic/catalog
Commerce catalog kernel for MongoDB. Products, variants, offers, pricing layers, modifiers, attributes, categories, offer execution modules, channels, and integration bridges.
Framework-agnostic by design. Catalog ships pure primitives — Mongoose
models, mongokit repositories, pure-domain utilities, Zod schemas, and a
typed event transport. There are no Arc / Fastify / Express / Next.js
imports anywhere in the package, and no ./http adapter ships. Hosts
wire catalog into their framework of choice with ~5 lines of glue code,
calling catalog.repositories.* directly — see the integration recipes
below.
Design principles
- Zero config to start —
createCatalog({ connection })works. - Progressive disclosure — four API levels: zero config, simple knobs, module opt-in, full config. Each is a natural step up, never a rewrite.
- Composition over configuration — modules opt in via
modules: { offers: true, ... }rather than monolithic presets. - Bounded contexts — Catalog Definition, Offers, Offer Execution, Channels, Projections, Integration. Each is an internal subdomain with a clear responsibility boundary.
- MongoDB-native via Mongoose +
@classytic/mongokitas peer deps. Not DB-agnostic — that's a deliberate simplification for v1. - Framework-agnostic — no framework imports, no HTTP adapter
shipped. Host apps wire their own routes against
catalog.repositories.*. - Better Auth aligned — storage field is always
organizationId. - Zod as source of truth — every schema is a Zod schema, types
derive via
z.infer<>, runtime validation at service boundaries. - No breaking changes post-1.0.
Package structure
src/
catalog-core/ Product, Variant, Attribute, Category, Exclusion,
Modifier, Compliance, Relationship, RankingSignals
offers/ Offer, PricingLayers, OfferAvailability,
CompositionPolicy, OfferBoost
offer-execution/ OfferExecutionModule interface + 11 built-in modules
(standard, affiliate, preorder, reservation, voucher,
auction, rental, subscription, course_offering,
dropship, pod)
channels/ ChannelPublication value object
projections/ SearchProjection + flat denorm builder/plugin
integration/ 13 bridge interfaces (inventory, pricing, tax,
media, currency, recommendation, review, promotion,
loyalty, ranking, composition, compliance, uom)
product-types/ ProductTypeHandler interface + physical / service
value-objects/ Money, Identifiers, ProductRef, Translatable
events/ 41 typed event contracts
engine/ createCatalog factory + CatalogEngine + tenant resolver
validators/ Zod schemas (exported via @classytic/catalog/schemas)
Catalog ships only the pure primitives. There is no src/http/ and no
@classytic/catalog/http subpath — HTTP integration is host-owned, see
the recipes below.
Peer dependencies
mongoose>= 9.4.1 (required)@classytic/mongokit>= 3.16.0 (required)@classytic/repo-core>= 0.6.0 (required)@classytic/primitives>= 0.5.0 (required)zod>= 4.0.0 (required)
No framework peer deps. No optional plugins in the peer list.
Quick start
import mongoose from 'mongoose';
import { createCatalog } from '@classytic/catalog';
await mongoose.connect(process.env.MONGO_URI!);
const catalog = await createCatalog({
connection: mongoose.connection,
multiTenant: true, // enable per-tenant scoping (default field: organizationId)
defaultCurrency: 'USD',
modules: {
offers: true, // enable marketplace-style offers + buy-box
},
});
// Use repositories directly in any runtime (Node, Fastify, Express, Next.js, CLI)
const ctx = { organizationId: 'org_123', actorId: 'user_456' };
const product = await catalog.repositories.product.create(
{ name: 'T-Shirt', productType: 'physical' },
ctx,
);Integration recipes
Catalog has no HTTP adapter — the engine returns repositories that hosts
call directly from their framework's request handler. Each recipe below
is ~15 lines of host glue: parse the request → call
catalog.repositories.<entity>.<verb>(...) → serialize the response.
The pattern (any framework)
The recipe is the same in every framework — read the request, build a
CatalogContext, call a repository / utility method, return the result.
No catalog-specific adapter is involved.
import { catalog } from './catalog.js';
// Inside any HTTP handler — Arc, Fastify, Express, Hono, Next.js Route
// Handler, tRPC, etc.
async function handlePostProduct(req, reply) {
const ctx = {
organizationId: req.user?.orgId,
actorId: req.user?.id,
};
const product = await catalog.repositories.product.create(req.body, ctx);
reply.send({ data: product });
}
async function handleGetProduct(req, reply) {
const ctx = { organizationId: req.user?.orgId, actorId: req.user?.id };
const doc = await catalog.repositories.product.getById(req.params.id, ctx);
if (!doc) return reply.code(404).send({ error: 'Not Found' });
reply.send({ data: doc });
}The repositories surface is whatever mongokit gives you (getAll,
getById, create, update, delete, count, aggregate) plus the
domain methods catalog adds (product.delete(id, ctx, { cascade }),
offer.commit(input, ctx), etc.). Pick the recipe for your framework's
auth + req/res wiring; the catalog call is always the same.
CLI / cron / workers (no HTTP)
import { catalog } from './catalog.js';
const ctx = { organizationId: 'org_123', actorId: 'system' };
const product = await catalog.repositories.product.create(
{ name: 'Widget', productType: 'physical' },
ctx,
);
const result = await catalog.repositories.offer.commit(
{ offerId: 'offer_abc', quantity: 1, idempotencyKey: 'job_run_xyz' },
ctx,
);Operational helpers
All on the repositories — no extra service surface. Full runbooks live in AGENTS.md.
import { SkuCollisionError } from '@classytic/catalog/catalog-core';
// Typed SKU/barcode collision contract on create — pre-flight check plus
// E11000 mapping; the unique index stays the authoritative guard.
try {
await catalog.repositories.product.create(input, ctx);
} catch (err) {
if (err instanceof SkuCollisionError) {
// err.field: 'sku' | 'barcode', err.values, err.conflictingProductId?
}
}
// Denormalized category counts — failures emit catalog:category.count_desync.
await catalog.repositories.category.validateCategoryCounts(ctx); // report drift
await catalog.repositories.category.repairCategoryCounts(ctx); // re-aggregate + fix
// Remove variants that are both user-disabled AND auto-disabled (frees SKUs).
await catalog.repositories.product.compactVariants(productId, ctx);
// Batched create-or-update keyed by per-tenant-unique slug.
await catalog.repositories.product.bulkUpsertProducts(inputs, ctx, { errorMode: 'continue' });
// Scheduled publishing — call from the host's cron (no scheduler ships).
await catalog.repositories.product.runScheduledPublishing(ctx);Zod schemas for frontends and SDKs
Catalog exports every Zod schema via the @classytic/catalog/schemas
subpath. Frontends, SDKs, and test code can import them to get runtime
validation + TypeScript types without pulling in the rest of the package:
import {
productCreateSchema,
offerCreateSchema,
type OfferCreateInput,
} from '@classytic/catalog/schemas';
// Client-side form validation — same Zod schema the backend uses.
const result = productCreateSchema.safeParse(formValues);
if (!result.success) {
// handle validation errors
}Testing
# Run the fast unit suite (no DB)
npm run test:unit
# Run integration tests (MongoDB memory server)
npm run test:integration
# Run both in CI
npm test
# Typecheck only
npm run typecheckLicense
MIT