Enterprise

Commerce CMS

A content and commerce system that runs a shop and its editorial pages from one application, with every cached screen given a lifetime in writing.

Fourteen domains in one application

The code is grouped by what it is about rather than by what kind of file it is: commerce, inventory, content, messaging, analytics, branding, tracking and seven more, each holding its own interfaces, services and types.

The alternative was a workspace of packages. For a single deployable that buys build configuration and very little else, and the boundary worth having is the one on imports, which a folder plus a lint rule already gives you. The game’s staff panel is a workspace of packages, where a reusable core has to survive into the next game and the boundary is worth its configuration.

Caching with a declared lifetime

Every cache key carries a server TTL and a client max-age, both in one config file, and the Cache-Control header is derived from that rather than written out per route. A screen that has to be fresh says so in the same place as a screen that can be five minutes stale.

The cache instance itself hangs off globalThis. Next.js builds more than one module graph, and a module-scoped instance quietly becomes several caches that cannot see each other’s writes. It looks like a shortcut and it is the opposite.

KeyDirect path
  1. Clients

    • Storefront

      Next.js · React · TypeScript

    • Admin surface

      Next.js · React · TypeScript

  2. Application

    • Fourteen domains

      TypeScript

      Why

      Grouped by subject rather than by file type: commerce, inventory, content, messaging, analytics, branding, tracking and seven more, each holding its own interfaces, services and types.

    • Event bus

      TypeScript

      Why

      A write publishes what changed, and the cache keys affected by it are dropped. Without this the TTL is the only thing expiring anything, and an editor waits five minutes to see their own edit.

  3. Adapters

    • Repositories

      Prisma · TypeScript

  4. State

    • Relational store

      PostgreSQL · Prisma

    • Read cache

      Redis · TypeScript

      Why

      An in-process LRU with a per-key lifetime, in front of Redis for the state that has to survive a restart. Every key declares a server TTL and a client max-age in one config file.

One cache, not one per module graphTypeScriptFrom Read cache
/** * Cache Service - Robust in-memory caching with LRU eviction * * Features: * - Max size limit (1000 entries by default) - prevents unbounded growth * - LRU eviction - automatically removes least recently used entries when full * - TTL support - per-entry expiration from config * - Proactive cleanup - automatically purges expired entries * - Type-safe API - generic get/set operations *//** * Global LRU cache instance using globalThis * Ensures same instance across all module graphs in Next.js Turbopack */declare global {  // eslint-disable-next-line no-var  var __appCache: LRUCache<string, object> | undefined;}function getOrCreateCache(): LRUCache<string, object> {  if (!globalThis.__appCache) {    globalThis.__appCache = new LRUCache<string, object>({      max: MAX_CACHE_SIZE,      ttl: DEFAULT_TTL_MS,      ttlAutopurge: true, // Proactively remove expired entries      updateAgeOnGet: true, // Reset TTL on access (true LRU behavior)      allowStale: false, // Don't return stale entries    });    console.log(      `[Cache] Initialized LRU cache (max: ${MAX_CACHE_SIZE}, defaultTTL: ${DEFAULT_SERVER_TTL}s, autoPurge: enabled)`    );  }  return globalThis.__appCache;}
The cache is bounded, evicts by least recent use, and purges expired entries without waiting to be asked, which are the three things an in-process cache has to do before it can be trusted with a shop. The instance hangs off globalThis for a reason worth stating: Next.js builds more than one module graph, and a module-scoped instance becomes several caches that never see each other's writes.

commerce-cms3d1d112src/domain/system/cache/service/cache.service.tsLines 1 to 4734 lines

/**
 * Cache Service - Robust in-memory caching with LRU eviction
 *
 * Features:
 * - Max size limit (1000 entries by default) - prevents unbounded growth
 * - LRU eviction - automatically removes least recently used entries when full
 * - TTL support - per-entry expiration from config
 * - Proactive cleanup - automatically purges expired entries
 * - Type-safe API - generic get/set operations
 */
/**
 * Global LRU cache instance using globalThis
 * Ensures same instance across all module graphs in Next.js Turbopack
 */
declare global {
  // eslint-disable-next-line no-var
  var __appCache: LRUCache<string, object> | undefined;
}

function getOrCreateCache(): LRUCache<string, object> {
  if (!globalThis.__appCache) {
    globalThis.__appCache = new LRUCache<string, object>({
      max: MAX_CACHE_SIZE,
      ttl: DEFAULT_TTL_MS,
      ttlAutopurge: true, // Proactively remove expired entries
      updateAgeOnGet: true, // Reset TTL on access (true LRU behavior)
      allowStale: false, // Don't return stale entries
    });
    console.log(
      `[Cache] Initialized LRU cache (max: ${MAX_CACHE_SIZE}, defaultTTL: ${DEFAULT_SERVER_TTL}s, autoPurge: enabled)`
    );
  }
  return globalThis.__appCache;
}
  • A read the cache can answer never reaches a repository. That is the whole reason for putting one there, and it is why the cache sits beside the store rather than in front of a single query.

Making an edit appear

A TTL alone means an editor publishes a change and then waits for it. Writes therefore publish on an event bus, and the keys affected are dropped immediately. The TTL stays as the backstop for everything the bus does not know about.

Setting it up

npm run cli chains the whole first run: check the Docker daemon, bring up Postgres, Redis and Mailpit, wait for the health check, push the schema, offer to seed. Six steps, and otherwise six things to do in the right order on a machine that has none of them.

Contribution

Commits authored
545

17 of 18 weeks active · 22 Dec 2025 – 26 Apr 2026

Longest run · 14 weeks · Dec – Mar

The first project here with a second developer in the history, and the last one written before the substrate existed.

Measured 2026-09-08