| name | core-banking-engineer |
| description | **Master Skill**: Backend Systems Architect for PayU. Specialized in Spring Boot 3.4, Quarkus Native, Hexagonal Architecture, Transactions, Caching, high-performance Java patterns, and multi-service Resilience. |
๐ Reference Implementation Patterns
For detailed patterns and historical context on PayU backend engineering, see:
PayU Core Banking Architect Master Skill
๐ฆ Build System & Dependency Management
All PayU microservices MUST inherit from the consolidated parent POM to ensure consistency in library versions, security configurations, and compiler settings (including Lombok/Annotation processing).
1. Parent POM Standard
NEVER use spring-boot-starter-parent directly in a microservice. Always use:
<parent>
<groupId>id.payu</groupId>
<artifactId>payu-backend-parent</artifactId>
<version>1.0.0-SNAPSHOT</version>
<relativePath>../pom.xml</relativePath>
</parent>
2. Lombok & Code Generation
Always use Lombok annotations for boilerplate. If compilation fails with "cannot find symbol", verify that the service is properly linked to the parent POM and that the maven-compiler-plugin hasn't been overridden without the lombok annotation processor.
๐๏ธ Hexagonal Architecture (The PayU Standard)
All core services MUST separate business logic from technical infrastructure:
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
โ Domain Layer โ
โ โโโโโโโโโโโโโโโ โโโโโโโโโโโโโโโ โโโโโโโโโโโโโโโ โ
โ โ Entities โ โ Value โ โ Ports โ โ
โ โ โ โ Objects โ โ (Interfaces)โ โ
โ โโโโโโโโโโโโโโโ โโโโโโโโโโโโโโโ โโโโโโโโโโโโโโโ โ
โ NO FRAMEWORK ANNOTATIONS โ
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโค
โ Application Layer โ
โ โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ โ
โ โ Use Cases / Input Ports โ โ
โ โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ โ
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโค
โ Adapters Layer โ
โ โโโโโโโโโโโโ โโโโโโโโโโโโ โโโโโโโโโโโโ โโโโโโโโโโโโ โ
โ โ REST โ โ Database โ โ Kafka โ โ External โ โ
โ โ Adapter โ โ Adapter โ โ Adapter โ โ Clients โ โ
โ โโโโโโโโโโโโ โโโโโโโโโโโโ โโโโโโโโโโโโ โโโโโโโโโโโโ โ
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
ArchUnit Enforcement
@ArchTest
static final ArchRule domainShouldNotDependOnInfrastructure =
noClasses()
.that().resideInAPackage("..domain..")
.should().dependOnClassesThat()
.resideInAnyPackage("..adapter..", "..config..", "org.springframework..");
@ArchTest
static final ArchRule servicesShouldOnlyAccessRepositoriesThroughPorts =
noClasses()
.that().resideInAPackage("..application..")
.should().dependOnClassesThat()
.resideInAnyPackage("..adapter.persistence..");
๐ Repository Pattern (Domain-Driven)
Port Definition (Domain Layer)
public interface AccountRepository {
Optional<Account> findById(AccountId id);
List<Account> findByUserId(UserId userId);
Account save(Account account);
void delete(AccountId id);
}
Adapter Implementation (Infrastructure Layer)
@Repository
@RequiredArgsConstructor
public class JpaAccountRepository implements AccountRepository {
private final AccountJpaRepository jpaRepository;
private final AccountMapper mapper;
@Override
public Optional<Account> findById(AccountId id) {
return jpaRepository.findById(id.value())
.map(mapper::toDomain);
}
@Override
public Account save(Account account) {
AccountEntity entity = mapper.toEntity(account);
AccountEntity saved = jpaRepository.save(entity);
return mapper.toDomain(saved);
}
@Query("SELECT new AccountSummaryDto(a.id, a.name, a.balance) FROM AccountEntity a WHERE a.userId = :userId")
List<AccountSummaryDto> findSummariesByUserId(@Param("userId") String userId);
}
๐ Transaction Patterns
Transactional Outbox (Atomicity DB + Kafka)
@Service
@RequiredArgsConstructor
public class TransferService {
private final AccountRepository accountRepository;
private final OutboxRepository outboxRepository;
@Transactional
public Transfer executeTransfer(TransferCommand command) {
Account source = accountRepository.findById(command.sourceId())
.orElseThrow(() -> new AccountNotFoundException(command.sourceId()));
Account target = accountRepository.findById(command.targetId())
.orElseThrow(() -> new AccountNotFoundException(command.targetId()));
source.debit(command.amount());
target.credit(command.amount());
accountRepository.save(source);
accountRepository.save(target);
outboxRepository.save(OutboxEvent.builder()
.aggregateType("Transfer")
.aggregateId(UUID.randomUUID().toString())
.eventType("TransferCompleted")
.payload(objectMapper.writeValueAsString(command))
.build());
return Transfer.completed(command);
}
}
N+1 Prevention
List<Account> accounts = accountRepository.findAll();
for (Account account : accounts) {
account.setOwner(userRepository.findById(account.getUserId()));
}
@Query("SELECT a FROM Account a JOIN FETCH a.owner WHERE a.status = :status")
List<Account> findAllWithOwnerByStatus(@Param("status") AccountStatus status);
List<Account> accounts = accountRepository.findAll();
Set<UserId> userIds = accounts.stream().map(Account::getUserId).collect(toSet());
Map<UserId, User> userMap = userRepository.findByIdIn(userIds).stream()
.collect(toMap(User::getId, Function.identity()));
accounts.forEach(a -> a.setOwner(userMap.get(a.getUserId())));
๐๏ธ Caching Strategies
Multi-Layer Caching (L1 Caffeine + L2 Redis)
@Configuration
@EnableCaching
public class CacheConfig {
@Bean
public CacheManager cacheManager(RedisConnectionFactory redisConnectionFactory) {
CaffeineCacheManager l1CacheManager = new CaffeineCacheManager();
l1CacheManager.setCaffeine(Caffeine.newBuilder()
.maximumSize(1000)
.expireAfterWrite(5, TimeUnit.MINUTES));
RedisCacheManager l2CacheManager = RedisCacheManager.builder(redisConnectionFactory)
.cacheDefaults(RedisCacheConfiguration.defaultCacheConfig()
.entryTtl(Duration.ofMinutes(30)))
.build();
return new CompositeCacheManager(l1CacheManager, l2CacheManager);
}
}
Cache-Aside Pattern
@Service
@RequiredArgsConstructor
public class AccountCacheService {
private final AccountRepository accountRepository;
private final RedisTemplate<String, Account> redisTemplate;
private static final Duration CACHE_TTL = Duration.ofMinutes(5);
public Account findById(AccountId id) {
String cacheKey = "account:" + id.value();
Account cached = redisTemplate.opsForValue().get(cacheKey);
if (cached != null) {
return cached;
}
Account account = accountRepository.findById(id)
.orElseThrow(() -> new AccountNotFoundException(id));
redisTemplate.opsForValue().set(cacheKey, account, CACHE_TTL);
return account;
}
public void invalidateCache(AccountId id) {
redisTemplate.delete("account:" + id.value());
}
}
โ Spring Boot 3.4 Patterns
Idempotency (Prevent Double-Spending)
@RestController
@RequestMapping("/api/v1/transfers")
@RequiredArgsConstructor
public class TransferController {
private final TransferService transferService;
private final IdempotencyService idempotencyService;
@PostMapping
public ResponseEntity<TransferResponse> createTransfer(
@RequestHeader("Idempotency-Key") String idempotencyKey,
@Valid @RequestBody TransferRequest request) {
Optional<TransferResponse> cached = idempotencyService.get(idempotencyKey);
if (cached.isPresent()) {
return ResponseEntity.ok(cached.get());
}
Transfer transfer = transferService.executeTransfer(request.toCommand());
TransferResponse response = TransferResponse.from(transfer);
idempotencyService.store(idempotencyKey, response, Duration.ofHours(24));
return ResponseEntity.status(HttpStatus.CREATED).body(response);
}
}
Resilience (Resilience4j)
@Service
@RequiredArgsConstructor
public class FxServiceClient {
private final RestTemplate restTemplate;
@CircuitBreaker(name = "fxService", fallbackMethod = "getCachedRate")
@Bulkhead(name = "fxService", type = Bulkhead.Type.THREADPOOL)
@Retry(name = "fxService")
public ExchangeRate getRate(String from, String to) {
return restTemplate.getForObject(
"/api/v1/rates?from={from}&to={to}",
ExchangeRate.class,
from, to
);
}
private ExchangeRate getCachedRate(String from, String to, Exception ex) {
log.warn("FX service unavailable, using cached rate", ex);
return cachedRateRepository.findLatest(from, to)
.orElseThrow(() -> new ServiceUnavailableException("FX rate unavailable"));
}
}
resilience4j:
circuitbreaker:
instances:
fxService:
registerHealthIndicator: true
slidingWindowSize: 100
slidingWindowType: COUNT_BASED
minimumNumberOfCalls: 10
permittedNumberOfCallsInHalfOpenState: 10
automaticTransitionFromOpenToHalfOpenEnabled: true
waitDurationInOpenState: 10s
failureRateThreshold: 50
slowCallRateThreshold: 50
slowCallDurationThreshold: 2000ms
recordExceptions:
- java.net.SocketTimeoutException
- org.springframework.web.client.ResourceAccessException
ignoreExceptions:
- id.payu.core.exception.BusinessException
bulkhead:
instances:
fxService:
maxConcurrentCalls: 20
maxWaitDuration: 500ms
retry:
instances:
fxService:
maxAttempts: 3
waitDuration: 1s
exponentialBackoffMultiplier: 2
retryExceptions:
- java.io.IOException
- java.net.SocketTimeoutException
โ๏ธ Quarkus Native (High-Velocity Services)
For lightweight tasks (Gateway, Notifications, Billing), use Quarkus for sub-second startup:
@ApplicationScoped
public class BillingProcessor {
@Inject
BillingRepository billingRepository;
@Incoming("billing-process")
@Acknowledgment(Acknowledgment.Strategy.POST_PROCESSING)
@Transactional
public CompletionStage<Void> process(BillingEvent event) {
return billingRepository.process(event)
.thenAccept(result -> Log.infof("Processed billing: %s", event.getId()));
}
}
๐ก๏ธ Financial Integrity & Security
@Value
@RequiredArgsConstructor(staticName = "of")
public class Money {
BigDecimal amount;
Currency currency;
public Money add(Money other) {
validateSameCurrency(other);
return Money.of(
this.amount.add(other.amount).setScale(2, RoundingMode.HALF_EVEN),
this.currency
);
}
public Money subtract(Money other) {
validateSameCurrency(other);
BigDecimal result = this.amount.subtract(other.amount)
.setScale(2, RoundingMode.HALF_EVEN);
if (result.compareTo(BigDecimal.ZERO) < 0) {
throw new InsufficientFundsException("Insufficient balance");
}
return Money.of(result, this.currency);
}
}
๐ Structured Logging
@Aspect
@Component
@RequiredArgsConstructor
public class RequestLoggingAspect {
@Around("@within(org.springframework.web.bind.annotation.RestController)")
public Object logRequest(ProceedingJoinPoint joinPoint) throws Throwable {
String requestId = MDC.get("requestId");
String method = joinPoint.getSignature().getName();
log.info("Request started",
StructuredArguments.kv("requestId", requestId),
StructuredArguments.kv("method", method),
StructuredArguments.kv("args", joinPoint.getArgs()));
long start = System.currentTimeMillis();
try {
Object result = joinPoint.proceed();
log.info("Request completed",
StructuredArguments.kv("requestId", requestId),
StructuredArguments.kv("durationMs", System.currentTimeMillis() - start));
return result;
} catch (Exception e) {
log.error("Request failed",
StructuredArguments.kv("requestId", requestId),
StructuredArguments.kv("error", e.getMessage()));
throw e;
}
}
}
๐ Quality & Reliability Checklist
๐จ audit Status โ Current Platform Reality (Feb 2026)
CRITICAL: Read .agent/context/ROADMAP.md for full details.
Production Readiness: 48/100 โ NOT ready for deployment.
Service Compliance Matrix (What You MUST Know)
| Service | Hex Arch | Shared Starters | Action Required |
|---|
| wallet-service | โ
Full | โ
All 4 | Integrate outbox-starter (R-002) |
| transaction-service | โ
Full | โ
All 4 | Integrate outbox-starter + saga-starter (R-002) |
| account-service | โ
Full | โ
All 4 | โ |
| auth-service | โ
Full | โ
All 4 | โ |
| kyc-service | โ
Full | โ
All 4 | โ |
| lending-service | โ ๏ธ Partial | โ
| Write integration tests (R-004), complete hex refactor |
| fx-service | โ ๏ธ Partial | โ
| Write integration tests (R-004), complete hex refactor |
| cms-service | ๐ด Flat | ๐ด ZERO starters | Add ALL starters (R-006), refactor to hexagonal |
| ab-testing-service | ๐ด Flat | ๐ด Only api-commons | Add security+resilience+cache (R-006) |
| statement-service | ๐ด Thin | ๐ด ZERO starters | Add ALL starters (R-006), write tests |
| support-service | ๐ด Flat | โ
| Refactor to hexagonal (R-008) |
| promotion-service | ๐ด Flat | โ
| Refactor to hexagonal (R-008) |
| backoffice-service | ๐ด Flat | โ
| Refactor to hexagonal (R-008) |
P0 Blockers Relevant to This Skill
-
P0-ARCH-001: outbox-starter and saga-starter exist in backend/shared/ but ZERO services use them. When implementing any financial transaction, you MUST integrate outbox-starter for atomic event publishing. See docs/guides/LESSONS.md ยง "Transactional Outbox Pattern Integration".
-
P0-TEST-001: lending-service and fx-service have ZERO integration tests. Any new feature in these services MUST include Testcontainers integration tests.
Hexagonal Refactoring Checklist (for flat-package services)
When asked to work on cms, ab-testing, statement, support, promotion, or backoffice services:
- Create
domain/model/, domain/port/in/, domain/port/out/ packages
- Move entities to domain (remove
@Entity from domain โ keep in adapter)
- Create
application/ package with use case interfaces
- Create
infrastructure/persistence/ adapter implementing domain ports
- Create
interfaces/rest/ with DTOs separate from domain models
- Add
ArchitectureTest.java using archunit-starter
- Add
security-starter, resilience-starter, cache-starter dependencies
- Run
mvn test to verify ArchUnit rules pass
Last Updated: 2026-05-04