Introduction
So, you’re setting up an e-commerce platform and need to process payments securely. How do you do it? Enter the payment gateway, your app’s friendly cashier that handles payment processing while you focus on other things like user experience.
In this guide, we’ll explore how to integrate a payment gateway into both microservices and monolithic architectures, covering everything from handling asynchronous calls to ensuring proper error handling.
You’ll see code examples that go beyond the surface to give you a deeper understanding of what’s happening under the hood.
Payment Gateway 101: What’s Going On?
A payment gateway acts as a secure bridge between your app and the payment processor (think banks, card networks like Visa, or mobile wallets like GPay). It handles all the heavy lifting, such as encrypting payment details, processing transactions, and sending the result (success or failure) back to your app.
The basic flow:
- Customer initiates payment: They provide card details or select a wallet like Paytm.
- Payment gateway processes the payment securely and communicates with the payment processor.
- Payment processor interacts with the customer’s bank, authorizing or declining the payment.
- Response is sent back to your app (Success or Failure).
Now, let’s look at the key integration points in both monolithic and microservices architectures.
Step-by-Step Implementation
1. Get API Keys
Before anything else, sign up with a payment gateway provider like Razorpay, GPay, or Paytm. You’ll be given:
- API Keys: To authenticate your app with the provider.
- Merchant ID: Your unique ID with the payment provider.
- Webhook URL setup: This is critical. You need to register a callback URL with the provider (more on this below).
2. Define Dependencies
If you’re using Spring Boot, add dependencies to handle HTTP requests and asynchronous calls.
For Gradle:
dependencies {
implementation 'org.springframework.boot:spring-boot-starter-web'
implementation 'org.springframework.boot:spring-boot-starter-webflux'
implementation 'io.github.resilience4j:resilience4j-spring-boot2:1.7.0'
}
For Maven:
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-webflux</artifactId>
</dependency>
<dependency>
<groupId>io.github.resilience4j</groupId>
<artifactId>resilience4j-spring-boot2</artifactId>
</dependency>
</dependencies>
Monolithic Payment Gateway Implementation
In a monolithic architecture, payment handling is part of the same application. Here’s an example flow:
- Customer places an order.
- The app sends the payment request to the payment gateway.
- The payment gateway responds, and the app processes the result.
Here’s how we can code the payment service:
PaymentService Implementation (Monolith)
@Service
public class PaymentService {
private final WebClient webClient;
public PaymentService(WebClient.Builder webClientBuilder) {
this.webClient = webClientBuilder.baseUrl("https://api.paymentprovider.com").build();
}
public Mono<PaymentResponse> initiatePayment(PaymentRequest request) {
return webClient.post()
.uri("/payments")
.body(Mono.just(request), PaymentRequest.class)
.retrieve()
.bodyToMono(PaymentResponse.class)
.doOnError(error -> {
// Log the error
throw new PaymentFailedException("Payment failed: " + error.getMessage());
});
}
}
Explanation:
- WebClient: We’re making an asynchronous HTTP POST request to the payment gateway.
- Mono.just(request): This wraps the request body into a reactive stream (thanks to WebFlux), allowing us to handle the response asynchronously.
- doOnError: If something goes wrong (e.g., network failure), we log the error and throw a custom exception.
Handling Payment Callbacks (Monolith)
Most payment gateways will notify your app about the status of the payment via a callback (also known as a webhook). This is where you handle the final success or failure status.
Here’s where your question about the callback comes into play. You need to register your callback URL with the payment provider. In their dashboard, you typically specify a URL like https://myapp.com/payment/callback
.
When the payment is processed, the provider will POST the result to that URL.
Here’s how we handle it:
@RestController
@RequestMapping("/payment")
public class PaymentController {
@PostMapping("/callback")
public ResponseEntity<String> handlePaymentCallback(@RequestBody PaymentResponse response) {
if (response.getStatus().equals("SUCCESS")) {
// Update order status to 'Paid'
return new ResponseEntity<>("Payment Successful", HttpStatus.OK);
} else {
// Log the failure and update order status
return new ResponseEntity<>("Payment Failed", HttpStatus.BAD_REQUEST);
}
}
}
Explanation:
- The payment provider sends the status update (e.g.,
SUCCESS
,FAILED
) to your app via a POST request to/payment/callback
. - We then update the order status and return the appropriate HTTP response.
Microservices Architecture
In a microservices world, each service is isolated. The payment service would be its own independent service, responsible solely for handling payments. Here’s how it works:
- The Order Service places an order.
- The Payment Service processes the payment independently.
- Services communicate via HTTP requests or events (Kafka, RabbitMQ).
Let’s look at the code for the Payment Service:
PaymentService (Microservices)
@Service
public class PaymentService {
private final WebClient webClient;
public PaymentService(WebClient.Builder webClientBuilder) {
this.webClient = webClientBuilder.baseUrl("https://api.paymentprovider.com").build();
}
public Mono<PaymentResponse> initiatePayment(PaymentRequest request) {
return webClient.post()
.uri("/payment")
.body(Mono.just(request), PaymentRequest.class)
.retrieve()
.bodyToMono(PaymentResponse.class)
.doOnError(error -> {
throw new PaymentFailedException("Payment failed: " + error.getMessage());
});
}
@Retry(name = "paymentRetry", fallbackMethod = "paymentFallback")
public Mono<PaymentResponse> retryPayment(PaymentRequest request) {
return initiatePayment(request);
}
public Mono<PaymentResponse> paymentFallback(PaymentRequest request, Throwable t) {
return Mono.just(new PaymentResponse("FAILED", "Retry attempts exceeded."));
}
}
Explanation:
- Retry: With Resilience4j, we can retry failed payment requests automatically.
-
Fallback: If all retry attempts fail, we return a default
PaymentResponse
indicating failure.
Handling Concurrent Requests
The Problem: Double-Charging
In a high-traffic app, multiple payment requests might be processed for the same order, leading to double charges.
Solution: We use pessimistic locking to ensure only one request is processed at a time.
@Transactional
public void processPayment(Long orderId, PaymentRequest request) {
Order order = orderRepository.findByIdWithLock(orderId); // Lock the order row
if (order.isPaid()) {
throw new PaymentAlreadyProcessedException("Order is already paid.");
}
// Continue with payment logic
}
Explanation:
- Transactional: Ensures that our database operation is atomic.
- findByIdWithLock(): We use a pessimistic lock to lock the order row, preventing other requests from modifying it simultaneously.
Integrating Third-Party Gateways (GPay, Paytm, etc.)
Most third-party providers like GPay or Paytm follow the same general flow:
- Redirect the user to the provider’s website for payment.
- The provider sends a callback to your app once the payment is processed.
Here’s an example for integrating GPay:
GPay Payment Initiation
public String redirectToGPay(PaymentRequest request) {
String gpayUrl = "https://gpay.api/checkout?merchantId=" + request.getMerchantId();
return "redirect:" + gpayUrl;
}
When the user completes the payment, GPay will send a POST request to your registered callback URL.
Handling Asynchronous Calls with CompletableFuture
When dealing with payment providers that may take a long time to respond, you want to avoid blocking your main thread. Using asynchronous calls ensures that your app can continue processing other tasks while waiting for the payment provider's response.
Here’s how you can implement asynchronous payment handling using CompletableFuture
:
Asynchronous Payment Processing
public CompletableFuture<PaymentResponse> processPaymentAsync(PaymentRequest request) {
return CompletableFuture.supplyAsync(() -> {
try {
// Call the payment provider's service and wait for the response
PaymentResponse response = paymentService.initiatePayment(request).block(); // Block until we get the response
return response;
} catch (Exception e) {
// Log the error and return a failure response
return new PaymentResponse("FAILED", "Payment processing failed due to " + e.getMessage());
}
});
}
Explanation:
- supplyAsync: This method creates a new task that runs asynchronously.
-
block(): Even though
WebClient
returns aMono
(a reactive type), we block here to wait for the response synchronously. Since this is running asynchronously in a separate thread, it's safe to block without affecting the main application thread. - Exception Handling: If something goes wrong during payment processing (e.g., network failure), we catch the exception and return a custom failure response.
Non-Blocking Callbacks
You might not always want to block and wait for a response. Instead, you can use non-blocking callbacks to handle payment results.
public CompletableFuture<Void> processPaymentWithCallback(PaymentRequest request) {
return CompletableFuture.runAsync(() -> {
paymentService.initiatePayment(request)
.doOnNext(response -> handlePaymentSuccess(response))
.doOnError(error -> handlePaymentFailure(error))
.subscribe();
});
}
private void handlePaymentSuccess(PaymentResponse response) {
// Process success (e.g., update order status)
if ("SUCCESS".equals(response.getStatus())) {
// Update the database, notify the user, etc.
}
}
private void handlePaymentFailure(Throwable error) {
// Handle the failure (e.g., log it, retry the payment, etc.)
log.error("Payment failed: " + error.getMessage());
}
Explanation:
- runAsync: This runs the payment initiation in a new thread.
- doOnNext: This method handles the payment success response, allowing us to act upon it.
- doOnError: This method handles any errors during the payment process.
-
subscribe(): We need to subscribe to the
Mono
to actually execute the payment request.
Handling Payment Webhooks (Callback URL Explained)
Now, let’s address your earlier question about how the callback works and how the /callback
endpoint gets hit.
When you integrate with a third-party payment gateway like GPay or Paytm, you'll usually configure a webhook URL with the payment provider. A webhook is an HTTP endpoint on your server that the provider will call once the payment is processed, whether it succeeded or failed.
Here’s the flow:
-
You configure the webhook URL in the payment provider’s dashboard. For example, your webhook URL could be
https://yourapp.com/payment/callback
. - After the payment is processed, the provider sends a POST request to that URL, with details about the payment (success or failure).
- Your app processes the response and updates the order status accordingly.
Configuring the Webhook with GPay
In the GPay dashboard, you would register https://yourapp.com/payment/callback
as your webhook. Here’s the controller method that will handle the callback:
@RestController
@RequestMapping("/payment")
public class PaymentController {
@PostMapping("/callback")
public ResponseEntity<String> handlePaymentCallback(@RequestBody PaymentResponse response) {
if ("SUCCESS".equals(response.getStatus())) {
// Update the order status in the database
orderService.updateOrderStatus(response.getOrderId(), "PAID");
return ResponseEntity.ok("Payment Successful");
} else {
// Handle payment failure
orderService.updateOrderStatus(response.getOrderId(), "FAILED");
return ResponseEntity.badRequest().body("Payment Failed");
}
}
}
Explanation:
-
@PostMapping("/callback"): This maps the incoming POST request from the payment provider to the
handlePaymentCallback
method. -
@RequestBody PaymentResponse: The payment provider sends the payment result as a JSON object. We bind this to a
PaymentResponse
object. - Order Service Update: We update the order status based on the payment result.
Dealing with Payment Errors and Concurrency
Problem: Double Payments and Race Conditions
Imagine two users accidentally clicking the “Pay” button twice or multiple payment processes for the same order getting triggered. This could lead to double payments.
Solution: Implement pessimistic locking to prevent this.
Here’s an example:
@Transactional
public void processPayment(Long orderId, PaymentRequest request) {
Order order = orderRepository.findByIdWithLock(orderId); // Lock the order row
if (order.isPaid()) {
throw new PaymentAlreadyProcessedException("This order is already paid.");
}
// Continue with payment processing
}
- Transactional: Ensures all operations within this method are performed atomically.
- findByIdWithLock: This method locks the row in the database, ensuring no other transaction can modify the same order until this one completes.
Problem: Payment Gateway Timeout
Sometimes the payment gateway might take too long to respond, causing a timeout.
Solution: Use Resilience4j or Hystrix to implement retries and circuit breaking. This ensures your app doesn’t fail completely if the payment gateway is down temporarily.
@Retry(name = "paymentRetry", fallbackMethod = "paymentFallback")
public Mono<PaymentResponse> retryPayment(PaymentRequest request) {
return paymentService.initiatePayment(request);
}
public Mono<PaymentResponse> paymentFallback(PaymentRequest request, Throwable t) {
return Mono.just(new PaymentResponse("FAILED", "Retry attempts exceeded."));
}
- @Retry: This annotation retries the payment process if it fails, with a fallback method if the retries are exhausted.
- Fallback: If the retries fail, the fallback method is triggered, returning a failure response.
Final Thoughts
Payment gateways are essential for any application handling financial transactions, but they come with their own set of challenges. Whether you’re building in a monolithic or microservices architecture, you need to ensure that payments are processed securely, errors are handled gracefully, and concurrency issues like double payments are avoided.
From setting up asynchronous calls with CompletableFuture
, to handling payment callbacks via webhooks, and implementing resilience patterns for retries, this guide covers all the critical pieces for implementing a payment gateway.
Now that you’ve learned the ropes, it’s time to dive into your code and integrate that payment gateway! 💸
If you need more explanations or a specific deep dive into any part of this article, feel free to ask!
Top comments (0)