<?xml version="1.0" encoding="UTF-8"?><rss xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:atom="http://www.w3.org/2005/Atom" version="2.0"><channel><title><![CDATA[Bhavansh's Engineering Notes]]></title><description><![CDATA[Engineering blog covering backend systems, fintech infrastructure, and real-world system design from a software engineer at work in the payments space.]]></description><link>https://bhavansh.hashnode.dev</link><image><url>https://cdn.hashnode.com/res/hashnode/image/upload/v1593680282896/kNC7E8IR4.png</url><title>Bhavansh&apos;s Engineering Notes</title><link>https://bhavansh.hashnode.dev</link></image><generator>RSS for Node</generator><lastBuildDate>Tue, 15 Sep 2026 20:13:25 GMT</lastBuildDate><atom:link href="https://bhavansh.hashnode.dev/rss.xml" rel="self" type="application/rss+xml"/><language><![CDATA[en]]></language><ttl>60</ttl><item><title><![CDATA[Decomposing a Legacy EJB Monolith" (a system design deep-dive)]]></title><description><![CDATA[📝 Note: This post was edited with AI assistance for clarity and structure. The system design, implementation decisions, and technical thinking are entirely my own.

TL;DR

Decomposed a legacy Java EJ]]></description><link>https://bhavansh.hashnode.dev/decomposing-a-legacy-ejb-monolith-a-system-design-deep-dive</link><guid isPermaLink="true">https://bhavansh.hashnode.dev/decomposing-a-legacy-ejb-monolith-a-system-design-deep-dive</guid><dc:creator><![CDATA[Bhavansh Gupta]]></dc:creator><pubDate>Sun, 05 Jul 2026 12:31:48 GMT</pubDate><content:encoded><![CDATA[<blockquote>
<p>📝 <strong>Note:</strong> This post was edited with AI assistance for clarity and structure. The system design, implementation decisions, and technical thinking are entirely my own.</p>
</blockquote>
<h2>TL;DR</h2>
<ul>
<li><p>Decomposed a legacy Java EJB monolith — the authoritative path for institutional client withdrawals, deposits, and transfers — into <strong>four independently deployable services</strong>: a Spring Boot REST API, a Spring Batch processor, a scoped-down commons library, and a CLI monitoring app.</p>
</li>
<li><p>Eliminated <strong>EJB connection-pool exhaustion</strong> entirely — previously triggered under batch load via a shared RMI-based EJB client — by moving batch processing off that client and onto REST calls to a modernized external signing service.</p>
</li>
<li><p>Replaced single-threaded, per-client <strong>modulus-sharded</strong> batch steps with multithreaded, chunk-based Spring Batch processing — ~70% throughput improvement.</p>
</li>
<li><p>Built a custom <strong>state-reconciliation ("auto-recovery") engine</strong> to resolve transactions left in ambiguous states after crashes or outages — because Spring Batch's own restart semantics can't be trusted when the source of truth for "did it happen" lives in an external system.</p>
</li>
<li><p>Ran a <strong>dual API surface</strong> (legacy XML-in-JSON alongside new pure JSON) simultaneously, migrated client-by-client and transaction-type-by-transaction-type, with feature-flag-based rollback at the routing layer.</p>
</li>
<li><p>Result: 4× release cadence, ~20% of prior rollback rate, zero pool-exhaustion incidents, ~1 hour of dev triage saved per outage.</p>
</li>
</ul>
<hr />
<h2>Introduction</h2>
<p>Institutional clients can move money in and out and much more using multiple transactions — withdrawals, deposits, wires, ACH, DWAC, position transfers (FOP), etc. For years, the logic that actually authorized and signed these transfers lived inside an <strong>EJB application owned by a separate team</strong>. The REST/API-facing servlet application embedded that EJB's main jar directly and called its functions in-process; batch processing, by contrast, went through a separate EJB client artifact invoked over RMI — and it's this RMI-based path, used exclusively by batch, that became the source of the pool-exhaustion problems described below. Direct embedding was a reasonable choice when the integration was small. It stopped being reasonable once it quietly became the authoritative path for money movement for 100+ institutional clients processing 100,000+ requests a day — at which point the hard requirement became:</p>
<blockquote>
<p>The system authorizing money movement must be independently deployable, independently scalable, observable by the team that owns it, and resilient to partial failure in systems it doesn't control.</p>
</blockquote>
<p>The legacy EJB integration met none of these.</p>
<hr />
<h2>The Problem We Actually Had</h2>
<p>Before REST ever entered the picture, the system went through two earlier eras worth understanding, because each one explains a layer of debt that had to be unwound later:</p>
<ol>
<li><p><strong>FTP + raw XML.</strong> Clients dropped signed XML files onto an FTP server; scheduled batch jobs picked them up and processed them.</p>
</li>
<li><p><strong>HTTP, XML retained.</strong> The system moved to HTTP, but the XML message structure was too deeply embedded to remove — so clients sent a <strong>base64-encoded, signed XML string wrapped inside a JSON payload.</strong> JSON on the outside, XML doing all the real work on the inside.</p>
</li>
</ol>
<p>By the time the EJB dependency became untenable, this was the operating scale:</p>
<table>
<thead>
<tr>
<th>Dimension</th>
<th>Value</th>
</tr>
</thead>
<tbody><tr>
<td>Institutional clients</td>
<td>100+</td>
</tr>
<tr>
<td>Transaction types</td>
<td>30+ (ACH, WIRE, DWAC, FOP, deposits, withdrawals, etc.)</td>
</tr>
<tr>
<td>Daily requests</td>
<td>100,000+</td>
</tr>
<tr>
<td>EJB connection pool (per server, 2 servers)</td>
<td>200+ slots, periodically exhausted</td>
</tr>
<tr>
<td>Wider EJB consumer footprint</td>
<td>Dozens of other frontend deployments and hundreds of scheduled jobs company-wide depended on the same EJB, well beyond this system alone</td>
</tr>
</tbody></table>
<hr />
<h2>Why the Legacy EJB Architecture Failed</h2>
<table>
<thead>
<tr>
<th>Failure Mode</th>
<th>Symptom</th>
<th>Root Cause</th>
</tr>
</thead>
<tbody><tr>
<td><strong>EJB session-pool exhaustion</strong></td>
<td>Batch steps failing outright during heavy trading load, high request volume, or new-client onboarding</td>
<td>Long-running, single-threaded batch steps held pool connections for their entire duration, starving other work — the pool has no concept of fairness</td>
</tr>
<tr>
<td><strong>Zero visibility</strong></td>
<td>Cross-team, high-latency debugging for any production incident</td>
<td>Signing logic ran on access-restricted servers the team had no access to</td>
</tr>
<tr>
<td><strong>Architectural drift</strong></td>
<td>Deployment discipline ("never touch old code, only add") kept blast radius contained, but at a rising maintenance cost</td>
<td>A small, well-scoped integration organically became a critical dependency without ever being re-architected for that criticality</td>
</tr>
</tbody></table>
<h3>Key Elimination: Why Not Just Decouple Without Fully Splitting?</h3>
<p>Before committing to a four-service decomposition, the alternatives were weighed honestly:</p>
<table>
<thead>
<tr>
<th>Approach</th>
<th>Ends Ownership Problem</th>
<th>Fixes Pool Exhaustion</th>
<th>Independent Scaling</th>
<th>Independent Deploys</th>
</tr>
</thead>
<tbody><tr>
<td>Tune/enlarge the existing EJB pool</td>
<td>❌</td>
<td>Partial, temporary</td>
<td>❌</td>
<td>❌</td>
</tr>
<tr>
<td>Move to a different app server</td>
<td>❌</td>
<td>Partial</td>
<td>❌</td>
<td>❌</td>
</tr>
<tr>
<td>Replace EJB with one new monolith</td>
<td>✅</td>
<td>✅</td>
<td>❌</td>
<td>❌</td>
</tr>
<tr>
<td>Decompose into purpose-built services</td>
<td>✅</td>
<td>✅</td>
<td>✅</td>
<td>✅</td>
</tr>
</tbody></table>
<p>A single replacement monolith would have solved the ownership and pool problems but reproduced the same coupling risk in a different shape — one deployable unit for a monitoring change, a batch change, and a client-facing API change would all still ship and fail together. That's what pushed the design to four separately deployable components rather than one.</p>
<hr />
<h2>System Architecture: Two Mechanisms, Two Concerns</h2>
<p>Much like separating in-transit integrity from at-rest integrity in a signed-payments system, this redesign relies on <strong>two distinct mechanisms solving two different problems</strong> — and conflating them was exactly the old system's mistake.</p>
<h3>1. The Processing Pipeline (Moving Transactions Forward)</h3>
<p>A lightweight <strong>database polling table</strong> acts as the queue between "signed" and "processed." It's deliberately thin:</p>
<pre><code class="language-plaintext">{
  id,
  transactionStatus,
  transactionType,
  signature,
  ...metadata
}
</code></pre>
<p>The actual payload — account, amount, and other type-specific fields — lives in <strong>separate per-transaction-type tables</strong>, kept out of the polled table entirely. This isn't a placeholder for "we'll add Kafka later" — traffic volume didn't justify a broker, and proper indexing on a thin table was sufficient.</p>
<h3>2. The Auto-Recovery Engine (Resolving Ambiguous State)</h3>
<p>This is the mechanism that answers a much harder question: <em>what happened to a transaction that crashed mid-flight, when the true answer lives in a system we don't control?</em> It's covered in depth below — it does not overlap with the processing pipeline's job. The pipeline moves transactions forward under normal conditions; the recovery engine exists solely to resolve abnormal ones.</p>
<hr />
<h2>Transaction Lifecycle</h2>
<pre><code class="language-plaintext">External Institutional Client
     │
     │  Submits transaction request (authenticated via separate OAuth service)
     ▼
  REST API (Spring Boot)
     │
     ├─► Validate request
     │
     ├─► Persist as "pending"
     │
     ▼
  Batch Processor (Spring Batch, polls "pending")
     │
     ├─► Call external Signing REST API (sign + validate)
     │
     ├─► Mark "in processing"
     │
     ├─► Write to DB polling table (queue)
     │
     ▼
  Processing Step
     │
     ├─► Route by transaction type to internal API
     │    (FOP → API A, cash transfer → API B, ...)
     │
     ├─ success → mark "completed"
     ├─ known failure → mark "failed"
     └─ crash / timeout / unknown outcome
            → picked up by Auto-Recovery Engine
