🔥 Zapisy zamknięte, ale możesz pobrać Roadmapę .NET i dołączyć do listy oczekujących — Pobierz i dołącz do Listy VIP →

Klasa abstrakcyjna vs interfejs w C# — kiedy użyć którego

“Jaka jest różnica między klasą abstrakcyjną a interfejsem?” pada na niemal każdej rozmowie rekrutacyjnej na Junior .NET Developera. Odpowiedź “interfejs nie ma implementacji, a klasa abstrakcyjna może mieć” jest technicznie poprawna, ale nie pokazuje, że od C# 8 nawet to przestało być jednoznaczne — i nie odpowiada na prawdziwe pytanie: kiedy użyć którego.

Problem: oba definiują kontrakt

public interface IPlatnosc
{
    void Przetworz(decimal kwota);
}

public abstract class PlatnoscBase
{
    public abstract void Przetworz(decimal kwota);
}

Obie wersje wymuszają na klasie implementującej metodę Przetworz. Obie kompilują się. Obie “działają”. Różnica ujawnia się dopiero, gdy dochodzą kolejne wymagania — i to one decydują, którego narzędzia użyć.

Interfejs — kontrakt “potrafi to zrobić”

public interface IPlatnosc
{
    void Przetworz(decimal kwota);
}

public interface IPodlegaZwrotowi
{
    void Zwroc(decimal kwota);
}

public class PlatnoscKarta : IPlatnosc, IPodlegaZwrotowi // ✅ dwa interfejsy naraz
{
    public void Przetworz(decimal kwota) => Console.WriteLine($"Karta: {kwota} zł");
    public void Zwroc(decimal kwota) => Console.WriteLine($"Zwrot na kartę: {kwota} zł");
}

Klasa może implementować dowolną liczbę interfejsów — to modelowanie “co ten obiekt potrafi zrobić”, niezależnie od tego, czym jest. PlatnoscKarta potrafi przetwarzać płatności i potrafi zwracać pieniądze — to dwie niezależne zdolności, nie hierarchia dziedziczenia.

Interfejs tradycyjnie nie ma stanu (pól) ani implementacji — jest czystym kontraktem. Od C# 8 może mieć domyślną implementację metody:

public interface IPlatnosc
{
    void Przetworz(decimal kwota);
    void Loguj(decimal kwota) => Console.WriteLine($"[LOG] Płatność: {kwota} zł"); // domyślna implementacja
}

To nie zamienia interfejsu w klasę abstrakcyjną — nadal nie ma pól ani konstruktora, a domyślna implementacja to głównie narzędzie do bezpiecznego rozszerzania istniejących interfejsów bez łamania klas, które je już implementują.

Klasa abstrakcyjna — kontrakt “jest tym” ze wspólnym kodem

public abstract class PracownikBase
{
    protected readonly string Imie;
    protected PracownikBase(string imie) => Imie = imie; // ✅ konstruktor — interfejs tego nie ma

    public abstract decimal ObliczWynagrodzenie(); // musi zaimplementować podklasa

    public void WyplacWynagrodzenie() // ✅ wspólna implementacja dla WSZYSTKICH podklas
    {
        var kwota = ObliczWynagrodzenie();
        Console.WriteLine($"{Imie}: wypłacono {kwota} zł");
    }
}

public class PracownikEtatowy : PracownikBase
{
    private readonly decimal _pensja;
    public PracownikEtatowy(string imie, decimal pensja) : base(imie) => _pensja = pensja;
    public override decimal ObliczWynagrodzenie() => _pensja;
}

public class PracownikNaUmowe : PracownikBase
{
    private readonly decimal _stawka;
    private readonly int _godziny;
    public PracownikNaUmowe(string imie, decimal stawka, int godziny) : base(imie)
        => (_stawka, _godziny) = (stawka, godziny);
    public override decimal ObliczWynagrodzenie() => _stawka * _godziny;
}

Klasa abstrakcyjna może mieć pola, konstruktor i w pełni zaimplementowane metody dzielone przez wszystkie podklasy — WyplacWynagrodzenie() napisane raz, działa dla każdego typu pracownika. To modelowanie “czym ten obiekt jest” (każdy PracownikBase), z realnym, współdzielonym stanem i zachowaniem. Cena: klasa może dziedziczyć tylko po jednej klasie bazowej — C# nie ma wielokrotnego dziedziczenia klas.

Tabela decyzyjna

