Building Scalable Mobile Wallet Backends with Java: A Complete Guide

Mobile wallets have revolutionized digital payments, requiring robust, secure, and scalable backend systems. Java's enterprise capabilities make it an ideal choice for building financial-grade wallet backends that can handle millions of transactions while maintaining security and compliance. This article explores the complete architecture and implementation of a mobile wallet backend using Java and modern microservices patterns.


Mobile Wallet Backend Architecture

Core Components:

Mobile App → API Gateway → Microservices → Databases & External Services
↓              ↓            ↓               ↓
Authentication  Routing   User Management   Payment Processors
↓              ↓            ↓               ↓
Security       Load Bal   Account Services  Banking APIs

Key Features Required:

  • User registration and KYC
  • Wallet creation and management
  • Fund transfers and payments
  • Transaction history
  • Security and fraud detection
  • Notifications
  • Reporting and analytics

Technology Stack

Maven Dependencies:

<properties>
<spring-boot.version>3.2.0</spring-boot.version>
<jwt.version>0.11.5</jwt.version>
</properties>
<dependencies>
<!-- Spring Boot Starters -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
<version>${spring-boot.version}</version>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-jpa</artifactId>
<version>${spring-boot.version}</version>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
<version>${spring-boot.version}</version>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-validation</artifactId>
<version>${spring-boot.version}</version>
</dependency>
<!-- Database -->
<dependency>
<groupId>org.postgresql</groupId>
<artifactId>postgresql</artifactId>
<version>42.6.0</version>
</dependency>
<dependency>
<groupId>org.flywaydb</groupId>
<artifactId>flyway-core</artifactId>
<version>9.22.0</version>
</dependency>
<!-- Security -->
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-api</artifactId>
<version>${jwt.version}</version>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-impl</artifactId>
<version>${jwt.version}</version>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-jackson</artifactId>
<version>${jwt.version}</version>
</dependency>
<!-- Money & Currency -->
<dependency>
<groupId>org.javamoney</groupId>
<artifactId>moneta</artifactId>
<version>1.4.2</version>
</dependency>
<!-- Testing -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<version>${spring-boot.version}</version>
<scope>test</scope>
</dependency>
</dependencies>

Domain Model and Database Schema

Entity Classes:

1. User Entity:

package com.wallet.backend.domain.user;
import jakarta.persistence.*;
import jakarta.validation.constraints.Email;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Size;
import java.time.LocalDateTime;
import java.util.HashSet;
import java.util.Set;
@Entity
@Table(name = "users", indexes = {
@Index(name = "idx_user_email", columnList = "email"),
@Index(name = "idx_user_phone", columnList = "phoneNumber")
})
public class User {
@Id
@GeneratedValue(strategy = GenerationType.UUID)
private String id;
@NotBlank
@Size(max = 100)
private String firstName;
@NotBlank
@Size(max = 100)
private String lastName;
@NotBlank
@Email
@Column(unique = true)
private String email;
@NotBlank
@Size(max = 15)
@Column(unique = true)
private String phoneNumber;
@NotBlank
@Size(max = 255)
private String passwordHash;
@Enumerated(EnumType.STRING)
private UserStatus status = UserStatus.PENDING_VERIFICATION;
@Enumerated(EnumType.STRING)
private KYCStatus kycStatus = KYCStatus.NOT_VERIFIED;
@ElementCollection(fetch = FetchType.EAGER)
@CollectionTable(name = "user_roles", joinColumns = @JoinColumn(name = "user_id"))
@Column(name = "role")
private Set<String> roles = new HashSet<>();
private LocalDateTime createdAt;
private LocalDateTime updatedAt;
private LocalDateTime lastLoginAt;
@PrePersist
protected void onCreate() {
createdAt = LocalDateTime.now();
updatedAt = LocalDateTime.now();
}
@PreUpdate
protected void onUpdate() {
updatedAt = LocalDateTime.now();
}
// Constructors, Getters, Setters
public User() {}
public User(String firstName, String lastName, String email, String phoneNumber) {
this.firstName = firstName;
this.lastName = lastName;
this.email = email;
this.phoneNumber = phoneNumber;
}
// Enum definitions
public enum UserStatus {
PENDING_VERIFICATION, ACTIVE, SUSPENDED, DELETED
}
public enum KYCStatus {
NOT_VERIFIED, PENDING_VERIFICATION, VERIFIED, REJECTED
}
}

