Skip to main content

✅ Feature 2: Reliability Improvements (Implemented)

Purpose: Fix unsafe patterns that cause runtime errors and data inconsistency.

Status: ✅ COMPLETE (2026-01-06) Priority: CRITICAL (prevents runtime failures) Estimated Time: 2 days Dependencies: None (but recommended after Feature 1)


Overview

This feature fixes 4 critical reliability issues that cause production crashes:

  1. ✅ Unsafe Optional.get() usage → Runtime exceptions
  2. ✅ Missing @Transactional → Data inconsistency
  3. ✅ Generic exception catching → Hidden bugs
  4. ✅ Missing null checks → NullPointerExceptions

Step 1: Fix Unsafe Optional.get() Usage

Problem: Optional.get() throws NoSuchElementException if empty.

File: server/src/main/java/com/saas/springular/user/api/AuthenticationServiceImpl.java

Current Code (UNSAFE):

var user = userRepository.findById(userPrincipal.getId()).get();  // ❌ CRASHES if not found

Fixed Code (SAFE):

var user = userRepository.findById(userPrincipal.getId())
.orElseThrow(() -> new UserOperationException(
"User not found with id: " + userPrincipal.getId(),
HttpStatus.NOT_FOUND
));

Find All Unsafe Optional Usage

Search command:

cd server
grep -rn "\.get()" src/main/java --include="*.java" | grep Optional

Common patterns to fix:

// ❌ UNSAFE - throws NoSuchElementException
Optional<User> userOpt = userRepository.findById(id);
User user = userOpt.get();

// ✅ SAFE - throws custom exception with context
User user = userRepository.findById(id)
.orElseThrow(() -> new UserNotFoundException("User not found: " + id));

// ❌ UNSAFE
User user = userRepository.findByEmail(email).get();

// ✅ SAFE
User user = userRepository.findByEmail(email)
.orElseThrow(() -> new UserNotFoundException("User not found with email: " + email));

Create Custom Exception Classes

File: server/src/main/java/com/saas/springular/common/exception/UserNotFoundException.java (NEW)

package com.saas.springular.common.exception;

import org.springframework.http.HttpStatus;
import org.springframework.web.bind.annotation.ResponseStatus;

@ResponseStatus(HttpStatus.NOT_FOUND)
public class UserNotFoundException extends RuntimeException {
public UserNotFoundException(String message) {
super(message);
}
}

File: server/src/main/java/com/saas/springular/common/exception/ResourceNotFoundException.java (NEW)

package com.saas.springular.common.exception;

import org.springframework.http.HttpStatus;
import org.springframework.web.bind.annotation.ResponseStatus;

@ResponseStatus(HttpStatus.NOT_FOUND)
public class ResourceNotFoundException extends RuntimeException {
private final String resourceName;
private final String fieldName;
private final Object fieldValue;

public ResourceNotFoundException(String resourceName, String fieldName, Object fieldValue) {
super(String.format("%s not found with %s: '%s'", resourceName, fieldName, fieldValue));
this.resourceName = resourceName;
this.fieldName = fieldName;
this.fieldValue = fieldValue;
}
}

Update All Services

AuthenticationServiceImpl.java:

@Service
@AllArgsConstructor
public class AuthenticationServiceImpl implements AuthenticationService {

@Override
public JwtResponse getCurrentUser(UserPrincipal userPrincipal) {
var user = userRepository.findById(userPrincipal.getId())
.orElseThrow(() -> new UserNotFoundException(
"User not found with id: " + userPrincipal.getId()
));

// Continue with user...
}

@Override
public void resetPassword(String token, String newPassword) {
var resetToken = passwordResetTokenRepository.findByToken(token)
.orElseThrow(() -> new InvalidTokenException("Invalid password reset token"));

var user = userRepository.findById(resetToken.getUserId())
.orElseThrow(() -> new UserNotFoundException(
"User not found with id: " + resetToken.getUserId()
));

// Continue...
}
}

Stripe Service (if applicable):

public Invoice getInvoice(String invoiceId) {
return stripeInvoiceRepository.findById(invoiceId)
.orElseThrow(() -> new ResourceNotFoundException(
"Invoice", "id", invoiceId
));
}

Step 2: Add @Transactional Annotations

