Spring Boot Budget Service
Introduction
The Budget Service is the financial core of PFMS. It handles budget CRUD operations, publishes events to Kafka for downstream notifications, and caches hot data with Caffeine. This post walks through the real implementation — what’s in the code, why it’s built that way, and where the tradeoffs are.
BigDecimal: Why Precision Matters in Fintech
The Budget entity uses BigDecimal for the amount field, not double:
// budget-service/.../entity/Budget.java
@Entity
@Table(name = "budgets")
public class Budget {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String userId;
private BudgetCategory category;
private BigDecimal amount; // Not double
private LocalDate startDate;
private LocalDate endDate;
}
This is a deliberate choice. double arithmetic produces rounding errors — 0.1 + 0.2 evaluates to 0.30000000000000004 in IEEE 754. For a finance app, that’s unacceptable. BigDecimal is slower (heap-allocated, method-call arithmetic) but guarantees exact decimal representation. When you’re tracking someone’s money, correctness wins over performance.
Post-Commit Event Publishing
The biggest architectural decision in this service is how events reach Kafka. The current blog post claimed a “Transactional Outbox pattern” with an outbox table — that’s not what’s actually implemented. The real approach uses Spring’s @TransactionalEventListener:
Step 1: Save and Publish a Spring Event
// budget-service/.../service/BudgetService.java
@Transactional
public Budget saveBudget(Budget budget) {
Budget savedBudget = this.budgetRepository.save(budget);
BudgetNotification notification = budgetMapper.toNotification(savedBudget);
eventPublisher.publishEvent(notification); // Spring application event
return savedBudget;
}
This does not send a Kafka message. It publishes a Spring ApplicationEvent within the current transaction.
Step 2: Post-Commit Handler
// budget-service/.../producer/BudgetNotificationHandler.java
@Component
public class BudgetNotificationHandler {
private final BudgetNotificationProducer budgetNotificationProducer;
@Async // Runs on a separate thread pool
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
public void handleBudgetNotification(BudgetNotification request) {
budgetNotificationProducer.sendBudgetRequest(request);
}
}
The key is TransactionPhase.AFTER_COMMIT. If the database transaction rolls back, this handler never fires — no orphaned Kafka messages. The @Async annotation moves the Kafka send to a background thread so the HTTP response returns immediately.
The tradeoff: slightly higher latency for notifications (they wait for the commit + thread handoff), but guaranteed consistency between the database and Kafka. This is not a classic Transactional Outbox (no outbox table, no polling) — it’s a lighter-weight approach that works well when you don’t need message replay.
Step 3: Kafka Producer
// budget-service/.../producer/BudgetNotificationProducer.java
@Service
public class BudgetNotificationProducer {
private final KafkaTemplate<String, BudgetNotification> kafkaTemplate;
private static final String TOPIC = "notification_requests_topic";
@Async
public void sendBudgetRequest(BudgetNotification request) {
kafkaTemplate.send(TOPIC, request.getUserId(), request);
}
}
request.getUserId() as the Kafka key guarantees per-user ordering within a partition.
MapStruct: Compile-Time DTO Mapping
Converting the Budget entity to a BudgetNotification DTO is handled by MapStruct, which generates the mapping code at compile time:
// budget-service/.../mapper/BudgetMapper.java
@Mapper(componentModel = "spring", unmappedTargetPolicy = ReportingPolicy.IGNORE)
public interface BudgetMapper {
@Mapping(target = "id", source = "budget.id")
@Mapping(target = "userId", source = "budget.userId")
@Mapping(target = "subject", constant = "Producer Test")
@Mapping(target = "channel", constant = "SMS")
@Mapping(target = "messageBody", expression = "java(\"Budget created for category: \" + budget.getCategory())")
BudgetNotification toNotification(Budget budget);
}
The expression attribute is MapStruct’s escape hatch for complex transformations — it injects raw Java into the generated implementation. At compile time, MapStruct generates a concrete class that does plain field assignments. No reflection, no runtime overhead.
Why not ModelMapper? ModelMapper uses reflection to match fields by name. It’s convenient but slower at runtime and silently swallows mismatched fields. MapStruct catches mapping errors at compile time.
Caffeine Caching with Miss Logging
Budget lookups by ID use Spring’s @Cacheable backed by Caffeine:
// budget-service/.../service/BudgetService.java
@Cacheable(value = "budgets")
public Optional<Budget> findBudgetById(Long id) {
long start = System.currentTimeMillis();
Optional<Budget> b = this.budgetRepository.findById(id);
log.info("Cache MISS - ID: {} | Execution: {}ms", id, (System.currentTimeMillis() - start));
return b;
}
The log statement only fires on cache misses — when the method body actually executes. On cache hits, Spring returns the cached value without entering the method at all.
The Caffeine configuration in application.yml:
# budget-service/src/main/resources/application.yml
spring:
cache:
caffeine:
spec:
maximumSize: 500
expireAfterAccess: 600s
500 items max, 10-minute TTL per entry. Caffeine is an in-process cache — no network hop, sub-microsecond reads. The tradeoff: it’s not shared across service replicas. If you scale to multiple instances, each has its own cache, which means stale reads are possible. A distributed cache (Redis) would solve that, but adds a network dependency.
Custom Actuator: Runtime Introspection
The service exposes runtime metadata through Spring Boot Actuator’s InfoContributor:
// budget-service/.../actuator/CustomInfoContributor.java
@Component
public class CustomInfoContributor implements InfoContributor {
private final Environment environment;
@Override
public void contribute(Info.Builder builder) {
Map<String, Object> appDetails = new HashMap<>();
appDetails.put("version", "2.5.0-SNAPSHOT");
appDetails.put("status", "OPERATIONAL");
appDetails.put("deployedAt", DateTimeFormatter.ISO_INSTANT.format(Instant.now()));
String[] activeProfiles = environment.getActiveProfiles();
Map<String, Object> environmentInfo = new HashMap<>();
environmentInfo.put("profile", activeProfiles.length > 0
? String.join(",", activeProfiles) : "default/not-specified");
environmentInfo.put("os", System.getProperty("os.name"));
builder.withDetail("application", appDetails);
builder.withDetail("runtime_environment", environmentInfo);
}
}
Hit /actuator/info and you get the active Spring profile, OS, and deployment timestamp — useful for verifying which config profile is active in a given environment.
Summary
- BigDecimal for currency: correctness over performance
- Post-commit event publishing via
@TransactionalEventListener(AFTER_COMMIT)+@Async: consistency over latency, no orphaned Kafka messages - MapStruct for DTO mapping: compile-time code generation, zero reflection overhead
- Caffeine cache: sub-microsecond reads, but per-instance — consider Redis if scaling horizontally