The Challenge: Building a Complete Mutual Fund Platform
When I joined the GIFT City Mutual Fund platform project, the requirements were ambitious:
- Process 10,000+ concurrent investor onboarding requests
- Integrate with 8+ external financial APIs (KYC, eSign, Portfolio, Banking)
- Handle 1M+ transactions daily with <300ms P95 latency
- Support multiple distribution channels (Web, Mobile, B2B APIs)
- Meet strict regulatory compliance (SEBI, RBI guidelines)
- Ensure 99.9% uptime for a financial system
This wasn’t just another CRUD application—it required careful architectural decisions to balance scalability, maintainability, and compliance.
Architecture Overview: Hexagonal + Microservices
We adopted a hexagonal architecture (ports and adapters) combined with reactive microservices on Google Cloud Platform. Here’s why:
Why Hexagonal Architecture?
Traditional layered architecture (Controller → Service → Repository) creates tight coupling between business logic and infrastructure. Changes to external APIs or databases ripple through the codebase.
Hexagonal architecture inverts this:
┌─────────────────────────────────────────┐
│ Application Core (Domain) │
│ - Business Rules │
│ - Domain Models │
│ - Use Cases │
└─────────────────────────────────────────┘
↑ ↑
│ Ports │ Ports
│ (Interfaces) │ (Interfaces)
↓ ↓
┌─────────────────┐ ┌─────────────────┐
│ Input Adapters │ │ Output Adapters │
│ - REST API │ │ - PostgreSQL │
│ - gRPC │ │ - CAMS API │
│ - GraphQL │ │ - CERSAI KYC │
└─────────────────┘ └─────────────────┘
Benefits:
- Business logic is independent of frameworks
- Easy to swap adapters (e.g., PostgreSQL → MongoDB without touching business rules)
- Testable without external dependencies
- Multiple input channels (REST, gRPC, events) share the same core
System Architecture: 100+ Domain Services
The platform consists of 100+ domain-driven microservices, organized into bounded contexts:
1. Onboarding Domain (15 services)
┌───────────────────────────────────────────────┐
│ Investor Onboarding Gateway │
│ (Spring Cloud Gateway + Apigee) │
└───────────────────────────────────────────────┘
↓
┌─────────────┼─────────────┐
↓ ↓ ↓
┌──────────────┐ ┌──────────┐ ┌──────────────┐
│ PAN │ │ CKYC │ │ AML │
│ Verification│ │ Lookup │ │ Screening │
│ Service │ │ Service │ │ Service │
└──────────────┘ └──────────┘ └──────────────┘
│ │ │
└─────────────┼─────────────┘
↓
┌──────────────────────────┐
│ Application Aggregator │
│ (Orchestrates workflow) │
└──────────────────────────┘
↓
┌──────────────────────────┐
│ Application Repository │
│ (R2DBC + AlloyDB) │
└──────────────────────────┘
Key Design Decisions:
-
Reactive Communication: Services use Spring WebFlux with non-blocking I/O
@Service public class OnboardingOrchestrator { public Mono<OnboardingResponse> onboardInvestor(OnboardingRequest request) { return Mono.zip( panService.verifyPan(request.getPan()), // Parallel ckycService.searchCkyc(request.getCkycId()), // Parallel amlService.screenInvestor(request.getName()) // Parallel ) .flatMap(tuple -> { PanResponse pan = tuple.getT1(); CkycResponse ckyc = tuple.getT2(); AmlResponse aml = tuple.getT3(); return applicationService.createApplication(pan, ckyc, aml); }) .flatMap(app -> eSignService.initiateESign(app)) .timeout(Duration.ofSeconds(30)) .onErrorResume(this::handleOnboardingFailure); } } -
Saga Pattern for Distributed Transactions:
- No distributed transactions across services
- Each step publishes events to Google Pub/Sub
- Compensating transactions on failure (e.g., cancel eSign if KYC fails)
-
External API Resilience:
@Service public class CamsApiClient { @CircuitBreaker(name = "cams", fallbackMethod = "fallbackVerifyPan") @Retry(name = "cams", fallbackMethod = "fallbackVerifyPan") @TimeLimiter(name = "cams") public Mono<PanResponse> verifyPan(String pan) { return webClient.post() .uri("/api/pan/verify") .bodyValue(new PanRequest(pan)) .retrieve() .bodyToMono(PanResponse.class); } private Mono<PanResponse> fallbackVerifyPan(String pan, Exception e) { // Check cache or return partial response return cacheService.getPanData(pan) .switchIfEmpty(Mono.just(PanResponse.partial(pan))); } }Resilience4j Configuration:
resilience4j: circuitbreaker: instances: cams: slidingWindowSize: 100 failureRateThreshold: 50 waitDurationInOpenState: 60s permittedNumberOfCallsInHalfOpenState: 10
2. Order Management Domain (25 services)
Handles mutual fund purchase, redemption, and switch orders:
@RestController
@RequestMapping("/api/orders")
public class OrderController {
@PostMapping("/purchase")
public Mono<OrderResponse> createPurchaseOrder(
@RequestBody @Valid PurchaseOrderRequest request
) {
return orderService.createOrder(request)
.flatMap(order -> paymentGateway.initiatePayment(order))
.flatMap(payment -> rtaService.submitToRTA(payment))
.flatMap(rtaResponse -> orderRepo.updateOrderStatus(
rtaResponse.getOrderId(),
OrderStatus.SUBMITTED
))
.map(OrderResponse::from);
}
}
Key Integrations:
- Payment Gateway: Razorpay API for UPI/Net Banking
- RTA (Registrar & Transfer Agent): CAMS, KFintech APIs
- Portfolio Service: Real-time NAV (Net Asset Value) updates
3. Portfolio Management Domain (20 services)
Tracks investor holdings, returns, and performance:
@Service
public class PortfolioService {
public Flux<Holding> getInvestorPortfolio(String investorId) {
return holdingRepo.findByInvestorId(investorId)
.flatMap(holding ->
navService.getCurrentNAV(holding.getSchemeCode())
.map(nav -> holding.withCurrentValue(
holding.getUnits() * nav.getValue()
))
)
.sort(Comparator.comparing(Holding::getCurrentValue).reversed());
}
public Mono<PortfolioSummary> getPortfolioSummary(String investorId) {
return getInvestorPortfolio(investorId)
.reduce(
PortfolioSummary.empty(),
(summary, holding) -> summary.add(holding)
);
}
}
Performance Optimization:
- Caching: Redis for NAV data (15-minute TTL)
- Batch Processing: Nightly job recalculates portfolio values
- Database Indexing: Composite indexes on (investor_id, scheme_code)
Data Layer: R2DBC + AlloyDB
We chose R2DBC (Reactive Relational Database Connectivity) over JDBC for true non-blocking database access.
Traditional JDBC (Blocking)
// ❌ BLOCKING: Thread waits for database query
@Repository
public class ApplicationRepository {
@Autowired
private JdbcTemplate jdbcTemplate;
public Application findById(String id) {
return jdbcTemplate.queryForObject(
"SELECT * FROM applications WHERE id = ?",
new Object[]{id},
new ApplicationRowMapper()
); // Thread blocks here for ~50-100ms
}
}
R2DBC (Non-Blocking)
// ✅ NON-BLOCKING: Returns Mono immediately
@Repository
public interface ApplicationRepository extends ReactiveCrudRepository<Application, String> {
Mono<Application> findById(String id);
@Query("SELECT * FROM applications WHERE investor_id = :investorId ORDER BY created_at DESC")
Flux<Application> findByInvestorId(@Param("investorId") String investorId);
@Query("SELECT * FROM applications WHERE status = :status AND created_at > :since")
Flux<Application> findRecentByStatus(
@Param("status") ApplicationStatus status,
@Param("since") Instant since
);
}
Connection Pooling:
spring:
r2dbc:
url: r2dbc:postgresql://10.x.x.x:5432/giftcity
username: ${DB_USER}
password: ${DB_PASSWORD}
pool:
initial-size: 20
max-size: 100
max-idle-time: 30m
max-acquire-time: 3s
validation-query: SELECT 1
Query Performance:
| Metric | JDBC (Blocking) | R2DBC (Reactive) | Improvement |
|---|---|---|---|
| Simple Query | 45ms avg | 38ms avg | 15% faster |
| Complex Join | 120ms avg | 95ms avg | 21% faster |
| Batch Insert (1000 rows) | 850ms | 320ms | 62% faster |
| Connection Acquisition | 5-10ms | <1ms | 90% faster |
Why AlloyDB over Cloud SQL?
- 100% PostgreSQL compatible (easy migration)
- 4x faster transactional workloads (Google’s claim, we saw 2-3x)
- Built-in read replicas for reporting queries
- Column-level encryption for PII data
API Gateway: Spring Cloud Gateway + Apigee
We use a two-tier gateway architecture:
┌────────────────────────────────────────┐
│ Apigee Edge (External) │
│ - Rate limiting (100 req/min) │
│ - API key validation │
│ - OAuth2 token exchange │
│ - DDoS protection │
└────────────────────────────────────────┘
↓
┌────────────────────────────────────────┐
│ Spring Cloud Gateway (Internal) │
│ - Service discovery (Consul) │
│ - Load balancing │
│ - Circuit breaking │
│ - Request/response transformation │
└────────────────────────────────────────┘
↓
┌────────┼────────┐
↓ ↓ ↓
[Service A] [Service B] [Service C]
Spring Cloud Gateway Routes:
@Configuration
public class GatewayConfig {
@Bean
public RouteLocator customRoutes(RouteLocatorBuilder builder) {
return builder.routes()
.route("onboarding", r -> r
.path("/api/onboarding/**")
.filters(f -> f
.rewritePath("/api/onboarding/(?<segment>.*)", "/${segment}")
.addRequestHeader("X-Gateway-Source", "GIFT-City")
.circuitBreaker(c -> c
.setName("onboarding-cb")
.setFallbackUri("forward:/fallback/onboarding")
)
)
.uri("lb://onboarding-service")
)
.route("orders", r -> r
.path("/api/orders/**")
.filters(f -> f
.rewritePath("/api/orders/(?<segment>.*)", "/${segment}")
.requestRateLimiter(c -> c
.setRateLimiter(redisRateLimiter())
.setKeyResolver(userKeyResolver())
)
)
.uri("lb://order-service")
)
.build();
}
}
Rate Limiting (Redis-backed):
spring:
cloud:
gateway:
routes:
- id: onboarding
uri: lb://onboarding-service
predicates:
- Path=/api/onboarding/**
filters:
- name: RequestRateLimiter
args:
redis-rate-limiter.replenishRate: 100 # tokens/second
redis-rate-limiter.burstCapacity: 200 # max burst
key-resolver: "#{@userKeyResolver}"
Observability: The Three Pillars
1. Metrics (Micrometer + Prometheus)
@Service
@Timed(value = "onboarding.service", percentiles = {0.5, 0.95, 0.99})
public class OnboardingService {
private final Counter onboardingCounter;
private final Timer onboardingTimer;
public OnboardingService(MeterRegistry registry) {
this.onboardingCounter = registry.counter("onboarding.requests.total");
this.onboardingTimer = registry.timer("onboarding.duration");
}
public Mono<OnboardingResponse> onboardInvestor(OnboardingRequest request) {
onboardingCounter.increment();
return onboardingTimer.recordCallable(() ->
orchestrator.onboardInvestor(request)
);
}
}
Grafana Dashboard Metrics:
- Request rate (req/second)
- P50/P95/P99 latency
- Error rate (%)
- Circuit breaker state
- Database connection pool usage
- JVM memory/GC metrics
2. Logs (Structured JSON + Cloud Logging)
@Slf4j
@Service
public class PaymentService {
public Mono<PaymentResponse> processPayment(PaymentRequest request) {
return Mono.defer(() -> {
log.info("Processing payment: orderId={}, amount={}, method={}",
request.getOrderId(), request.getAmount(), request.getMethod());
return paymentGateway.initiatePayment(request)
.doOnSuccess(response ->
log.info("Payment successful: orderId={}, transactionId={}",
request.getOrderId(), response.getTransactionId())
)
.doOnError(error ->
log.error("Payment failed: orderId={}, error={}",
request.getOrderId(), error.getMessage(), error)
);
});
}
}
Structured Logging (Logback configuration):
<configuration>
<appender name="JSON" class="ch.qos.logback.core.ConsoleAppender">
<encoder class="net.logstash.logback.encoder.LogstashEncoder">
<customFields>
{"service":"onboarding-service","env":"production"}
</customFields>
</encoder>
</appender>
<root level="INFO">
<appender-ref ref="JSON" />
</root>
</configuration>
Output:
{
"timestamp": "2026-04-15T10:30:45.123Z",
"level": "INFO",
"service": "onboarding-service",
"trace_id": "a1b2c3d4e5f6",
"span_id": "1234567890ab",
"message": "Processing payment: orderId=ORD-12345, amount=10000.00, method=UPI"
}
3. Distributed Tracing (OpenTelemetry)
@Configuration
public class TracingConfig {
@Bean
public OpenTelemetry openTelemetry() {
return OpenTelemetrySdk.builder()
.setTracerProvider(
SdkTracerProvider.builder()
.addSpanProcessor(BatchSpanProcessor.builder(
OtlpGrpcSpanExporter.builder()
.setEndpoint("http://otel-collector:4317")
.build()
).build())
.setResource(Resource.create(
Attributes.of(
ResourceAttributes.SERVICE_NAME, "onboarding-service",
ResourceAttributes.SERVICE_VERSION, "1.0.0"
)
))
.build()
)
.buildAndRegisterGlobal();
}
}
Trace Example (Onboarding Flow):
Trace ID: a1b2c3d4e5f6
Total Duration: 2.3s
├─ HTTP POST /api/onboarding/verify-kyc (2.3s)
│ ├─ PAN Verification Service (1.8s)
│ │ ├─ CAMS API Call (1.5s)
│ │ └─ Cache Write (0.3s)
│ ├─ CKYC Lookup Service (1.2s) [parallel with PAN]
│ │ └─ CERSAI API Call (1.1s)
│ ├─ AML Screening Service (0.8s) [parallel with PAN/CKYC]
│ └─ Database Insert (0.05s)
Security: OAuth2 + JWT
@Configuration
@EnableWebFluxSecurity
public class SecurityConfig {
@Bean
public SecurityWebFilterChain securityWebFilterChain(ServerHttpSecurity http) {
return http
.csrf(ServerHttpSecurity.CsrfSpec::disable)
.authorizeExchange(exchanges -> exchanges
.pathMatchers("/api/public/**").permitAll()
.pathMatchers("/api/admin/**").hasRole("ADMIN")
.pathMatchers("/api/onboarding/**").hasAnyRole("INVESTOR", "DISTRIBUTOR")
.anyExchange().authenticated()
)
.oauth2ResourceServer(oauth2 -> oauth2
.jwt(jwt -> jwt.jwtDecoder(jwtDecoder()))
)
.build();
}
@Bean
public ReactiveJwtDecoder jwtDecoder() {
return ReactiveJwtDecoders.fromIssuerLocation(
"https://accounts.google.com" // Google OAuth2
);
}
}
JWT Token Structure:
{
"sub": "investor-12345",
"email": "investor@example.com",
"roles": ["INVESTOR"],
"iss": "https://accounts.google.com",
"exp": 1714056000,
"iat": 1714052400
}
Deployment: Google Kubernetes Engine
# deployment.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
name: onboarding-service
namespace: giftcity-prod
spec:
replicas: 5
selector:
matchLabels:
app: onboarding-service
template:
metadata:
labels:
app: onboarding-service
version: v1.2.0
spec:
containers:
- name: onboarding-service
image: gcr.io/giftcity/onboarding-service:v1.2.0
ports:
- containerPort: 8080
env:
- name: SPRING_PROFILES_ACTIVE
value: "production"
- name: DB_HOST
valueFrom:
secretKeyRef:
name: db-credentials
key: host
resources:
requests:
memory: "512Mi"
cpu: "500m"
limits:
memory: "1Gi"
cpu: "1000m"
livenessProbe:
httpGet:
path: /actuator/health/liveness
port: 8080
initialDelaySeconds: 60
periodSeconds: 10
readinessProbe:
httpGet:
path: /actuator/health/readiness
port: 8080
initialDelaySeconds: 30
periodSeconds: 5
---
# service.yaml
apiVersion: v1
kind: Service
metadata:
name: onboarding-service
namespace: giftcity-prod
spec:
selector:
app: onboarding-service
ports:
- protocol: TCP
port: 80
targetPort: 8080
type: ClusterIP
---
# hpa.yaml (Horizontal Pod Autoscaler)
apiVersion: autoscaling/v2
kind: HorizontalPodAutoscaler
metadata:
name: onboarding-service-hpa
namespace: giftcity-prod
spec:
scaleTargetRef:
apiVersion: apps/v1
kind: Deployment
name: onboarding-service
minReplicas: 5
maxReplicas: 50
metrics:
- type: Resource
resource:
name: cpu
target:
type: Utilization
averageUtilization: 70
- type: Resource
resource:
name: memory
target:
type: Utilization
averageUtilization: 80
Autoscaling in Action:
- Normal load: 5 pods (baseline)
- Peak hours (10 AM - 3 PM): 15-20 pods
- Flash sale/campaign: Scales to 50 pods in 2 minutes
- Cost optimization: Scales down to 3 pods at night
Results: Performance & Scale
After 6 months in production:
| Metric | Target | Achieved | Status |
|---|---|---|---|
| P95 Latency | <500ms | 287ms | ✅ 43% better |
| Throughput | 10,000 req/s | 12,500 req/s | ✅ 25% better |
| Uptime | 99.9% | 99.94% | ✅ Exceeded |
| Daily Transactions | 500K | 1.2M | ✅ 140% better |
| Error Rate | <0.1% | 0.03% | ✅ 70% better |
| Database Queries | <100ms P95 | 65ms P95 | ✅ 35% better |
| API Integration Latency | <2s P95 | 1.8s P95 | ✅ Met |
Cost Efficiency:
- Infrastructure cost: ₹2.5L/month for 1M+ transactions
- Per-transaction cost: ₹0.008 (less than 1 paisa)
- CPU utilization: 65% avg (well-optimized)
- Database connections: 85/100 avg (good pooling)
Lessons Learned
1. ✅ What Worked Well
Hexagonal Architecture:
- Swapped CAMS API with KFintech in 2 days (just changed adapter)
- Added GraphQL endpoint without touching business logic
- Unit tests don’t need database/external APIs
Reactive Programming:
- Handled 10x traffic spike during campaign with same infrastructure
- Reduced memory usage by 67% (8 threads vs 200 threads)
- Parallel API calls saved 60% latency
GCP Managed Services:
- AlloyDB auto-scaling saved us during unexpected load
- Cloud Pub/Sub handled 100K messages/second without tuning
- GKE autopilot reduced ops burden (no node management)
2. ❌ Challenges & Mistakes
Learning Curve:
- Team took 3 weeks to become productive with reactive programming
- Debugging reactive stack traces is harder:
reactor.core.publisher.FluxFlatMap$FlatMapMain - Had to create custom training materials
External API Reliability:
- CKYC API had 15% timeout rate (1.5-2 seconds avg)
- Implemented aggressive caching (24-hour TTL) to mitigate
- Built retry logic with exponential backoff
Database Schema Changes:
- R2DBC doesn’t support Flyway/Liquibase well in reactive mode
- Had to run migrations separately in blocking mode
- Solution: Separate migration job before deployment
3. 💡 Key Takeaways
- Don’t go reactive unless you need it: If your app has <100 concurrent users, Spring MVC is simpler
- Invest in observability early: Distributed tracing saved us countless debugging hours
- Test resilience scenarios: Circuit breakers, timeouts, retries are useless if not tested
- Document architecture decisions: We created ADRs (Architecture Decision Records) for every major choice
- Reactive all the way or not at all: Mixing blocking and non-blocking code kills performance
What’s Next?
Current Roadmap:
- Event Sourcing for audit compliance (CQRS pattern)
- Read Replicas for reporting queries (separate from transactional DB)
- Multi-region deployment for disaster recovery
- Real-time portfolio updates via WebSockets
- ML-based fraud detection (TensorFlow Serving integration)
Tech Stack Summary
Backend:
- Java 21, Spring Boot 3.3.5, Spring WebFlux
- R2DBC, PostgreSQL (AlloyDB)
- Spring Cloud Gateway, Resilience4j
- gRPC, Protocol Buffers
Infrastructure:
- Google Kubernetes Engine (GKE)
- Google Cloud Pub/Sub
- Apigee API Gateway
- Redis (caching)
Observability:
- Micrometer + Prometheus
- Grafana dashboards
- OpenTelemetry tracing
- Cloud Logging (structured JSON)
Testing:
- JUnit 5, Mockito
- Testcontainers (integration tests)
- Gatling (load testing)
- Contract testing (Pact)
Resources
- Hexagonal Architecture (Alistair Cockburn)
- Spring WebFlux Documentation
- R2DBC Specification
- Google Cloud AlloyDB
- Resilience4j Documentation
Questions or want to discuss architecture? Contact me or connect on LinkedIn.
Related Posts:
- Spring WebFlux: When and Why to Go Reactive
- Coming soon: “gRPC vs REST: Performance Comparison”
- Coming soon: “Event Sourcing in Financial Systems”