</code></pre>
<hr />
<h2>Why Mark State Before the Call, Not After</h2>
<p>During recovery, the question is never "what request did we send" — it's "what actually happened downstream." Marking a transaction <code>in processing</code> <strong>before</strong> calling out (rather than only recording state on response) is what gives the recovery engine something to anchor on. If the process crashes after the call but before a result is recorded, <code>in processing</code> is the signal that says: <em>this one needs verification, not automatic replay.</em></p>
<p>Before the recovery engine acts on any stuck transaction, it runs a short sequence of checks:</p>
<ol>
<li><p>Has our own table already been updated with a result?</p>
</li>
<li><p>Does polling the downstream service confirm the transaction was actually recorded on their end?</p>
</li>
<li><p>Do related, dependent tasks provide additional evidence of the outcome?</p>
</li>
</ol>
<p>Only after this evidence is gathered does the engine decide the transaction's correct resolution — completed, needs-retry, or failed. <strong>It never blindly re-sends.</strong></p>
<blockquote>
<p>Spring Batch's own restart-from-checkpoint guarantee assumes the side effects of a step are captured by its local transaction. That assumption breaks the moment the real side effect — money moving — happens inside an external system Spring Batch has no visibility into. You cannot outsource idempotency to a batch framework when the source of truth lives outside it.</p>
</blockquote>
<hr />
<h2>Retry Design: Avoiding Double Processing</h2>
<p>A blind retry on top of a timed-out network call is one of the most dangerous states this system can be in — it risks re-sending money that already moved. The design deliberately does <strong>not</strong> delegate retries to a generic transport-layer resilience library. Instead:</p>
<ol>
<li><p>Once a transaction is recorded, the client immediately receives a <code>pending</code> response.</p>
</li>
<li><p><strong>The client is responsible for polling</strong> — not the server for pushing.</p>
</li>
<li><p>Actual retries of downstream work are driven entirely by the <strong>batch + auto-recovery state machine</strong>, which has full context on what has and hasn't actually happened.</p>
</li>
</ol>
<p>This keeps every retry decision state-aware rather than blind — a generic retry-on-timeout policy has no way to know the difference between "the call never reached the downstream system" and "the call succeeded, but the response was lost."</p>
<hr />
<h2>Fixing the Batch Architecture</h2>
<p>The old batch design sharded work <strong>per client</strong>, single-threaded, using a modulus-based shard filter:</p>
<pre><code class="language-plaintext">step.filter = (transaction_id % N == shardIndex)
# e.g., Client A → 4 steps, shard indices 0,1,2,3
</code></pre>
<p>This caused <strong>step explosion</strong> — step count scaled with clients × shard count — and wasted steps: a low-volume client still needed its full set of steps running, each one scanning and finding little or no work.</p>
<p>The replacement uses Spring Batch's multithreaded, chunk-based processing with async calls to internal downstream APIs:</p>
<pre><code class="language-java">@Bean
public Step processTransactionsStep(
        TaskExecutor taskExecutor,
        ItemReader&lt;Transaction&gt; reader,
        ItemProcessor&lt;Transaction, RoutedTransaction&gt; processor,
        ItemWriter&lt;RoutedTransaction&gt; writer) {

    return stepBuilderFactory.get("processTransactions")
        .&lt;Transaction, RoutedTransaction&gt;chunk(CHUNK_SIZE)
        .reader(reader)
        .processor(processor)
        .writer(writer)
        .taskExecutor(taskExecutor)
        .throttleLimit(THREAD_POOL_SIZE)
        .build();
}
</code></pre>
<p>Most clients were consolidated into shared, chunked processing. A small number of <strong>high-volume, tight-SLA clients kept dedicated steps</strong> — an explicit carve-out, not a uniform rule, to protect them from noisy-neighbor contention in the shared pool.</p>
<hr />
<h2>Multi-Transaction-Type Routing</h2>
<p>Different transaction types (FOP, cash transfer, wire, ACH, and more) route to entirely different internal APIs, each with its own request shape. A branching approach doesn't scale:</p>
<pre><code class="language-java">if (type == FOP) { ... }
else if (type == CASH_TRANSFER) { ... }
else if (type == WIRE) { ... }
</code></pre>
<p>Instead, routing is isolated behind a factory + strategy pattern:</p>
<pre><code class="language-java">public interface TransactionRouter {
    RoutingResult route(SignedTransaction txn);
}

public class FopTransferRouter implements TransactionRouter { ... }
public class CashTransferRouter implements TransactionRouter { ... }
public class WireTransferRouter implements TransactionRouter { ... }

