DEV Community

Ugochukwu Oluo
Ugochukwu Oluo

Posted on

How Redis Scales Banking Systems That SQL Struggles With

Stop Putting Everything in SQL: A Redis Guide for Fintech Engineers

Let me be honest with you. I have seen backend engineers, myself included at some point, stuff everything into a relational database just because it feels safe and familiar. PostgreSQL, Oracle, MySQL... they are solid, battle-tested tools. But they were never designed to be the answer to every single problem.

If you are building or maintaining a banking system in .NET and you are reaching for SQL for everything, you are probably making your database work much harder than it needs to. And at scale, that becomes a real problem.

Redis is an in-memory data store that handles certain banking operations with dramatically better speed and efficiency. This article walks through practical scenarios where Redis genuinely belongs, with C# code using the StackExchange.Redis library that you can actually use.

To get started, install the package:

dotnet add package StackExchange.Redis
dotnet add package Newtonsoft.Json
Enter fullscreen mode Exit fullscreen mode

Then set up your connection:

var redis = await ConnectionMultiplexer.ConnectAsync("localhost:6379");
IDatabase db = redis.GetDatabase();
Enter fullscreen mode Exit fullscreen mode

1. OTP (One-Time Password) Storage

This one is probably the most common mistake I see in banking applications.

When a user wants to log in or confirm a transfer, your system generates a 6-digit OTP that should expire in maybe 60 to 300 seconds. If you store that OTP in SQL, here is what you are actually doing:

  • Writing a row to the database when the OTP is generated
  • Querying that row when the user submits the OTP
  • Updating or deleting the row after verification
  • Running a scheduled cleanup job to clear expired OTPs

That is 3 to 4 database operations for something that will not even exist in 5 minutes. It just does not make sense.

With Redis, the whole thing becomes simple:

public class OtpService
{
    private readonly IDatabase _redis;

    public OtpService(IDatabase redis)
    {
        _redis = redis;
    }

    // Store OTP with automatic expiry of 120 seconds
    public async Task SaveOtpAsync(int userId, string otp)
    {
        string key = $"otp:user:{userId}";
        await _redis.StringSetAsync(key, otp, TimeSpan.FromSeconds(120));
    }

    // Verify OTP submitted by the user
    public async Task<bool> VerifyOtpAsync(int userId, string submittedOtp)
    {
        string key = $"otp:user:{userId}";
        string? storedOtp = await _redis.StringGetAsync(key);

        if (storedOtp == null) return false; // Expired or never set

        bool isValid = storedOtp == submittedOtp;

        if (isValid)
            await _redis.KeyDeleteAsync(key); // Invalidate after successful use

        return isValid;
    }
}
Enter fullscreen mode Exit fullscreen mode

Redis TTL handles expiry on its own. No cron jobs. No stale data sitting around. Just one write and one read, and Redis takes care of the rest.


2. Caching Records That Rarely Change

Think about the kind of data your banking system reads thousands of times a day but almost never updates. Things like bank branch details, SWIFT codes, country lists, currency codes, and product configurations.

Every time a user sends an international transfer, your system probably looks up a SWIFT code. Why hit your main database every single time for data that changes maybe once a year?

public class BranchService
{
    private readonly IDatabase _redis;
    private readonly IBranchRepository _repository;

    public BranchService(IDatabase redis, IBranchRepository repository)
    {
        _redis = redis;
        _repository = repository;
    }

    public async Task<BranchDetails?> GetBranchDetailsAsync(string branchCode)
    {
        string cacheKey = $"branch:{branchCode}";

        // Check Redis first
        string? cached = await _redis.StringGetAsync(cacheKey);
        if (cached != null)
            return JsonConvert.DeserializeObject<BranchDetails>(cached);

        // If not in cache, go to the database
        BranchDetails? branch = await _repository.GetByCodeAsync(branchCode);
        if (branch == null) return null;

        // Store in Redis for 1 hour
        await _redis.StringSetAsync(
            cacheKey,
            JsonConvert.SerializeObject(branch),
            TimeSpan.FromHours(1)
        );

        return branch;
    }
}
Enter fullscreen mode Exit fullscreen mode

