ChourioDev Logo
FinTech Featured

From 60 Seconds to Milliseconds: Refactoring a FinTech Payments Core

How I led the refactor of a legacy Java/SOAP payment API into NestJS microservices, cutting response times from 60 seconds to 150–300 ms for banks and telcos.

Miguel Mendoza Chourio
8 min read Leer en español
From 60 Seconds to Milliseconds: Refactoring a FinTech Payments Core
In this article

The Problem: A Scary Monolith Nobody Dared to Touch

In 2018, at Andrómeda Ventures, I ran into a challenge every senior developer knows well: the dreaded legacy system the entire business depends on. The core service behind the payments vertical was literally untouchable, not out of respect but out of sheer terror.

This service acted as an intermediary API between providers (Movistar, Digitel, Inter, DirecTV) and banking clients (Banco Plaza, Bancrecer, Banco Activo, Bancamiga). The problem was that it had been built as a Java/SOAP monolith, loaded with technical debt and completely undocumented.

The Reality of the Legacy Code

The situation was critical:

  • ⏱️ Response time: 45-60 seconds per transaction
  • 😰 Technical debt: Extensive, with no documentation
  • 🚫 Fear of change: Nobody dared to modify the code
  • 💰 Business risk: Any outage meant lost revenue
  • 🔗 External dependency: A third-party service handled transaction management
  • 📉 Scalability: Horizontal scaling was impossible
// A sample of the legacy code we found
@WebService
public class PaymentService {
    // No docs, no tests, no hope
    public PaymentResponse processPayment(PaymentRequest request) {
        // 200+ lines of nested logic
        // Multiple synchronous calls
        // Error handling? Nonexistent
        // ... 60 seconds later...
        return response;
    }
}

The Proposal: One Microservice per Provider

I decided to pitch a refactoring project that would completely change the system’s paradigm. The proposal had three pillars:

1. Distributed Microservices Architecture

Every provider integration would become its own independent microservice, which would improve:

  • ✅ Availability: One provider going down doesn’t take the others with it
  • ✅ Supportability: Debugging and maintenance isolated per provider
  • ✅ Horizontal scalability: Scale only what actually needs more resources

2. A Modern Tech Stack

  • Backend: Node.js + NestJS
  • Database: PostgreSQL
  • ORM: TypeORM in each microservice
  • API Gateway: An intermediate communication layer
  • Compatibility: SOAP and REST side by side

3. Centralized Data Model

I designed a data model from scratch to handle:

  • 🗃️ Transactions: Optimized storage
  • 👥 Users and credentials: Secure management
  • 🏢 Providers: Per-provider configuration
  • ✅ Validations: Provider-specific logic

The Implementation: From Monolith to Microservices

Final Architecture: The Design That Changed Everything

// New per-provider controller with NestJS
@Controller("movistar")
@UseGuards(JwtAuthGuard)
export class MovistarController {
  constructor(
    private readonly movistarService: MovistarService,
    private readonly transactionService: TransactionService
  ) {}

  @Post("balance")
  @ApiOperation({ summary: "Movistar balance inquiry" })
  async getBalance(@Body() request: BalanceRequest): Promise<BalanceResponse> {
    // From 60 seconds down to 200ms
    return await this.movistarService.getBalance(request);
  }
}

The Microservices We Built

  1. Movistar Service: Movistar-specific handling
  2. Digitel Service: Optimized connection to Digitel
  3. Inter Service: Integration with Inter services
  4. DirecTV Service: Handling of TV services
  5. Auth Service: Centralized JWT for every service
  6. Transaction Service: Centralized transaction management
  7. API Gateway: Smart routing across services

API Gateway: The Brain of the System

// API Gateway with SOAP and REST compatibility
@Controller("gateway")
export class ApiGatewayController {
  constructor(private readonly routingService: RoutingService) {}