public class TransactionRouterFactory {
    public static TransactionRouter forType(TransactionType type) {
        return switch (type) {
            case FOP -&gt; new FopTransferRouter();
            case CASH_TRANSFER -&gt; new CashTransferRouter();
            case WIRE -&gt; new WireTransferRouter();
            default -&gt; throw new UnsupportedTransactionTypeException(type);
        };
    }
}
</code></pre>
<p>Signing, retry, and recovery logic all operate on one canonical <code>SignedTransaction</code> model and remain entirely type-agnostic. Adding a new transaction type is a new router implementation — no changes to core logic.</p>
<hr />
<h2>Legacy vs. Modern API Surface</h2>
<table>
<thead>
<tr>
<th>Legacy (XML-in-JSON)</th>
<th>Modern (Pure JSON)</th>
</tr>
</thead>
<tbody><tr>
<td>Base64-encoded, signed XML string inside a JSON envelope</td>
<td>Native JSON fields</td>
</tr>
<tr>
<td>Auth baked into the XML signing convention</td>
<td>OAuth2, delegated entirely to a separate auth service</td>
</tr>
<tr>
<td>One implicit "version" — the XML schema in use</td>
<td>Explicit URL-based versioning (<code>/v1</code>, <code>/v2</code>)</td>
</tr>
<tr>
<td>Every request pays a decode → parse → verify tax</td>
<td>Single canonical internal object model, translated once at the edge</td>
</tr>
</tbody></table>
<p>Both surfaces run <strong>simultaneously</strong> — not sequentially deprecated — translated at the edge into one internal model, so validation, signing, and processing never need to know which surface a request arrived on. This is what let migration happen client-by-client instead of as a single flag day.</p>
<hr />
<h2>Migration: Granular, Reversible, Client-by-Client</h2>
<ul>
<li><p>Rollout was granular to <strong>client + transaction type</strong>: Client A's ACH traffic could move to the new service while their wire transfers stayed on the old path, independently controlled via routing rules at the OAuth/auth layer. (XML-surface migration specifically was coarser — client-level only.)</p>
</li>
<li><p>Validation was <strong>manual sign-off per client</strong>, checked against historical transaction data, backed by failure-rate monitoring to surface anomalies.</p>
</li>
<li><p>Rollback used <strong>database-backed feature flags</strong> throughout the codebase — including at the OAuth/routing layer — allowing traffic to flip back to the old path without a redeploy.</p>
</li>
<li><p>Not everything went cleanly: both old and new paths stored <strong>serialized Java objects</strong> in the database, and objects serialized by the new service turned out to be incompatible with deserialization on the legacy side. Fixing it required touching the old EJB codebase — something the migration had otherwise avoided for its entire duration.</p>
</li>
<li><p>Total timeline: <strong>12+ months</strong>, with ~6 months specifically spent on the client-by-client rollout, executed by a team of 5 engineers working this alongside other commitments. The legacy EJB path is still live today, deprecated but not decommissioned.</p>
</li>
</ul>
<hr />
<h2>Results</h2>
<table>
<thead>
<tr>
<th>Metric</th>
<th>Outcome</th>
</tr>
</thead>
<tbody><tr>
<td>Release cadence</td>
<td>4× improvement (bimonthly → biweekly)</td>
</tr>
<tr>
<td>Rollback rate</td>
<td>~20% of prior levels</td>
</tr>
<tr>
<td>EJB pool-exhaustion incidents</td>
<td>Zero since migration</td>
</tr>
<tr>
<td>Batch processing throughput</td>
<td>~70% improvement</td>
</tr>
<tr>
<td>Dev triage time per outage</td>
<td>~1 hour saved, across 100–500 affected transactions</td>
</tr>
</tbody></table>
<hr />
<h2>Is This "Microservices"?</h2>
<p>Worth stating precisely rather than reaching for the label: this is <strong>not</strong> database-per-service, fully isolated microservices. All four components share one database schema, with ownership expressed at the table level rather than full isolation. A commons library is shared across all of them — deliberately scoped down to DTOs and translation logic only, as a conscious mitigation against repeating the original EJB-jar mistake, but a shared dependency nonetheless. The more accurate description: <strong>a modular decomposition into purpose-built, independently deployable services, with deliberate, narrowly-scoped shared coupling retained by design</strong> — not textbook microservices purity for its own sake.</p>
<hr />
<h2>Key Takeaways</h2>
<ul>
<li><p>A shared connection/resource pool has no concept of fairness — one noisy client (or one onboarding event) can starve every other consumer sharing it.</p>
</li>
<li><p>Deployment discipline ("never touch old code, only add") can contain blast radius, but it's a manual workaround for coupling, not a fix for it.</p>
</li>
<li><p>Treat the "move transactions forward" pipeline and the "resolve ambiguous state" recovery engine as separate concerns — conflating them is what made the old system fragile.</p>
</li>
<li><p>You cannot outsource idempotency to a batch framework's restart semantics when the real source of truth for success lives in an external system you don't control.</p>
</li>
<li><p>Retry logic that risks re-sending money needs to be state-machine-driven, not a generic transport-layer policy.</p>
</li>
<li><p>A shared library is not inherently the old monolith's mistake repeated — scope matters. DTOs and translation only, not business logic.</p>
</li>
<li><p>Running two API surfaces simultaneously, translated to one canonical internal model, is what makes gradual, client-by-client migration possible instead of a forced flag day.</p>
</li>
<li><p>Be precise about what you actually built. "Modular decomposition with deliberate shared coupling" is a more defensible claim than "microservices" if the architecture doesn't fully earn the second term.</p>
</li>
</ul>
]]></content:encoded></item><item><title><![CDATA[Why Your Webhook Handler Needs a State Machine (And What That Actually Means in Practice)]]></title><description><![CDATA[📝 Note: This post was edited with AI assistance for clarity and structure. The system design, implementation decisions, and technical thinking are entirely my own.

TL;DR

Integrated a third-party pa]]></description><link>https://bhavansh.hashnode.dev/why-your-webhook-handler-needs-a-state-machine</link><guid isPermaLink="true">https://bhavansh.hashnode.dev/why-your-webhook-handler-needs-a-state-machine</guid><dc:creator><![CDATA[Bhavansh Gupta]]></dc:creator><pubDate>Sun, 28 Jun 2026 07:04:10 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/5f367218877a013acb03c707/3c3d4f5d-0d12-40d0-b442-c76f4b27f693.jpg" length="0" type="image/jpeg"/><content:encoded><![CDATA[<blockquote>
<p>📝 <strong>Note:</strong> This post was edited with AI assistance for clarity and structure. The system design, implementation decisions, and technical thinking are entirely my own.</p>
</blockquote>
<h2>TL;DR</h2>
<ul>
<li><p>Integrated a third-party payment provider with a dual-flow architecture (server-to-server token exchange + client-side browser SDK), where webhooks are the primary state update mechanism and polling is the fallback.</p>
</li>
<li><p>Discovered that webhook delivery order does not guarantee event order — SUCCESS webhooks arrive before PROCESSING, FAILURE before PROCESSING — silently corrupting the raw provider status table while internal state guards rejected the bad transitions downstream.</p>
</li>
<li><p>Modelled payment states into three groups: <strong>intermediate states</strong>, <strong>final success states</strong>, and <strong>final failure states</strong> — and derived all valid transitions from group membership alone.</p>
</li>
<li><p>Enforced transition guards at two layers: DB update procedures and Java service layer, rejecting reverse transitions and redundant same-state updates at both.</p>
</li>
<li><p>Added operational alerting on out-of-order webhook arrivals to make the failure mode observable rather than silent.</p>
</li>
</ul>
<hr />
<h2>Introduction</h2>
<p>When integrating a third-party payment provider, most of the engineering attention goes to the obvious hard parts: authentication, request construction, error handling, and retry design. State management tends to feel like a solved problem — the provider tells you what happened, you update your records accordingly.</p>
<p>The assumption hidden in that sentence is that the provider tells you what happened <em>in the order it happened</em>. In practice, with webhook-based integrations, that assumption does not hold. Webhook delivery is not guaranteed to be ordered. Network conditions, provider-side queuing, and retry mechanisms mean that a SUCCESS notification for a payment can arrive at your server before the PROCESSING notification that logically preceded it.</p>
<p>This post is about the failure mode that surfaces when you trust webhook delivery order, the state modelling pattern that fixes it, and the specific enforcement approach we implemented.</p>
<hr />
<h2>Integration Architecture</h2>
<p>The payment provider integration uses a dual-flow design:</p>
<ol>
<li><p><strong>Server-to-server token exchange</strong> — our backend calls the provider API to generate a short-lived token, which is passed to the client.</p>
</li>
<li><p><strong>Client-side browser SDK</strong> — the client uses the token to complete the payment flow directly with the provider. At the end of this flow, the provider returns a result event to the browser.</p>
</li>
<li><p><strong>Webhook delivery</strong> — simultaneously and independently of the browser event, the provider POSTs webhook notifications to our server as the payment progresses through its internal pipeline.</p>
</li>
</ol>
<p>Webhooks are the <strong>primary mechanism</strong> for server-side state updates because they are event-driven and near real-time. Polling against the provider's status API runs as a <strong>fallback</strong> — batched, periodic, and intended to catch payments where webhooks were missed or dropped.</p>
<p>On the storage side, we maintain two separate tables:</p>
<ul>
<li><p><strong>Provider status table</strong> — stores the raw payment status as reported by the provider (webhook payloads, poll responses).</p>
</li>
<li><p><strong>Internal payment status table</strong> — stores our own payment lifecycle state, derived from provider statuses via a sync process.</p>
</li>
</ul>
<p>A background sync process reads from the provider status table, maps provider-specific statuses to our internal status model and applies the transition to the internal table. The internal table is the source of truth for all downstream systems.</p>
<img src="https://cdn.hashnode.com/uploads/covers/5f367218877a013acb03c707/e6b7936f-b36e-4f4f-806f-6c2752d5dfb2.png" alt="" style="display:block;margin:0 auto" />