Problem: Without transactions, partial updates can occur on errors, causing data inconsistency.

Add to Service Layer

File: server/src/main/java/com/saas/springular/user/api/AuthenticationServiceImpl.java

Before (NO TRANSACTION):

@Override
public void registerUser(SignupRequest signupRequest) {
// Multiple database operations without transaction
if (userRepository.existsByEmail(signupRequest.getEmail())) {
throw new IllegalArgumentException("Email already in use");
}

User user = new User();
user.setEmail(signupRequest.getEmail());
user.setPassword(passwordEncoder.encode(signupRequest.getPassword()));
userRepository.save(user); // ❌ If this fails, no rollback

// Create default role
roleRepository.save(new Role(user.getId(), "USER")); // ❌ Could be saved even if user save fails
}

After (WITH TRANSACTION):

@Override
@Transactional // ✅ All operations in single transaction
public void registerUser(SignupRequest signupRequest) {
if (userRepository.existsByEmail(signupRequest.getEmail())) {
throw new IllegalArgumentException("Email already in use");
}

User user = new User();
user.setEmail(signupRequest.getEmail());
user.setPassword(passwordEncoder.encode(signupRequest.getPassword()));
userRepository.save(user);

// If this fails, user save is rolled back
roleRepository.save(new Role(user.getId(), "USER"));
}

Transaction Best Practices

State-changing operations need @Transactional:

@Service
@RequiredArgsConstructor
public class UserService {

@Transactional // ✅ Creates/updates data
public User createUser(UserRequest request) { }

@Transactional // ✅ Updates data
public User updateUser(Long id, UserRequest request) { }

@Transactional // ✅ Deletes data
public void deleteUser(Long id) { }

@Transactional(readOnly = true) // ✅ Read-only transaction (performance optimization)
public User getUser(Long id) { }

@Transactional(readOnly = true) // ✅ Read-only
public List<User> getAllUsers() { }
}

Add to All Services

AuthenticationServiceImpl.java:

@Service
@RequiredArgsConstructor
public class AuthenticationServiceImpl implements AuthenticationService {

@Transactional(readOnly = true)
public JwtResponse getCurrentUser(UserPrincipal userPrincipal) {
// Read-only operation
}

@Transactional
public void registerUser(SignupRequest signupRequest) {
// Creates user + roles
}

@Transactional
public void resetPassword(String token, String newPassword) {
// Updates user password + deletes token
}
}

Stripe/Payment Service:

@Transactional
public Subscription createSubscription(Long userId, String priceId) {
// Multiple operations that must succeed/fail together
User user = userRepository.findById(userId)
.orElseThrow(() -> new UserNotFoundException("User not found: " + userId));

// Create Stripe subscription
Subscription subscription = stripeService.createSubscription(user.getStripeCustomerId(), priceId);

// Save subscription record
subscriptionRepository.save(subscription);

// Update user status
user.setSubscriptionStatus(SubscriptionStatus.ACTIVE);
userRepository.save(user);

return subscription;
}

Step 3: Improve Exception Handling

Problem: Generic catch (Exception e) hides bugs and makes debugging difficult.

Replace Generic Exception Handling

File: server/src/main/java/com/saas/springular/common/stripe/webhook/StripeWebhookController.java

Before (GENERIC):

@PostMapping("/webhooks")
public ResponseEntity<String> handleWebhook(@RequestBody String payload, @RequestHeader("Stripe-Signature") String signature) {
try {
Event event = webhookService.constructEvent(payload, signature);
handleEvent(event);
return ResponseEntity.ok("Success");
} catch (Exception e) { // ❌ Catches everything, including programming errors
log.error("Error handling Stripe event: {}", event.getType(), e);
return ResponseEntity.internalServerError().build();
}
}

After (SPECIFIC):

@PostMapping("/webhooks")
public ResponseEntity<String> handleWebhook(
@RequestBody String payload,
@RequestHeader("Stripe-Signature") String signature
) {
try {
Event event = webhookService.constructEvent(payload, signature);
handleEvent(event);
return ResponseEntity.ok("Success");
} catch (SignatureVerificationException e) {
log.error("Invalid Stripe signature", e);
return ResponseEntity.status(HttpStatus.BAD_REQUEST).body("Invalid signature");
} catch (StripeException e) {
log.error("Stripe API error: {}", e.getMessage(), e);
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body("Stripe error");
} catch (IllegalStateException e) {
log.error("Invalid webhook state: {}", e.getMessage(), e);
return ResponseEntity.status(HttpStatus.BAD_REQUEST).body("Invalid state");
}
// Let framework handle other exceptions (programming errors should propagate)
}

