Bellight / Payments & laundry delivery
Backend case studyTwo services. One laundry platform.
Self-service payments at the washer. Pickup and delivery from your doorstep. Explore how Bellight powers both experiences.
Two services. Choose either to explore.
Different customer journeys, connected by one Bellight backend.
Choose either service above, then use Previous / Next or select a workflow node. The highlighted path, explanation and sample state update together. Nothing plays automatically.
Watch the recorded backend runKiosk payment only · Swagger, logs and database states · local stand-ins
Use the left and right arrow keys to switch slides.
Recorded from a local run with fictional data; Stripe and the ESP32 relay are local stand-ins, and setup calls ran off camera.
A kiosk payment runs end to end locally: Swagger call, signed Stripe webhook, outbox to Kafka to an MQTT coin command, status COMPLETED, and a replayed webhook ignored.
1 / 5Explore the architectureMachine payments, Uber Direct tracking and module boundaries
System overview
- Kiosk + M2 readerAndroid, Stripe TerminalStep 1, card tapStripeTerminal + Connectintent / webhook, both waysDOCKER COMPOSE ON EC2Spring Modulith apppayment, machine, orderStep 2, outbox rowDOCKER COMPOSE ON EC2MySQLorders, payments, outboxrelayDOCKER COMPOSE ON EC2Kafkamachine / order eventsmachine.eventsSpring Modulith appcoin cmdMQTT brokerTLS, QoS 1IN-STORE RELAYESP32 coin relaydrops repeated idscoin pulsesIN-STORE RELAYWashercoin line
- ESP32 coin relayStep 3, coin_doneMQTT brokercoin_doneSpring Modulith app
Other connections
- Kiosk + M2 readertoSpring Modulith app
- Customer sitetoSpring Modulith app
- Admin dashboardtoSpring Modulith app
- Spring Modulith apptoSlack, AWS, Uberalerts, couriers
View the diagram as text
Parts
- Kiosk + M2 reader (Android, Stripe Terminal): Client / UI
- Customer site (orders, saved cards): Client / UI
- Admin dashboard (machines, kiosks, retries): Client / UI
- Stripe (Terminal + Connect): External service
- Slack, AWS, Uber (alerts, OTP, couriers): External service
- Spring Modulith app (payment, machine, order): Owned service
- MySQL (orders, payments, outbox): Data store / broker
- Kafka (machine / order events): Data store / broker
- MQTT broker (TLS, QoS 1): Data store / broker
- ESP32 coin relay (drops repeated ids): Hardware
- Washer (coin line): Hardware
Connections
- Step 1: Kiosk + M2 reader → Stripe: card tap
- Stripe ↔ Spring Modulith app: intent / webhook
- Step 2: Spring Modulith app → MySQL: outbox row
- MySQL → Kafka: relay
- Kafka → Spring Modulith app: machine.events
- Spring Modulith app → MQTT broker: coin cmd
- MQTT broker → ESP32 coin relay
- ESP32 coin relay → Washer: coin pulses
- Step 3: ESP32 coin relay → MQTT broker: coin_done
- MQTT broker → Spring Modulith app: coin_done
- Kiosk + M2 reader → Spring Modulith app
- Customer site → Spring Modulith app
- Admin dashboard → Spring Modulith app
- Spring Modulith app → Slack, AWS, Uber: alerts, couriers
Module boundaries on the payment path
- StripePaymentIntentswebhookSPRING MODULITH APPpayment modulewebhooks, Stripe callssame txSPRING MODULITH APPTransactional outboxoutbox table + relayevery 3 sKafkamachine.eventslistener + DLTSPRING MODULITH APPmachine moduledispatch, coin_donetargetSPRING MODULITH APPstore moduleMQTT or coin API
Other connections
- machine moduletopayment modulein.* ports
- machine moduletoEvent registryOpsAlertRaised
- Event registrytonotificationafter commit
View the diagram as text
Parts
- Stripe (PaymentIntents): External service
- payment module (webhooks, Stripe calls): Owned service
- Transactional outbox (outbox table + relay): Background job
- Kafka (machine.events): Data store / broker
- store module (MQTT or coin API): Owned service
- machine module (dispatch, coin_done): Owned service
- Event registry (event_publication): Data store / broker
- notification (Slack, SES, SMS): Owned service
Connections
- Stripe → payment module: webhook
- payment module → Transactional outbox: same tx
- Transactional outbox → Kafka: every 3 s
- Kafka → machine module: listener + DLT
- machine module → store module: target
- machine module → payment module: in.* ports
- machine module → Event registry: OpsAlertRaised
- Event registry → notification: after commit
Pickup, return delivery & tracking
- Laundry orderconfirmed / readyorder eventsTwo trip schedulesPICKUP / DELIVERYschedule each legUber Directcourier tripsstatus webhooksStored webhook eventsdedupe, time + status checksorder eventsOrder fulfillmentpickup ≠ return delivery
- Customers & adminstrip-specific trackingtrack order, both waysTracking APIcourier, ETA, tracking link
Other connections
- Stored webhook eventstoTwo trip schedulestrip snapshot
- Tracking APIandUber Directquery trip details
- Order fulfillmenttoCustomers & adminsorder status
View the diagram as text
Parts
- Laundry order (confirmed / ready): Owned service
- Two trip schedules (PICKUP / DELIVERY): Background job
- Uber Direct (courier trips): External service
- Order fulfillment (pickup ≠ return delivery): Owned service
- Stored webhook events (dedupe, time + status checks): Data store / broker
- Customers & admins (trip-specific tracking): Client / UI
- Tracking API (courier, ETA, tracking link): Owned service
Connections
- Laundry order → Two trip schedules: order events
- Two trip schedules → Uber Direct: schedule each leg
- Uber Direct → Stored webhook events: status webhooks
- Stored webhook events → Order fulfillment: order events
- Stored webhook events → Two trip schedules: trip snapshot
- Customers & admins ↔ Tracking API: track order
- Tracking API ↔ Uber Direct: query trip details
- Order fulfillment → Customers & admins: order status
How a kiosk card payment starts the washer
Kiosk opens a payment
The kiosk asks for a self-service payment for its wash program. If that washer has an open payment from the last 4 minutes, the server reuses it; otherwise it saves a machine payment with a snapshot of the price and coin count and creates a card-present PaymentIntent with an idempotency key built from that payment.
Customer taps a card
The Stripe Terminal SDK on the kiosk processes the PaymentIntent on the Bluetooth M2 reader, and Stripe charges the card with automatic capture.
Webhook lands in the outbox
Stripe posts payment_intent.succeeded. The server checks the signature, ignores event ids it has already logged and marks the transaction captured, writing a MachinePaymentCaptured outbox row with a unique dedupe key in the same database transaction.
Outbox to Kafka to machine
Every 3 seconds the relay publishes pending rows to machine.events in order for each payment, backing off when a publish fails. The machine module's listener, with retry and dead-letter topics, marks the payment captured and starts the dispatch after the commit.
Coins go out over MQTT
The server publishes the channel, coin count, pulse timing and the payment UUID as requestId at QoS 1, then records DISPATCHED. A failed publish is retried by JobRunr after 30 seconds, up to three attempts. The ESP32 skips any requestId it has seen recently and pulses the washer's coin line.
coin_done or UNCONFIRMED
The board reports coin_done with the requestId and the coins inserted, moving the payment to COMPLETED. If no report arrives within 60 seconds, a sweeper marks it UNCONFIRMED and alerts Slack without resending coins. The kiosk polls the status and shows success once it sees DISPATCHED.
How laundry travels from home and back
Confirmed order schedules pickup
For an online order with pickup, confirmation creates a customer-to-store schedule. The pickup and return delivery are distinct trips, each with its own delivery ID and tracking data.
Track the courier to the store
Uber webhooks update the pickup status. Customer and admin tracking APIs retrieve courier and vehicle details, pickup and dropoff ETAs, and a tracking link when available.
Pickup delivered means laundry received
When Uber reports delivered on the pickup trip, Bellight marks pickup completed and moves the confirmed online order into processing. The whole laundry order is not completed yet.
Ready laundry schedules the return trip
The order-ready event schedules store-to-customer delivery. When that courier collects the clean laundry, the return leg becomes out for delivery while the pickup remains completed.
Delivery and settlement complete the order
The return trip arriving at the customer marks delivery delivered. The order can complete once its fulfillment and payment conditions are satisfied. Older tracking updates cannot rewind its stored trip snapshot.
The project & my contribution
One backend supports in-store machine payments and online laundry orders, including Uber Direct pickup, return delivery and tracking for customers and admins.
- Problem
- Two laundromats with eight coin-operated machines wanted card payments at the machines and online pickup-and-delivery orders.
- Solution
- A Kotlin 2.0 / Spring Boot backend on Java 21 where a kiosk card tap starts the washer over MQTT, and online orders run on Stripe with Stripe Connect payouts and Uber Direct couriers.
- Result
- In production since November 2025, with about 150 payments processed.
- ~150 payments processed
- 524 → 0 module violations
- 1,340 automated tests
Live site: Opens the operator console, which needs a sign-in. The demo below shows the backend.
The source code is private. I'm happy to walk through it on request.
How it's built
- Kotlin 2.0 on Java 21 with Spring Boot, split into Spring Modulith modules with dependency rules checked in CI
- Cross-module events go to Kafka through a transactional outbox with deduplication keys and dead-letter replay; ops alerts go to Slack
- Uber Direct schedules customer-to-store pickup on order confirmation and store-to-customer delivery when laundry is ready
- Docker Compose on EC2 behind nginx; Prometheus and Alertmanager watch the payment and webhook flows
- Spring Security and Resilience4j; Bash scripts for deployment and MySQL backup and restore rehearsals
Highlights
- A database-backed idempotency layer validates requests and replays stored responses, so retries and webhook redelivery never charge twice
- Each coin command carries its payment UUID, and a payment completes only when the board reports coin_done
- Unconfirmed payments alert Slack instead of resending coins, since a resend could give a free wash
- A reconciliation job repairs orders whose status drifted from their payment records
- Customer and admin tracking APIs expose each courier trip, with courier details, ETAs and tracking links
- Delivery webhooks update separate pickup and return statuses; timestamp and progression checks prevent stale tracking updates
- Replacing 11 synchronous calls between modules with events took module-boundary violations from 524 to 0
- 79 Flyway migrations; optimistic locking (@Version) and pessimistic row locks protect concurrent payment updates; order search uses QueryDSL
- 1,340 automated tests (JUnit 5, Testcontainers) must pass before a GitHub Actions release