# Reference Architecture

## The deployment constraint

CommerceDex is **single-tenant per install**: one deployment runs one merchant's store, the same model as a self-hosted WooCommerce or Shopify-alternative install — not a multi-store SaaS platform.

The merchant should be able to run CommerceDex **cheaply** — on a single VPS or a couple of Docker containers — without needing to pay for or operate a sprawl of managed services (separate search clusters, message brokers, multiple databases). It must still deliver the full feature set: catalog search, a commerce automation engine, multi-channel notifications, image processing, and everything else in `01-product-vision.md`.

## The decision: a modular monolith on a single PostgreSQL database

**CommerceDex ships as a single deployable application, internally organized into strictly-bounded modules (see `04-domain-modules.md`), backed by one PostgreSQL database.** PostgreSQL itself substitutes for the search engine, the message broker, and the job queue — no Elasticsearch, no Redis/RabbitMQ/Kafka required for a standard install.

### Why not other options

| Approach | Cost/ops for a single merchant | Verdict |
|---|---|---|
| **Microservices per module** | 10-20 independently deployed services, plus a broker, a search cluster, and a scheduler to operate. Requires real DevOps capability the target merchant doesn't have. | Rejected — violates the deployment constraint and "merchant productivity over developer convenience" |
| **"Buy every SaaS" (managed search, managed queue, managed cache, etc.)** | No infra to run, but recurring per-service fees stack up, and merchant becomes dependent on multiple third-party vendors — undermines the "self-hosted, own your data" value proposition | Rejected — contradicts the self-hosting premise and the cost constraint |
| **Modular monolith, single Postgres** *(chosen)* | One database, one deployable artifact, one thing to back up. Merchant (or their host) runs one app container and one Postgres instance. | **Selected** — satisfies the cost/ops constraint while the internal module discipline preserves a future extraction path if ever needed |

### Why Postgres can carry search, queue, and cache duty at this scale

- **Job queue**: A Postgres table with `SKIP LOCKED`-based polling (the pattern used by tools like `graphile-worker`/`pg-boss`) comfortably handles 100,000+ jobs/day — far beyond what a single-store install needs for background automation (abandoned cart checks, low-stock detection, image processing, etc.). No separate broker.
- **Search**: Postgres full-text search (`tsvector`/`tsquery`) combined with trigram matching for typo-tolerant/fuzzy queries covers the large majority of catalog search needs for a single-store catalog. It is not a tuned Elasticsearch, and that's an accepted, deliberate trade-off — see `06-open-questions.md` for the ceiling on this.
- **Cache**: Most hot-read data (catalog pages, settings) fits comfortably in an in-process LRU cache. Redis is not required for v1; it's an additive option if a merchant's traffic later justifies it, not a rewrite.

## Feasibility-proof tech stack

This table proves the vision is cheaply achievable. **It is non-binding — Phase 3 (System Architecture & Database Design) makes the final call**, informed by this framing.

| Concern | Choice | Why |
|---|---|---|
| Language/runtime | TypeScript on Node.js | One runtime for API and background workers; deep ecosystem for the "auto-handle" features (image libraries, email templating) |
| Web/API framework | An opinionated, modular backend framework (or a lighter framework with an enforced module convention) | The framework should itself help enforce module boundaries — an "opinionated defaults" decision, not just a library pick |
| Database | PostgreSQL, single instance, schema-per-module | One system of record; ACID guarantees for orders/payments/inventory; also serves as search engine, queue, and cache substitute |
| Background jobs / automation | Postgres-backed job queue (`SKIP LOCKED`), running as workers inside the same deployable | No Redis, no RabbitMQ, no Kafka; the Automation Engine is just scheduled/triggered jobs against the same database |
| Search | Postgres full-text search + trigram extension | Covers catalog and admin search without a dedicated search cluster |
| Cache | In-process LRU for hot reads; Postgres is the source of truth | Avoids a mandatory extra service |
| Image processing | In-process library (e.g. `sharp`) for compression and responsive variant generation | No paid image CDN/service required; assets on local disk or a single self-hostable S3-compatible bucket (e.g. MinIO) |
| Notifications | Pluggable outbound adapter (SMTP by default), triggered via the same job queue | Same job-scheduling story as the Automation Engine — no separate notification microservice |
| Deployment | Single container image, or two (app + Postgres) via Docker Compose | Matches "single VPS or a couple of containers" literally |

## The discipline that makes "modular" true, not aspirational

A monolith only earns the word "modular" if the boundaries are real:

1. **Each module owns its own tables/schema namespace.** No cross-module joins in application code — only access via the owning module's public interface.
2. **Cross-module communication happens exactly two ways**: (a) synchronous in-process calls to another module's public service interface, or (b) asynchronous domain events published to the Postgres-backed event/job table and consumed by subscriber modules. See `05-module-interaction-model.md` for the full rule and a worked example.
3. **No module reaches into another module's private data**, ever, even though it's technically all one database.

This discipline is what keeps a future extraction — pulling Search, Automation Engine, or any other module into a separately-deployed service — possible without a rewrite. The module boundary is a service boundary in waiting; it's simply not deployed separately today because that would violate the cost constraint for no current benefit.

## Scaling story

Growth is handled in this order, without changing the architecture shape:

1. **Vertical first.** A bigger single VPS/instance is the first lever — Postgres and a single app process scale a long way on modern hardware.
2. **Horizontal, same artifact.** Run more copies of the identical container behind a load balancer; Postgres remains the single source of truth (with read replicas if needed).
3. **Extraction, only if ever needed.** If a specific module (e.g., Search, or the Automation Engine's background workers) genuinely outgrows the shared process, the module boundaries drawn in `04-domain-modules.md` and enforced per `05-module-interaction-model.md` are what make that extraction a targeted change instead of a rewrite. This is a deliberately deferred decision — not a roadmap commitment.
