My stack The stack · Java 21 & Spring AI

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.

4Maven modules under one parent, on Java 21
3layers of tenant isolation, none of them trusted alone
0Elasticsearch clusters — hybrid search lives inside Postgres
01The shape, and why it is not one service

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.

02Modular monolith, not microservices Two services were carved out. Everything else stayed put, and that was the decision.
The shape I keep choosing

Microservices distribute your monolith's problems and add a network to them.

one deployable corecommerce domain
carve out only what differsagent, image-gen
shared contractscompile-time, not runtime
Kafka only at the boundaryevents in-process otherwise
1core deployable
2carved out, for named reasons
0networks added between things that agree

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.

03Multi-tenancy, three layers deep Never weaken a layer without a test proving the others still hold.
Defence in depth

One tenant reading another tenant's rows has to get through three different mechanisms.

RBACis this role allowed
Hibernate filterscoped at the ORM
Postgres RLSenforced in the database
3independent layers
1shared database
RLSthe backstop that survives an application bug

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.

04What Spring is actually carrying Each one is there for a named failure.
Spring Kafka, with an outbox

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.

Spring events in-process

Inside the monolith the same decoupling is a Spring event, with no broker in the path. Kafka is for process boundaries, not for fashion.

Resilience4j

Circuit breaking and retry around the calls that leave the process — payments, models, storage.

Spring AI

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.

Actuator and structured logs

Health and metrics from Actuator, logs through logstash-logback-encoder so they come out structured rather than needing to be parsed later.

springdoc

The OpenAPI surface is generated from the controllers, so the documented API and the real one cannot disagree.

05Search, without the second cluster The decision I would defend hardest on this project.
Hybrid retrieval inside Postgres

Two rankings, fused — and one database to run, back up and reason about.

pg_search BM25lexical, ParadeDB
pgvectorsemantic neighbours
RRFreciprocal rank fusion
one result setone datastore
BM25exact terms
pgvectormeaning
RRFfused without a shared scale

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 authenticates
06Deployment, and the keys that do not exist The instruction in the repo is written in the imperative for a reason.
CI to AWS is OIDC

GitHub 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.

Host to AWS is an instance profile

The host resolves credentials from instance metadata. Nothing is injected into a container and nothing expires into an outage at three in the morning.

Terraform and Ansible

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.

Honest state

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.

07The repository
The case study

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.