Exception Handling Patterns

Good Pattern (specific exceptions):

try {
processPayment(order);
} catch (InsufficientFundsException e) {
log.warn("Payment failed - insufficient funds: {}", e.getMessage());
return ResponseEntity.status(HttpStatus.PAYMENT_REQUIRED).body(e.getMessage());
} catch (PaymentProviderException e) {
log.error("Payment provider error", e);
return ResponseEntity.status(HttpStatus.SERVICE_UNAVAILABLE).body("Payment service unavailable");
} catch (InvalidCardException e) {
log.warn("Invalid card: {}", e.getMessage());
return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(e.getMessage());
}

Bad Pattern (generic catch-all):

try {
processPayment(order);
} catch (Exception e) { // ❌ Hides all errors
log.error("Payment error", e);
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).build();
}

Update Global Exception Handler

File: server/src/main/java/com/saas/springular/common/exception/GlobalExceptionHandler.java

@RestControllerAdvice
@Slf4j
public class GlobalExceptionHandler {

@ExceptionHandler(UserNotFoundException.class)
public ResponseEntity<ErrorResponse> handleUserNotFound(
UserNotFoundException ex,
HttpServletRequest request
) {
ErrorResponse error = new ErrorResponse(
ex.getMessage(),
HttpStatus.NOT_FOUND.value(),
request.getRequestURI()
);
return ResponseEntity.status(HttpStatus.NOT_FOUND).body(error);
}

@ExceptionHandler(BadCredentialsException.class)
public ResponseEntity<ErrorResponse> handleBadCredentials(
BadCredentialsException ex,
HttpServletRequest request
) {
log.warn("Authentication failed: {}", ex.getMessage());
ErrorResponse error = new ErrorResponse(
"Invalid credentials",
HttpStatus.UNAUTHORIZED.value(),
request.getRequestURI()
);
return ResponseEntity.status(HttpStatus.UNAUTHORIZED).body(error);
}

@ExceptionHandler(MethodArgumentNotValidException.class)
public ResponseEntity<ErrorResponse> handleValidationErrors(
MethodArgumentNotValidException ex,
HttpServletRequest request
) {
List<String> errors = ex.getBindingResult()
.getFieldErrors()
.stream()
.map(error -> error.getField() + ": " + error.getDefaultMessage())
.collect(Collectors.toList());

ErrorResponse error = new ErrorResponse(
"Validation failed: " + String.join(", ", errors),
HttpStatus.BAD_REQUEST.value(),
request.getRequestURI()
);
return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(error);
}

@ExceptionHandler(JwtException.class)
public ResponseEntity<ErrorResponse> handleJwtException(
JwtException ex,
HttpServletRequest request
) {
log.error("JWT error: {}", ex.getMessage());
ErrorResponse error = new ErrorResponse(
"Invalid authentication token",
HttpStatus.UNAUTHORIZED.value(),
request.getRequestURI()
);
return ResponseEntity.status(HttpStatus.UNAUTHORIZED).body(error);
}

// Still catch unexpected errors, but log them properly
@ExceptionHandler(Exception.class)
public ResponseEntity<ErrorResponse> handleUnexpectedErrors(
Exception ex,
HttpServletRequest request
) {
log.error("Unexpected error", ex); // Full stack trace for debugging
ErrorResponse error = new ErrorResponse(
"An unexpected error occurred",
HttpStatus.INTERNAL_SERVER_ERROR.value(),
request.getRequestURI()
);
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(error);
}
}

Step 4: Add Null Checks

Problem: Dereferencing null objects causes NullPointerException.

Add Null Checks in Critical Paths

Stripe Webhook Handler:

