0%

Building a Mutual Fund Platform: Architecture Deep-Dive

Technical article

Apr 15, 2026 12 min read Vaibhav Waghmare

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:

  1. 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);
        }
    }
    
  2. 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)
  3. 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:

MetricJDBC (Blocking)R2DBC (Reactive)Improvement
Simple Query45ms avg38ms avg15% faster
Complex Join120ms avg95ms avg21% faster
Batch Insert (1000 rows)850ms320ms62% faster
Connection Acquisition5-10ms<1ms90% 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:

MetricTargetAchievedStatus
P95 Latency<500ms287ms✅ 43% better
Throughput10,000 req/s12,500 req/s✅ 25% better
Uptime99.9%99.94%✅ Exceeded
Daily Transactions500K1.2M✅ 140% better
Error Rate<0.1%0.03%✅ 70% better
Database Queries<100ms P9565ms P95✅ 35% better
API Integration Latency<2s P951.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

  1. Don’t go reactive unless you need it: If your app has <100 concurrent users, Spring MVC is simpler
  2. Invest in observability early: Distributed tracing saved us countless debugging hours
  3. Test resilience scenarios: Circuit breakers, timeouts, retries are useless if not tested
  4. Document architecture decisions: We created ADRs (Architecture Decision Records) for every major choice
  5. 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

Questions or want to discuss architecture? Contact me or connect on LinkedIn.

Related Posts:

Topics

Architecture Spring Boot Microservices GCP FinTech WebFlux

Share this article

Related Articles