2. Wallet Entity:

package com.wallet.backend.domain.wallet;
import com.wallet.backend.domain.user.User;
import jakarta.persistence.*;
import org.hibernate.annotations.CreationTimestamp;
import org.hibernate.annotations.UpdateTimestamp;
import java.math.BigDecimal;
import java.time.LocalDateTime;
import java.util.Currency;
@Entity
@Table(name = "wallets", indexes = {
@Index(name = "idx_wallet_user", columnList = "user_id"),
@Index(name = "idx_wallet_number", columnList = "walletNumber")
})
public class Wallet {
@Id
@GeneratedValue(strategy = GenerationType.UUID)
private String id;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "user_id", nullable = false)
private User user;
@Column(unique = true, nullable = false)
private String walletNumber;
@Column(nullable = false)
private BigDecimal balance = BigDecimal.ZERO;
@Column(nullable = false)
private Currency currency;
@Enumerated(EnumType.STRING)
private WalletStatus status = WalletStatus.ACTIVE;
@CreationTimestamp
private LocalDateTime createdAt;
@UpdateTimestamp
private LocalDateTime updatedAt;
@Version
private Long version; // For optimistic locking
// Constructors, Getters, Setters
public Wallet() {}
public Wallet(User user, String walletNumber, Currency currency) {
this.user = user;
this.walletNumber = walletNumber;
this.currency = currency;
}
public enum WalletStatus {
ACTIVE, SUSPENDED, CLOSED
}
}

3. Transaction Entity:

package com.wallet.backend.domain.transaction;
import com.wallet.backend.domain.wallet.Wallet;
import jakarta.persistence.*;
import org.hibernate.annotations.CreationTimestamp;
import java.math.BigDecimal;
import java.time.LocalDateTime;
import java.util.Currency;
@Entity
@Table(name = "transactions", indexes = {
@Index(name = "idx_txn_reference", columnList = "referenceNumber"),
@Index(name = "idx_txn_timestamp", columnList = "createdAt"),
@Index(name = "idx_txn_wallet", columnList = "fromWalletId, toWalletId")
})
public class Transaction {
@Id
@GeneratedValue(strategy = GenerationType.UUID)
private String id;
@Column(unique = true, nullable = false)
private String referenceNumber;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "from_wallet_id")
private Wallet fromWallet;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "to_wallet_id")
private Wallet toWallet;
@Column(nullable = false)
private BigDecimal amount;
@Column(nullable = false)
private Currency currency;
@Column(nullable = false)
private BigDecimal fee = BigDecimal.ZERO;
@Enumerated(EnumType.STRING)
private TransactionType type;
@Enumerated(EnumType.STRING)
private TransactionStatus status = TransactionStatus.PENDING;
private String description;
private String metadata; // JSON data for additional info
@CreationTimestamp
private LocalDateTime createdAt;
// Constructors, Getters, Setters
public Transaction() {}
public Transaction(String referenceNumber, Wallet fromWallet, Wallet toWallet, 
BigDecimal amount, Currency currency, TransactionType type) {
this.referenceNumber = referenceNumber;
this.fromWallet = fromWallet;
this.toWallet = toWallet;
this.amount = amount;
this.currency = currency;
this.type = type;
}
public enum TransactionType {
TRANSFER, DEPOSIT, WITHDRAWAL, PAYMENT, REFUND
}
public enum TransactionStatus {
PENDING, PROCESSING, COMPLETED, FAILED, CANCELLED
}
}

Core Service Layer

1. Wallet Service:

