Abstract Class in C#: Build a Shipping Calculator Step by Step (.NET 10)

Say you run shipping for a Columbus, Ohio, outdoor-gear store. Every order goes out by UPS ground, FedEx air or XPO freight, and each carrier prices a package its own way.

That’s the job of an abstract class in C#: a base class you can’t create with new, where shared code lives once and each subclass must override the abstract members. We’ll build that calculator on .NET 10, one piece at a time.

Here’s what we’re building. Three shipments, three carriers, one shared label format and a total:

Abstract class in C# shipping calculator in VS Code: Ground, Air and Freight classes derived from an abstract Shipment class print labels and a $238.60 total
The finished calculator: one abstract Shipment base class and three carriers
  • Shipment (abstract) holds the ID, customer, weight, the Label() format and the total.
  • Ground, Air and Freight each supply Carrier and Cost().
  • Only Freight changes Surcharge, a virtual member with a $0 default.

Before You Start the Abstract Class Build

You need the .NET 10 SDK and VS Code. I run every step as a single-file app with dotnet run File.cs; if that’s new to you, start with my guide to create a console application in Visual Studio Code.

Every step reuses the same three orders and these rates:

Tracking IDCustomerWeightServiceRate
CMH-2041Tyler Brooks12 lbsUPS ground$8.50 + $0.45/lb
CMH-2042Jenna Morales3 lbsFedEx air$24.00 + $2.10/lb
CMH-2043Marcus Reed68 lbsXPO freight$95.00 + $0.80/lb + $45 liftgate

You should also be comfortable with inheritance in C#, since an abstract class is useless until something derives from it.

Step 1: Declare an Abstract Class with an Abstract Method

Put abstract in front of class, then declare Cost() as an abstract method. It has no body, only a semicolon, because the base class can’t know any carrier’s price:

var box = new Shipment();   // try to create the base itself

abstract class Shipment
{
    public abstract decimal Cost();   // no body
}

Build output:

C:\DotNetDemo\AbstractClassDemo\Step1Declare.cs(1,11): error CS0144: Cannot create an instance of the abstract type or interface 'Shipment'

The build failed. Fix the build errors and run again.
C# abstract class Shipment with an abstract Cost method; new Shipment() fails with error CS0144 in the VS Code terminal
Creating the abstract class itself fails with CS0144

