DEV Community

Cover image for Implementing a Payment Gateway in Microservices and Monolithic Architectures: A Deep Dive
Harshit Singh
Harshit Singh

Posted on

Implementing a Payment Gateway in Microservices and Monolithic Architectures: A Deep Dive

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:

  1. Customer initiates payment: They provide card details or select a wallet like Paytm.
  2. Payment gateway processes the payment securely and communicates with the payment processor.
  3. Payment processor interacts with the customer’s bank, authorizing or declining the payment.
  4. 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'
}

Enter fullscreen mode Exit fullscreen mode

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>

Enter fullscreen mode Exit fullscreen mode

Monolithic Payment Gateway Implementation

In a monolithic architecture, payment handling is part of the same application. Here’s an example flow:

  1. Customer places an order.
  2. The app sends the payment request to the payment gateway.
  3. 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());
                });
    }
}

Enter fullscreen mode Exit fullscreen mode

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);
        }
    }
}

Enter fullscreen mode Exit fullscreen mode

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."));
    }
}

Enter fullscreen mode Exit fullscreen mode

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
}

Enter fullscreen mode Exit fullscreen mode

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;
}

Enter fullscreen mode Exit fullscreen mode

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());
        }
    });
}

Enter fullscreen mode Exit fullscreen mode

Explanation:

  • supplyAsync: This method creates a new task that runs asynchronously.
  • block(): Even though WebClient returns a Mono (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());
}

Enter fullscreen mode Exit fullscreen mode

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:

  1. You configure the webhook URL in the payment provider’s dashboard. For example, your webhook URL could be https://yourapp.com/payment/callback.
  2. After the payment is processed, the provider sends a POST request to that URL, with details about the payment (success or failure).
  3. 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");
        }
    }
}

Enter fullscreen mode Exit fullscreen mode

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
}

Enter fullscreen mode Exit fullscreen mode
  • 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."));
}

Enter fullscreen mode Exit fullscreen mode
  • @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)