package com.wallet.backend.service.wallet;
import com.wallet.backend.domain.wallet.Wallet;
import com.wallet.backend.domain.wallet.WalletRepository;
import com.wallet.backend.exception.InsufficientFundsException;
import com.wallet.backend.exception.WalletNotFoundException;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.math.BigDecimal;
import java.util.Currency;
@Service
@Transactional
public class WalletService {
private final WalletRepository walletRepository;
private final TransactionService transactionService;
public WalletService(WalletRepository walletRepository, 
TransactionService transactionService) {
this.walletRepository = walletRepository;
this.transactionService = transactionService;
}
public Wallet createWallet(String userId, Currency currency) {
String walletNumber = generateWalletNumber();
// In real implementation, create wallet entity
Wallet wallet = new Wallet(/* user, walletNumber, currency */);
return walletRepository.save(wallet);
}
public Wallet getWallet(String walletId) {
return walletRepository.findById(walletId)
.orElseThrow(() -> new WalletNotFoundException("Wallet not found: " + walletId));
}
public Wallet getWalletByNumber(String walletNumber) {
return walletRepository.findByWalletNumber(walletNumber)
.orElseThrow(() -> new WalletNotFoundException("Wallet not found: " + walletNumber));
}
@Transactional
public void debitWallet(String walletId, BigDecimal amount, String reference) {
Wallet wallet = getWallet(walletId);
if (wallet.getBalance().compareTo(amount) < 0) {
throw new InsufficientFundsException("Insufficient funds in wallet: " + walletId);
}
// Use optimistic locking to prevent race conditions
int updatedRows = walletRepository.debitWallet(walletId, amount, wallet.getVersion());
if (updatedRows == 0) {
throw new RuntimeException("Concurrent modification detected for wallet: " + walletId);
}
// Record transaction
transactionService.recordDebitTransaction(wallet, amount, reference);
}
@Transactional
public void creditWallet(String walletId, BigDecimal amount, String reference) {
Wallet wallet = getWallet(walletId);
int updatedRows = walletRepository.creditWallet(walletId, amount, wallet.getVersion());
if (updatedRows == 0) {
throw new RuntimeException("Concurrent modification detected for wallet: " + walletId);
}
// Record transaction
transactionService.recordCreditTransaction(wallet, amount, reference);
}
public BigDecimal getBalance(String walletId) {
return getWallet(walletId).getBalance();
}
private String generateWalletNumber() {
// Generate unique wallet number (e.g., 10-digit number)
return String.format("%010d", System.currentTimeMillis() % 10000000000L);
}
}

2. Transaction Service:

