Controlling Inheritance: Virtual Methods vs Abstract Members in C#

Understanding Method Override Options

It's tempting to mark every overridable method as virtual and call it a day. It works—until you need derived classes to implement critical behavior and realize they can silently skip it. Without the abstract keyword forcing implementation, you're relying on documentation and developer discipline rather than compiler guarantees.

The virtual keyword gives derived classes the option to override a method, while abstract requires them to do so. Virtual methods provide a working default implementation that child classes can refine. Abstract methods define a contract without implementation, ensuring every concrete derived class supplies its own version. Understanding when to use each shapes maintainable inheritance hierarchies.

You'll learn how virtual methods enable optional customization with safe defaults, how abstract methods enforce required behavior, and how to combine both approaches in abstract classes. We'll build a payment processing system that demonstrates these patterns in action, showing you exactly when each keyword fits your design.

Virtual Methods: Optional Override with Default Behavior

A virtual method provides a complete implementation in the base class that derived classes can choose to override. If a derived class doesn't override it, the base implementation runs automatically. This gives you safe defaults while allowing customization when needed.

Virtual methods work in any class—abstract or concrete. You use them when most derived classes can use your default logic, but some need specialized behavior. The override keyword in derived classes clearly signals intentional customization rather than accidental shadowing.

Payment.cs
public class Payment
{
    public decimal Amount { get; set; }
    public string Currency { get; set; }

    // Virtual method with default implementation
    public virtual string GetTransactionDescription()
    {
        return $"Payment of {Amount} {Currency}";
    }

    // Virtual method for calculating fees
    public virtual decimal CalculateFee()
    {
        // Default: 2% processing fee
        return Amount * 0.02m;
    }

    public decimal GetTotalAmount()
    {
        return Amount + CalculateFee();
    }
}

public class CreditCardPayment : Payment
{
    public string CardType { get; set; }

    // Override to add card-specific details
    public override string GetTransactionDescription()
    {
        return $"{CardType} payment of {Amount} {Currency}";
    }

    // Override with higher fee for credit cards
    public override decimal CalculateFee()
    {
        return Amount * 0.03m; // 3% for credit cards
    }
}

public class BankTransferPayment : Payment
{
    public string BankName { get; set; }

    // Only override description, use default fee calculation
    public override string GetTransactionDescription()
    {
        return $"Bank transfer via {BankName}: {Amount} {Currency}";
    }
    // CalculateFee not overridden - uses base 2% fee
}

The BankTransferPayment class overrides only the description method, inheriting the base fee calculation. CreditCardPayment overrides both methods for complete customization. This flexibility lets you reuse logic selectively rather than rewriting everything in each derived class.

Abstract Methods: Required Implementation

Abstract methods have no implementation in the base class. They define what derived classes must do without specifying how. Every concrete class inheriting from an abstract class must override all abstract members, or it won't compile. This enforces contracts across your hierarchy.

You can only declare abstract methods inside abstract classes. The class itself must be marked abstract, preventing direct instantiation. This makes sense—you can't create an object with incomplete behavior. Abstract methods work perfectly when derived classes need fundamentally different implementations with no reasonable default.

PaymentProcessor.cs
public abstract class PaymentProcessor
{
    public string ProcessorName { get; set; }
    public DateTime TransactionTime { get; protected set; }

    // Abstract methods - must be implemented by derived classes
    public abstract bool Authorize(decimal amount);
    public abstract string ProcessPayment(Payment payment);
    public abstract void Refund(string transactionId);

    // Concrete method shared by all processors
    public void LogTransaction(string message)
    {
        Console.WriteLine($"[{ProcessorName}] {TransactionTime}: {message}");
    }
}

public class StripeProcessor : PaymentProcessor
{
    public StripeProcessor()
    {
        ProcessorName = "Stripe";
    }

    public override bool Authorize(decimal amount)
    {
        LogTransaction($"Authorizing ${amount}");
        // Stripe-specific authorization logic
        return amount <= 10000; // Example limit
    }

    public override string ProcessPayment(Payment payment)
    {
        TransactionTime = DateTime.UtcNow;
        if (Authorize(payment.Amount))
        {
            LogTransaction($"Processing {payment.Amount}");
            return $"STRIPE-{Guid.NewGuid():N}";
        }
        return null;
    }

    public override void Refund(string transactionId)
    {
        LogTransaction($"Refunding transaction {transactionId}");
        // Stripe refund API call
    }
}

public class PayPalProcessor : PaymentProcessor
{
    public PayPalProcessor()
    {
        ProcessorName = "PayPal";
    }

    public override bool Authorize(decimal amount)
    {
        LogTransaction($"PayPal authorization for ${amount}");
        // PayPal-specific authorization
        return true;
    }

    public override string ProcessPayment(Payment payment)
    {
        TransactionTime = DateTime.UtcNow;
        LogTransaction($"Processing via PayPal");
        return $"PAYPAL-{DateTime.UtcNow.Ticks}";
    }

    public override void Refund(string transactionId)
    {
        LogTransaction($"PayPal refund: {transactionId}");
        // PayPal refund logic
    }
}

Each processor implements the abstract methods differently because payment gateways have unique APIs and rules. The compiler guarantees every processor can authorize, process, and refund—you can't accidentally create an incomplete processor. The shared LogTransaction method provides common functionality without forcing reimplementation.

Combining Virtual and Abstract Members

Abstract classes commonly mix virtual and abstract members to balance flexibility with enforcement. Abstract methods define required behavior, while virtual methods offer overridable defaults. This combination lets you create robust frameworks where critical operations are guaranteed but optional features remain customizable.