  // REST endpoint for new clients
  @Post("rest/:provider/balance")
  async restBalance(@Param("provider") provider: string, @Body() request: any) {
    return await this.routingService.routeToProvider(provider, request);
  }

  // SOAP endpoint for legacy clients
  @Post("soap")
  @Header("Content-Type", "text/xml")
  async soapBalance(@Body() soapRequest: string) {
    const parsed = await this.parseSoapRequest(soapRequest);
    return await this.routingService.routeToProvider(
      parsed.provider,
      parsed.data
    );
  }
}

The Data Model: PostgreSQL + TypeORM

Core Entities

@Entity('transactions')
export class Transaction {
  @PrimaryGeneratedColumn('uuid')
  id: string;

  @Column()
  providerId: string;

  @Column()
  bankId: string;

  @Column({ type: 'decimal', precision: 10, scale: 2 })
  amount: number;

  @Column()
  currency: string;

  @Column({ type: 'enum', enum: TransactionStatus })
  status: TransactionStatus;

  @Column({ type: 'jsonb' })
  metadata: any;

  @CreateDateColumn()
  createdAt: Date;

  @UpdateDateColumn()
  updatedAt: Date;

  // Query optimization with indexes
  @Index(['providerId', 'createdAt'])
  @Index(['bankId', 'status'])
}

Provider Configuration

@Entity("providers")
export class Provider {
  @PrimaryGeneratedColumn("uuid")
  id: string;

  @Column()
  name: string; // 'movistar', 'digitel', 'inter', 'directv'

  @Column({ type: "jsonb" })
  configuration: {
    endpoint: string;
    timeout: number;
    retryPolicy: RetryPolicy;
    authentication: AuthConfig;
  };

  @Column({ default: true })
  isActive: boolean;

  @OneToMany(() => Transaction, (transaction) => transaction.providerId)
  transactions: Transaction[];
}

Handling Compatibility: SOAP and REST Side by Side

The Challenge of a Zero-Downtime Migration

The biggest challenge was keeping clients running in production while we migrated. The answer was to run both layers at the same time:

// SOAP ↔ REST transformation service
@Injectable()
export class TransformationService {
  // Convert a legacy SOAP request into the internal format
  transformSoapToInternal(soapRequest: string): InternalRequest {
    const parsed = this.xmlParser.parse(soapRequest);

    return {
      provider: this.extractProvider(parsed),
      operation: this.extractOperation(parsed),
      credentials: this.extractCredentials(parsed),
      data: this.extractData(parsed),
    };
  }

  // Convert the internal response back to SOAP for legacy clients
  transformInternalToSoap(response: InternalResponse): string {
    return this.xmlBuilder.build({
      "soap:Envelope": {
        "soap:Body": {
          BalanceResponse: {
            Amount: response.balance,
            Currency: response.currency,
            Status: response.status,
            Timestamp: response.timestamp,
          },
        },
      },
    });
  }
}

The Results: A Complete Business Transformation

Impact Metrics

MetricBefore (Java/SOAP)After (Microservices)Improvement
Response time45-60s150-300ms99.5%
Per-provider availability80%99.8%19.8%
Throughput50 tx/hour5,000+ tx/hour100×
ScalabilityVertical onlyHorizontal per service∞
Deployment time4-6 hours15 minutes96%
Recovery time2-4 hours5-10 minutes95%

Business Impact

For the Banks (Clients)

  • 💰 Revenue growth: +300% in processed volume
  • ⚡ User experience: From 60s to under 1s
  • 🛡️ Reliability: 99.8% uptime vs. 94% before
  • 📈 New opportunities: Products that performance had previously made impossible

For Andrómeda Ventures

  • 👨‍💻 Team satisfaction: Maintainable, documented code
  • 🔧 Technical support: From restarting the service to actually fixing the issues we detected
  • 💡 Innovation: A solid foundation for new FinTech products

Lessons Learned: Refactoring Mission-Critical Systems

1. A Solid Technical Proposal Matters

