Don't romanticize code.
The real job is breaking down problems, spotting patterns, and delivering the right solution, code or otherwise.
Builds systems that ship, survive, and scale.
6+ years building distributed systems across Procurement, Receivables & CRM.
01 — About
Backend engineer, six years in, across Jaggaer, LeadSquared and HighRadius. Most of my work has been on systems that other teams depend on, and that changes the bar. It is not about whether the happy path works. It is about what happens when something upstream breaks.
I like the parts nobody demos. What happens when the same message arrives twice. What a customer sees when a background job fails at two in the morning. I started as a product analyst before moving into engineering, and I still ask what a feature is for before I ask how to build it.
The real job is breaking down problems, spotting patterns, and delivering the right solution, code or otherwise.
Intent-revealing code, structured design, and documentation are the backbone of fast, efficient team collaboration.
Clear articulation, communicating, negotiating, and delegation will always outperform individual heroics.
02 — Experience
If you want the shape of these systems rather than the summary, the diagrams and the reasoning are here.
Building Catelyze, the platform layer that lets several acquired procurement products behave like one. Seven business modules now talk to Catelyze instead of to each other.
The pipeline that moves business objects between modules. An application layer over RabbitMQ, and the only service in the platform with no database of its own.
The single source of truth for tenant configuration, replacing settings that used to live twice, once inside a product and once in that product's own platform layer.
One service per outside vendor, so an external API can never reach into core business logic.
Built the communication side of Service CRM, a ticketing and helpdesk product. Notifications going out, support mail coming in, and the AI that helps agents answer.
Designed and built end to end. Every other module in the product publishes into it over Redis Streams, used here as the message bus and not as a cache.
The business rules for email. Which ticket does this mail belong to, should it reopen one, and what can the agent be helped with.
The only door to the outside. Three email providers, three completely different ways of receiving mail, one internal model.
Cash reconciliation and invoice presentment. Accounts receivable software, where being right about money is the product. I started here on the product side and moved into engineering.
Payment and remittance file parsers on a scheduled clearance pipeline, and the rules that turn those files into settled invoices.
Replaced the legacy SOAP and RPC integration between the invoicing and payment systems with REST APIs.
Before engineering I was a product analyst here, and the habit stuck.
03 — Selected Work
Personal project · In progress
An end-to-end email intelligence pipeline that ingests, processes, and understands emails, grouping and labelling them by meaning, not keywords. Built to replace static inbox rules with adaptive, AI-driven organisation.
Gmail Pipeline & Data Extraction
Embeddings, Similarity & Clustering
nomic-embed-text) for on-device
embedding, so no data leaves the machineLabelling, Feedback & Future
NextLeap · 2023
Product skills complement engineering capabilities, hence this certification. Solved real-world product problems through strong product thinking, user research, and metric-driven execution.
GeeksForGeeks · 2025
Exposure to Cloud & DevOps practices, so that I don't sound like a fool in team meetings.
04 — Education
Kalinga Institute Of Industrial Technology
05 — Contact
Hiring for a backend role, scoping a hard distributed-systems problem, or just want to compare notes on resilience patterns? My inbox is open.
rja.ankit@gmail.comSystems
Two production systems, drawn the way I actually think about them. Company internals and vendor names are left out on purpose. What is left is the part that matters, which is the shape of the system and the reasoning behind it.
Purple outline means the service was mine. Grey means it belonged to someone else on the team.
A procurement suite assembled by acquisition. Every product arrived with its own platform layer, and the products were wired to each other pair by pair, with a different contract each time. The job was to replace all of that with one platform, so the modules talk to it instead of to each other.
scroll the diagram sideways to see all of it
The inbound side is one exchange with a single queue bound to it. No routing, no fan-out, nothing clever. The outbound side is one exchange per business object with routing keys taken from tenant configuration.
The pipeline transforms and routes. It does not know what a purchase requisition means, and that is on purpose.
Consume, look up config, gate, transform, publish, and only then acknowledge. At-least-once, with redelivery when a message is rejected.
Everything the product says to a customer, and everything a customer sends back. Three email providers that each do the same job differently, a rules layer that decides what an incoming mail means, and a notification platform the whole product publishes into.
scroll the diagram sideways to see all of it
Nothing downstream should know which provider a mail arrived on. That single rule is what makes the rest of the product simple.
The provider already computes a technical spam score, and it costs nothing to read. So that runs first, at the boundary.
Every module in the product publishes into it. That only works if the cost of one notification does not grow with the number of publishers.
The provider layer writes the mail to a database and the raw body to object storage. Two writes, no transaction across them. If the second fails, the record and the body disagree. We accepted it, and I can tell you exactly where it bites and what I would do about it.
The single inbound queue is simple and it scales sideways, but it gives one noisy module the ability to slow everyone down. The fix is per-module queues or a prefetch limit, and the cost is a topology that no longer explains itself in one sentence.
In an asynchronous system a trace id has to be carried in the message headers, or the story of one event stops at the queue and starts again on the other side as something unrelated. That is the first thing I would add.
These are on the page on purpose. A system with no known weaknesses is a system nobody has looked at closely.
Four Spring Boot services, a topic-exchange message bus, vector search, a distributed lock, and a resilient AI provider layer — all the moving parts senior backend interviews actually drill into. Each engineering choice below is paired with the file in the repo that implements it, so the work can be verified, not just claimed.
Each card is a real interview-grade problem this system had to answer. The blue tag is the technical pattern; the file path lets you verify the implementation.
Gmail Pub/Sub may redeliver the same historyId on transient errors. Workers can crash between any two side-effects. The pipeline must never drop an email and never write a row twice.
Two-layer dedup. (1) DB unique constraint uq_email_content_message(mailbox_id, message_id) — Gmail's message id is the natural idempotency key. (2) Explicit existsByGmailMailboxIdAndMessageId check before the API fetch, so a redelivery doesn't even spend a quota call. Every stage progresses processed_status after the durable write, never before — so a crash mid-stage replays from the last persisted state.
→ ingester/.../GmailMessageProcessingService.java, EmailContent.java
Gmail can fire many Pub/Sub events per mailbox in seconds. Naively syncing each one wastes API quota and risks racing into duplicate work.
Per-mailbox ReentrantLock + high-watermark coalescing. Events merge(eventHistoryId, Math::max) into a per-mailbox watermark; only the first event acquires the lock and runs a paginated sync that catches up to the latest watermark. Subsequent events tryLock(), see it's held, and exit immediately. Bounded by MAX_SYNC_ITERATIONS = 50 to prevent unbounded catch-up loops.
→ ingester/.../GmailMessageSyncService.java
Sanitize is microseconds; the LLM normalization call is seconds; Bedrock embedding is hundreds of milliseconds. A single queue means the slowest stage back-pressures the entire pipeline.
RabbitMQ topic exchange with 4 isolated stage queues (sanitization · normalization · embedding · clustering), each declared with x-dead-letter-exchange + x-dead-letter-routing-key at bean construction — every queue gets a sibling .dlq. Each listener factory carries its own retry interceptor (maxAttempts=3, exponential 1s → 2s → 4s capped at 10s) with RejectAndDontRequeueRecoverer, prefetch 10, concurrent consumers 4–8.
→ processor/.../config/RabbitMQConfig.java
Bedrock 5xx, 429 throttling, and socket I/O errors should retry — they pass. A 400 malformed request or a revoked refresh token will never succeed — retrying just delays the DLQ and burns rate limit.
Exception classification at the HTTP boundary. HttpServerErrorException, HttpClientErrorException.TooManyRequests, and ResourceAccessException wrap to RetryableAIException → handled by @Retry(name="aiRetry") (3 attempts, 2s base, 2× backoff, 0.5 jitter). All other 4xx wrap to IllegalStateException → fails fast to DLQ. The Pub/Sub subscriber detects invalid_grant and marks the mailbox DISCONNECTED instead of nacking forever.
→ processor/.../BedrockModelProvider.java, ingester/.../GmailPubSubSubscriber.java
Every new email needs a cluster now for live labelling — but you also need the system to discover new clusters as the inbox evolves. Doing DBSCAN per email is too slow; doing only periodic batch leaves new emails uncategorised for hours.
Two cooperating clusterers. Incremental: cosine vs existing centroids, assign if similarity ≥ configurable threshold, else mark CLUSTER_ASSIGNMENT_COMPLETED and defer to batch. Batch: full @Transactional Smile DBSCAN over all embeddings — flushes old assignments and re-creates them atomically. Coordinated by BatchClusteringLock.isActive(): incremental skips while batch runs, so a re-cluster doesn't race with hot-path writes. Each assignment records cluster_assignment_type = INCREMENTAL or BATCH for downstream reasoning.
→ processor/.../EmailClusteringService.java, taxonomy-engine/.../BatchClusteringService.java
Batch re-clustering deletes every cluster and assignment for a mailbox, then re-creates them inside one transaction. Two replicas running simultaneously could orphan rows or corrupt the centroid set. A worker that crashes mid-job must not leave the lock held forever.
Redis setIfAbsent + 1-hour TTL. Atomic acquire; second replica fails fast with no spin. TTL guarantees release on crash. Plus belt-and-braces: an ApplicationRunner sweeps stale keys matching <prefix>:mailbox:* on startup; @PreDestroy releases on graceful shutdown.
→ taxonomy-engine/.../BatchClusteringLock.java, BatchClusteringLockCleanerConfig.java
Each new email needs its nearest cluster centroid in real time. Naive cosine over an entire table is O(n·d). A separate vector DB (Pinecone, Weaviate) means two-phase commits and a second operational surface.
Postgres + pgvector with HNSW + cosine_ops. Partial index WHERE embedding IS NOT NULL keeps the index lean during the gap between insert and embed. Hibernate 6.5 maps Java float[] directly via @JdbcTypeCode(SqlTypes.VECTOR) + @Array(length=1024) — no custom converter, no dialect hack. One transaction commits the embedding, the cluster assignment, and the status flip together.
→ db-migrations/.../V1.005__create_email_enrichment.sql, persistence-lib/.../EmailEnrichment.java
Naive prompts ("classify this email") invent urgency from words like verify, alert, action. The LLM picks a label first and rationalises later. Output drifts and is non-deterministic.
Reason-before-rate JSON schema: the model writes importance_reason before importance, category_reason before category. Override rules baked into the prompt (all bank transaction alerts → LOW UPDATES; all OTPs → LOW UPDATES). Job-hunt persona override promotes named recruiter outreach to HIGH. 8 worked examples cover real edge cases (Axis Bank UPI, LinkedIn InMail vs Job Alerts, IRCTC booking, ATS receipts). Defensive parsing: regex-extracts the JSON block even if the model adds prose; READ_UNKNOWN_ENUM_VALUES_AS_NULL falls back to LOW / PRIMARY on garbage. Temperature 0.0, top-p 1.0 — deterministic. Bedrock prompt capped at MAX_PROMPT_CHARS = 24_000.
→ processor/.../NormalizationPromptHelper.java
Real email HTML is hostile: tracking pixels, hidden divs, fancy quotes, zero-width chars, table-shaped layouts, CID inline images. A single regex pass either misses cases or becomes unreadable.
Annotation-driven pipeline registry. Each step is a @SanitizationStep(order=N) Spring bean; ContentSanitizationPipelineRegistry discovers and sorts them at startup — adding a new step is one new class. Steps include: jsoup HTML→text (preserves block boundaries as newlines, list items as - , table cells as | , appends (href) when anchor text ≠ url); inline-reference remover; CharacterNormalizer with 30+ curly-quote / em-dash / arrow / ellipsis / TM mappings, six regex passes for Unicode spaces / invisible chars / pipe-only rows / multi-space / trailing-space / excessive-newline collapse. Skips parsing entirely when no HTML hints are present.
→ processor/.../sanitization/pipeline/
Each batch re-cluster invents fresh cluster IDs but the user already has labels in Gmail. Naively creating a new label per cluster floods their sidebar and the system drifts further with every run.
Three-stage label decision. (1) Sort cluster members by cosine-to-centroid, take top 8, send to LLM with the existing label-name pool — model is biased toward suggesting an existing name. (2) Case-insensitive string match → reuse. (3) Otherwise embed the suggestion (Titan v2) and find nearest existing label by cosine; if ≥ MERGE_THRESHOLD = 0.80, merge into it; else create a new Label with the suggestion's embedding stored as reference_embedding. The pool grows monotonically across runs.
→ taxonomy-engine/.../ClusterLabelingService.java
Bedrock costs money and needs IAM. Local dev and CI shouldn't burn either. Hard-coding a provider makes the test loop painful and the architecture vendor-locked.
ModelProvider interface with Bedrock and Ollama implementations. Selected by config (embedding-provider-name, llm-provider-name). Same call shape; Titan v2-specific knobs (dimensions, normalize) only set when model id matches. Both Bedrock and the local Ollama embedding return 1024-d so swapping providers doesn't invalidate the vector index. Same trick applied to EmailStorageProvider — Local for dev, S3 for prod, one interface.
→ processor/.../model/factory/, persistence-lib/.../storage/
JPA entities are needed by ingester, processor, and taxonomy-engine. Copy-pasting guarantees silent drift; a data-access microservice adds a network hop per query.
Shared persistence-lib published as a private Maven package (GitHub Packages). Pinned versions force conscious upgrades. The only cross-service contract for data shape lives in one place. EmailStorageProvider and 7 Flyway migrations ride along in the same library. Hibernate uses ddl-auto: validate in every service — schema mismatches fail at startup, not at runtime.
→ persistence-lib/.../entity/, db-migrations/.../V1.001 … V1.007
Every email carries a processed_status. The 5 stages each have
4 sub-states (PUBLISHED_FOR_X → X_STARTED →
X_COMPLETED / X_FAILED) — 20 states total in the
ProcessedStatus enum. Transitions happen after
the side-effect (publish, save, embed), never before. A crash mid-stage is
replayed cleanly on restart; Pub/Sub redeliveries see a later state and
no-op.
COMPLETED state, re-publishes the next-stage event. Downstream stages no-op on observing a later state.uq_email_content_message(mailbox_id, message_id) constraint blocks duplicate inserts; the listener acks and exits.invalid_grant is caught at the subscriber, the mailbox is marked DISCONNECTED, the message is acked — no infinite nack loop.How a transient failure (Bedrock 5xx, Gmail 429) escalates from Resilience4j retries to a stage-specific Dead Letter Queue — without ever blocking the other stages.
Three services + a shared persistence library. Click any node or edge for the reasoning behind it.
The non-obvious pieces — verbatim from the repo, with file paths so they can be verified.
Three sample emails enter the pipeline. Watch each one get sanitized, normalized, embedded, and assigned a cluster. At the end, DBSCAN groups them and the LLM picks names. The code panel shows the real Java method running at each stage.
Press “Run demo” to see the code that processes each email at every stage.
After DBSCAN re-clusters globally and the LLM names each group:
Source code and how to run the stack locally.
Three Spring Boot services + a shared persistence library + Flyway migrations. Each service is its own repo under the Inbox-intelligence org.
github.com/Inbox-intelligence →
End-to-end runbook: GCP project + Gmail OAuth + Pub/Sub setup, docker-compose for Postgres/RabbitMQ/Redis, Bedrock credentials, Flyway migrate, service start order.
Read the runbook on GitHub →