Decyzja między dziedziczeniem a kompozycją potrafi przesądzić o tym, czy kod w .NET będzie łatwy do rozwijania, czy po kilku miesiącach zamieni się w sieć zależności trudną do bezpiecznej zmiany. Porównanie composition vs inheritance sprowadza się nie tylko do składni C#, ale przede wszystkim do odpowiedzi na pytanie, jak modelować odpowiedzialności i zmienność systemu. Pokażę różnice, praktyczne przykłady, typowe pułapki oraz kryteria, którymi sam kieruję się przy projektowaniu architektury.
Najważniejsza decyzja dotyczy relacji między obiektami
- Dziedziczenie opisuje relację „jest rodzajem” i tworzy silne powiązanie z klasą bazową.
- Kompozycja opisuje relację „ma” lub „korzysta z” i pozwala składać obiekt z wymiennych zależności.
- W większości kodu aplikacyjnego kompozycja daje większą elastyczność i ułatwia testowanie.
- Dziedziczenie ma sens, gdy podtyp rzeczywiście spełnia kontrakt klasy bazowej i można bezpiecznie używać go w jej miejsce.
- Najlepsze rozwiązania często łączą interfejsy, kompozycję i ograniczone dziedziczenie.

Dwie różne drogi do ponownego użycia kodu
Dziedziczenie tworzy relację między klasą bazową a klasą pochodną. Klasa potomna przejmuje dostępne elementy klasy bazowej i może dodawać własne zachowanie albo nadpisywać wybrane metody. W C# taki mechanizm wspiera polimorfizm, czyli możliwość traktowania różnych typów przez wspólny kontrakt.
public abstract class Notification
{
public string Recipient { get; }
protected Notification(string recipient)
{
Recipient = recipient;
}
public abstract Task SendAsync(string message);
}
public sealed class EmailNotification : Notification
{
public EmailNotification(string recipient)
: base(recipient)
{
}
public override Task SendAsync(string message)
{
Console.WriteLine($"Email do {Recipient}: {message}");
return Task.CompletedTask;
}
}Kompozycja działa inaczej. Obiekt nie przejmuje kodu od rodzica, tylko otrzymuje inne obiekty jako zależności i deleguje im konkretne zadania. Przykładowo serwis powiadomień może korzystać z interfejsu wysyłki, bez dziedziczenia po żadnej klasie implementującej transport.
public interface IMessageSender
{
Task SendAsync(string recipient, string message);
}
public sealed class NotificationService
{
private readonly IMessageSender _sender;
public NotificationService(IMessageSender sender)
{
_sender = sender;
}
public Task NotifyAsync(string recipient, string message)
{
return _sender.SendAsync(recipient, message);
}
}Najprostsza heurystyka brzmi „is-a” dla dziedziczenia i „has-a” dla kompozycji. Traktuję ją jednak jako punkt startowy, nie gotową regułę. Samo stwierdzenie, że samochód „ma silnik”, nie rozstrzyga jeszcze, czy silnik powinien być tworzony wewnątrz samochodu, wstrzykiwany przez konstruktor czy opakowany za interfejsem.
Co naprawdę zmienia się w architekturze
Największa różnica dotyczy siły powiązania. Przy dziedziczeniu klasa pochodna zna szczegóły klasy bazowej, zależy od jej chronionego stanu, kolejności wywołań metod i założeń, których czasem nie widać w samym interfejsie. Zmiana klasy bazowej może więc wpłynąć na wiele klas potomnych naraz.
Kompozycja ukrywa szczegóły za kontraktem. Jeżeli NotificationService korzysta z IMessageSender, można podmienić wysyłkę e-mail na SMS, Azure Service Bus albo atrapę testową bez zmiany samego serwisu. To właśnie dlatego kompozycja dobrze pasuje do aplikacji, w których wymagania zmieniają się częściej niż podstawowe pojęcia domenowe.
| Kryterium | Dziedziczenie | Kompozycja |
|---|---|---|
| Relacja | „Jest rodzajem” | „Ma” lub „korzysta z” |
| Powiązanie | Silne i często statyczne | Luźniejsze, możliwe do wymiany |
| Reużycie kodu | Przez klasę bazową | Przez delegowanie do zależności |
| Testowanie | Może wymagać całej hierarchii | Łatwe zastępowanie zależności atrapami |
| Zmiana zachowania | Często wymaga nowej klasy potomnej | Może polegać na podmianie komponentu |
| Ryzyko | Krucha hierarchia i nieoczekiwane skutki zmian | Więcej obiektów i konfiguracji |
Kompozycja nie jest jednak darmowa. Wprowadza dodatkowe interfejsy, konstruktory i obiekty, więc w małym, stabilnym modelu może wyglądać na przesadę. Gdy zachowanie jest naprawdę wspólne, a hierarchia ma wyraźny sens, dziedziczenie potrafi być prostsze i czytelniejsze.
Kiedy dziedziczenie jest dobrym wyborem
Dziedziczenie wybieram wtedy, gdy relacja między typami jest trwała, a klasa bazowa definiuje rzeczywisty kontrakt, a nie tylko zestaw wygodnych metod do odziedziczenia. Klasa pochodna powinna móc być użyta wszędzie tam, gdzie oczekiwany jest typ bazowy, bez zaskakujących wyjątków i dodatkowych warunków.
Stabilna hierarchia domenowa
Dobrym przykładem może być model dokumentów, jeżeli wszystkie dokumenty mają wspólną tożsamość, cykl życia i reguły walidacji. Invoice i CreditNote mogą dziedziczyć po Document, o ile wspólne zachowanie jest naprawdę domenowe, a nie wynika wyłącznie z chęci uniknięcia kilku powtórzeń.
Podobnie działa hierarchia wyjątków, elementów UI czy komunikatów, gdy typ bazowy jasno określa wspólny kontrakt. W takich miejscach polimorfizm jest naturalny, bo kod wyżej w architekturze nie musi znać wszystkich szczegółowych typów.
Klasa bazowa kontroluje rozszerzanie
Dziedziczenie sprawdza się także przy wzorcu Template Method. Klasa bazowa definiuje ogólny przebieg operacji, a klasy potomne dostarczają wybrane kroki. To rozsądne rozwiązanie, gdy kolejność działań ma pozostać stała, a punkty rozszerzeń są dobrze opisane.
Trzeba tylko pilnować, aby metoda bazowa nie wymagała od potomków znajomości dziesięciu ukrytych reguł. Im więcej metod protected virtual, tym większy kontrakt, który trzeba utrzymywać. W praktyce taka klasa szybko staje się trudniejsza w zmianie niż kilka mniejszych komponentów.
Dlaczego kompozycja zwykle wygrywa w aplikacjach
W typowej aplikacji biznesowej zachowanie często składa się z niezależnych decyzji. Proces płatności może potrzebować walidatora, kalkulatora prowizji, klienta operatora i mechanizmu logowania. Dziedziczenie próbowałoby odwzorować kombinacje tych cech w hierarchii klas, natomiast kompozycja pozwala połączyć je w czasie konfiguracji.
public interface IFeeCalculator
{
decimal Calculate(decimal amount);
}
public interface IPaymentGateway
{
Task ChargeAsync(decimal amount);
}
public sealed class PaymentService
{
private readonly IFeeCalculator _feeCalculator;
private readonly IPaymentGateway _gateway;
public PaymentService(
IFeeCalculator feeCalculator,
IPaymentGateway gateway)
{
_feeCalculator = feeCalculator;
_gateway = gateway;
}
public async Task PayAsync(decimal amount)
{
var total = amount + _feeCalculator.Calculate(amount);
await _gateway.ChargeAsync(total);
}
}W takim modelu zmiana operatora płatności nie wymaga tworzenia nowej gałęzi dziedziczenia. Wystarczy inna implementacja IPaymentGateway. Ten styl dobrze współgra z dependency injection, czyli przekazywaniem zależności z zewnątrz zamiast tworzenia ich bezpośrednio w klasie.
Kompozycja pomaga także ograniczyć odpowiedzialność klas. Jeżeli jedna klasa ma obsługiwać bazę danych, autoryzację, retry i formatowanie odpowiedzi, problemem nie jest brak dziedziczenia, tylko zbyt wiele obowiązków. Rozdzielenie tych zadań na komponenty daje kod, który łatwiej testować i wymieniać.
Nie należy jednak budować interfejsu dla każdej prywatnej klasy tylko dlatego, że kompozycja jest modna. Interfejs ma sens wtedy, gdy istnieje realna granica zmienności, potrzeba testowania alternatyw albo niezależny kontrakt używany przez inne części systemu.
Najczęstsze pułapki obu podejść
Dziedziczenie tylko po to, by odziedziczyć kod
Jeżeli klasa ReportExporter dziedziczy po FileHelper wyłącznie po to, aby użyć jednej metody zapisującej plik, relacja prawdopodobnie jest błędna. Eksporter nie jest rodzajem pomocnika plikowego. Lepszym rozwiązaniem będzie prywatna zależność albo osobny interfejs.
Fałszywa abstrakcja w stylu „wszystko jest encją”
Wspólna klasa bazowa z polami Id, CreatedAt i kilkoma metodami może wyglądać niewinnie, ale często zaczyna skupiać odpowiedzialności, które nie należą do jednego modelu. Po kilku iteracjach każda zmiana wymaga sprawdzania całej hierarchii. To sygnał, że wspólny kod powinien zostać przeniesiony do kompozycji albo prostego obiektu wartości.
Przeczytaj również: MVVM w .NET - jak oddzielić UI od logiki aplikacji?
Kompozycja prowadząca do „bagna zależności”
Kompozycja również może zostać nadużyta. Klasa z dwunastoma zależnościami nie jest automatycznie dobrze zaprojektowana. Taka liczba często pokazuje, że brakuje warstwy pośredniej, fasady albo podziału procesu na mniejsze przypadki użycia.
Uważam też na kompozycję, która tylko przenosi problem za interfejs. Jeżeli komponenty nadal znają swoje szczegóły, zarządzają cyklem życia innych obiektów i mają wzajemne odwołania, luźne interfejsy niewiele pomagają. Liczy się nie liczba abstrakcji, lecz granica odpowiedzialności, którą dzięki nim uzyskujemy.
Jak podjąć decyzję w konkretnym projekcie
Przed wyborem rozwiązania zadaję sobie kilka praktycznych pytań. Nie chodzi o mechaniczne odhaczanie reguł, tylko o sprawdzenie, czy relacja będzie stabilna także po kolejnych zmianach wymagań.
- Czy obiekt rzeczywiście jest rodzajem typu bazowego, czy tylko korzysta z jego funkcji?
- Czy można użyć obiektu potomnego w każdym miejscu, w którym używany jest typ bazowy?
- Czy zachowanie będzie wymieniane w konfiguracji, teście albo zależnie od klienta?
- Czy klasa bazowa posiada stan i reguły, które muszą być wspólne dla wszystkich potomków?
- Czy dodanie nowego wariantu wymaga kolejnej klasy, czy wystarczyłaby nowa implementacja małego interfejsu?
- Czy rozwiązanie pozostanie czytelne dla osoby, która nie zna całej hierarchii?
Jeżeli odpowiedzi wskazują na zmienne strategie, wybieram kompozycję. Jeżeli mamy stabilny kontrakt domenowy i prawdziwą relację podtypu, dziedziczenie jest uzasadnione. Często najlepszy efekt daje połączenie obu technik, na przykład abstrakcyjna klasa bazowa z kilkoma wspólnymi regułami oraz kompozycja dla wymiennych usług pobocznych.
| Sytuacja | Preferowane rozwiązanie | Powód |
|---|---|---|
| Wymienne algorytmy, na przykład rabaty | Kompozycja i interfejs strategii | Nowy wariant nie zmienia istniejącego procesu |
| Trwała relacja podtypu w domenie | Dziedziczenie | Wspólny kontrakt jest częścią modelu |
| Integracja z zewnętrznym dostawcą | Kompozycja i adapter | Dostawcę można wymienić bez zmiany logiki domenowej |
| Stały przebieg procesu z punktami rozszerzeń | Kontrolowane dziedziczenie | Klasa bazowa pilnuje kolejności operacji |
| Testowanie usług aplikacyjnych | Kompozycja i wstrzykiwanie zależności | Łatwe zastępowanie infrastruktury atrapami |
Najlepszym testem jest próba opisania zmiany, która pojawi się za pół roku. Jeżeli dodanie nowego zachowania oznacza tworzenie kolejnych poziomów hierarchii, kompozycja prawdopodobnie ograniczy ryzyko. Jeżeli natomiast usunięcie dziedziczenia sprawiłoby, że każdy typ musiałby samodzielnie powielać ten sam niezmienny kontrakt, rozsądniej zostawić prostą klasę bazową.
Najzdrowszy kompromis dla kodu w .NET
Nie traktuję zasady „preferuj kompozycję nad dziedziczeniem” jako zakazu używania klas bazowych. To raczej ostrzeżenie przed budowaniem hierarchii tylko dlatego, że na początku pozwala szybko usunąć duplikację. Kompozycja zwykle lepiej znosi zmienność, ale dziedziczenie nadal jest wartościowe tam, gdzie model podtypów jest prawdziwy i dobrze ograniczony.
W praktyce zaczynam od małych kontraktów, wstrzykiwania zależności i prostych klas. Dziedziczenie dodaję wtedy, gdy wspólne zachowanie ma znaczenie domenowe, a testy potwierdzają, że klasy potomne spełniają oczekiwania typu bazowego. Taka kolejność decyzji daje elastyczność bez tworzenia architektury pełnej abstrakcji na zapas.
Jeśli masz wątpliwość, wybierz rozwiązanie, które pozwoli dodać kolejny wariant bez modyfikowania wielu istniejących klas. W większości systemów biznesowych będzie to kompozycja, ale dobrze zaprojektowana hierarchia dziedziczenia nadal pozostaje prostym i trafnym narzędziem tam, gdzie relacja „jest rodzajem” naprawdę opisuje domenę.