package com.wallet.backend.service.transaction;
import com.wallet.backend.domain.transaction.Transaction;
import com.wallet.backend.domain.transaction.TransactionRepository;
import com.wallet.backend.domain.transaction.TransactionStatus;
import com.wallet.backend.domain.transaction.TransactionType;
import com.wallet.backend.domain.wallet.Wallet;
import com.wallet.backend.service.fraud.FraudDetectionService;
import com.wallet.backend.service.notification.NotificationService;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.math.BigDecimal;
import java.time.LocalDateTime;
import java.util.List;
import java.util.UUID;
@Service
@Transactional
public class TransactionService {
private final TransactionRepository transactionRepository;
private final FraudDetectionService fraudDetectionService;
private final NotificationService notificationService;
public TransactionService(TransactionRepository transactionRepository,
FraudDetectionService fraudDetectionService,
NotificationService notificationService) {
this.transactionRepository = transactionRepository;
this.fraudDetectionService = fraudDetectionService;
this.notificationService = notificationService;
}
public Transaction initiateTransfer(String fromWalletId, String toWalletId, 
BigDecimal amount, String description) {
// Generate unique reference
String reference = generateTransactionReference();
// Create transaction record
Transaction transaction = new Transaction();
transaction.setReferenceNumber(reference);
transaction.setAmount(amount);
transaction.setType(TransactionType.TRANSFER);
transaction.setDescription(description);
transaction.setStatus(TransactionStatus.PENDING);
Transaction savedTransaction = transactionRepository.save(transaction);
// Process asynchronously
processTransactionAsync(savedTransaction.getId());
return savedTransaction;
}
@Transactional
public void processTransaction(String transactionId) {
Transaction transaction = transactionRepository.findById(transactionId)
.orElseThrow(() -> new RuntimeException("Transaction not found"));
try {
// Check for fraud
if (fraudDetectionService.isSuspiciousTransaction(transaction)) {
transaction.setStatus(TransactionStatus.FAILED);
transactionRepository.save(transaction);
return;
}
// Update status to processing
transaction.setStatus(TransactionStatus.PROCESSING);
transactionRepository.save(transaction);
// Process the transaction (debit from, credit to)
// This would involve calling wallet service
// Mark as completed
transaction.setStatus(TransactionStatus.COMPLETED);
transactionRepository.save(transaction);
// Send notifications
notificationService.sendTransactionNotification(transaction);
} catch (Exception e) {
transaction.setStatus(TransactionStatus.FAILED);
transactionRepository.save(transaction);
throw new RuntimeException("Transaction processing failed", e);
}
}
public List<Transaction> getTransactionHistory(String walletId, 
LocalDateTime fromDate,
LocalDateTime toDate,
int page, int size) {
return transactionRepository.findByWalletAndDateRange(walletId, fromDate, toDate, page, size);
}
private String generateTransactionReference() {
return "TXN" + System.currentTimeMillis() + UUID.randomUUID().toString().substring(0, 8).toUpperCase();
}
@Async
public void processTransactionAsync(String transactionId) {
processTransaction(transactionId);
}
}

Security Implementation

1. JWT Authentication:

package com.wallet.backend.security;
import io.jsonwebtoken.*;
import io.jsonwebtoken.security.Keys;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.stereotype.Component;
import javax.crypto.SecretKey;
import java.util.Date;
import java.util.HashMap;
import java.util.Map;
import java.util.function.Function;
@Component
public class JwtTokenProvider {
@Value("${app.jwt.secret}")
private String jwtSecret;
@Value("${app.jwt.expiration}")
private long jwtExpiration;
private SecretKey getSigningKey() {
return Keys.hmacShaKeyFor(jwtSecret.getBytes());
}
public String generateToken(UserDetails userDetails) {
Map<String, Object> claims = new HashMap<>();
return createToken(claims, userDetails.getUsername());
}
private String createToken(Map<String, Object> claims, String subject) {
return Jwts.builder()
.setClaims(claims)
.setSubject(subject)
.setIssuedAt(new Date(System.currentTimeMillis()))
.setExpiration(new Date(System.currentTimeMillis() + jwtExpiration))
.signWith(getSigningKey(), SignatureAlgorithm.HS256)
.compact();
}
public Boolean validateToken(String token, UserDetails userDetails) {
final String username = extractUsername(token);
return (username.equals(userDetails.getUsername()) && !isTokenExpired(token));
}
public String extractUsername(String token) {
return extractClaim(token, Claims::getSubject);
}
public Date extractExpiration(String token) {
return extractClaim(token, Claims::getExpiration);
}
public <T> T extractClaim(String token, Function<Claims, T> claimsResolver) {
final Claims claims = extractAllClaims(token);
return claimsResolver.apply(claims);
}
private Claims extractAllClaims(String token) {
return Jwts.parserBuilder()
.setSigningKey(getSigningKey())
.build()
.parseClaimsJws(token)
.getBody();
}
private Boolean isTokenExpired(String token) {
return extractExpiration(token).before(new Date());
}
}

2. Security Configuration:

