Test Questions

1. What is ArgumentCaptor and what does it do in the shouldMaskCardNumberWhenGeneratingResponse test?

What it is: ArgumentCaptor is a Mockito utility that captures the actual argument passed to a mocked method during execution, so you can inspect it afterwards.

How it's used in the test (lines 145-153 of PaymentGatewayServiceTest):

ArgumentCaptor<BankPaymentRequest> bankReq = ArgumentCaptor.forClass(BankPaymentRequest.class);
when(restTemplate.postForObject(eq(BANK_URL), bankReq.capture(), eq(BankPaymentResponse.class)))
    .thenReturn(
        BankPaymentResponse.builder()
            .authorized(true)
            .authorizationCode("123456")
            .build());

The test calls createPayment() with a PostPaymentRequest containing a full card number "2222405343248878". Internally, the service converts that into a BankPaymentRequest before sending it to the bank. By using bankReq.capture() as the second argument in the when(...) stub, Mockito will:

  1. Match any BankPaymentRequest passed in that position (similar to any()).
  2. Store the actual object so you can call bankReq.getValue() later to inspect exactly what was passed.

The primary assertion in this test is that the response masks the card number to only the last four digits ("8878"). The ArgumentCaptor is set up here so the mock can match the call — and if needed, the test could also verify the exact BankPaymentRequest that was constructed (e.g., checking the expiry date format conversion).

In short: It lets the test "spy on" the intermediate BankPaymentRequest object that the service creates internally, which is not otherwise accessible from outside the method.


2. What is eq() and why is it used?

What it is: eq() is a Mockito argument matcher that matches a parameter by equality (using .equals()).

Why it's needed: When stubbing a method with when(...), Mockito has a rule: if you use an argument matcher for one parameter, you must use matchers for all parameters. You cannot mix raw values and matchers.

For example, in this line from the test:

when(restTemplate.postForObject(eq(BANK_URL), eq(request), eq(BankPaymentResponse.class)))

All three parameters use eq(). If you wrote it as:

// ILLEGAL — mixing raw values with matchers
when(restTemplate.postForObject(BANK_URL, eq(request), BankPaymentResponse.class))

Mockito would throw an error at runtime. Since the test needs to verify that the method is called with these exact values (not just any values), eq() is the correct matcher — as opposed to any() which would match anything.

In the ArgumentCaptor test, notice how bankReq.capture() replaces eq(request) for the second parameter. Since .capture() is itself a matcher, the other parameters must also use matchers (eq(BANK_URL) and eq(BankPaymentResponse.class)).


3. Why does the controller test use @MockBean but the service test uses @Mock for RestTemplate?

This comes down to the type of test each class is running:

Controller Test Service Test
Annotation @SpringBootTest + @AutoConfigureMockMvc @ExtendWith(MockitoExtension.class)
What runs Full Spring application context Pure unit test, no Spring context
Mock type @MockBean @Mock

Controller test (@MockBean): The controller test boots the entire Spring application context (because of @SpringBootTest). This means Spring creates all real beans — including the real RestTemplate bean defined in ApplicationConfiguration. If the test used @Mock, Spring would still inject the real RestTemplate into the service, and the test would make actual HTTP calls to the bank. @MockBean tells Spring: "Replace the real RestTemplate bean in the application context with this mock." Every bean that depends on RestTemplate (i.e., PaymentGatewayService) automatically receives the mock.

Service test (@Mock): The service test does not start Spring at all. It manually constructs PaymentGatewayService in setUp():

paymentGatewayService = new PaymentGatewayService(paymentsRepository, restTemplate, BANK_URL);

There is no application context, so @MockBean would not work (there is no bean registry to replace into). @Mock simply creates a Mockito mock object, and the test injects it manually via the constructor. This makes the test faster and more isolated.

Rule of thumb: Use @Mock for unit tests (no Spring). Use @MockBean for integration tests (with Spring context) where you need to override a real bean.


Main Project Question

4. If we only log whether the bank is authorised or not (line 79 of PaymentGatewayService), can we identify which request that response is for?

The log line in question:

LOG.debug("Received response from the bank authorized={}", response.isAuthorized());

