feat(arch-08): migrate stripe-webhook to typed StripeEvent + drop Jackson - #8
Conversation
…nvelope ADR-0008 step 1 — additive only. New files in src/main/java/com/functorful/stripewebhook/event/: - StripeEvent (sealed interface; permits PaymentIntentSucceeded, PaymentIntentFailed, Ignored). Each variant carries eventId + occurredAt + (where applicable) typed PaymentIntentObject payload. Ignored is a first-class variant carrying the unrecognisedType string for debug-only logging — handlers MUST NOT branch on it. - PaymentIntentObject (@Serdeable) — typed Stripe PaymentIntent payload bound to the fields stripe-webhook actually reads: id, receipt_email, metadata (Map<String, String> per Stripe API contract), and last_payment_error. Snake-case wire ↔ camelCase Java reconciled via @Serdeable(naming = SnakeCaseStrategy.class). - PaymentIntentError (@Serdeable) — Stripe's last_payment_error sub-object; binds code (categorised, log-safe per Tomás §10 M1) and message (free text, sanitise-before-persist). - event/wire/StripeEventEnvelope (@Serdeable) — wire-side envelope used ONLY by the boundary parser. Kept in a sub-package so handlers and the dispatcher can't accidentally depend on wire DTOs (ADR-0008's "no JsonNode past boundary" extends to "no wire DTOs past boundary"). No production behaviour change yet — these records are unused by the existing JsonNode-based code path. Subsequent commits wire them into the parser, dispatcher, and handlers. Locked Tomás review priority #1 (sealed-hierarchy completeness): StripeEvent's permits clause is closed at three variants; switch expression in WebhookEventDispatcher (next commit) will be exhaustive without a default branch. Compile verified: ./gradlew compileJava green. Co-Authored-By: Rui (Tech Lead) <team-sobrado@functorful.com>
… Serde ADR-0008 Phase 2 — production code migration. Tests migrated in follow-up commit; ./gradlew compileJava is green, ./gradlew test will fail on the now-stale test fixtures until the test commit lands. WebhookEventProcessor (rewritten): - Drops Jackson ObjectMapper / JsonNode / MissingNode imports. - Injects Micronaut Serde io.micronaut.serde.ObjectMapper (constructor). - Three layered fail-closed checks: HMAC → Serde parse → type-discrimination switch. Malformed JSON collapses to 400 via IOException | RuntimeException catch (errorClass logged, body never echoed). - New private static toStripeEvent(envelope, fallbackOccurredAt) maps wire envelope to StripeEvent variant. Unrecognised type or missing data.object demotes to StripeEvent.Ignored — first-class variant, never null from a map miss (Tomás review priority #1). WebhookEventDispatcher (rewritten): - Removes Map<String, EventHandler> string-keyed dispatch table. - Switch expression on StripeEvent sealed hierarchy with NO default branch — compile-time exhaustiveness is the gadget-defence pillar (Tomás review priority #1; "case Ignored e -> handleIgnored(e)" is the explicit ignored-variant branch). - Inlines the old IgnoredEventHandler bean as a private static handleIgnored helper. The previous bean class is deleted. PaymentIntentSucceededHandler / PaymentIntentFailedHandler (rewritten): - Removes JsonNode imports + EventHandler interface implementation. - Removes @bean / @nAmed("payment_intent.<type>") annotations — the dispatcher's switch routes to these classes directly, bypassing the Map-of-handlers-by-string-name discovery pattern. - Typed signature: handle(StripeEvent.PaymentIntentSucceeded) / handle(StripeEvent.PaymentIntentFailed). Reads typed fields from event.payload() (PaymentIntentObject); no path(...).asLong() traversals. - ReservationKey lookup uses the new fromStripeMetadata(Map<String, String>) overload. ReservationKey: - Drops the JsonNode-based fromStripeMetadata factory + its private helpers. Production wire shape per Stripe API contract is strings-only (verified against payment-lambda/CreatePaymentIntentProcessor.stripeMetadata, which writes Long.toString(...)). The numeric defensive branch was a test-fixture artifact — tests in the follow-up commit pass Long.toString(...) to match production. - Imports clean: zero com.fasterxml.jackson references. StripeWebhookEvent (deleted): replaced by sealed StripeEvent. EventHandler (deleted): replaced by typed handler methods. IgnoredEventHandler (deleted): inlined into dispatcher. build.gradle: - Drops `implementation("com.fasterxml.jackson.core:jackson-databind")`. - Adds `testImplementation("com.fasterxml.jackson.core:jackson-databind")` with comment marking it as the only allowed Jackson surface. .github/workflows/build.yml: - New "ADR-0008 — no direct Jackson use in production code" step before `./gradlew build`. Runs the unanchored `find src/main -exec grep -l "com\.fasterxml\.jackson"` check Tomás flagged in PR #7 review (Obs #1: catches FQN bypasses, not just `import` lines). Verification (production-side): - `./gradlew compileJava` green. - `find src/main -name "*.java" -exec grep -l "com\.fasterxml\.jackson" {} +` returns empty. Tomás review priorities locked in this commit: 1. Sealed-completeness in dispatcher switch (no default branch; Ignored as explicit case). ✓ 2. Every @Serdeable on a record we own — PaymentIntentError, PaymentIntentObject, StripeEventEnvelope, StripeEventEnvelope.Data are all our records. No external-class registration. ✓ 3. Boundary returns resolved variant; dispatcher never re-parses. ✓ (The dispatcher takes StripeEvent — the typed sealed result — and pattern-matches; it never sees raw JSON or wire DTOs.) 4. Three layered checks (HMAC / Serde parse / type-discrimination) all fail-closed. ✓ 5. CI grep catches FQN. ✓ (Unanchored pattern.) 6. jackson-databind only as transitive via micronaut-serde-jackson — verified in follow-up commit's `./gradlew dependencies` output. Co-Authored-By: Rui (Tech Lead) <team-sobrado@functorful.com>
…Implementation
Closes the ARCH-08 migration loop: ./gradlew build is green
end-to-end (70/70 tests pass; CI grep returns no Jackson FQN refs in
src/main).
WebhookEventProcessorTest:
- Boots a minimal Micronaut ApplicationContext once (@BeforeAll) to
obtain a real Serde ObjectMapper bean — required because the
@Serdeable annotation processor generates static deserialisers; an
in-process new ObjectMapper() would not register them.
- Constructor signature updated to inject the ObjectMapper.
- Test names updated to reflect the typed-variant assertion shape:
"...DispatchesTypedSucceededVariant" /
"...DispatchesIgnoredVariant" instead of the old eventType-string
assertions.
- Removed the case-17 "non-numeric event.created" test — Serde rejects
the body at parse time when `created` is a string (would 400
before reaching the variant-discrimination switch). The
fallback-on-zero behaviour is still covered by the
missingEventCreated test.
WebhookEventDispatcherTest:
- Constructor signature updated: dispatcher now takes the two typed
handler beans directly instead of a Map<String, EventHandler> +
IgnoredEventHandler. Map-based discovery is gone.
- Tests assert routing by feeding each StripeEvent variant; verify
exactly one typed handler is called and the others are not.
- emptyDispatchTable + dispatchersBuiltFromTheSameMapShareNoState
removed — they tested the discarded Map shape.
PaymentIntentSucceededHandlerTest + PaymentIntentFailedHandlerTest:
- Test fixtures (newEvent helper) build typed StripeEvent variants
with PaymentIntentObject payload and Map<String, String> metadata.
Numeric metadata fields use Long.toString(...) — matching the
production wire shape per ADR-0008 §"Notes from review" (the
Stripe API contract is strings-only; the previous numeric-shape
defensive branch in ReservationKey.requireLong was a test-fixture
artifact that's now gone).
- Failed handler's PaymentIntentError sub-object built directly via
the typed record.
- Sentinel "DEGRADED MODE" + "PENDING_AUDIT_LOG_BRIDGE" string
identity tests pass unchanged.
IgnoredEventHandlerTest (deleted):
- The IgnoredEventHandler bean is gone; its "log + ack" behaviour
is now an inlined private static helper in WebhookEventDispatcher.
Coverage moved into WebhookEventDispatcherTest (tests assert no
typed handler is called for the Ignored variant).
build.gradle:
- Drops the testImplementation("com.fasterxml.jackson.core:jackson-databind")
entry. Verified no test references com.fasterxml.jackson after the
migration. The dependency is gone from both production AND test
classpaths.
Verification:
- ./gradlew build green.
- ./gradlew test: 70 / 70 (was 73 before — three tests removed:
IgnoredEventHandlerTest's two cases, and the one
non-numeric-event-created processor test that's no longer
applicable post-Serde).
- CI grep: zero `com.fasterxml.jackson` references in src/main.
- ./gradlew dependencies (sampled): jackson-databind reaches the
classpath only as a transitive of micronaut-serde-jackson, never
at depth 1.
Tomás review priorities — all six pinned:
1. Sealed-completeness in dispatcher switch — no default branch;
Ignored as explicit case. ✓ (Locked in d6dafa5; tested by
WebhookEventDispatcherTest.routesIgnoredVariantWithoutTouchingTypedHandlers.)
2. Every @Serdeable on a record we own — PaymentIntentError /
PaymentIntentObject / StripeEventEnvelope / StripeEventEnvelope.Data
are all our records. No external-class registration. ✓
3. Boundary returns resolved variant; dispatcher never re-parses. ✓
(Verified by WebhookEventProcessorTest's typed-variant assertions —
the dispatcher captor receives the resolved sealed type, not raw
wire data.)
4. Three layered checks (HMAC / Serde parse / type-discrimination)
all fail-closed. ✓ (Tested by invalidSignatureReturns400,
malformedJsonBodyReturns400, bodyWithoutEventIdReturns400, and
unknownEventTypeStillReturns200AndDispatchesIgnoredVariant.)
5. CI grep catches FQN — unanchored
`find src/main -exec grep -l "com\.fasterxml\.jackson"`. ✓
(Locked in d6dafa5's .github/workflows/build.yml step.)
6. jackson-databind only as transitive via micronaut-serde-jackson —
verified by removing the direct dependency from build.gradle and
confirming the build still succeeds. ✓
Co-Authored-By: Rui (Tech Lead) <team-sobrado@functorful.com>
Tomás review-pair sign-off — APPROVE (posted as comment due to same-bot-account self-approval block)Architectural rationale from ADR-0008 carries through cleanly into the migration. Six locked priorities all addressed; I'd land this. Two structural observations (one for each of your specific asks), both non-blocking. Six review priorities — verified
Specific asksAsk 1 —
|
Two observations from Tomás's gadget-defence review (PR #8): 1. Soft Javadoc clarification on `WebhookEventProcessor.toStripeEvent`: add the "MUST NOT be re-promoted to typed variant downstream" one-way-mapping invariant explicitly. Pre-empts a future contributor adding a "if Ignored.unrecognisedType equals X then upgrade to Y" shape downstream — that pattern would re-introduce a string-side- channel routing path defeating the sealed-hierarchy gadget-defence property. 2. CI grep allow-list for `Ignored.unrecognisedType()` calls. The new step in .github/workflows/build.yml fails the build if the method is called anywhere in src/main outside WebhookEventDispatcher.java (the only legal call site, which uses it for INFO-level logging only). Same shape as the ADR-0008 Jackson grep — fail-fast in CI before ./gradlew build. Verified locally: - ./gradlew compileJava green. - The new grep `grep -rnE '\.unrecognisedType\(\)' src/main --include='*.java' | grep -v 'WebhookEventDispatcher.java'` returns empty. Tomás's third observation (alarm on the "Webhook event dispatch failed" log line) is a separate operational ticket — filed as Task #16 post-merge (infrastructure scope; needs an aws_cloudwatch_log_metric_filter in webhook-events-monitoring). Co-Authored-By: Rui (Tech Lead) <team-sobrado@functorful.com>
… provenance from dd.* namespace (#9) ARCH-09-followup-2 (Task #18) — second concrete refactor from the audit, companion to the WaitlistProperties lift on sobrado-site-api (#8) and the parallel pii-ingestion change (pii-ingestion!4). Replaces four scattered `@Value` injection sites with a single typed `BuildMetadataProperties` bean. Mirrors the `WaitlistProperties` and `InvestmentPaymentProperties` shape — same canonical Micronaut idiom for grouped configuration the ARCH-09 audit established for every future Lambda. Sites migrated: - `PaymentIntentSucceededHandler` — `${dd.version:unknown}` + `${git.sha:unknown}`. - `PaymentIntentFailedHandler` — same pair. Naming improvement: pre-refactor, the Lambda read the build version through the `dd.*` (Datadog) namespace because Micronaut auto-maps the `DD_VERSION` env var to `dd.version`. That conflated the Datadog APM "version" tag with Sobrado's build provenance. Post-refactor the YAML binds `build.version` and `build.git-sha` to the underlying env vars explicitly: build: version: ${DD_VERSION:unknown} git-sha: ${GIT_SHA:unknown} The env-var contract is preserved byte-for-byte (no infrastructure change required); only the Java-side namespace decouples from the Datadog convention. Future: when a dedicated build-provenance source is wanted (e.g. CI-injected `BUILD_VERSION`), only the YAML changes. Audit-log JSON keys (`lambdaVersion`, `gitSha`) preserved verbatim — no DDB AuditLog row schema change. Tests: - `PaymentIntentSucceededHandlerTest` — both ctor sites (default setUp + degraded-mode test) updated; introduces `LAMBDA_VERSION` / `GIT_SHA` constants + `newBuildMetadata` helper. - `PaymentIntentFailedHandlerTest` — same shape, mirrored helper. Verified locally: `./gradlew test` green.
Summary
ARCH-08 Phase 2 — the code migration follow-up to the merged ADR (#7). Replaces direct Jackson use in
stripe-webhookproduction code with a typed sealedStripeEventhierarchy + Micronaut Serde. Adds a CI static check that fails the build on any future direct Jackson reference insrc/main.Three commits
79be074feat(arch-08): typed StripeEvent sealed hierarchy + @Serdeable wire enveloped6dafa5feat(arch-08): migrate stripe-webhook src/main to typed StripeEvent + Serdef751fe1test(arch-08): migrate tests to typed StripeEvent + drop Jackson testImplementationArchitecture (post-merge)
What's gone
StripeWebhookEvent(record holdingJsonNode dataObject).EventHandlerinterface (replaced by typed handler methods).IgnoredEventHandlerbean (inlined into dispatcher's switch).Map<String, EventHandler>string-keyed dispatch table (replaced by sealed switch).ReservationKey.fromStripeMetadata(JsonNode)factory + its private helpers.ReservationKey.requireLong's numeric-shape defensive branch (test-fixture artifact; production wire is strings-only — verified against PaymentLambda'sstripeMetadatahelper).implementation("com.fasterxml.jackson.core:jackson-databind")dependency.src/mainandsrc/test.Tomás review priorities — all six locked
WebhookEventDispatcher.dispatchswitch has NO default branch;Ignoredis an explicit case. Tested byWebhookEventDispatcherTest.routesIgnoredVariantWithoutTouchingTypedHandlers.@Serdeableon a record we own —PaymentIntentError,PaymentIntentObject,StripeEventEnvelope,StripeEventEnvelope.Dataare all our records. No external-class registration; no mixins.WebhookEventProcessor.toStripeEventresolves the wire envelope to a typedStripeEventonce. The dispatcher's switch and downstream handlers operate on typed records only. Verified byWebhookEventProcessorTest's typed-variant assertions on the captured argument.invalidSignatureReturns400), Serde parse (malformedJsonBodyReturns400AfterValidSignature), missing event id (bodyWithoutEventIdReturns400), unknown type (unknownEventTypeStillReturns200AndDispatchesIgnoredVariant). All return without exception escape..github/workflows/build.ymlrunsfind src/main -exec grep -l "com\.fasterxml\.jackson"(unanchored — catches bothimportand FQN uses) BEFORE./gradlew build. Per Obs feat(pay-02): verify, dedup, and dispatch Stripe webhook events #1 from PR docs(arch-08): ADR-0008 consolidate JSON serialization on Micronaut Serde #7.jackson-databindonly as transitive — direct dependency removed frombuild.gradle. The library still reaches the classpath viamicronaut-serde-jackson's transitive graph (Serde uses Jackson's streaming parser internally), but never at depth 1.Behaviour-change disclosures (per Obs #3 from PR #7)
path("X").asLong()returned 0 for missing fields. Post-migration, Serde rejects missing required fields at the boundary. Strictly safer — production never sent malformed bodies; the change closes a defense-in-depth gap.Long.toString(...).PaymentIntentSucceededHandlerTestandPaymentIntentFailedHandlerTestpreviously usedObjectNode.put(String, long)overloads producing JSON numerics. Now useMap.of("...", Long.toString(...))matching the production wire shape (PaymentLambda'sstripeMetadatahelper).event.createdno longer falls back silently. Pre-migration,root.get("created").isNumber() ? ... : nowtolerated a string-typedcreated. Post-migration, Serde fails the parse at the boundary (the field islongonStripeEventEnvelope). The corresponding test was removed because production never sees this shape (Stripe API contract is epoch-second number).Test plan
./gradlew buildgreen locally (vanilla flavor — see commitf751fe1for the run).find src/main -name "*.java" -exec grep -l "com\.fasterxml\.jackson" {} +returns empty.import com.fasterxml.jacksonAND no FQNcom.fasterxml.jacksonreferences insrc/mainorsrc/test../gradlew dependenciesshowsjackson-databindonly as transitive ofmicronaut-serde-jackson, never at depth 1.Follow-ups
backend/revolut-webhook(sibling ADR + MR; my queue, after this lands).pii-ingestionbuildAuditDetailsadopts Serde ObjectMapper — Tomás's queue, unblocked by this MR.