package com.wallet.backend.security;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.config.annotation.authentication.configuration.AuthenticationConfiguration;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
@Configuration
@EnableWebSecurity
public class SecurityConfig {
private final JwtAuthenticationEntryPoint jwtAuthenticationEntryPoint;
private final JwtRequestFilter jwtRequestFilter;
public SecurityConfig(JwtAuthenticationEntryPoint jwtAuthenticationEntryPoint,
JwtRequestFilter jwtRequestFilter) {
this.jwtAuthenticationEntryPoint = jwtAuthenticationEntryPoint;
this.jwtRequestFilter = jwtRequestFilter;
}
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http.csrf(csrf -> csrf.disable())
.authorizeHttpRequests(authz -> authz
.requestMatchers("/api/v1/auth/**", "/api/v1/public/**").permitAll()
.requestMatchers("/api/v1/admin/**").hasRole("ADMIN")
.requestMatchers("/api/v1/transactions/**").hasAnyRole("USER", "ADMIN")
.anyRequest().authenticated()
)
.exceptionHandling(ex -> ex
.authenticationEntryPoint(jwtAuthenticationEntryPoint)
)
.sessionManagement(session -> session
.sessionCreationPolicy(SessionCreationPolicy.STATELESS)
);
http.addFilterBefore(jwtRequestFilter, UsernamePasswordAuthenticationFilter.class);
return http.build();
}
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
@Bean
public AuthenticationManager authenticationManager(
AuthenticationConfiguration authenticationConfiguration) throws Exception {
return authenticationConfiguration.getAuthenticationManager();
}
}

REST API Controllers

1. Wallet Controller:

package com.wallet.backend.controller;
import com.wallet.backend.domain.wallet.Wallet;
import com.wallet.backend.service.wallet.WalletService;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.security.SecurityRequirement;
import jakarta.validation.Valid;
import org.springframework.http.ResponseEntity;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.web.bind.annotation.*;
import java.math.BigDecimal;
import java.util.Currency;
@RestController
@RequestMapping("/api/v1/wallets")
@SecurityRequirement(name = "bearerAuth")
public class WalletController {
private final WalletService walletService;
public WalletController(WalletService walletService) {
this.walletService = walletService;
}
@PostMapping
@Operation(summary = "Create a new wallet")
public ResponseEntity<WalletResponse> createWallet(
@AuthenticationPrincipal UserPrincipal userPrincipal,
@Valid @RequestBody CreateWalletRequest request) {
Wallet wallet = walletService.createWallet(
userPrincipal.getId(), 
Currency.getInstance(request.getCurrency())
);
return ResponseEntity.ok(WalletResponse.fromEntity(wallet));
}
@GetMapping("/{walletId}/balance")
@Operation(summary = "Get wallet balance")
public ResponseEntity<BalanceResponse> getBalance(
@AuthenticationPrincipal UserPrincipal userPrincipal,
@PathVariable String walletId) {
BigDecimal balance = walletService.getBalance(walletId);
return ResponseEntity.ok(new BalanceResponse(walletId, balance));
}
@GetMapping("/{walletId}")
@Operation(summary = "Get wallet details")
public ResponseEntity<WalletResponse> getWallet(
@AuthenticationPrincipal UserPrincipal userPrincipal,
@PathVariable String walletId) {
Wallet wallet = walletService.getWallet(walletId);
return ResponseEntity.ok(WalletResponse.fromEntity(wallet));
}
// DTO Classes
public record CreateWalletRequest(String currency) {}
public record WalletResponse(String id, String walletNumber, 
String balance, String currency) {
public static WalletResponse fromEntity(Wallet wallet) {
return new WalletResponse(
wallet.getId(),
wallet.getWalletNumber(),
wallet.getBalance().toString(),
wallet.getCurrency().getCurrencyCode()
);
}
}
public record BalanceResponse(String walletId, BigDecimal balance) {}
}

2. Transaction Controller:

package com.wallet.backend.controller;
import com.wallet.backend.domain.transaction.Transaction;
import com.wallet.backend.service.transaction.TransactionService;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.security.SecurityRequirement;
import jakarta.validation.Valid;
import org.springframework.data.domain.Page;
import org.springframework.http.ResponseEntity;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.web.bind.annotation.*;
import java.math.BigDecimal;
import java.time.LocalDateTime;
@RestController
@RequestMapping("/api/v1/transactions")
@SecurityRequirement(name = "bearerAuth")
public class TransactionController {
private final TransactionService transactionService;
public TransactionController(TransactionService transactionService) {
this.transactionService = transactionService;
}
@PostMapping("/transfer")
@Operation(summary = "Initiate fund transfer")
public ResponseEntity<TransactionResponse> transferFunds(
@AuthenticationPrincipal UserPrincipal userPrincipal,
@Valid @RequestBody TransferRequest request) {
Transaction transaction = transactionService.initiateTransfer(
request.fromWalletId(),
request.toWalletId(),
request.amount(),
request.description()
);
return ResponseEntity.ok(TransactionResponse.fromEntity(transaction));
}
@GetMapping("/history")
@Operation(summary = "Get transaction history")
public ResponseEntity<Page<TransactionResponse>> getTransactionHistory(
@AuthenticationPrincipal UserPrincipal userPrincipal,
@RequestParam String walletId,
@RequestParam(required = false) LocalDateTime fromDate,
@RequestParam(required = false) LocalDateTime toDate,
@RequestParam(defaultValue = "0") int page,
@RequestParam(defaultValue = "20") int size) {
Page<Transaction> transactions = transactionService.getTransactionHistory(
walletId, fromDate, toDate, page, size
);
return ResponseEntity.ok(transactions.map(TransactionResponse::fromEntity));
}
// DTO Classes
public record TransferRequest(String fromWalletId, String toWalletId,
BigDecimal amount, String description) {}
public record TransactionResponse(String id, String referenceNumber,
String fromWallet, String toWallet,
String amount, String status, String type) {
public static TransactionResponse fromEntity(Transaction transaction) {
return new TransactionResponse(
transaction.getId(),
transaction.getReferenceNumber(),
transaction.getFromWallet() != null ? transaction.getFromWallet().getWalletNumber() : null,
transaction.getToWallet() != null ? transaction.getToWallet().getWalletNumber() : null,
transaction.getAmount().toString(),
transaction.getStatus().name(),
transaction.getType().name()
);
}
}
}

Fraud Detection Service

package com.wallet.backend.service.fraud;
import com.wallet.backend.domain.transaction.Transaction;
import com.wallet.backend.domain.transaction.TransactionRepository;
import org.springframework.stereotype.Service;
import java.math.BigDecimal;
import java.time.LocalDateTime;
@Service
public class FraudDetectionService {
private final TransactionRepository transactionRepository;
// Configuration
private static final BigDecimal DAILY_LIMIT = new BigDecimal("50000");
private static final BigDecimal SINGLE_TXN_LIMIT = new BigDecimal("10000");
private static final int MAX_TXN_PER_HOUR = 10;
public FraudDetectionService(TransactionRepository transactionRepository) {
this.transactionRepository = transactionRepository;
}
public boolean isSuspiciousTransaction(Transaction transaction) {
String walletId = transaction.getFromWallet().getId();
BigDecimal amount = transaction.getAmount();
LocalDateTime now = LocalDateTime.now();
// Check single transaction limit
if (amount.compareTo(SINGLE_TXN_LIMIT) > 0) {
return true;
}
// Check daily limit
BigDecimal dailyTotal = transactionRepository.getDailyTransactionTotal(
walletId, now.toLocalDate().atStartOfDay(), now);
if (dailyTotal.add(amount).compareTo(DAILY_LIMIT) > 0) {
return true;
}
// Check transaction frequency
long hourCount = transactionRepository.getTransactionCountSince(
walletId, now.minusHours(1));
if (hourCount >= MAX_TXN_PER_HOUR) {
return true;
}
// Check for unusual patterns (simplified)
if (isUnusualAmount(amount) || isUnusualTime(now)) {
return true;
}
return false;
}
private boolean isUnusualAmount(BigDecimal amount) {
// Check for round numbers or specific patterns
return amount.remainder(BigDecimal.valueOf(1000)).compareTo(BigDecimal.ZERO) == 0;
}
private boolean isUnusualTime(LocalDateTime time) {
// Flag transactions between 1 AM and 5 AM as unusual
int hour = time.getHour();
return hour >= 1 && hour <= 5;
}
}