<hr />
<h2>State Model</h2>
<h3>Provider Statuses</h3>
<p>The provider exposes a set of statuses that cover the full lifecycle of a payment:</p>
<table>
<thead>
<tr>
<th>Provider Status</th>
<th>Meaning</th>
</tr>
</thead>
<tbody><tr>
<td><code>INITIATED</code></td>
<td>Payment request received by the provider</td>
</tr>
<tr>
<td><code>PROCESSING</code></td>
<td>Payment is being processed</td>
</tr>
<tr>
<td><code>PENDING_APPROVAL</code></td>
<td>Awaiting approval (bank-side or compliance hold)</td>
</tr>
<tr>
<td><code>SUCCESS</code></td>
<td>Payment completed successfully</td>
</tr>
<tr>
<td><code>FAILED</code></td>
<td>Payment failed (insufficient funds, rejected, etc.)</td>
</tr>
<tr>
<td><code>RETURNED</code></td>
<td>Payment was returned after initial success</td>
</tr>
<tr>
<td><code>CANCELLED</code></td>
<td>Payment was cancelled before processing completed</td>
</tr>
</tbody></table>
<h3>Internal Statuses</h3>
<p>These map to our domain model, independent of provider-specific terminology:</p>
<table>
<thead>
<tr>
<th>Internal Status</th>
<th>Meaning</th>
</tr>
</thead>
<tbody><tr>
<td><code>PENDING</code></td>
<td>Payment initiated, not yet confirmed by provider</td>
</tr>
<tr>
<td><code>IN_PROGRESS</code></td>
<td>Provider is actively processing</td>
</tr>
<tr>
<td><code>ON_HOLD</code></td>
<td>Awaiting external approval</td>
</tr>
<tr>
<td><code>COMPLETED</code></td>
<td>Payment settled successfully</td>
</tr>
<tr>
<td><code>FAILED</code></td>
<td>Payment failed terminally</td>
</tr>
<tr>
<td><code>RETURNED</code></td>
<td>Payment reversed post-settlement</td>
</tr>
<tr>
<td><code>CANCELLED</code></td>
<td>Payment voided pre-settlement</td>
</tr>
</tbody></table>
<hr />
<h2>State Grouping: The Core Design Decision</h2>
<p>The first step in designing the transition model was not enumerating individual allowed transitions between statuses. That approach scales poorly — with N statuses, you potentially have N² pairs to reason about, and it becomes easy to miss a case or introduce an inconsistency.</p>
<p>Instead, we grouped statuses by their role in the payment lifecycle:</p>
<p><strong>Intermediate states</strong> — the payment is still in progress; further transitions are expected. <code>PENDING</code>, <code>IN_PROGRESS</code>, <code>ON_HOLD</code></p>
<p><strong>Final success states</strong> — the payment has settled successfully; no further provider-driven transitions are valid.<code>COMPLETED</code>, <code>RETURNED</code> <em>(a return is a terminal outcome, not a reversal back to intermediate)</em></p>
<p><strong>Final failure states</strong> — the payment has terminated unsuccessfully. <code>FAILED</code>, <code>CANCELLED</code></p>
<p>From these three groups, all valid transitions follow from four rules:</p>
<img src="https://cdn.hashnode.com/uploads/covers/5f367218877a013acb03c707/01f399bb-ce67-4c9a-9cf2-c1b09f17c3c2.png" alt="" style="display:block;margin:0 auto" />

<p>And two explicit rejections:</p>
<img src="https://cdn.hashnode.com/uploads/covers/5f367218877a013acb03c707/33d23e14-1034-4ad1-9a0f-67f01c8ca70f.png" alt="" style="display:block;margin:0 auto" />

<p>Note that final-to-same-final is treated as a no-op, not an error. A duplicate SUCCESS webhook for an already-COMPLETED payment is expected behaviour given webhook retry semantics. Silently ignoring it is the correct response — rejecting it as an error would generate false alerts.</p>
<h3>State Transition Diagram</h3>
<img src="https://cdn.hashnode.com/uploads/covers/5f367218877a013acb03c707/671a192d-fff1-45bd-9736-209f999e4e7a.png" alt="" style="display:block;margin:0 auto" />

<hr />
<h2>What Broke Before This Model</h2>
<p>Before the transition guard was applied uniformly to both tables, the raw provider status table was updated on every incoming webhook without validation.</p>
<p>The failure scenario played out as follows:</p>
<img src="https://cdn.hashnode.com/uploads/covers/5f367218877a013acb03c707/2805a30f-fa02-4b4d-bf09-4c60603f1c6d.png" alt="" style="display:block;margin:0 auto" />

<p>The internal state was correct because the Java service layer already had transition guards. But the provider table had been overwritten to a stale state. Any process reading the provider table directly — monitoring queries, reconciliation jobs, support tooling — would see a payment stuck in PROCESSING that had already settled.</p>
<p>The only way to reconstruct the actual sequence of events was to go through the state transition audit log manually and trace each webhook arrival by timestamp.</p>
<p>The internal guards held. The problem was that the provider table had no equivalent protection, so the corruption happened one layer above where the guards lived.</p>
<hr />
<h2>The Fix: Transition Guards at Every Layer</h2>
<p>The fix was to apply the same group-based transition model to the provider status table, not just the internal one.</p>
<h3>Layer 1 — DB Update Procedures</h3>
<p>Direct writes to either status table are not permitted from application code. All updates go through stored procedures. We added transition validation inside the procedures:</p>
<pre><code class="language-sql">-- Pseudocode representation of the guard logic
IF current_group = 'FINAL' AND new_group = 'INTERMEDIATE' THEN
    -- Reverse transition: reject silently, raise alert
    RETURN;
END IF;
 
IF current_group = 'FINAL_SUCCESS' AND new_group = 'FINAL_FAILURE' THEN
    -- Cross-terminal transition: reject, raise alert
    RETURN;
END IF;
 
IF current_group = 'FINAL_FAILURE' AND new_group = 'FINAL_SUCCESS' THEN
    -- Cross-terminal transition: reject, raise alert
    RETURN;
END IF;
 
IF current_status = new_status THEN
    -- Idempotent duplicate: reject silently, no alert
    RETURN;
END IF;
 
-- Valid transition: proceed with update
UPDATE payment_status SET status = new_status ...
</code></pre>
<p>The DB layer is the last line of defence. If the application layer fails to catch something, the procedure will.</p>
<h3>Layer 2 — Java Service Layer</h3>
<p>The service layer validates transitions before issuing any DB call, using an explicit group classification and an allowed-transition check:</p>
<pre><code class="language-java">public enum PaymentStatusGroup {
    INTERMEDIATE, FINAL_SUCCESS, FINAL_FAILURE
}
 
public enum InternalPaymentStatus {
    PENDING(INTERMEDIATE),
    IN_PROGRESS(INTERMEDIATE),
    ON_HOLD(INTERMEDIATE),
    COMPLETED(FINAL_SUCCESS),
    RETURNED(FINAL_SUCCESS),
    FAILED(FINAL_FAILURE),
    CANCELLED(FINAL_FAILURE);
 
    private final PaymentStatusGroup group;
 
    InternalPaymentStatus(PaymentStatusGroup group) {
        this.group = group;
    }
 