This pattern is called cache-aside. The first request hits the database, but every request after that gets served from Redis until the cache expires. Your database gets to breathe.


3. Session and Token Management

Picture this: a user opens their mobile banking app and logs in. Now every single API call they make needs to verify their session token. If you are checking the database every time, that is a lot of unnecessary load, especially when you have thousands of active users at the same time.

Redis handles sessions really well:

public class SessionService
{
    private readonly IDatabase _redis;

    public SessionService(IDatabase redis)
    {
        _redis = redis;
    }

    // Save session after successful login
    public async Task CreateSessionAsync(string sessionToken, UserSession session)
    {
        string key = $"session:{sessionToken}";
        string value = JsonConvert.SerializeObject(session);
        await _redis.StringSetAsync(key, value, TimeSpan.FromMinutes(30));
    }

    // Validate session on each API request
    public async Task<UserSession?> GetSessionAsync(string sessionToken)
    {
        string key = $"session:{sessionToken}";
        string? value = await _redis.StringGetAsync(key);

        return value == null ? null : JsonConvert.DeserializeObject<UserSession>(value);
    }

    // Invalidate session on logout
    public async Task DeleteSessionAsync(string sessionToken)
    {
        await _redis.KeyDeleteAsync($"session:{sessionToken}");
    }
}
Enter fullscreen mode Exit fullscreen mode

When someone logs out or their token gets revoked, you just delete the key. And if a user simply closes the app and never logs out properly, the session expires on its own after 30 minutes. Clean and simple.


4. Rate Limiting to Protect Sensitive Endpoints

Nobody should be able to guess a PIN by trying 1,000 combinations per second. Rate limiting is one of those things that sounds optional until something goes wrong.

Redis makes this very straightforward:

public class RateLimitService
{
    private readonly IDatabase _redis;
    private const int MaxAttempts = 5;
    private const int WindowSeconds = 60;

    public RateLimitService(IDatabase redis)
    {
        _redis = redis;
    }

