Your Legacy .NET Payment System Isn't Slow, You're Using It Wrong
How to get 100x performance from your existing .NET and SQL Server system without a costly rewrite
Your .NET payment system from 2000 takes 3 hours to process nightly batches. That "modern" platform you started building in 2019? It's still only handling 5% of payments and is filled with bugs and technical debt. Your board wants answers. Everyone believes the legacy system is beyond saving.
They're all wrong.
Your 25-year-old .NET system running on SQL Server isn't the problem. The way you're using it is. And your new platform? It's making the same mistakes with fancier technology and increased complexity.
The €20 Million Mistake
Here's what's actually happening in your system:
- Processing payments one at a time in a foreach loop
- Unoptimized SQL Server queue tables - not using SQL Server Service Broker or proper query hints
- Poor observability - flying blind despite having modern observability tools in the organization
Your unoptimized system is struggling while you spend millions building a replacement that will have the same fundamental problems.
This is a classic case of the second-system effect - the tendency to over-engineer a replacement system with unnecessary complexity while ignoring the simple fixes that would solve the original problems.
What's Actually Killing Your Performance
1. Processing Payments One at a Time
This is what your batch processing looks like:
// Processing 500,000 payments one by one
foreach (var payment in payments)
{
// Each iteration makes 3 database calls
var accountFrom = db.GetAccount(payment.FromAccountId);
var accountTo = db.GetAccount(payment.ToAccountId);
var transaction = new PaymentTransaction
{
FromAccount = accountFrom,
ToAccount = accountTo,
Amount = payment.Amount,
Status = "Pending"
};
db.InsertTransaction(transaction);
db.Commit();
}
// Result: 1,500,000 database calls (3 per payment)
// Time: 3+ hours
// CPU Usage: 5% (mostly waiting for I/O)
The fix: Process in batches, use bulk operations, run parallel threads. This code works on .NET Framework 4.5+ - what your legacy system actually runs.
const int batchSize = 1000;
// Split payments into manageable batches
var batches = payments
.Select((payment, index) => new { payment, index })
.GroupBy(x => x.index / batchSize)
.Select(g => g.Select(x => x.payment).ToList());
// Process batches in parallel (use all CPU cores)
Parallel.ForEach(batches, new ParallelOptions
{
MaxDegreeOfParallelism = Environment.ProcessorCount
},
batch =>
{
// Build batch data in memory first
var dataTable = new DataTable();
dataTable.Columns.Add("FromAccountId", typeof(int));
dataTable.Columns.Add("ToAccountId", typeof(int));
dataTable.Columns.Add("Amount", typeof(decimal));
dataTable.Columns.Add("Status", typeof(string));
foreach (var payment in batch)
{
dataTable.Rows.Add(
payment.FromAccountId,
payment.ToAccountId,
payment.Amount,
"Pending"
);
}
// Single bulk insert for entire batch
using (var bulkCopy = new SqlBulkCopy(connectionString))
{
bulkCopy.DestinationTableName = "PaymentTransactions";
bulkCopy.BatchSize = batchSize;
bulkCopy.BulkCopyTimeout = 300; // 5 minutes timeout
bulkCopy.WriteToServer(dataTable);
}
});
// Result: 500 parallel bulk inserts
// Time: 8 minutes (down from 3+ hours)
// CPU Usage: 70% (fully utilized)
2. Unoptimized SQL Server Queue Processing
It's 2025. You're still using table-based queues without proper SQL hints like READPAST and ROWLOCK, causing threads to block each other. Even worse, you're ignoring SQL Server Service Broker that's been available since 2005.
Simple SQL hints can give you 60x performance improvement by allowing parallel processing. Service Broker eliminates queue contention entirely - it's built for this exact use case.
3. Ignoring SQL Server Query Hints
SQL Server has powerful hints that most developers never use. These hints can give you 10-60x performance improvements:
READPAST | Skip locked rows instead of waiting |
ROWLOCK | Lock only rows, not entire pages |
MAXDOP | Use multiple CPU cores for the query |
NOLOCK | Allow dirty reads when acceptable |
TABLOCK | Optimize bulk operations with table-level locks |
Yet your queries use none of them.
4. Connection Pool Exhaustion
Your connection string is using defaults. SQL Server connection pooling defaults to Max Pool Size=100, but you're probably exhausting it because you're not disposing connections properly or holding them too long. Configure it explicitly and monitor pool saturation.
5. Poor Observability - You're Flying Blind
You probably already have Grafana or Splunk in your organization. Use it! Add proper instrumentation to see what's actually happening:
// Add OpenTelemetry to your legacy .NET app
// (supports .NET Framework 4.6.2+)
// NuGet: OpenTelemetry.Instrumentation.SqlClient
using var tracerProvider = Sdk.CreateTracerProviderBuilder()
.AddSource("Payments.Service")
.AddSqlClientInstrumentation(options =>
{
options.SetDbStatementForText = true;
options.RecordException = true;
options.EnableConnectionLevelAttributes = true;
})
.AddOtlpExporter(options =>
{
// Send to your existing Grafana/Splunk/Jaeger
options.Endpoint = new Uri("http://otel-collector:4317");
})
.Build();
// Instrument your code
using var activity = ActivitySource.StartActivity("ProcessPayments");
activity?.SetTag("payment.count", payments.Count);
activity?.SetTag("batch.size", batchSize);
// Now you can see in your dashboards:
// ✓ Which queries are slow (with full SQL text)
// ✓ Database calls per payment (should be < 0.1)
// ✓ Connection pool saturation metrics
// ✓ Actual processing time vs waiting time
OpenTelemetry supports older .NET versions - no excuses for not having visibility into your system.
Why Your New Platform Is Also Slow
Here's the uncomfortable truth: You built the new platform with the same architects who designed the original system. The team that wrote foreach loops processing payments one at a time in 2000 is now applying the same patterns in the new system, just spread across 30 highly coupled microservices using a chaotic mix of synchronous HTTP calls and queues, still backed by the same SQL Server bottlenecks.
Your new platform has the same bottlenecks, just distributed:
- Better parallelism in theory, but still bottlenecked by I/O waits and passing large payloads between services
- Mixed synchronous HTTP with message queues - now you have both blocking calls AND misconfigured acknowledgments causing reprocessing or data loss
- Still hitting the same SQL Server database from every service
- Added network latency and serialization overhead to every operation
- Lost control of payment execution - poor dead letter queue handling, duplicates from missing idempotency checks, out-of-order processing
- Turned one ACID transaction into eventual consistency with no way to track payment status
You didn't solve the performance problems. You distributed them across 30 services and added network complexity on top.
The 3-Month Fix vs 5-10 Year Rewrite
Option 1: Optimization (3 months)
- Fix queries and indexing
- Refactor critical parts of the existing code
- Implement batch processing
- Configure connection pooling
- Enable parallel execution
- Cost: €200,000
- Result: 10-100x performance improvement
Option 2: Complete Rewrite (5-10 years)
- Build new platform while maintaining the old one
- Migrate thousands of business rules and edge cases
- Run both systems in parallel indefinitely
- Train staff on new technology
- Cost: €20,000,000+
- Result: Same problems, new technology
The Right Way Forward
Look, I'm not saying never modernize. Event-driven architectures, Kafka, microservices — they have their place. Modern technology can deliver incredible results when applied correctly.
But I've seen too many organizations hire 3 teams to build "the future" while their current system limps along at 20% capacity. They're trying to build a spaceship while their car is running on flat tires. They haven't even created a solid foundation, yet they're already trying to bypass their legacy system's performance problems by throwing bodies at a complete rewrite.
Here's what actually works: Fix your legacy system first. Get it running at full capacity. Learn what your actual bottlenecks are — not what you assume they are. With that breathing room and knowledge, you can make informed decisions about whether you need that new system at all.
Half the time, once we optimize the existing system, the "urgent" need for a rewrite mysteriously disappears. The other half? You now have a stable, performant system buying you time to build something better — properly, without panic, and without burning millions on failed transformations.