Java 21 and Spring AI
Mercala is the one built the way a team would need it built, because that is the constraint it was written under.
Mercala is a modular monolith with two things carved out of it. mercala-core holds the commerce domain; mercala-agent runs the Spring AI side; mercala-image-gen does product imagery; and mercala-contracts is its own module so the three cannot quietly drift apart.
That last one is the decision worth defending. Contracts as a separate artefact means a change to a shared type breaks the build of everything that depends on it — at compile time, in CI, rather than at runtime in front of a customer. It is the cheapest possible version of the integration test I would otherwise have to write.
Microservices distribute your monolith's problems and add a network to them.
Mercala could have been eight services. It is a core plus two, and the two came out for reasons I can name: the agent has a different scaling shape and a different failure mode, and image generation is slow, bursty work that should not sit in a request thread. Everything else stayed in the monolith because nothing about it wanted a network in the middle. The cost of a service is not the code — it is a deployment, a version, a retry policy, a tracing story and a class of failure that did not exist when it was a method call. A module boundary gives you most of the isolation for none of that, and it is reversible: promoting a module to a service later is mechanical, while merging two services back is not.
One tenant reading another tenant's rows has to get through three different mechanisms.
Shared database with a tenant_id is the cheap topology and the dangerous one, because a single missing WHERE leaks everything. So the isolation is asserted three times: role-based access at the edge, a Hibernate tenant filter on every query the ORM builds, and row-level security in Postgres itself, which holds even if the application is wrong. The rule in the repo is explicit — never weaken a layer without a test proving the others still hold.
Messages cross process boundaries through a transactional outbox with idempotent consumers and a dead-letter queue — so a message is never published for a transaction that rolled back, and a redelivery is not a second order.
Inside the monolith the same decoupling is a Spring event, with no broker in the path. Kafka is for process boundaries, not for fashion.
Circuit breaking and retry around the calls that leave the process — payments, models, storage.
The agent module talks to an OpenAI-compatible API, so the provider is swappable. It currently runs GLM-4.7 behind nginx, which is the point: none of the code knows.
Health and metrics from Actuator, logs through logstash-logback-encoder so they come out structured rather than needing to be parsed later.
The OpenAPI surface is generated from the controllers, so the documented API and the real one cannot disagree.
Two rankings, fused — and one database to run, back up and reason about.
Lexical search finds the exact model number; vector search finds “something for a christening”. You want both, and the usual answer is Elasticsearch beside Postgres — a second cluster to run, secure, back up and keep in sync, with its own failure modes and its own staleness. ParadeDB puts BM25 inside Postgres, pgvector is already there, and reciprocal rank fusion combines the two rankings without needing them to share a scale. Embeddings come from a local ONNX model, zero-padded to 1536 dimensions so the column does not change when the model does.
There are no AWS access keys, anywhere. Do not reintroduce them.
mercala/CLAUDE.md, on how the deployment authenticatesGitHub Actions assumes a role through OpenID Connect, with a trust condition scoped to repo:hallelx2/mercala:ref:refs/heads/main. The only thing stored in GitHub is a role ARN, which is not a secret.
The host resolves credentials from instance metadata. Nothing is injected into a container and nothing expires into an outage at three in the morning.
Infrastructure is Terraform with remote S3 state; configuration is Ansible. The bootstrap that creates the OIDC provider runs once, locally, and its state is gitignored.
The payment path is modelled through a PaymentProvider strategy for Stripe, Paystack and Flutterwave — and it is not wired. That is on the Mercala page in those words too.
mercala
Read CLAUDE.md first — it is the architecture document, and it is blunt about which packages are real (com.mercala.order, singular) and which are empty scaffolding awaiting deletion.
Spring earns its weight when the constraints are a team's constraints.
Multi-tenancy, money, a message bus and an audit trail are where a framework with twenty years of answers costs less than writing your own — which is the opposite of the argument on the Bun page, and both are true.