    public PaymentStatusGroup getGroup() {
        return group;
    }
}
</code></pre>
<pre><code class="language-java">public TransitionResult validateTransition(
        InternalPaymentStatus current,
        InternalPaymentStatus next) {
 
    PaymentStatusGroup currentGroup = current.getGroup();
    PaymentStatusGroup nextGroup = next.getGroup();
 
    // Idempotent: same status, nothing to do
    if (current == next) {
        return TransitionResult.IDEMPOTENT;
    }
 
    // Final → Intermediate: reverse transition
    if (currentGroup != INTERMEDIATE &amp;&amp; nextGroup == INTERMEDIATE) {
        return TransitionResult.REJECTED_REVERSE;
    }
 
    // Cross-terminal: final success ↔ final failure
    if (currentGroup == FINAL_SUCCESS &amp;&amp; nextGroup == FINAL_FAILURE
            || currentGroup == FINAL_FAILURE &amp;&amp; nextGroup == FINAL_SUCCESS) {
        return TransitionResult.REJECTED_CROSS_TERMINAL;
    }
 
    // All other transitions are valid
    return TransitionResult.ALLOWED;
}
</code></pre>
<p>The service layer acts on the <code>TransitionResult</code>:</p>
<ul>
<li><p><code>ALLOWED</code> → proceed, call DB procedure</p>
</li>
<li><p><code>IDEMPOTENT</code> → no-op, return without error</p>
</li>
<li><p><code>REJECTED_REVERSE</code> or <code>REJECTED_CROSS_TERMINAL</code> → reject, fire alert</p>
</li>
</ul>
<h3>Alerting</h3>
<p>Silent rejection is operationally dangerous. If out-of-order webhooks are arriving and being silently dropped, you have no visibility into how frequently this happens, whether it is a transient network issue or a systematic provider problem, or which specific payments are affected.</p>
<p>We fire an alert on every rejected transition (excluding idempotent duplicates). The alert payload includes the payment ID, the current status, the rejected incoming status, the webhook timestamp, and the arrival timestamp — enough to reconstruct the ordering discrepancy without manual log archaeology.</p>
<hr />
<h2>Key Takeaways</h2>
<ul>
<li><p>External payment providers do not guarantee webhook delivery order. Treat every incoming status update as potentially out-of-order.</p>
</li>
<li><p>Model payment states by group — intermediate, final success, final failure — and derive allowed transitions from group membership. This is more maintainable than enumerating individual valid pairs, and makes it trivial to classify any new status correctly.</p>
</li>
<li><p>Apply transition guards at every layer that stores payment state, not just the authoritative internal one. A raw provider status table with no guards will silently corrupt even when your internal table is protected.</p>
</li>
<li><p>Idempotent transitions (final → same final) should be silent no-ops, not errors. Duplicate webhook delivery is expected behaviour, not a bug.</p>
</li>
<li><p>Invalid transitions should fire operational alerts, not be silently dropped. Frequency and pattern of bad-order arrivals is genuinely useful signal for diagnosing provider behaviour.</p>
</li>
<li><p>Polling as a fallback is not optional. Webhooks can be missed or dropped; polling is what recovers payments that would otherwise get stuck in intermediate states indefinitely.</p>
</li>
</ul>
]]></content:encoded></item><item><title><![CDATA[Building an Internal Admin Dashboard with HTMX and Thymeleaf in 2025 — Why We Skipped React]]></title><description><![CDATA[📝 Note: This post was edited with AI assistance for clarity and structure. The system design, implementation decisions, and technical thinking are entirely my own.

TL;DR
Built a full internal admin ]]></description><link>https://bhavansh.hashnode.dev/building-an-internal-admin-dashboard-with-htmx-and-thymeleaf</link><guid isPermaLink="true">https://bhavansh.hashnode.dev/building-an-internal-admin-dashboard-with-htmx-and-thymeleaf</guid><dc:creator><![CDATA[Bhavansh Gupta]]></dc:creator><pubDate>Sat, 27 Jun 2026 09:24:23 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/5f367218877a013acb03c707/6bc3bb16-5f7d-4c57-8c43-5357cd628eb9.jpg" length="0" type="image/jpeg"/><content:encoded><![CDATA[<hr />
<p>📝 <em>Note: This post was edited with AI assistance for clarity and structure. The system design, implementation decisions, and technical thinking are entirely my own.</em></p>
<hr />
<h2>TL;DR</h2>
<p>Built a full internal admin platform — health dashboards, approval workflows, RBAC, query consoles, batch job management — using Spring Boot, Thymeleaf, and HTMX. Single deployable JAR. Zero React.</p>
<ul>
<li><p>Replaced a legacy administrative interface with limited access controls and no audit trail with a dedicated, properly secured application.</p>
</li>
<li><p>Chose HTMX over React for three concrete reasons: backend team skillset, single deployable requirement, and internal dependency approval overhead. AI made HTMX + Tailwind the obvious choice — Thymeleaf templates are straightforward to prompt for, and we went from zero to working prototype faster than we could have set up a React project.</p>
</li>
<li><p>Used <code>hx-trigger</code> polling for live health dashboards, partial swaps for search, and modals for Prometheus snapshots.</p>
</li>
<li><p>Built a config approval workflow with a git-like diff view — JSON CLOB deserialized against the same Java class for validation.</p>
</li>
<li><p>Layered custom RBAC on top of SSO using Spring Security's built-in authority model.</p>
</li>
<li><p>Discovered (the hard way) that DOM ID collisions are HTMX's scaling gotcha — solved with namespace conventions on Thymeleaf fragments.</p>
</li>
</ul>
<hr />
<h2>Introduction</h2>
<p>When we migrated a backend service from a legacy Java 8 EJB monolith to modern Spring Boot, we inherited a problem that had nothing to do with the migration itself:</p>
<p>A legacy administrative interface secured by a single shared password known to the entire team.</p>
<p>No audit trail. No per-user accountability. No roles. Just a URL and a password in an internal wiki.</p>
<p>When the new service went live, we decided we weren't dragging that interface along. We'd build a dedicated internal admin platform — properly secured, decoupled from the application it managed, and extensible enough to absorb future operational needs.</p>
<p>What started as a simple tracking UI grew into:</p>
<ul>
<li><p><strong>Health check dashboard</strong> — live status for dozens of backend services and their dependencies, with Prometheus/Micrometer metrics</p>
</li>
<li><p><strong>Audit log viewer</strong> — searchable by record ID, live lifecycle status</p>
</li>
<li><p><strong>Query console</strong> — prebuilt debugging queries for non-engineering teams</p>
</li>
<li><p><strong>Spring Batch job management</strong> — trigger, monitor, and inspect batch job runs</p>
</li>
<li><p><strong>Config management with approval workflows</strong> — diff views, async approval, RBAC-gated persistence</p>
</li>
<li><p><strong>User management</strong> — role assignment, internal auth integration, server-side integrity-validated RBAC changes</p>
</li>
</ul>
<hr />
<h2>Why Not React?</h2>
<p>Before describing what we built, it's worth being direct about the decision not to reach for React or Vue.</p>
<p>React was never seriously considered. Here's the actual reasoning:</p>
<p><strong>Team skillset.</strong> We're a backend team. Java, Spring, SQL, distributed systems. Nobody had strong React experience, and a migration project is not the right moment to introduce a framework none of us know well.</p>
<p><strong>Single deployable.</strong> A React SPA means a client-side application with its own build pipeline, deployment target, and hosting, talking to a Spring Boot backend over JSON APIs. That's two things to deploy, two things to monitor, two things to break at 2am. We wanted one JAR that runs with <code>java -jar</code>.</p>
<p><strong>Internal dependency overhead.</strong> On internal systems, adding new libraries isn't always frictionless. A typical React project can pull in hundreds of transitive npm packages. Getting security approval for all of them — especially those with known CVEs — is a real process that takes real time. HTMX is a single JavaScript file with no transitive dependencies. That matters operationally.</p>
<p><strong>No JSON translation layer.</strong> With Thymeleaf, the server renders HTML directly. With React, the server renders JSON, the client consumes it, and the client renders HTML. For an internal dashboard that displays operational data and executes actions, that translation layer adds complexity without adding value.</p>
<p>The question wasn't "HTMX vs React." It was: does the complexity React brings actually serve our requirements? The answer was no.</p>
<p><strong>AI as a deciding factor.</strong> One thing worth calling out explicitly: AI-assisted development made HTMX + Tailwind an even clearer choice. Thymeleaf templates are plain HTML with attributes — easy to describe, easy to prompt for, and the output is immediately readable and debuggable. We went from zero to a working prototype faster than we could have bootstrapped a React project. With React, you're prompting for components, hooks, state management, and build config. With Thymeleaf + HTMX, you're prompting for HTML. The feedback loop is tighter.</p>
<table>
<thead>
<tr>
<th>Concern</th>
<th>React + Spring Boot</th>
<th>Thymeleaf + HTMX</th>
</tr>
</thead>
<tbody><tr>
<td>Deployables</td>
<td>2 (client + server)</td>
<td>1</td>
</tr>
<tr>
<td>Team ramp-up</td>
<td>High</td>
<td>Low (mostly HTML)</td>
</tr>
<tr>
<td>Dependency footprint</td>
<td>Large (npm tree)</td>
<td>Minimal</td>
</tr>
<tr>
<td>Spring Security integration</td>
<td>Manual (CORS, JWT)</td>
<td>Native</td>
</tr>
<tr>
<td>Time to first working prototype</td>
<td>Days</td>
<td>Hours</td>
</tr>
<tr>
<td>Suitable for rich client-side state</td>
<td>✅</td>
<td>❌</td>
</tr>
</tbody></table>
<hr />
<h2>Dynamic Interfaces with HTMX</h2>
<p>HTMX's model is simple: HTML attributes trigger server requests and swap fragments into the DOM. Here's where it delivered concretely.</p>
<h3>Health Dashboard — Polling Without JavaScript</h3>
<p>The health dashboard shows live status for dozens of internal services. Each card displays current health and key Prometheus metrics. Clicking a card opens a modal with a full point-in-time stats snapshot.</p>
<p>The polling setup is four attributes:</p>
<pre><code class="language-html">&lt;div id="health-grid"
     hx-get="/admin/health"
     hx-trigger="every 10s"
     hx-swap="innerHTML"&gt;
  &lt;!-- Server renders updated cards as a Thymeleaf fragment --&gt;
