At some point, a monolith starts working against you.
In my case, Finovara was a single Spring Boot application handling everything (authentication, transactions, limits, piggy banks, notifications, activity logs, currency conversion)
The bigger it got, the harder it was to change anything without worrying about something else breaking.
So I broke it apart.
This post is about the first three pieces I extracted: the API Gateway, the activity-log-backend, and a shared module called contracts-backend.
The Old Structure
Everything lived in one Spring Boot app.
The activity log — which tracks everything a user does in the app (expenses added, limits changed, logins, account changes) — was tangled up with the core business logic. Adding a new type of activity meant touching the same codebase responsible for transactions, security, and everything else.
The New Structure
finovara-backend/
├── api-gateway/
├── activity-log-backend/
├── contracts-backend/
└── core-backend/
Four modules (there will be more). Each with a clear responsibility.
API Gateway
The gateway is the single entry point for every request coming from the frontend.
It runs on port 8888 with SSL enabled and uses Spring Cloud Gateway (WebFlux-based).
Routing is simple and declarative:
routes:
- id: activity-log-backend
uri: ${ACTIVITY_LOG_URL}
predicates:
- Path=/api/account-activity/**,/api/archive-activities/**
- id: notification-backend
uri: ${NOTIFICATION_BACKEND_URL}
predicates:
- Path=/api/notification-settings/**
- id: core-backend
uri: ${CORE_BACKEND_URL}
predicates:
- Path=/**
Activity log routes go to activity-log-backend. Notification routes go to notification-backend. Everything else falls through to core-backend.
The gateway also handles CORS centrally — https://localhost:5173 is the only allowed origin, with credentials support. No individual service needs to worry about CORS anymore.
One thing worth noting: the gateway uses use-insecure-trust-manager: true for the HTTP client. The services communicate over HTTPS internally, and during local development with self-signed certs this was the pragmatic solution.
contracts-backend
This is the shared kernel of the whole system.
It's a separate module — no business logic, no controllers, no database. Just shared code that multiple services depend on:
- Kafka event records used between services
- DTOs shared across service boundaries
- Utility classes for extracting client data
For example, every activity event is a Java record defined here:
public record ExpenseActivityEvent(
Long userId,
ExpenseActivityType type,
BigDecimal amount,
ExpenseCategory category,
BigDecimal previousAmount,
ExpenseCategory previousCategory,
LocalDateTime occurredAt
) {}
Same pattern for LoginActivityEvent, LimitActivityEvent, PiggyBankActivityEvent, RevenueActivityEvent, SettingsActivityEvent, AccountChangesActivityEvent.
The utility classes handle things like extracting the client's real IP behind a proxy:
public static String getClientIpAddress(HttpServletRequest request) {
String xfHeader = request.getHeader("X-Forwarded-For");
if (xfHeader == null) {
return request.getRemoteAddr();
}
return xfHeader.split(",")[0];
}
And resolving location from IP using the ip-api.com API:
String url = "http://ip-api.com/json/" + ip;
RestTemplate restTemplate = new RestTemplate();
Map<String, Object> response = restTemplate.getForObject(url, Map.class);
Browser detection uses a User-Agent parser:
Client client = parser.parse(userAgent);
return client.userAgent.family;
All of this lives in contracts-backend so it can be reused by core-backend, activity-log-backend, or any future service without duplication.
activity-log-backend
This service handles everything related to user activity history.
When a user adds an expense, changes a limit, logs in, or modifies account settings — an event is published to Kafka by core-backend. The activity-log-backend consumes those events and persists them independently.
The consumer class listens to all activity topics:
@KafkaListener(topics = "activity.expense")
public void handleExpense(ExpenseActivityEvent event) {
expenseActivityService.handleEvent(event);
}
@KafkaListener(topics = "activity.login")
public void handleLogin(LoginActivityEvent event) {
loginActivityService.handleEvent(event);
}
@KafkaListener(topics = "activity.account-changes")
public void handleAccountChanges(AccountChangesActivityEvent event) {
accountChangesActivityService.handleEvent(event);
}
@KafkaListener(topics = "user-account.deleted")
public void handleAccountDeleted(UserAccountDeletedEvent event) {
deletableServices.forEach(service -> service.deleteByUserId(event.userId()));
}
Seven topics in total: activity.settings, activity.revenue, activity.piggybank, activity.login, activity.limit, activity.expense, activity.account-changes.
When a user deletes their account, core-backend publishes a user-account.deleted event. The activity service handles this by calling deleteByUserId on every service that implements UserDataDeletable. Clean and consistent.
Security section requires password confirmation
The login activity and account changes sections show sensitive data — IP address, browser, location. To access them, the user has to confirm their password first.
The activity service can't verify passwords itself. That logic lives in core-backend. So instead, it calls core-backend through a Feign client:
@FeignClient(name = "core-backend", url = "${core-backend.url}")
public interface CoreBackendClient {
@PostMapping("/internal/verify-password")
Void verifyPassword(@RequestHeader("X-User-Id") Long userId,
@RequestBody ConfirmPasswordDto dto);
}
This is the only synchronous call between activity-log-backend and core-backend. Everything else goes through Kafka.
core-backend
Everything that wasn't activity logging stayed here.
Authentication, transactions, limits, piggy banks, notifications, currency conversion, reports. The full business logic of Finovara — just without the activity tracking responsibility.
When something significant happens, core-backend publishes an event to Kafka and moves on. It doesn't know or care what activity-log-backend does with it.
What This Split Actually Solved
Isolation.
Activity logging can fail without touching transactions. The two failure domains are now separate.
Independent deployability.
I can update the activity service without touching core logic.
Cleaner codebase.
Each service has a clear reason to exist. The activity service handles activity. The gateway handles routing. The contracts module handles shared types.
What Was Painful
Kafka event design.
Getting the event records right took iteration. The contracts-backend module helps here — one place to change an event definition and both sides see it immediately.
Internal communication.
Deciding when to use Kafka vs Feign wasn't always obvious. The rule I settled on: async Kafka for everything event-driven, Feign only when the caller needs an actual response (like password verification).
Local SSL between services.
All services run HTTPS locally. The use-insecure-trust-manager in the gateway config is the result of that — not ideal, but it works for development.
This is still a work in progress. More services will be extracted from core-backend over time.
Thanks for reading!
If you want to follow Finovara's development, the source is on GitHub.
Top comments (0)