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.
System
Clients
Storefront
Next.js · React · TypeScript
Admin surface
Next.js · React · TypeScript
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.
Adapters
Repositories
Prisma · TypeScript
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.
1/**2 * Cache Service - Robust in-memory caching with LRU eviction3 *4 * Features:5 * - Max size limit (1000 entries by default) - prevents unbounded growth6 * - LRU eviction - automatically removes least recently used entries when full7 * - TTL support - per-entry expiration from config8 * - Proactive cleanup - automatically purges expired entries9 * - Type-safe API - generic get/set operations10 */24/**25 * Global LRU cache instance using globalThis26 * Ensures same instance across all module graphs in Next.js Turbopack27 */28declare global {29 // eslint-disable-next-line no-var30 var __appCache: LRUCache<string, object> | undefined;31}3233function getOrCreateCache(): LRUCache<string, object> {34 if (!globalThis.__appCache) {35 globalThis.__appCache = new LRUCache<string, object>({36 max: MAX_CACHE_SIZE,37 ttl: DEFAULT_TTL_MS,38 ttlAutopurge: true, // Proactively remove expired entries39 updateAgeOnGet: true, // Reset TTL on access (true LRU behavior)40 allowStale: false, // Don't return stale entries41 });42 console.log(43 `[Cache] Initialized LRU cache (max: ${MAX_CACHE_SIZE}, defaultTTL: ${DEFAULT_SERVER_TTL}s, autoPurge: enabled)`44 );45 }46 return globalThis.__appCache;47}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