Database Migration with Flyway

V1__Initial_schema.sql:

-- Users table
CREATE TABLE users (
id VARCHAR(36) PRIMARY KEY,
first_name VARCHAR(100) NOT NULL,
last_name VARCHAR(100) NOT NULL,
email VARCHAR(255) UNIQUE NOT NULL,
phone_number VARCHAR(15) UNIQUE NOT NULL,
password_hash VARCHAR(255) NOT NULL,
status VARCHAR(50) NOT NULL DEFAULT 'PENDING_VERIFICATION',
kyc_status VARCHAR(50) NOT NULL DEFAULT 'NOT_VERIFIED',
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
last_login_at TIMESTAMP
);
-- User roles
CREATE TABLE user_roles (
user_id VARCHAR(36) NOT NULL,
role VARCHAR(50) NOT NULL,
PRIMARY KEY (user_id, role),
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
);
-- Wallets table
CREATE TABLE wallets (
id VARCHAR(36) PRIMARY KEY,
user_id VARCHAR(36) NOT NULL,
wallet_number VARCHAR(20) UNIQUE NOT NULL,
balance DECIMAL(15,2) NOT NULL DEFAULT 0.00,
currency VARCHAR(3) NOT NULL,
status VARCHAR(50) NOT NULL DEFAULT 'ACTIVE',
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
version BIGINT NOT NULL DEFAULT 0,
FOREIGN KEY (user_id) REFERENCES users(id)
);
-- Transactions table
CREATE TABLE transactions (
id VARCHAR(36) PRIMARY KEY,
reference_number VARCHAR(50) UNIQUE NOT NULL,
from_wallet_id VARCHAR(36),
to_wallet_id VARCHAR(36),
amount DECIMAL(15,2) NOT NULL,
currency VARCHAR(3) NOT NULL,
fee DECIMAL(15,2) NOT NULL DEFAULT 0.00,
type VARCHAR(50) NOT NULL,
status VARCHAR(50) NOT NULL DEFAULT 'PENDING',
description TEXT,
metadata JSONB,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (from_wallet_id) REFERENCES wallets(id),
FOREIGN KEY (to_wallet_id) REFERENCES wallets(id)
);
-- Indexes
CREATE INDEX idx_users_email ON users(email);
CREATE INDEX idx_users_phone ON users(phone_number);
CREATE INDEX idx_wallets_user ON wallets(user_id);
CREATE INDEX idx_wallets_number ON wallets(wallet_number);
CREATE INDEX idx_txn_reference ON transactions(reference_number);
CREATE INDEX idx_txn_timestamp ON transactions(created_at);
CREATE INDEX idx_txn_wallets ON transactions(from_wallet_id, to_wallet_id);

Application Configuration

application.yml:

spring:
datasource:
url: jdbc:postgresql://localhost:5432/wallet_db
username: wallet_user
password: ${DB_PASSWORD:default_password}
hikari:
maximum-pool-size: 20
minimum-idle: 5
connection-timeout: 30000
idle-timeout: 600000
max-lifetime: 1800000
jpa:
hibernate:
ddl-auto: validate
properties:
hibernate:
dialect: org.hibernate.dialect.PostgreSQLDialect
jdbc:
batch_size: 20
order_inserts: true
order_updates: true
show-sql: false
flyway:
enabled: true
locations: classpath:db/migration
app:
jwt:
secret: ${JWT_SECRET:mySuperSecretKeyForJWTTokenGenerationInWalletApplication}
expiration: 86400000 # 24 hours
transaction:
daily-limit: 50000
single-limit: 10000
max-hourly-transactions: 10
notification:
email-enabled: true
sms-enabled: true
push-enabled: true
logging:
level:
com.wallet.backend: INFO
org.springframework.security: WARN
pattern:
console: "%d{yyyy-MM-dd HH:mm:ss} - %logger{36} - %msg%n"

