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
- Movistar Service: Movistar-specific handling
- Digitel Service: Optimized connection to Digitel
- Inter Service: Integration with Inter services
- DirecTV Service: Handling of TV services
- Auth Service: Centralized JWT for every service
- Transaction Service: Centralized transaction management
- 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
| Metric | Before (Java/SOAP) | After (Microservices) | Improvement |
|---|---|---|---|
| Response time | 45-60s | 150-300ms | 99.5% |
| Per-provider availability | 80% | 99.8% | 19.8% |
| Throughput | 50 tx/hour | 5,000+ tx/hour | 100× |
| Scalability | Vertical only | Horizontal per service | ∞ |
| Deployment time | 4-6 hours | 15 minutes | 96% |
| Recovery time | 2-4 hours | 5-10 minutes | 95% |
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
- Document the current state: You can’t improve a legacy system you don’t understand
- Pitch with a clear ROI: Stakeholders need to see the business value
- Migrate gradually: Zero downtime is achievable with the right strategy
- Monitor from day one: Observability isn’t optional in production
- 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:
Related articles
📧 Subscribe to the Blog
Get notified about new articles and exclusive technical content.
No spam. Unsubscribe anytime.