Short answer: No, not reliably.

This log only prints authorized=true or authorized=false. If two payment requests are processed around the same time, there is no way to correlate which log entry belongs to which request. The log has no request-specific identifier.

What's missing:

  • No payment ID, transaction reference, or correlation ID in the log message.
  • No card number (even masked) or amount to distinguish requests.
  • In a concurrent environment (e.g., multiple threads processing payments simultaneously), these log lines would be interleaved with no way to tell them apart.

How to fix it — two approaches:

Approach A: Add identifying info directly to the log message (quick fix)

The simplest fix is to include the masked card number in the log line itself:

LOG.debug("Received response from the bank for card ending {}, authorized={}",
    paymentRequest.getCardNumber().substring(paymentRequest.getCardNumber().length() - 4),
    response.isAuthorized());

This helps, but has limitations:

  • You must remember to add context to every log line manually.
  • Two requests for the same card would still be ambiguous.
  • Log lines from different classes (controller, service, exception handler) can't be correlated.

Approach B: Use MDC (Mapped Diagnostic Context) — the production solution

MDC attaches key-value pairs to the thread so that every log line produced during that request automatically includes the correlation ID — without changing any individual LOG.debug(...) call.

Step 1 — Generate a correlation ID in the RequestResponseLogger filter:

The project already has a RequestResponseLogger filter that runs once per request. This is the perfect place to set up MDC:

@Component
public class RequestResponseLogger extends OncePerRequestFilter {

  private static final Logger LOG = LoggerFactory.getLogger(RequestResponseLogger.class);

  @Override
  protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response,
      FilterChain chain) throws ServletException, IOException {
    String correlationId = Optional
        .ofNullable(request.getHeader("X-Correlation-Id"))
        .orElse(UUID.randomUUID().toString());
    MDC.put("correlationId", correlationId);
    try {
      LOG.info("Request -> {} {}", request.getMethod(), request.getRequestURI());
      chain.doFilter(request, response);
    } finally {
      MDC.clear();  // prevent leaking to the next request on the same thread
    }
  }
}

Key details:

  • If the caller sends an X-Correlation-Id header (common in microservice architectures), we reuse it for tracing across services.
  • If no header is present, we generate a new UUID.
  • MDC.clear() in finally is critical — servlet containers reuse threads, so stale MDC values would leak into unrelated requests.

Step 2 — Include the MDC key in the log pattern (application.properties):

logging.pattern.console=%d{yyyy-MM-dd HH:mm:ss} [%X{correlationId}] %-5level %logger{36} - %msg%n

%X{correlationId} tells the logging framework to read the correlationId value from MDC and print it.

Step 3 — No changes needed to individual log statements.

After this, every log line produced during the request automatically includes the correlation ID:

2026-04-16 14:30:01 [a1b2c3d4-...] INFO  RequestResponseLogger - Request -> POST /api/payments
2026-04-16 14:30:01 [a1b2c3d4-...] DEBUG PaymentGatewayService - Requesting to create payment
2026-04-16 14:30:01 [a1b2c3d4-...] DEBUG PaymentGatewayService - Sending request to the bank http://localhost:8080/payments
2026-04-16 14:30:02 [a1b2c3d4-...] DEBUG PaymentGatewayService - Received response from the bank authorized=true
2026-04-16 14:30:02 [a1b2c3d4-...] INFO  PaymentGatewayService - Payment Authorized with GBP100 (card ending with 8877)

Now even the unchanged authorized={} log on line 79 is identifiable — every line with [a1b2c3d4-...] belongs to the same request.

Why MDC is better than manual context:

Concern Manual (Approach A) MDC (Approach B)
Requires changing every log line Yes No
Works across classes automatically No Yes
Supports distributed tracing No Yes (via X-Correlation-Id header)
Risk of forgetting to add context High None — it's automatic
Works with log aggregation tools (ELK, Splunk) Partially Fully — you can filter/search by correlationId

Additional Questions

5. What should be added for production readiness?

Based on the existing code, here are the key areas to address:

A. Persistent Storage