InterfejsKlasa abstrakcyjna
Wielokrotne dziedziczenie✅ dowolna liczba❌ tylko jedna klasa bazowa
Pola (stan)❌ nie✅ tak
Konstruktor❌ nie✅ tak
Domyślna implementacja⚠️ częściowo (C# 8+)✅ pełna
Modeluje“potrafi to zrobić” (capability)“jest tym” (hierarchia, wspólny kod)

Praktyczna reguła: jeśli różne, niepowiązane ze sobą klasy mają robić to samo (logowanie, serializacja, walidacja) — interfejs. Jeśli grupa ściśle powiązanych klas dzieli realny kod i stan (nie tylko sygnaturę metody) — klasa abstrakcyjna.

Pułapka: klasa abstrakcyjna tam, gdzie potrzebujesz kilku zdolności

public abstract class EntitaBase { /* ... */ }
public abstract class LogowalnaBase { /* ... */ }

// ❌ nie skompiluje się — C# nie ma wielokrotnego dziedziczenia klas
public class Zamowienie : EntitaBase, LogowalnaBase { }

Jeśli zaprojektujesz LogowalnaBase jako klasę abstrakcyjną, każda klasa, która ma “umieć się logować”, musi z niej dziedziczyć — a jeśli już dziedziczy po czymś innym, utknęła. Rozwiązanie: capability (możliwość logowania) powinna być interfejsem, nie klasą bazową.

public interface ILogowalna { void Zaloguj(); }

public class Zamowienie : EntitaBase, ILogowalna // ✅ jedna klasa bazowa + dowolnie wiele interfejsów
{
    public void Zaloguj() => Console.WriteLine("Zamówienie zalogowane");
}

Pułapka: interfejs tam, gdzie potrzebujesz współdzielonego stanu

public interface IPracownik
{
    string Imie { get; } // ❌ interfejs wymusza implementację getterów w KAŻDEJ klasie osobno
    decimal ObliczWynagrodzenie();
}

public class PracownikEtatowy : IPracownik
{
    public string Imie { get; }               // ta sama logika...
    public PracownikEtatowy(string imie) => Imie = imie;
    public decimal ObliczWynagrodzenie() => 5000m;
}

public class PracownikNaUmowe : IPracownik
{
    public string Imie { get; }               // ...powtórzona w każdej klasie
    public PracownikNaUmowe(string imie) => Imie = imie;
    public decimal ObliczWynagrodzenie() => 3200m;
}

Każda implementacja IPracownik musi od nowa napisać przechowywanie Imie — interfejs nie może tego zrobić za nie. To dokładnie sytuacja, w której klasa abstrakcyjna z polem protected readonly string Imie (jak w przykładzie wyżej) eliminuje powtórzony kod.

Można łączyć oba naraz

public interface IPodlegaZwrotowi { void Zwroc(decimal kwota); }

public abstract class PlatnoscBase : IPodlegaZwrotowi // klasa abstrakcyjna implementująca interfejs
{
    protected readonly string NumerTransakcji;
    protected PlatnoscBase(string numer) => NumerTransakcji = numer;

    public abstract void Przetworz(decimal kwota);
    public virtual void Zwroc(decimal kwota) => Console.WriteLine($"Zwrot {kwota} zł dla {NumerTransakcji}");
}

To nie jest wybór “albo-albo” na całe życie projektu — klasa abstrakcyjna daje wspólny stan i częściową implementację, jednocześnie deklarując zgodność z interfejsem, którego mogą używać inne, niepowiązane klasy w systemie.

Szybkie pytania uzupełniające (typowe na rozmowie)

Czy interfejs może mieć pola? Nie — nawet po C# 8. Może mieć właściwości ({ get; set; }), ale nie gołe pola (private string _imie). Właściwość w interfejsie to nadal tylko sygnatura — implementująca klasa sama decyduje, czy stoi za nią pole, czy coś obliczanego w locie.

Czy można stworzyć instancję klasy abstrakcyjnej? Nie:

var p = new PracownikBase("Anna"); // ❌ błąd kompilacji — nie można utworzyć instancji klasy abstrakcyjnej

Klasa abstrakcyjna istnieje wyłącznie po to, żeby po niej dziedziczyć — tworzysz instancje jej konkretnych podklas (PracownikEtatowyPracownikNaUmowe), nigdy jej samej.

Czy interfejs może dziedziczyć po innym interfejsie? Tak, i to wielokrotnie:

public interface IPodlegaZwrotowi : IPlatnosc, ILogowalna { } // łączy trzy kontrakty w jeden

Podsumowanie

Interfejs modeluje zdolność (“potrafi to zrobić”) — klasa może mieć ich dowolnie wiele, ale interfejs nie niesie stanu ani konstruktora. Klasa abstrakcyjna modeluje tożsamość i wspólną implementację (“jest tym, dzieli ten kod”) — daje pola, konstruktor i gotowe metody, kosztem ograniczenia do jednej klasy bazowej. W realnym kodzie najczęściej potrzebujesz obu naraz: klasy abstrakcyjnej dla rdzenia współdzielonej logiki i interfejsów dla dodatkowych, niezależnych zdolności doczepianych do niej z boku.

👨‍💻
Mariusz Jurczenko
Senior .NET Developer · 10+ lat doświadczenia komercyjnego

Programista .NET z doświadczeniem komercyjnym w firmach takich jak NFZ, Kamsoft, Diagnostyka, Hermes Reply Polska czy Etisoft Smart Solutions. Twórca kursów, z których skorzystało już ponad 11 000 osób w Strefie Kursów i ponad 1 000 kursantów na dev-hobby.pl.

Specjalizacja: Clean Code, Clean Architecture i uczenie programowania tak, żeby dało się je naprawdę zrozumieć — nie wykuć.

🚀 Co dalej?

Zobacz to w praktyce na wideo i pobierz darmową roadmapę, żeby ułożyć naukę w spójną ścieżkę do pierwszej pracy.

czytanie to początek

Zamień wiedzę w umiejętności

Pobierz darmową Roadmapę .NET i ułóż takie tematy jak ten w spójną ścieżkę do pierwszej pracy.

Pobieram roadmapę →