&lt;/div&gt;
</code></pre>
<p>Every 10 seconds, HTMX fires a GET, the server renders the updated fragment, and only that region of the DOM is replaced. No WebSockets. No SSE setup. No JavaScript.</p>
<p>The Prometheus metrics are point-in-time snapshots — a live scrape of <code>/actuator/prometheus</code> at request time. We deliberately chose not to store time-series data. For our use case ("is this service healthy right now?"), a snapshot at the moment of viewing was enough. Storing historical time-series would have introduced infrastructure we didn't need.</p>
<p>The modal for drill-down stats follows the same pattern — <code>hx-get</code> on the card element, <code>hx-target</code> pointing at a modal container, server returns a rendered fragment.</p>
<h3>Server-Driven Search</h3>
<p>The query console — used by non-engineering teams to look up records — has a search input that filters results in place as you type:</p>
<pre><code class="language-html">&lt;input type="text"
       name="query"
       hx-get="/admin/console/search"
       hx-trigger="input changed delay:400ms"
       hx-target="#results-table"
       hx-swap="innerHTML"
       placeholder="Search..."&gt;
</code></pre>
<p>Debounced, server-driven search. The results table updates without a page reload. Four attributes, no JavaScript event handlers.</p>
<p>The audit log viewer works differently — it's primarily a developer debugging tool. You enter a specific record ID and retrieve the full lifecycle event trace for that record. No live filtering; a deliberate lookup. HTMX handles the form submission and swaps the result panel in place, but the interaction model is submit-and-display rather than type-and-filter.</p>
<p>This pattern — debounced input triggering a partial server render — is where HTMX makes the "mostly static server-rendered site" feel genuinely interactive.</p>
<hr />
<h2>Config Management with Approval Workflows</h2>
<p>Configuration changes — application parameters and feature flags — go through a two-step async flow:</p>
<ol>
<li><p>A <strong>requestor</strong> submits a proposed config change via the UI</p>
</li>
<li><p>An <strong>approver</strong> reviews the diff and approves or rejects it at their convenience</p>
</li>
</ol>
<p>The interesting implementation detail: we store config state as a JSON CLOB in the database, serialized from the same Java class that represents the config at runtime.</p>
<p>When an approver reviews a pending change, we deserialize both the current and proposed config, diff them, and render the result as a structured diff view:</p>
<pre><code class="language-plaintext">  feature.rateLimitEnabled:  true
- feature.maxRequestsPerMin: 100
+ feature.maxRequestsPerMin: 250
  feature.retryOnFailure:    true
</code></pre>
<p>On approval, the proposed JSON is deserialized back against the config class, validated, and persisted. The same class handles serialization and deserialization — no schema drift, no separate migration scripts for config format changes.</p>
<p>We deliberately did not build live UI updates for approval status. When something gets approved, the requestor finds out via internal notification. The HTMX live-update complexity wasn't justified for a workflow that operates on the timescale of minutes-to-hours, not seconds.</p>
<hr />
<h2>Authentication and Authorization</h2>
<h3>Internal Auth + Custom RBAC</h3>
<p>We integrated with the organization's internal authentication system — users log in with the same credentials they use across internal tooling. The shared password was gone on day one.</p>
<p>The internal auth system handled authentication cleanly. Authorization was a different story.</p>
<p>Its role model was too coarse-grained for what we needed. It could tell us who someone was, not what they were allowed to do inside our specific dashboard. We layered our own RBAC on top using Spring Security's built-in authority model.</p>
<p>Users can hold multiple roles simultaneously. Roles map to Spring Security authorities, which gives us <code>sec:authorize</code> in Thymeleaf templates:</p>
<pre><code class="language-html">&lt;!-- Only rendered for users with the APPROVER role --&gt;
&lt;button sec:authorize="hasRole('APPROVER')"
        hx-post="/admin/config/approve"
        hx-target="#approval-status"&gt;
  Approve Change
&lt;/button&gt;
</code></pre>
<p>And <code>@PreAuthorize</code> on controller methods for actual enforcement (template visibility alone is never sufficient):</p>
<pre><code class="language-java">@PostMapping("/config/approve")
@PreAuthorize("hasRole('APPROVER')")
public String approveConfigChange(...) {
    // ...
}
</code></pre>
<p>Privileged roles — approvers, admins — are pinned to specific credentials managed at the server level. RBAC changes are validated using server-side authorization and integrity checks before any role modification takes effect.</p>
<hr />
<h2>DOM ID Collisions at Scale</h2>
<p>As the codebase grew — more fragments, more interactive regions, more reused components — a class of bugs started appearing that were time-consuming to track down: event listeners firing multiple times, interactions behaving unpredictably, state leaking between sections of the page.</p>
<p>The root cause was straightforward once identified: ID collisions across Thymeleaf fragments.</p>
<p>When multiple instances of the same fragment exist on a page — a service health card rendered for dozens of services, for example — and each fragment contains <code>id="status-badge"</code> or <code>id="refresh-btn"</code>, the DOM behaves unpredictably. The browser doesn't enforce ID uniqueness; it quietly picks one and ignores the rest. HTMX targets by ID, so the wrong element gets updated.</p>
<p>The fix is a namespacing convention: propagate a context identifier from the calling template into every fragment, and use it as the dynamic suffix of every element ID.</p>
<pre><code class="language-html">&lt;!-- Caller passes a context ID --&gt;
&lt;div th:replace="~{fragments/service-card :: card(serviceId=${service.id})}"&gt;&lt;/div&gt;

&lt;!-- Fragment namespaces all IDs using that context --&gt;
&lt;div th:fragment="card(serviceId)"&gt;
  &lt;div th:id="'status-' + ${serviceId}"&gt;...&lt;/div&gt;

  &lt;button th:id="'refresh-' + ${serviceId}"
          th:attr="hx-get='/admin/health/' + ${serviceId},
                   hx-target='#status-' + ${serviceId}"&gt;
    Refresh
  &lt;/button&gt;
