backend / mercala
Mercala
A multi-tenant e-commerce backend where merchants run their store through an AI agent: catalog, inventory, cart, checkout, payments, hybrid product search and generated product imagery.
2026 · paused · open source
- [*]LanguagesJava · SQL · HCL
- [*]FrameworksSpring Boot · Spring Security · Spring Data JPA / Hibernate · Spring Kafka · Spring AI · Resilience4j · Flyway · springdoc OpenAPI + Scalar · LangChain4j ONNX embeddings
- [*]StoresPostgres (ParadeDB) · pg_search (BM25) · pgvector · S3 · MinIO (local)
- [*]AsyncKafka · Transactional-outbox relay (@Scheduled poller) · Spring in-process transactional events · Kafka dead-letter topics
- [*]InfraAWS EC2 spot · AWS ECR · AWS S3 · Terraform · Ansible · Docker Compose · nginx + Let's Encrypt · GitHub Actions (OIDC)
- [*]Third partiesStripe · Paystack · Flutterwave · OpenAI-compatible chat + embeddings API · Cloudflare Workers AI · Replicate · Pollinations
Mercala is a Java 21 Spring Boot backend in four Maven modules: a modular-monolith core (identity, catalog, inventory, cart, order, payment, media), an agent service built on Spring AI, an image-generation service, and a shared contracts module. It is about 26,000 lines of Java across 100 commits, with 20 Flyway migrations, 19 controllers and 49 HTTP endpoints.
Tenancy is enforced three times. Every request carries a JWT whose tenant_id lands in a ThreadLocal; an aspect on every Spring Data repository call turns on a Hibernate tenant filter and sets the Postgres session variable app.current_tenant; and 13 of the 17 tables have FORCE ROW LEVEL SECURITY policies that read that variable as a non-owner role. An integration test opens a raw JDBC connection as that role, with the Hibernate filter out of the picture, to prove RLS still holds.
Product search runs inside Postgres: a ParadeDB pg_search BM25 index and a pgvector HNSW index each return 50 candidates, and the service fuses them with weighted Reciprocal Rank Fusion (k=60). Embeddings come from an OpenAI-compatible endpoint behind a circuit breaker, falling back to an in-process ONNX all-MiniLM-L6-v2 model whose 384-dim vectors are zero-padded and renormalised to fit the 1536-dim column.
Work that crosses process boundaries goes over Kafka (product.events, image.requests, image.results) with a polled outbox table, a processed_event dedupe table, and dead-letter topics. Image generation walks a configurable provider chain (Cloudflare Workers AI, Replicate, OpenAI, Pollinations, a local placeholder) with a separate Resilience4j circuit breaker per provider. It deploys from GitHub Actions through OIDC to a single AWS spot instance provisioned with Terraform and configured with Ansible.
How it holds up
- Auth
- Stateless HS256 JWTs carrying user id, tenant_id, email and role, with method-level @PreAuthorize RBAC (e.g. PLATFORM_ADMIN for Kafka replay); the agent service holds no keys of its own and validates each bearer token by calling the core's /auth/me; webhooks are public and authenticated by per-provider signature checks (Stripe-Signature, Paystack HMAC-SHA512, Flutterwave hash); tenant isolation is RBAC, then a Hibernate filter, then Postgres RLS.
- Data
- 17 tables across 20 Flyway migrations: tenant, app_user, store_membership, category, product (BM25 + HNSW indexed, tags array, 1536-dim embedding), variant, stock_item, cart, cart_line, orders, order_line, payments, provider_refs, product_image, media_asset, outbox_event, processed_event. 13 are tenant-scoped with RLS policies, and a deferred constraint trigger rejects a stock_item whose variant belongs to another tenant.
- Consistency
- stock_item uses JPA @Version optimistic locking plus CHECK constraints (quantity >= 0, reserved <= quantity); checkout validates availability in its transaction and reserves stock in an AFTER_COMMIT listener running in its own REQUIRES_NEW transaction. Outbox delivery is at-least-once: the relay polls 50 unpublished rows every 500 ms, records keyed by aggregate id with tenant_id and correlation_id headers. The core consumer dedupes against processed_event; the agent and image-gen services dedupe with a 10,000-entry in-memory LRU.
- Failure
- Kafka consumers in all three services use DefaultErrorHandler with one retry after 1 s, then publish to <topic>.DLT; a PLATFORM_ADMIN endpoint seeks the image.results consumer group back to the start to replay. Image providers each sit behind their own circuit breaker and retry, and the router skips open circuits and falls through the chain to a local placeholder. Payment and embedding calls have @CircuitBreaker/@Retry with fallbacks, and the embedding path degrades API -> local ONNX -> a deterministic hash vector.
- Testing
- 85 test files with 386 @Test methods (plus 3 @ParameterizedTest): core 45 files / 166 tests, agent 26 / 140, image-gen 14 / 80. 37 core test classes extend a singleton ParadeDB Testcontainers base, covering RLS under the restricted role, hybrid search, checkout, inventory and Kafka partition ordering. CI runs ./mvnw clean verify.
Techniques
- Three-layer tenant isolation ending in Postgres RLSAn aspect runs before every Spring Data repository call, enables the Hibernate tenantFilter and sets app.current_tenant with set_config(..., true) so it is scoped to the transaction; FORCE RLS policies on 13 tables read that variable as the non-owner mercala_app role. A test bypasses Hibernate entirely and connects as that role to prove the database alone still isolates tenants.
mercala-core/src/main/java/com/mercala/platform/multitenancy/TenantFilterAspect.java:27mercala-core/src/main/java/com/mercala/platform/multitenancy/TenantFilterAspect.java:40mercala-core/src/main/resources/db/migration/V3__rls.sql:26mercala-core/src/test/java/com/mercala/identity/TenantIsolationIntegrationTest.java:73 - In-database hybrid search with weighted RRFLexical candidates come from a ParadeDB BM25 index (p @@@ pdb.parse, ordered by paradedb.score) and semantic candidates from a pgvector HNSW cosine index with a 0.5 distance cutoff; the service fuses the two ranked lists as weight/(k+rank) with k, weights and candidate limit set in config. No separate search cluster.
mercala-core/src/main/java/com/mercala/catalog/ProductRepository.java:26mercala-core/src/main/java/com/mercala/catalog/ProductRepository.java:41mercala-core/src/main/java/com/mercala/catalog/service/ProductService.java:44mercala-core/src/main/java/com/mercala/catalog/service/ProductService.java:187mercala-core/src/main/resources/db/migration/V5__search.sql:8 - Embedding degradation ladder with dimension paddingThe embedding call sits behind a circuit breaker and retry. On failure, or with no key set, it falls back to an in-process ONNX all-MiniLM-L6-v2 model, zero-pads the 384-dim output to 1536 and L2-renormalises it so the same vector column and HNSW index still work.
mercala-core/src/main/java/com/mercala/catalog/adapters/OpenAiEmbeddingClient.java:58mercala-core/src/main/java/com/mercala/catalog/adapters/OpenAiEmbeddingClient.java:119mercala-core/src/main/java/com/mercala/catalog/adapters/OpenAiEmbeddingClient.java:177 - Outbox relay, idempotent consumers and DLQ replayEvents are written to outbox_event (a partial index on unpublished rows) and a scheduled relay publishes them in batches, keyed by aggregate id for per-entity ordering and carrying tenant_id and correlation_id headers. Consumers dedupe on event id and dead-letter after one retry, and an admin-only endpoint can rewind the image.results consumer group to replay.
mercala-core/src/main/resources/db/migration/V14__outbox.sql:19mercala-core/src/main/java/com/mercala/platform/outbox/OutboxRelay.java:43mercala-core/src/main/java/com/mercala/platform/idempotency/IdempotentConsumerService.java:31mercala-core/src/main/java/com/mercala/platform/kafka/KafkaConsumerConfig.java:27mercala-core/src/main/java/com/mercala/media/MediaController.java:45 - Image provider chain with per-provider circuit breakersImageProviderRouter resolves primary plus fallback chain from config (deduplicated), wraps each provider in its own named breaker so one failing backend opens only its own circuit, and filters enhancement to capable providers rather than catching errors, so unsupported calls never trip a breaker. Output formats are identified by magic bytes, not assumed to be PNG.
mercala-image-gen/src/main/java/com/mercala/imagegen/provider/ImageProviderRouter.java:130mercala-image-gen/src/main/java/com/mercala/imagegen/provider/ImageProviderRouter.java:170mercala-image-gen/src/main/resources/application.yml:43mercala-image-gen/src/main/java/com/mercala/imagegen/storage/ImageFormat.java:44 - AG-UI frontend-declared toolsThe browser sends its tool list with each agent run. The server registers each tool as a Spring AI callback that emits the call over SSE and returns an awaitingUser sentinel. The result comes back as a tool message on the next run, the same round trip the human-in-the-loop askUser/confirmAction/proposeEdit tools use.
mercala-agent/src/main/java/com/mercala/agent/agui/FrontendToolCallback.java:28mercala-agent/src/main/java/com/mercala/agent/agui/FrontendToolCallback.java:76mercala-agent/src/main/java/com/mercala/agent/tool/HitlTools.java:62
Counted
- 100Commits
git rev-list --count HEAD (main and HEAD agree) - 26,091Java LOC
git ls-files '*.java' | xargs wc -l | tail -1 - 4Maven modules
pom.xml <module> entries (contracts, core, agent, image-gen) - 20Flyway migrations
mercala-core/src/main/resources/db/migration/V1..V20 - 17Tables
grep -ci 'create table' over migrations - 13Tables under FORCE RLS
grep 'FORCE ROW LEVEL SECURITY' over migrations - 19REST controllers
@RestController/@Controller classes in src/main, excluding 2 @RestControllerAdvice - 49HTTP endpoints
method-level @Get/Post/Put/Patch/DeleteMapping in src/main - 85Test files
git ls-files 'mercala-*/src/test/*.java' | wc -l - 386@Test methods
grep -a '@Test\b' over test files (plus 3 @ParameterizedTest) - 37Testcontainers integration test classes
classes extending AbstractIntegrationTest - 9Agent server-side tools
Function @Beans in mercala-agent tool/ (Catalog 4, Image 2, HITL 3) - 5Image providers
mercala-image-gen provider/*ImageProvider.java - 3 + DLTsKafka topics
application.yml: product.events, image.requests, image.results - 3Payment providers
mercala-core payment/{stripe,paystack,flutterwave}