    public async Task CheckPinAttemptAsync(int userId)
    {
        string key = $"rate:pin_attempt:{userId}";

        long attempts = await _redis.StringIncrementAsync(key);

        if (attempts == 1)
        {
            // Start the time window on the very first attempt
            await _redis.KeyExpireAsync(key, TimeSpan.FromSeconds(WindowSeconds));
        }

        if (attempts > MaxAttempts)
        {
            throw new InvalidOperationException(
                "Too many PIN attempts. Account temporarily locked."
            );
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

The StringIncrementAsync call maps to Redis INCR, which is atomic. It is safe even when multiple requests come in at the same time. After 5 failed attempts within a minute, the account gets locked. No database involved.


5. Caching Account Balances During Peak Traffic

If you have ever worked in a Nigerian bank or any bank really during salary week or month-end, you know what peak traffic looks like. Everyone is checking their balance at the same time. Most of those requests are just reads, not actual transactions.

You can offload those reads to Redis:

public class BalanceService
{
    private readonly IDatabase _redis;
    private readonly IAccountRepository _repository;

    public BalanceService(IDatabase redis, IAccountRepository repository)
    {
        _redis = redis;
        _repository = repository;
    }

    public async Task<decimal> GetBalanceAsync(string accountNumber)
    {
        string key = $"balance:account:{accountNumber}";

        string? cached = await _redis.StringGetAsync(key);
        if (cached != null)
            return decimal.Parse(cached);

        // Go to the core banking database
        decimal balance = await _repository.GetBalanceAsync(accountNumber);

        // Cache for 30 seconds
        await _redis.StringSetAsync(key, balance.ToString(), TimeSpan.FromSeconds(30));

        return balance;
    }

    // Call this after every confirmed transaction to keep the cache fresh
    public async Task UpdateCachedBalanceAsync(string accountNumber, decimal newBalance)
    {
        string key = $"balance:account:{accountNumber}";
        await _redis.StringSetAsync(key, newBalance.ToString(), TimeSpan.FromSeconds(30));
    }
}
Enter fullscreen mode Exit fullscreen mode

The TTL keeps the cache fresh. For actual debits and credits, you still go straight to the source of truth. But for read traffic during peak hours, Redis takes the load off your database comfortably.


6. Fraud Blacklist Lookups

Your fraud detection system probably maintains a list of flagged card numbers, account numbers, device IDs, and IP addresses. Every transaction needs to be checked against this list before it goes through.

Running a SQL query against a blacklist table on every transaction is slow. Redis Sets handle this in a much cleaner way:

public class FraudBlacklistService
{
    private readonly IDatabase _redis;
    private const string BlacklistKey = "blacklist:cards";

    public FraudBlacklistService(IDatabase redis)
    {
        _redis = redis;
    }

    // Add a flagged card to the blacklist
    public async Task FlagCardAsync(string cardNumber)
    {
        await _redis.SetAddAsync(BlacklistKey, cardNumber);
    }

    // Check if a card is flagged before processing any transaction
    public async Task<bool> IsCardFlaggedAsync(string cardNumber)
    {
        return await _redis.SetContainsAsync(BlacklistKey, cardNumber);
    }

    // Remove a card from the blacklist if the flag is cleared
    public async Task UnflagCardAsync(string cardNumber)
    {
        await _redis.SetRemoveAsync(BlacklistKey, cardNumber);
    }
}
Enter fullscreen mode Exit fullscreen mode

SetContainsAsync maps to Redis SISMEMBER, which is O(1). It runs in constant time regardless of whether your blacklist has 100 entries or 100,000. That is exactly what you want on a hot path like transaction processing.


7. Idempotency Keys to Prevent Duplicate Transactions

This one is really important in distributed systems. Imagine a user taps "Send Money" and the network times out. The app retries the request. Now did that transaction go through once or twice?

Without proper handling, you could end up crediting or debiting an account twice. Redis gives you a clean way to solve this:

public class PaymentService
{
    private readonly IDatabase _redis;
    private readonly ICoreBank _coreBank;

    public PaymentService(IDatabase redis, ICoreBank coreBank)
    {
        _redis = redis;
        _coreBank = coreBank;
    }

    public async Task<PaymentResult> ProcessPaymentAsync(
        string idempotencyKey,
        PaymentPayload payload)
    {
        string redisKey = $"idempotency:{idempotencyKey}";

        // Check if this request has already been processed
        string? existing = await _redis.StringGetAsync(redisKey);
        if (existing != null)
        {
            // Return the cached result instead of processing again
            return JsonConvert.DeserializeObject<PaymentResult>(existing)!;
        }

        // Process the transaction
        PaymentResult result = await _coreBank.ExecuteTransferAsync(payload);

        // Store the result for 24 hours
        await _redis.StringSetAsync(
            redisKey,
            JsonConvert.SerializeObject(result),
            TimeSpan.FromHours(24)
        );

        return result;
    }
}
Enter fullscreen mode Exit fullscreen mode

Each payment request comes with a unique key. If the same key shows up again, you return the cached result instead of processing a duplicate. No database locking, no complex constraint logic.


A Simple Rule to Follow

Before you decide where to store something, ask yourself these three questions:

  1. Does this data need to live forever? Use your relational database.
  2. Does this data expire or change very frequently? Use Redis.
  3. Is this data read way more often than it is written? Cache it in Redis.

Your relational database is your system of record. Redis is your system of speed. They work best together, each doing what it is actually good at.


Wrapping Up

Redis is not here to replace your database. It is here to take the work your database should never have been doing in the first place.

OTPs, sessions, rate limits, fraud checks, idempotency keys. These are not relational problems. The sooner we stop treating them like they are, the better our systems will perform.

If you are building anything in the fintech space with .NET, give Redis a serious look. Your database will thank you, and so will your users when transactions stop timing out on a busy Friday evening.

Have you used Redis in a banking or fintech project? What worked well or what caused you headaches? I would love to hear your experience in the comments.

Redis #Fintech #Banking #DotNet #CSharp #SystemDesign #BackendEngineering

Top comments (0)