// Architecture documentation from day one
interface SystemArchitecture {
  currentState: LegacySystemAnalysis;
  proposedState: MicroservicesArchitecture;
  migrationPlan: StepByStepPlan;
  riskMitigation: RiskMatrix;
  businessImpact: ROICalculation;
}

2. Gradual Migration with Zero Downtime

  • Phase 1: API Gateway with dual routing (SOAP/REST)
  • Phase 2: Provider-by-provider migration
  • Phase 3: Gradual deprecation of SOAP endpoints
  • Phase 4: Ongoing monitoring and optimization

3. Load Testing in Production

// Circuit breaker implementation as a safety net
@Injectable()
export class CircuitBreakerService {
  private circuits = new Map<string, CircuitBreaker>();

  async executeWithCircuitBreaker<T>(
    providerName: string,
    operation: () => Promise<T>
  ): Promise<T> {
    const circuit = this.getCircuit(providerName);

    if (circuit.isOpen()) {
      throw new ServiceUnavailableException(
        `Provider ${providerName} temporarily unavailable`
      );
    }

    try {
      const result = await operation();
      circuit.recordSuccess();
      return result;
    } catch (error) {
      circuit.recordFailure();
      throw error;
    }
  }
}

Final Tech Stack

Backend Architecture

  • Framework: NestJS with TypeScript
  • Database: PostgreSQL with targeted optimizations
  • ORM: TypeORM with connection pooling
  • API Gateway: Custom, with Kong as the proxy
  • Authentication: JWT with Redis sessions
  • Message Queue: RabbitMQ for asynchronous communication

DevOps & Monitoring

  • Containerization: Docker with multi-stage builds
  • Monitoring: Custom dashboards
  • CI/CD: GitLab CI with automated testing per microservice

Validation and Security

// Provider-specific validations
@Injectable()
export class MovistarValidationService {
  validateRequest(request: MovistarRequest): ValidationResult {
    return {
      isValid:
        this.validateAmount(request.amount) &&
        this.validateCredentials(request.credentials) &&
        this.validatePhoneNumber(request.phoneNumber),
      errors: this.collectErrors(request),
    };
  }

  private validatePhoneNumber(phone: string): boolean {
    // Validation specific to Movistar phone numbers
    return /^0414|0424|0414|0424/.test(phone) && phone.length === 11;
  }
}

Conclusion: Beyond the Refactor

This transformation wasn’t just about better code; it was about changing the engineering culture of a FinTech company. It proved that you can migrate mission-critical systems without disrupting operations, and that investing in modern architecture pays off right away.

Key Takeaways for Similar Refactors

  1. Document the current state: You can’t improve a legacy system you don’t understand
  2. Pitch with a clear ROI: Stakeholders need to see the business value
  3. Migrate gradually: Zero downtime is achievable with the right strategy
  4. Monitor from day one: Observability isn’t optional in production
  5. Get team buy-in: The team has to understand and embrace the new architecture

Have you led a similar refactor? Which strategies worked best for you when dealing with critical legacy systems? Share your experience in the comments.


Enjoyed this article? Follow me on LinkedIn for more on systems architecture and FinTech. Coming up next: “Circuit Breakers in FinTech Microservices” and “Migrating from SOAP to GraphQL: Lessons Learned”.


Miguel Mendoza Chourio is a Senior Full-Stack Engineer with 9+ years of experience specializing in FinTech and legacy system refactoring. Available for microservices architecture consulting and international remote opportunities.

Tags:

#api-optimization #microservices #nestjs #performance #payment-systems #soap-migration #refactoring
Miguel Mendoza Chourio

Miguel Mendoza Chourio

Miguel Mendoza Chourio (Chourio DEV) — senior FinTech engineer and software architect in Venezuela. 9+ years in high-volume payments, microservices and Enterprise AI. International remote.

Related articles

📧 Subscribe to the Blog

Get notified about new articles and exclusive technical content.

No spam. Unsubscribe anytime.