private void handleEvent(Event event) {
StripeObject stripeObject = event.getDataObjectDeserializer()
.getObject()
.orElse(null);

if (stripeObject == null) { // ✅ Check before dereferencing
log.error("Stripe event has no data object: {}", event.getId());
throw new IllegalStateException("Invalid Stripe event - no data object");
}

// Safe to use stripeObject now
if (stripeObject instanceof Subscription subscription) {
handleSubscriptionEvent(event.getType(), subscription);
}
}

JWT Token Extraction:

private String getJwtFromRequest(HttpServletRequest request) {
String bearerToken = request.getHeader("Authorization");

if (bearerToken == null) { // ✅ Check for null
return null;
}

if (StringUtils.hasText(bearerToken) && bearerToken.startsWith("Bearer ")) {
return bearerToken.substring(7);
}

return null;
}

Use @NonNull Annotation

Add to method parameters:

import lombok.NonNull;

public User updateUser(@NonNull Long id, @NonNull UserRequest request) {
// Lombok generates null checks at compile time
}

Use Objects.requireNonNull

For critical validations:

import java.util.Objects;

public void processPayment(Order order) {
Objects.requireNonNull(order, "Order cannot be null");
Objects.requireNonNull(order.getCustomerId(), "Customer ID cannot be null");
Objects.requireNonNull(order.getAmount(), "Amount cannot be null");

// Safe to proceed
}

Verification

Test Optional Safety

Create test:

@Test
void getCurrentUser_UserNotFound_ThrowsException() {
UserPrincipal principal = new UserPrincipal(999L, "test@test.com", null);
when(userRepository.findById(999L)).thenReturn(Optional.empty());

assertThatThrownBy(() -> authService.getCurrentUser(principal))
.isInstanceOf(UserNotFoundException.class)
.hasMessageContaining("User not found");
}

Test Transaction Rollback

Create test:

@Test
@Transactional
void registerUser_RoleCreationFails_RollsBackUserCreation() {
SignupRequest request = new SignupRequest();
request.setEmail("test@test.com");
request.setPassword("Test123!@#");

// Make role creation fail
doThrow(new RuntimeException("Role creation failed"))
.when(roleRepository).save(any());

assertThatThrownBy(() -> authService.registerUser(request))
.isInstanceOf(RuntimeException.class);

// Verify user was NOT saved (rolled back)
assertThat(userRepository.existsByEmail("test@test.com")).isFalse();
}

Test Exception Handling

Test specific exception:

# Should return specific error message, not generic 500
curl -X POST http://localhost:8081/api/auth/login \
-H "Content-Type: application/json" \
-d '{"email":"nonexistent@test.com","password":"Test123"}'

# Expected: 401 Unauthorized with "Invalid credentials" message

Common Patterns Fixed

Pattern 1: Repository Calls

Before:

User user = userRepository.findById(id).get();  // ❌

After:

User user = userRepository.findById(id)
.orElseThrow(() -> new UserNotFoundException("User not found: " + id)); // ✅

Pattern 2: Service Methods

Before:

public void updateUser(Long id, UserRequest request) {  // ❌ No transaction
User user = userRepository.findById(id).get(); // ❌ Unsafe
user.setName(request.getName());
userRepository.save(user);
}

After:

@Transactional  // ✅ Transaction
public void updateUser(Long id, UserRequest request) {
User user = userRepository.findById(id)
.orElseThrow(() -> new UserNotFoundException("User not found: " + id)); // ✅ Safe
user.setName(request.getName());
userRepository.save(user);
}

Pattern 3: Exception Handling

Before:

try {
stripeService.charge(amount);
} catch (Exception e) { // ❌ Generic
log.error("Error", e);
}

After:

try {
stripeService.charge(amount);
} catch (StripeException e) { // ✅ Specific
log.error("Stripe API error: {}", e.getMessage(), e);
throw new PaymentProcessingException("Payment failed", e);
}

Summary

Reliability Issues Fixed:

  • ✅ Unsafe Optional.get() → orElseThrow() with custom exceptions
  • ✅ No transactions → @Transactional on all state-changing methods
  • ✅ Generic exceptions → Specific exception handling
  • ✅ Missing null checks → Validation before dereferencing

Benefits:

  • No more NoSuchElementException crashes
  • Data consistency guaranteed by transactions
  • Better error messages for debugging
  • Fewer NullPointerException errors

Next: Feature 3: Configuration Cleanup