The current PaymentsRepository uses an in-memory HashMap. All payment data is lost when the application restarts. For production:

  • Use a relational database (e.g., PostgreSQL) with Spring Data JPA.
  • Add database migrations (e.g., Flyway or Liquibase).

B. Idempotency

If a client retries the same payment request (e.g., due to a network timeout), the current code would create a duplicate payment. Add an idempotency key (a unique key sent by the client in the request header) so that duplicate requests return the same response instead of processing twice.

C. Authentication & Authorisation

There is no authentication on the API endpoints. Anyone can create payments or look up payment data. Add:

  • API key or OAuth2/JWT-based authentication.
  • Role-based access control.

D. Rate Limiting

No rate limiting exists. A malicious client could flood the API with requests. Add rate limiting (e.g., using Spring Cloud Gateway, Bucket4j, or an API gateway like Kong/AWS API Gateway).

E. Encryption & Security

  • Card numbers are handled in plaintext in memory and in the BankPaymentRequest. Consider PCI-DSS compliance requirements.
  • Use HTTPS in production (the bank URL is currently http://).
  • Mask or avoid logging sensitive data (card numbers, CVVs).

F. Health Checks & Monitoring

  • Add Spring Boot Actuator for health endpoints (/actuator/health).
  • Add metrics (Micrometer + Prometheus/Datadog) for monitoring payment success/failure rates, latency, etc.
  • Add structured logging (JSON format) for log aggregation tools (ELK, Splunk).

G. Resilience

  • The current RestTemplate has a 10-second timeout but no retry logic or circuit breaker. Add retries with exponential backoff for transient bank errors, and a circuit breaker (e.g., Resilience4j) to stop hammering the bank when it's down.

H. Configuration Management

  • Externalise secrets (bank URL, API keys) using environment variables or a secrets manager (e.g., AWS Secrets Manager, HashiCorp Vault) instead of application.properties.
  • Add profiles for different environments (application-dev.properties, application-prod.properties).

I. API Versioning

The current endpoint is /api/payments. For production APIs that may evolve, consider versioning (e.g., /api/v1/payments).

J. Concurrency Safety

PaymentsRepository uses a plain HashMap, which is not thread-safe. In a concurrent environment, this would cause data corruption. At minimum, switch to ConcurrentHashMap (though a real database is the proper fix).

K. Testing

  • Add integration tests that test the full flow end-to-end (e.g., using Testcontainers for a real database).
  • Add contract tests for the bank API to ensure the request/response format stays compatible.

6. Lombok upgrade from 1.18.20 to 1.18.34 — are there trade-offs?

Why upgrade?

Lombok 1.18.20 was released in early 2021. Version 1.18.34 (released mid-2024) includes:

  • Java compatibility: 1.18.20 does not fully support Java 17+ (which this project uses as sourceCompatibility). Newer Java versions introduce changes to the compiler internals that Lombok hooks into. Running 1.18.20 on Java 17 can cause build failures or warnings.
  • Bug fixes: Three years of accumulated bug fixes for @Builder, @Data, @SneakyThrows, etc.
  • New features: Improved support for records, sealed classes, and other modern Java features.

Potential trade-offs

Concern Risk Level Details
Behavioural changes Low Lombok rarely makes breaking changes, but some edge cases in @Builder, @EqualsAndHashCode, or @ToString defaults may have shifted between versions.
Generated code differences Low The bytecode Lombok generates may differ slightly (e.g., null-check behaviour, method ordering). This should not affect runtime behaviour but could affect tools that depend on exact bytecode.
IDE compatibility Low Newer Lombok requires updated IDE plugins. If using an older IntelliJ or Eclipse Lombok plugin, you may see false compilation errors in the IDE (the Gradle build itself would be fine).
Annotation processor conflicts Low If other annotation processors (e.g., MapStruct) are in use, a Lombok upgrade can occasionally require updating those too. This project only uses Lombok, so this is not a concern here.

Verdict

For this project, the upgrade from 1.18.20 to 1.18.34 is strongly recommended and essentially risk-free. The main reason is Java 17 compatibility — 1.18.20 predates Java 17's GA release and is known to have issues with it. Not upgrading is riskier than upgrading.