Testing

Wallet Service Test:

package com.wallet.backend.service.wallet;
import com.wallet.backend.domain.wallet.Wallet;
import com.wallet.backend.domain.wallet.WalletRepository;
import com.wallet.backend.exception.InsufficientFundsException;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import java.math.BigDecimal;
import java.util.Optional;
import java.util.UUID;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.*;
@ExtendWith(MockitoExtension.class)
class WalletServiceTest {
@Mock
private WalletRepository walletRepository;
@Mock
private TransactionService transactionService;
private WalletService walletService;
private Wallet testWallet;
@BeforeEach
void setUp() {
walletService = new WalletService(walletRepository, transactionService);
testWallet = new Wallet();
testWallet.setId(UUID.randomUUID().toString());
testWallet.setBalance(new BigDecimal("1000.00"));
testWallet.setVersion(1L);
}
@Test
void debitWallet_SufficientFunds_ShouldSucceed() {
// Arrange
when(walletRepository.findById(testWallet.getId())).thenReturn(Optional.of(testWallet));
when(walletRepository.debitWallet(any(), any(), any())).thenReturn(1);
// Act & Assert
assertDoesNotThrow(() -> 
walletService.debitWallet(testWallet.getId(), new BigDecimal("500.00"), "TEST")
);
verify(walletRepository).debitWallet(testWallet.getId(), new BigDecimal("500.00"), 1L);
}
@Test
void debitWallet_InsufficientFunds_ShouldThrowException() {
// Arrange
when(walletRepository.findById(testWallet.getId())).thenReturn(Optional.of(testWallet));
// Act & Assert
assertThrows(InsufficientFundsException.class, () ->
walletService.debitWallet(testWallet.getId(), new BigDecimal("1500.00"), "TEST")
);
}
}

Deployment and Monitoring

Dockerfile:

FROM eclipse-temurin:17-jre-alpine
WORKDIR /app
COPY target/wallet-backend-1.0.0.jar app.jar
RUN addgroup -S spring && adduser -S spring -G spring
USER spring
EXPOSE 8080
ENTRYPOINT ["java", "-jar", "app.jar"]

docker-compose.yml:

version: '3.8'
services:
wallet-backend:
build: .
ports:
- "8080:8080"
environment:
- DB_PASSWORD=wallet_password
- JWT_SECRET=your_jwt_secret_here
depends_on:
- postgres
networks:
- wallet-network
postgres:
image: postgres:15-alpine
environment:
- POSTGRES_DB=wallet_db
- POSTGRES_USER=wallet_user
- POSTGRES_PASSWORD=wallet_password
ports:
- "5432:5432"
volumes:
- postgres_data:/var/lib/postgresql/data
networks:
- wallet-network
volumes:
postgres_data:
networks:
wallet-network:
driver: bridge

Conclusion

Building a mobile wallet backend in Java requires careful consideration of:

Key Components:

  • Security: JWT authentication, encryption, fraud detection
  • Transactions: ACID properties, concurrency control, idempotency
  • Scalability: Microservices architecture, database partitioning
  • Compliance: KYC/AML regulations, data protection

Best Practices:

  1. Security First: Implement comprehensive security measures
  2. Idempotent Operations: Ensure duplicate requests don't cause issues
  3. Proper Error Handling: Clear error messages and logging
  4. Monitoring: Real-time monitoring and alerting
  5. Testing: Comprehensive test coverage including integration tests

Production Considerations:

  • Use distributed caching (Redis)
  • Implement circuit breakers for external service calls
  • Set up comprehensive logging and monitoring
  • Plan for database scaling and replication
  • Implement proper backup and disaster recovery procedures

By following these patterns and leveraging Java's robust ecosystem, you can build a secure, scalable, and maintainable mobile wallet backend that can handle millions of users and transactions while maintaining financial-grade reliability and security.

Leave a Reply

Your email address will not be published. Required fields are marked *


Macro Nepal Helper