Coding Standards
Overview
This document defines the coding standards that all engineers must follow for every V2 backend implementation. These standards are enforced in code review and automated checks. They apply to all domains — payments, customers, merchants, reporting, and any future domain.
Project Structure
All V2 code lives in versioned packages. The package structure within a service must reflect this explicitly:
com.optum.wallet.{service}
├── v2/
│ ├── controllers/ ← HTTP entry points
│ ├── services/ ← Business orchestration
│ ├── commands/ ← Command pipeline steps
│ ├── validators/ ← Custom ConstraintValidators
│ │ └── annotations/ ← Custom constraint annotations
│ ├── dto/ ← Request/response DTOs (versioned)
│ │ ├── payment/
│ │ ├── refund/
│ │ └── error/
│ ├── domain/
│ │ └── model/ ← Domain entities and value objects
│ ├── mappers/ ← Entity ↔ DTO mapping
│ ├── exception/ ← Typed exceptions + ExceptionHandlersV2
│ ├── enums/ ← Service-local enums (prefer wallet-event-commons)
│ └── util/ ← Stateless utility classes
├── repository/ ← JPA repositories and entities (shared across versions)
├── config/ ← Spring configuration beans
└── client/ ← External service HTTP clients
Do not mix V1 and V2 code in the same package. V1 classes live at the root level (e.g., com.optum.wallet.payment.controller); V2 classes live under v2/.
Lombok Usage
Use Lombok to eliminate boilerplate. All DTOs must use @Data @Builder @NoArgsConstructor @AllArgsConstructor.
// Correct — V2 DTO with Lombok and Bean Validation
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
@PaymentRequestConstraint
@PaymentBusinessRuleConstraint
public class PaymentRequest {
@NotNull(message = "is required")
@Range(min = 1L, max = 99999999L, message = "must be an integer between 1 and 99999999")
@Digits(integer = 8, fraction = 0, message = "must be an integer between 1 and 99999999")
private BigDecimal amount;
@NotBlank(message = "is required")
@Size(max = 50, message = "cannot exceed {max} characters")
private String merchantTransactionId;
@Valid
@NotNull(message = "is required")
private List<PaymentAllocation> paymentAllocations;
}
@Builder with @Valid: When using @Builder on a class that contains @Valid nested objects, ensure the @AllArgsConstructor is present so Spring's validator can instantiate the object. Missing this causes runtime binding failures.
Named Constants — No Magic Values
Every string literal, numeric limit, or regex pattern used in logic or validation must be a named constant:
// Correct
private static final int MAX_MERCHANT_TRANSACTION_ID_LENGTH = 50;
private static final long MIN_AMOUNT = 1L;
private static final long MAX_AMOUNT = 99_999_999L;
private static final String PAYMENT_NOT_FOUND = "Payment information not found";
private static final String PAYMENT_METHOD_ID_DUPLICATE =
"paymentAllocations[:].paymentMethodId must be unique";
// Incorrect — magic values
if (merchantTransactionId.length() > 50) { ... }
throw new PaymentNotFoundException("Payment information not found");
Group constants at the top of the class or in a dedicated *Constants / *ErrorMessages utility class if shared across multiple classes.
Reactive Code — Project Reactor
Controllers return Mono<ResponseEntity<T>>. Services return Mono<T>. Never block.
// Correct — reactive chain
return transactionRepository.findById(paymentId)
.switchIfEmpty(Mono.error(new PaymentNotFoundException(PAYMENT_NOT_FOUND)))
.flatMap(tx -> validateMerchantOwnership(merchantId, tx))
.map(mapper::toPaymentResponse)
.map(response -> ResponseEntity.accepted().body(
Resource.<PaymentResponse>builder()
.data(response)
.url(httpRequest.getURI().toString())
.build()));
// Incorrect — blocking inside reactive context
Transaction tx = transactionRepository.findById(paymentId).block(); // ❌ never
Use flatMap for operations that return a Mono/Flux. Use map for synchronous transformations. Use switchIfEmpty for absent-value handling.
Exception Design
Every exception must carry enough context to diagnose the failure from logs alone — without needing to query the database.
Use Typed Exceptions — Not RuntimeException
// Correct — typed, carries context
throw new PaymentFailedException("Payment processing failed", paymentResponse);
throw PaymentFailedException.forBusinessViolation("sum of allocations must equal total amount");
throw new TransactionCommandException("CustomerCheckCommand", "Customer not active: " +
LogSanitizationUtil.sanitizeForLog(customerId));
throw new PaymentNotFoundException(PAYMENT_NOT_FOUND);
// Incorrect — loses type information, untraceable
throw new RuntimeException("something went wrong"); // ❌
throw new RuntimeException(e); // ❌
Never Swallow Exceptions
// Correct — log and rethrow meaningfully
try {
vendorClient.charge(request);
} catch (VendorDeclineException e) {
log.error("Vendor declined payment for allocationId: {}",
LogSanitizationUtil.sanitizeForLog(allocationId), e);
throw new PaymentFailedException("Vendor declined payment", buildPartialResponse(e));
}
// Incorrect — silent swallow
try {
vendorClient.charge(request);
} catch (Exception e) {
// do nothing ❌
}
Log Sanitization
All user-controlled strings must pass through LogSanitizationUtil.sanitizeForLog() before appearing in any log statement. This prevents log injection via \n/\r in user input.
// Correct
log.info("Processing payment merchantTransactionId={}",
LogSanitizationUtil.sanitizeForLog(request.getMerchantTransactionId()));
log.error("Payment failed paymentId={} message={}",
LogSanitizationUtil.sanitizeForLog(paymentId), ex.getMessage(), ex);
// Incorrect — raw user input
log.info("Processing {}", request.getMerchantTransactionId()); // ❌
Fields that must always be sanitized: merchantTransactionId, description, agent.name, agent.id, any metadata values, any customer-supplied free-text.
Sensitive Data in Logs
Never log the following, regardless of context:
| Field | Why |
|---|---|
| Full card number (PAN) | Financial data |
| CVV / CVC | Security code — single-use |
| Full bank account number | Financial data |
| Full SSN | PII |
| OAuth tokens | Security credential |
| Stripe secret keys | Security credential |
Always safe to log: payment IDs, allocation IDs, merchant IDs, customer IDs (UUIDs), last4, card brand, payment status, error codes, merchantTransactionId (after sanitization).
Imports — Commons Library
Always import shared types from wallet-event-commons. Never redefine locally:
// Correct — from wallet-event-commons
import com.optum.wallet.common.enums.payment.PaymentStatus;
import com.optum.wallet.common.enums.ErrorGroup;
import com.optum.wallet.common.enums.refund.RefundReason;
import com.optum.wallet.common.events.v2.dto.error.Problem;
import com.optum.wallet.common.events.v2.dto.refund.error.RefundApiErrorResponse;
// Incorrect — do not use V1 packages in new V2 code
import com.optum.wallet.common.event.payment.dto.Payment; // ❌ V1
import com.optum.wallet.common.pojo.ErrorResponse; // ❌ V1
Javadoc
All public non-trivial methods and classes must have Javadoc. Trivial getters/setters (Lombok-generated) are exempt.
/**
* Exception thrown when a payment request fails during processing.
*
* <p>Carries an optional {@link PaymentResponse} payload so the
* {@link ExceptionHandlersV2} can include failed payment details
* in the HTTP 422 response body.</p>
*
* @see ExceptionHandlersV2#paymentFailedExceptionHandler(PaymentFailedException)
*/
public class PaymentFailedException extends RuntimeException { ... }
/**
* Returns the least-progressed (lowest rank) status from the child allocation statuses.
* Used to derive the parent payment status from its allocations.
*
* @param childStatuses list of allocation-level statuses; must not be null or empty
* @param paymentType the payment flow type (SALE vs PRE_AUTH) — affects special cases
* @return derived parent {@link PaymentStatus}
* @throws IllegalArgumentException if {@code childStatuses} is empty
*/
public static PaymentStatus getParentPaymentStatus(
List<PaymentStatus> childStatuses, PaymentType paymentType) { ... }
Code Review Requirements
Every PR must satisfy all of the following before merge:
- All automated checks pass (compilation, tests, linting, coverage gates)
- No raw user input in log statements —
LogSanitizationUtilused - No sensitive fields (PAN, CVV, SSN, tokens) in logs or error responses
- No magic strings or numbers — named constants used
- Typed exceptions used — no
new RuntimeException(...) - No
.block()calls in reactive code - All V1 imports replaced with V2 equivalents where applicable
- Tests cover at least 2 error/edge cases per changed method
- PR description explains why the change is needed, not just what it does
- Javadoc present on new public non-trivial classes and methods