That error is the point. An abstract class is a template for other classes, so the compiler blocks new Shipment() outright (that’s abstraction in C# enforced at build time).

Checkpoint: one CS0144 error, “Cannot create an instance of the abstract type or interface ‘Shipment'”.

Step 2: Override the Abstract Method in a Derived Class

Now add Ground, derive it from Shipment and implement Cost() with the override keyword. Notice the variable is typed as the abstract base:

System.Globalization.CultureInfo.CurrentCulture = new("en-US");
Shipment parcel = new Ground(12);
Console.WriteLine($"Ground, 12 lbs: {parcel.Cost():C}");

abstract class Shipment
{
    public abstract decimal Cost();
}
class Ground(decimal lbs) : Shipment
{
    public override decimal Cost() => 8.50m + lbs * 0.45m;
}

Output:

Ground, 12 lbs: $13.90
C# Ground class overrides the abstract Cost method of Shipment; a Shipment variable holding a Ground object prints $13.90 in VS Code
A Shipment variable can hold any concrete subclass

You can’t create a Shipment, but you can declare one and point it at a Ground. The call goes to the override because an abstract method is implicitly virtual, the same mechanism as method overriding in C#.

Skip the override and the derived class won’t build:

Shipment parcel = new Ground();

abstract class Shipment { public abstract decimal Cost(); }
class Ground : Shipment { }   // forgot the override

Build output:

C:\DotNetDemo\AbstractClassDemo\MissingOverride.cs(4,7): error CS0534: 'Ground' does not implement inherited abstract member 'Shipment.Cost()'

The build failed. Fix the build errors and run again.

CS0534 means a non-abstract class left an inherited abstract member empty. Either implement it, or mark the derived class abstract too.

Checkpoint: Ground, 12 lbs: $13.90 (8.50 + 12 x 0.45).

Step 3: Add an Abstract Property and a Shared Concrete Method

Abstract members aren’t limited to methods. Here Carrier is an abstract property, and Label() is a normal method that every carrier inherits:

System.Globalization.CultureInfo.CurrentCulture = new("en-US");
Shipment[] today = [new Ground(12), new Air(3)];
foreach (var s in today) Console.WriteLine(s.Label());

abstract class Shipment
{
    public abstract string Carrier { get; }
    public abstract decimal Cost();
    public string Label() => $"{Carrier,-6}{Cost(),8:C}";
}
class Ground(decimal lbs) : Shipment
{
    public override string Carrier => "UPS";
    public override decimal Cost() => 8.50m + lbs * 0.45m;
}
class Air(decimal lbs) : Shipment
{
    public override string Carrier => "FedEx";
    public override decimal Cost() => 24m + lbs * 2.10m;
}

Output:

UPS     $13.90
FedEx   $30.30
C# abstract property Carrier and a concrete Label method in the abstract Shipment class print UPS $13.90 and FedEx $30.30 in VS Code
Label() is written once and calls each subclass’s Carrier and Cost()

Label() calls members it doesn’t implement, and each subclass fills them in. That mix of finished and unfinished code is what an interface in C# traditionally couldn’t give you.

Checkpoint: UPS at $13.90 and FedEx at $30.30.

Step 4: Give the Abstract Class a Protected Constructor

Every shipment needs a tracking ID and a weight, so that data belongs in the base. An abstract class can have a constructor; make it protected and call it from the subclass with base(...):

System.Globalization.CultureInfo.CurrentCulture = new("en-US");
var parcel = new Ground("CMH-2041", 12);
Console.WriteLine($"{parcel.Id}: {parcel.Cost():C}");

abstract class Shipment
{
    protected Shipment(string id, decimal lbs)
    {
        (Id, Lbs) = (id, lbs);
        Console.WriteLine($"1. Shipment constructor: {id}");
    }
    public string Id { get; }
    protected decimal Lbs { get; }
    public abstract decimal Cost();
}
class Ground : Shipment
{
    public Ground(string id, decimal lbs) : base(id, lbs)
        => Console.WriteLine("2. Ground constructor");
    public override decimal Cost() => 8.50m + Lbs * 0.45m;
}

Output:

1. Shipment constructor: CMH-2041
2. Ground constructor
CMH-2041: $13.90
C# abstract class protected constructor runs before the Ground constructor called with base(id, lbs), output CMH-2041: $13.90
The abstract base constructor runs first, then the derived one

The output proves the base constructor runs, even though you never create a Shipment directly. A public constructor compiles too, but code analysis rule CA1012 flags it, since only derived types can call it.

My constructor in C# guide covers chaining in more depth.

Checkpoint: line 1 from the Shipment constructor, line 2 from Ground, then the cost.

Step 5: Abstract vs Virtual Members (the Freight Surcharge)

Freight adds a $45 liftgate fee and the other carriers don’t. Making Surcharge abstract would force every carrier to write => 0m, so use virtual with a default instead:

System.Globalization.CultureInfo.CurrentCulture = new("en-US");
Shipment[] today = [new Ground(12), new Freight(68)];
foreach (var s in today)
    Console.WriteLine($"{s.GetType().Name,-8}{s.Cost(),8:C}"
        + $" + {s.Surcharge:C} = {s.Cost() + s.Surcharge:C}");

abstract class Shipment
{
    public abstract decimal Cost();            // must override
    public virtual decimal Surcharge => 0m;    // may override
}
class Ground(decimal lbs) : Shipment
{
    public override decimal Cost() => 8.50m + lbs * 0.45m;
}
class Freight(decimal lbs) : Shipment
{
    public override decimal Cost() => 95m + lbs * 0.80m;
    public override decimal Surcharge => 45m;  // liftgate
}

Output:

Ground    $13.90 + $0.00 = $13.90
Freight  $149.40 + $45.00 = $194.40
C# abstract Cost method versus virtual Surcharge property: Ground keeps the $0.00 default while Freight overrides it with a $45.00 liftgate fee
Abstract = must override; virtual = may override

My rule: if every subclass needs its own answer, it’s abstract. If most share a sensible default, it’s virtual.

Checkpoint: Ground $13.90 + $0.00 = $13.90 and Freight $149.40 + $45.00 = $194.40.

Test the Finished Abstract Class Shipping Calculator

Here’s the full program from the screenshot at the top. The derived classes use primary constructors that pass their values to the protected base constructor, and the orders sit in a List in C# typed as the abstract base:

System.Globalization.CultureInfo.CurrentCulture = new("en-US");
List<Shipment> today = [
    new Ground("CMH-2041", "Tyler Brooks", 12),
    new Air("CMH-2042", "Jenna Morales", 3),
    new Freight("CMH-2043", "Marcus Reed", 68)];
foreach (var s in today) Console.WriteLine(s.Label());
Console.WriteLine($"Total due: {today.Sum(s => s.Total):C}");

abstract class Shipment
{
    protected Shipment(string id, string to, decimal lbs)
        => (Id, To, Lbs) = (id, to, lbs);
    public string Id { get; }
    public string To { get; }
    protected decimal Lbs { get; }
    public abstract string Carrier { get; }
    public abstract decimal Cost();
    public virtual decimal Surcharge => 0m;
    public decimal Total => Cost() + Surcharge;
    public string Label() =>
        $"{Id} {To,-14}{Carrier,-6}{Total,9:C}";
}
class Ground(string id, string to, decimal lbs)
    : Shipment(id, to, lbs)
{
    public override string Carrier => "UPS";
    public override decimal Cost() => 8.50m + Lbs * 0.45m;
}
class Air(string id, string to, decimal lbs)
    : Shipment(id, to, lbs)
{
    public override string Carrier => "FedEx";
    public override decimal Cost() => 24m + Lbs * 2.10m;
}
class Freight(string id, string to, decimal lbs)
    : Shipment(id, to, lbs)
{
    public override string Carrier => "XPO";
    public override decimal Cost() => 95m + Lbs * 0.80m;
    public override decimal Surcharge => 45m;   // liftgate
}

Output:

CMH-2041 Tyler Brooks  UPS      $13.90
CMH-2042 Jenna Morales FedEx    $30.30
CMH-2043 Marcus Reed   XPO     $194.40
Total due: $238.60

Each line comes from the same Label() method, while Carrier and Total differ per class. Freight is $95.00 + 68 x $0.80 + $45 = $194.40, and the day’s total is $238.60.

When Should You Use an Abstract Class Instead of an Interface?

Use an abstract class when related types share state and code; use an interface when unrelated types only share a capability. Here’s the short version:

Abstract classInterface
Instantiate with newNoNo
Fields and constructorsYesNo instance fields or constructors
Members with codeYesDefault methods since C# 8
How many per classOne base classMany
Access modifiers on membersAny, abstract ones not privatePublic by default
Best forAn is-a family (Ground is a Shipment)A can-do role (ITrackable)

You can also combine them. An abstract class that implements an interface may leave the interface member abstract:

System.Globalization.CultureInfo.CurrentCulture = new("en-US");
ITrackable parcel = new Ground("CMH-2041", 12);
Console.WriteLine(parcel.Track());

interface ITrackable { string Track(); }

abstract class Shipment(string id) : ITrackable
{
    public string Id { get; } = id;
    public abstract decimal Cost();
    public abstract string Track();   // interface member left abstract
}
class Ground(string id, decimal lbs) : Shipment(id)
{
    public override decimal Cost() => 8.50m + lbs * 0.45m;
    public override string Track() => $"{Id}: UPS ground, {Cost():C}";
}

Output:

CMH-2041: UPS ground, $13.90

Because C# allows only one base class, interfaces are how a class picks up extra roles; see multiple inheritance in C# for that pattern.

Troubleshooting Abstract Class Errors in C#

I packed the common mistakes into one file. The build stops with all of them:

Console.WriteLine("This file never builds");

abstract class Shipment
{
    public abstract decimal Cost() { return 0m; }
    private abstract string Carrier { get; }
    public static abstract decimal BaseRate();
}
sealed abstract class Pallet { }
class Label { public abstract void Print(); }

Build output:

C:\DotNetDemo\AbstractClassDemo\Rules.cs(9,23): error CS0418: 'Pallet': an abstract type cannot be sealed or static
C:\DotNetDemo\AbstractClassDemo\Rules.cs(10,36): error CS0513: 'Label.Print()' is abstract but it is contained in non-abstract type 'Label'
C:\DotNetDemo\AbstractClassDemo\Rules.cs(5,29): error CS0500: 'Shipment.Cost()' cannot declare a body because it is marked abstract
C:\DotNetDemo\AbstractClassDemo\Rules.cs(7,36): error CS0112: A static member cannot be marked as 'abstract'
C:\DotNetDemo\AbstractClassDemo\Rules.cs(7,36): error CS0501: 'Shipment.BaseRate()' must declare a body because it is not marked abstract, extern, or partial
C:\DotNetDemo\AbstractClassDemo\Rules.cs(6,29): error CS0621: 'Shipment.Carrier': virtual or abstract members cannot be private

The build failed. Fix the build errors and run again.
ErrorWhat you wroteFix
CS0500An abstract method with a bodyRemove the body, or drop abstract
CS0621A private abstract memberMake it public, protected or internal
CS0112static abstract in a classAllowed only in interfaces
CS0418sealed abstract classPick one: they contradict each other
CS0513Abstract member in a non-abstract classMark the class abstract

The extra CS0501 comes from the same static abstract line: once abstract is rejected, the method needs a body.

Note: static abstract members are legal in interfaces since C# 11 (generic math uses them), but never in a class.

Where to Take It Next

  • Add a Drone carrier: one new class with two overrides, and Label() works unchanged.
  • Seal a finished override with sealed override so subclasses of Freight can’t change the surcharge, part of good encapsulation in C#.
  • Move the carriers into separate files; partial classes help when generated code joins in.

For the bigger picture of C# class design, these are worth reading next:

Frequently Asked Questions

What is an abstract class in C#?

It’s a class marked with the abstract keyword that you can’t instantiate. It exists to be a base class: it can hold fields, constructors and finished methods, plus abstract members that every non-abstract derived class must override.

Can an abstract class have a constructor in C#?

Yes. The constructor runs when a derived object is created, called through base(...). Make it protected, because only derived classes can call it.

Can an abstract class have non-abstract methods?

Yes. An abstract class can mix concrete methods, properties and fields with abstract members, and it can even have no abstract members at all, just to block direct instantiation.

What is the difference between an abstract and a virtual method?

An abstract method has no body and must be overridden by every non-abstract subclass. A virtual method has a default body that subclasses may override if they need to.

Can an abstract class be sealed or static?

No. sealed blocks inheritance and abstract requires it, so the compiler rejects the pair with CS0418. A static class can’t be abstract either.

Where can I read the official rules?

Microsoft’s abstract keyword reference lists every rule for abstract classes, methods and properties.