&lt;/div&gt;
</code></pre>
<p>Static prefix, dynamic suffix. Safe fragment reuse, predictable HTMX targeting.</p>
<p>This is the discipline that React's component model enforces by default through scoped rendering. With HTMX and Thymeleaf, you enforce it yourself — as a team convention established early, before the codebase is large enough to make retroactive fixes painful.</p>
<hr />
<h2>When This Stack Makes Sense</h2>
<p>For an internal tool on a backend team, the calculus is clear: one deployable, native Spring Security integration, no build pipeline, and you ship faster. With AI assistance, the gap widens further — Thymeleaf templates are plain HTML that's easy to prompt for and immediately readable. By the time a React project is bootstrapped, you've already shipped v1.</p>
<p>The threshold where we'd reconsider:</p>
<table>
<thead>
<tr>
<th>Requirement</th>
<th>Recommendation</th>
</tr>
</thead>
<tbody><tr>
<td>Display data, execute actions, enforce permissions</td>
<td>HTMX + Thymeleaf</td>
</tr>
<tr>
<td>Backend team, single deployable, fast iteration</td>
<td>HTMX + Thymeleaf</td>
</tr>
<tr>
<td>Rich client-side state (drag-and-drop, collaborative editing)</td>
<td>React / Vue</td>
</tr>
<tr>
<td>Complex multi-step wizard with branching client-side logic</td>
<td>React / Vue</td>
</tr>
<tr>
<td>Dedicated frontend team, component reuse at scale</td>
<td>React / Vue</td>
</tr>
</tbody></table>
<p>The shared password is gone. The legacy interface is gone. And we never had to write a Redux reducer.</p>
<hr />
<h2>Key Takeaways</h2>
<ul>
<li><p><strong>Single deployable wins for internal tools.</strong> Two servers means two failure domains, two deployment pipelines, and double the on-call surface area.</p>
</li>
<li><p><strong>HTMX's model is</strong> <code>hx-get</code> <strong>+</strong> <code>hx-trigger</code> <strong>+</strong> <code>hx-swap</code><strong>.</strong> Most interactive requirements — polling, search, modals, in-place updates — reduce to these three attributes.</p>
</li>
<li><p><strong>AI + HTMX is a fast prototyping stack.</strong> Thymeleaf templates are plain HTML — easy to describe, easy to prompt for, and immediately debuggable. The feedback loop is significantly tighter than with component-based frameworks.</p>
</li>
<li><p><strong>Template-level visibility (</strong><code>sec:authorize</code><strong>) is never sufficient.</strong> Always enforce authorization at the controller layer with <code>@PreAuthorize</code>. UI hiding is UX, not security.</p>
</li>
<li><p><strong>Store config as a serialized domain object.</strong> Deserializing proposed changes against the same Java class at approval time means the validation layer and the storage layer share a single source of truth — no schema drift.</p>
</li>
<li><p><strong>Establish a fragment ID namespacing convention early.</strong> DOM ID collisions are HTMX's main scaling pitfall. Dynamic suffixes from context identifiers keep fragment reuse safe and HTMX targeting predictable.</p>
</li>
<li><p><strong>React is the right tool for rich client-side state.</strong> It's the wrong default for every web interface.</p>
</li>
</ul>
]]></content:encoded></item><item><title><![CDATA[“HTTP Signatures in Real-Time Payments” (detailed system design deep-dive)]]></title><description><![CDATA[📝 Note: This post was edited with AI assistance for clarity and structure. The system design, implementation decisions, and technical thinking are entirely my own.

TL;DR

Designed a dual-signature m]]></description><link>https://bhavansh.hashnode.dev/http-signatures-in-real-time-payments</link><guid isPermaLink="true">https://bhavansh.hashnode.dev/http-signatures-in-real-time-payments</guid><category><![CDATA[HTTP-signatures]]></category><dc:creator><![CDATA[Bhavansh Gupta]]></dc:creator><pubDate>Sat, 27 Jun 2026 04:41:46 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/5f367218877a013acb03c707/1c76f7e7-b350-444e-9a73-bb6ee472ca5f.jpg" length="0" type="image/jpeg"/><content:encoded><![CDATA[<blockquote>
<p>📝 <strong>Note:</strong> This post was edited with AI assistance for clarity and structure. The system design, implementation decisions, and technical thinking are entirely my own.</p>
</blockquote>
<h2>TL;DR</h2>
<ul>
<li><p>Designed a dual-signature model for a multi-currency real-time payments system:</p>
<ul>
<li><p><strong>HTTP Signatures</strong> (RFC 9421 / draft-cavage) for in-transit integrity, authentication, and non-repudiation.</p>
</li>
<li><p><strong>HMAC</strong> persisted alongside transfer records for at-rest tamper detection.</p>
</li>
</ul>
</li>
<li><p>Eliminated mTLS, OAuth, and shared-secret signing after a systematic security requirements analysis.</p>
</li>
<li><p>Built idempotent retries with a client-generated correlation ID to prevent duplicate transfers during network timeouts.</p>
</li>
<li><p>A background integrity job re-validates persisted HMACs; mismatches halt retries and raise alerts.</p>
</li>
<li><p>Isolated currency-specific request construction behind a factory + strategy pattern, keeping core logic currency-agnostic.</p>
</li>
</ul>
<hr />
<h2>Introduction</h2>
<p>Our payments platform previously routed interbank fund transfers over traditional correspondent banking rails. While reliable, settlement could take several hours. Integrating with a real-time payments API gave us near-instant settlement, but introduced a hard security requirement:</p>
<blockquote>
<p>Every request must be authenticated, tamper-proof in transit, tamper-evident at rest, and non-repudiable.</p>
</blockquote>
<hr />
<h2>Security Requirements</h2>
<p>Before choosing an authentication mechanism, we defined what we actually needed:</p>
<ul>
<li><p><strong>Sender authentication</strong> — cryptographic proof the request originated from our service.</p>
</li>
<li><p><strong>Message integrity (in transit)</strong> — any modification to payload or headers in flight must be detectable.</p>
</li>
<li><p><strong>Integrity at rest</strong> — if a persisted transfer record is modified after the fact, we must know before retrying it.</p>
</li>
<li><p><strong>Non-repudiation</strong> — the authorizing party cannot later deny that the request was made with those exact parameters.</p>
</li>
<li><p><strong>Replay protection</strong> — a captured valid request must not be replayable.</p>
</li>
</ul>
<hr />
<h2>Why Not JWT, OAuth, or mTLS?</h2>
<table>
<thead>
<tr>
<th>Mechanism</th>
<th>Sender Authentication</th>
<th>Message Integrity</th>
<th>Non-Repudiation</th>
<th>Replay Protection</th>
</tr>
</thead>
<tbody><tr>
<td>OAuth / JWT</td>
<td>✅</td>
<td>❌</td>
<td>❌</td>
<td>❌</td>
</tr>
<tr>
<td>mTLS</td>
<td>✅</td>
<td>Transport only</td>
<td>❌</td>
<td>Transport only</td>
</tr>
<tr>
<td>HMAC Request Signing</td>
<td>✅</td>
<td>✅</td>
<td>❌</td>
<td>Implementation-specific</td>
</tr>
<tr>
<td>HTTP Signatures</td>
<td>✅</td>
<td>✅</td>
<td>✅*</td>
<td>✅*</td>
</tr>
</tbody></table>
<blockquote>
<ul>
<li>Requires asymmetric keys for non-repudiation and timestamps/nonces with server-side validation for replay protection.</li>
</ul>
</blockquote>
<h3>Key Eliminations</h3>
<ul>
<li><p><strong>mTLS</strong> protects the transport channel, not the message. Once a request is stored in a database, mTLS has done its job and is gone.</p>
</li>
<li><p><strong>HMAC</strong> requires a shared secret. Since both parties can generate valid signatures, it does not provide non-repudiation.</p>
</li>
<li><p><strong>JWT and OAuth</strong> operate at the token level, not the HTTP request level. They authenticate principals but do not protect individual headers, request targets, or payload integrity.</p>
</li>
</ul>
<p>HTTP Signatures (<code>draft-cavage-http-signatures-12</code>, later standardized as RFC 9421) was the only mechanism that met all our requirements without additional round-trips or a separate verification service.</p>
<hr />
<h2>How HTTP Signatures Work</h2>
<p>The specification defines a <strong>signing string</strong> — a canonical, newline-separated concatenation of selected HTTP components — which is then signed and base64-encoded into the request headers.</p>
<p>Here's what the signing string looks like for a typical transfer request — this exact string is what gets signed with your private key:</p>
<h3>Signing String</h3>
<pre><code class="language-plaintext">(request-target): post /v1/transfers
(created): 1718300000
host: api.example.com
digest: SHA-256=X48E9qOokqqrvdts8nOJRJN3OWDUoyWxBf7kbu9DBPE=
content-length: 142
x-nonce: a3f9c21b-7e84-4d12-b001-9e5c3d8f0a72
</code></pre>
<h3>Authorization Header</h3>
<pre><code class="language-plaintext">Authorization: Signature
  keyId="payments-service-prod",
  algorithm="hs2019",
  headers="(request-target) (created) host digest content-length x-nonce",
  signature="Base64(Signature(signing-string))"