PaymentGateway.cs
public abstract class PaymentGateway
{
    // Abstract: every gateway must validate
    public abstract bool ValidatePaymentDetails(Payment payment);

    // Abstract: every gateway must process differently
    public abstract Task<string> SubmitPaymentAsync(Payment payment);

    // Virtual: default retry logic, but gateways can customize
    public virtual async Task<string> ProcessWithRetryAsync(Payment payment)
    {
        int attempts = 0;
        while (attempts < 3)
        {
            try
            {
                if (!ValidatePaymentDetails(payment))
                    return null;

                return await SubmitPaymentAsync(payment);
            }
            catch (Exception ex)
            {
                attempts++;
                if (attempts >= 3) throw;
                await Task.Delay(1000 * attempts);
            }
        }
        return null;
    }

    // Virtual: default logging, gateways can enhance
    public virtual void LogPaymentAttempt(string message)
    {
        Console.WriteLine($"[{GetType().Name}] {DateTime.Now}: {message}");
    }
}

Derived classes must implement ValidatePaymentDetails and SubmitPaymentAsync because payment validation and submission are gateway-specific. The retry logic is virtual—most gateways can use the default exponential backoff, but some might need custom retry strategies based on their API requirements.

Try It Yourself

Build a working example that demonstrates virtual and abstract methods in a payment system. You'll create base classes with both types of members and see how derived classes interact with them.

Program.cs
// Abstract base with both virtual and abstract members
abstract class PaymentMethod
{
    public abstract string GetPaymentType();
    public abstract decimal CalculateProcessingFee(decimal amount);

    public virtual string FormatAmount(decimal amount)
    {
        return $"${amount:F2}";
    }

    public void ProcessPayment(decimal amount)
    {
        Console.WriteLine($"Payment Type: {GetPaymentType()}");
        Console.WriteLine($"Amount: {FormatAmount(amount)}");
        Console.WriteLine($"Fee: {FormatAmount(CalculateProcessingFee(amount))}");
        Console.WriteLine($"Total: {FormatAmount(amount + CalculateProcessingFee(amount))}");
    }
}

class CreditCard : PaymentMethod
{
    public override string GetPaymentType() => "Credit Card";
    public override decimal CalculateProcessingFee(decimal amount) => amount * 0.029m;
}

class Bitcoin : PaymentMethod
{
    public override string GetPaymentType() => "Bitcoin";
    public override decimal CalculateProcessingFee(decimal amount) => 0.50m;

    public override string FormatAmount(decimal amount) => $"{amount:F8} BTC";
}

PaymentMethod card = new CreditCard();
card.ProcessPayment(100m);

Console.WriteLine();

PaymentMethod crypto = new Bitcoin();
crypto.ProcessPayment(0.002m);
VirtualAbstractDemo.csproj
<Project Sdk="Microsoft.NET.Sdk">
  <PropertyGroup>
    <OutputType>Exe</OutputType>
    <TargetFramework>net8.0</TargetFramework>
    <Nullable>enable</Nullable>
    <ImplicitUsings>enable</ImplicitUsings>
  </PropertyGroup>
</Project>

Steps:

  1. Create a new console project: dotnet new console -n VirtualAbstractDemo
  2. Navigate to the directory: cd VirtualAbstractDemo
  3. Replace Program.cs with the code above
  4. Run the project: dotnet run

Output:

Payment Type: Credit Card
Amount: $100.00
Fee: $2.90
Total: $102.90

Payment Type: Bitcoin
Amount: 0.00200000 BTC
Fee: 0.50000000 BTC
Total: 0.50200000 BTC

Notice how CreditCard uses the default FormatAmount method, while Bitcoin overrides it to display amounts in BTC format. Both classes must implement the abstract methods, but they can choose whether to customize the virtual method.

Choosing the Right Approach

Choose abstract methods when every derived class needs a different implementation and no sensible default exists. Payment authorization, data validation rules, and API integration points typically fall into this category. Abstract methods create compile-time contracts that prevent incomplete implementations.

Choose virtual methods when you can provide a reasonable default that works for most cases, but some derived classes need customization. Formatting, logging, retry logic, and caching strategies often fit this pattern. Virtual methods reduce code duplication while preserving flexibility.

If you're unsure, start with virtual methods that provide working defaults. You can refactor to abstract later if you discover the default implementation is never actually used. This evolutionary approach prevents over-engineering while maintaining clean hierarchies.

Avoid marking methods as virtual "just in case" someone might want to override them. Every virtual method adds a vtable lookup cost and increases the surface area for bugs in derived classes. Make methods virtual when you have a concrete use case for customization, not speculatively.

Quick Answers

When should I use virtual instead of abstract?

Use virtual when you provide a default implementation that derived classes can optionally override. Use abstract when derived classes must provide their own implementation. Virtual methods work in any class; abstract methods require abstract classes.

Can I have virtual methods in abstract classes?

Yes, abstract classes can contain both virtual and abstract members. Virtual methods provide default behavior, while abstract methods force derived classes to implement specific functionality. This combination gives you flexibility in designing class hierarchies.

What happens if I don't override a virtual method?

The derived class inherits the base class implementation automatically. Your code compiles and runs using the base version. This differs from abstract methods, which must be overridden or you'll get a compiler error.

Can abstract methods have a body?

No, abstract methods cannot have an implementation in the abstract class. They're declaration-only. Derived classes must provide the complete implementation. If you need a default implementation, use virtual instead.

Back to Articles