</code></pre>
<p>The header order in the signature string is a bilateral contract with the payment provider. Our agreed canonical order is:</p>
<p><code>(request-target)</code> → <code>(created)</code> → <code>host</code> → <code>digest</code> → <code>content-length</code> → <code>x-nonce</code></p>
<h3>Component Breakdown</h3>
<table>
<thead>
<tr>
<th>Component</th>
<th>Purpose</th>
</tr>
</thead>
<tbody><tr>
<td><code>(request-target)</code></td>
<td>Prevents method or path tampering</td>
</tr>
<tr>
<td><code>(created)</code></td>
<td>Limits replay window</td>
</tr>
<tr>
<td><code>host</code></td>
<td>Prevents endpoint substitution</td>
</tr>
<tr>
<td><code>digest</code></td>
<td>Protects request body integrity</td>
</tr>
<tr>
<td><code>content-length</code></td>
<td>Prevents truncation or padding attacks (defense-in-depth)</td>
</tr>
<tr>
<td><code>x-nonce</code></td>
<td>Prevents replay of captured requests</td>
</tr>
</tbody></table>
<hr />
<h2>RFC 9421 vs <code>draft-cavage-http-signatures-12</code></h2>
<p>The mental model is nearly identical. The terminology evolved:</p>
<table>
<thead>
<tr>
<th>draft-cavage-12</th>
<th>RFC 9421</th>
</tr>
</thead>
<tbody><tr>
<td>Signing string</td>
<td>Signature input</td>
</tr>
<tr>
<td><code>(request-target)</code></td>
<td><code>@method</code> + <code>@target-uri</code> derived components</td>
</tr>
<tr>
<td><code>(created)</code> / <code>(expires)</code></td>
<td>Same concept with cleaner specification language</td>
</tr>
<tr>
<td>Nonce</td>
<td>Explicitly covered in security considerations</td>
</tr>
</tbody></table>
<p>The investment in understanding <code>draft-cavage-http-signatures-12</code> transfers directly to RFC 9421. The model remains the same; the specification simply tightened the details.</p>
<p>With that context established, here's how both mechanisms fit together in practice.</p>
<hr />
<h2>System Architecture</h2>
<p>We use two distinct signing mechanisms serving two different concerns.</p>
<h3>1. HTTP Signatures (In Transit)</h3>
<p>The outbound request to the payment provider is signed using HTTP Signatures with an <strong>asymmetric keypair</strong> (e.g., Ed25519). The private key signs the signing string; the provider verifies with our public key. This provides non-repudiation — we cannot later deny having generated the request. The signature covers:</p>
<ul>
<li><p><code>(request-target)</code></p>
</li>
<li><p><code>(created)</code></p>
</li>
<li><p><code>digest</code></p>
</li>
<li><p><code>x-nonce</code></p>
</li>
<li><p>agreed headers</p>
</li>
</ul>
<p>This protects the request while it traverses networks and intermediary infrastructure.</p>
<h3>2. HMAC Stored in the Database (At Rest)</h3>
<p>The business intent is independently signed with an <strong>HMAC</strong> (symmetric, shared only between our signing and verification services) and persisted alongside the transfer record.</p>
<pre><code class="language-plaintext">{
  sourceIdentifier,
  destinationIdentifier,
  amount,
  currency,
  timestamp
}
</code></pre>
<p>This HMAC serves as an at-rest tamper-detection mechanism and is entirely separate from the HTTP Signature. It cannot provide non-repudiation (the verifying side also knows the key), but that’s not its job — it exists solely to detect unauthorized modification after the fact.</p>
<hr />
<h2>Request Lifecycle</h2>
<pre><code class="language-plaintext">Internal Transfer Initiator
     │
     │  Input: Source ID, Destination ID, Amount, Currency
     ▼
  Payments Service
     │
     ├─► Resolve payment details
     │
     ├─► Compute HMAC over
     │    {source, destination, amount, currency, timestamp}
     │
     ├─► Persist
     │    {transfer payload + HMAC}
     │
     ├─► Construct outbound request
     │    (fresh timestamp, nonce, correlation ID)
     │
     ├─► Sign outbound request via HTTP Signatures
     │
     └─► POST to payment provider
          │
          ├─ 2xx → update state
          ├─ 4xx → fail request
          └─ timeout / transient failure
                 → asynchronous retry workflow
</code></pre>
<hr />
<h2>Why Sign the Input Instead of the Outbound Request?</h2>
<p>During retries, the outbound request is reconstructed:</p>
<ul>
<li><p>New timestamp</p>
</li>
<li><p>New nonce</p>
</li>
<li><p>New correlation identifiers</p>
</li>
</ul>
<p>These changes are legitimate.</p>
<p>What must never change is the business intent:</p>
<blockquote>
<p>Who is transferring what amount to whom.</p>
</blockquote>
<p>Signing the input at receipt cryptographically locks the business intent at the moment of authorization.</p>
<p>Before every retry:</p>
<ol>
<li><p>Recompute HMAC from persisted values.</p>
</li>
<li><p>Compare with stored HMAC.</p>
</li>
<li><p>If they differ, halt processing and raise an operational alert.</p>
</li>
</ol>
<p>A modified amount or substituted beneficiary never reaches the payment provider.</p>
<hr />
<h2>Retry Design: Avoiding Double Posts</h2>
<p>A timeout during a real-time payment operation is one of the most dangerous states in fintech. Retrying blindly risks duplicate transfers.</p>
<h3>Idempotency</h3>
<p>The payment provider supports idempotent requests through a client-generated correlation identifier.</p>
<p>The service:</p>
<ol>
<li><p>Generates the identifier during the first attempt.</p>
</li>
<li><p>Persists it alongside the transfer record.</p>
</li>
<li><p>Reuses the same identifier for every retry.</p>
</li>
</ol>
<p>This guarantees duplicate submissions resolve to the original transaction rather than creating additional transfers.</p>
<p>The HMAC verification gate serves a dual purpose:</p>
<ul>
<li><p>security validation, and</p>
</li>
<li><p>correctness validation that retries are reconstructing the identical business intent.</p>
</li>
</ul>
<hr />
<h2>Multi-Currency Request Construction</h2>
<p>A single transfer endpoint often supports multiple currencies, but field requirements vary considerably:</p>
<ul>
<li><p>Fields mandatory for currency A may be optional or invalid for currency B.</p>
</li>
<li><p>Corridor-specific requirements may apply only to certain regions.</p>
</li>
<li><p>Some fields must be explicitly omitted for specific currency pairs.</p>
</li>
</ul>
<p>A branching approach quickly becomes unmaintainable:</p>
<pre><code class="language-java">if (currency == USD) { ... }
else if (currency == GBP) { ... }
else if (currency == EUR) { ... }
</code></pre>
<p>Instead, we implemented a factory and generator pattern:</p>
<pre><code class="language-java">public interface PaymentRequestGenerator {
    PaymentRequest generate(TransferInput input);
}

public class UsdPaymentRequestGenerator
        implements PaymentRequestGenerator { ... }

public class GbpPaymentRequestGenerator
        implements PaymentRequestGenerator { ... }

public class EurPaymentRequestGenerator
        implements PaymentRequestGenerator { ... }

public class PaymentRequestGeneratorFactory {
    public static PaymentRequestGenerator
            forCurrency(Currency currency) {

        return switch (currency) {
            case USD -&gt; new UsdPaymentRequestGenerator();
            case GBP -&gt; new GbpPaymentRequestGenerator();
            case EUR -&gt; new EurPaymentRequestGenerator();
            default -&gt;
                throw new UnsupportedCurrencyException(currency);
        };
    }
}
</code></pre>
<p>The signing, retry, and tamper-detection layers operate entirely on <code>TransferInput</code> and remain currency-agnostic.</p>
<p>Adding a new payment corridor becomes a single new generator implementation with no changes to core logic.</p>
<hr />
<h2>Key Takeaways</h2>
<ul>
<li><p>HTTP Signatures provide authentication, message integrity, and—when asymmetric keys are used—non-repudiation in a stateless mechanism.</p>
</li>
<li><p>Replay protection requires timestamps and nonce validation; neither alone is sufficient.</p>
</li>
<li><p>Sign what you receive, not what you send. Outbound requests legitimately evolve across retries; business intent must not.</p>
</li>
<li><p>Treat in-transit signatures and at-rest integrity verification as separate concerns. They answer different threat questions.</p>
</li>
<li><p>A factory plus generator pattern cleanly isolates currency-specific rules and keeps core logic currency-agnostic.</p>
</li>
<li><p>Header ordering is a bilateral contract between producer and consumer. Establish it before writing code.</p>
</li>
</ul>
]]></content:encoded></item></channel></rss>