Projektując model domenowy, szybko trafiam na pytanie, czy jeden obiekt tylko korzysta z innych, czy naprawdę je posiada. To właśnie sedno różnicy między agregacją a kompozycją, dwóch relacji często mylonych w UML i kodzie obiektowym. Pokażę, jak rozpoznać każdą z nich, jak wpływa na cykl życia obiektów oraz jak zapisać ten wybór w C# i .NET.
O własności obiektu decyduje jego cykl życia
- Agregacja oznacza luźną relację całość-część, w której element może istnieć niezależnie.
- Kompozycja wiąże część z właścicielem znacznie silniej, także pod względem cyklu życia.
- W UML agregację oznacza pusty romb, a kompozycję wypełniony romb.
- Najlepszym testem jest pytanie, kto tworzy, usuwa i kontroluje część.
- W C# sam typ referencji nie rozstrzyga relacji. Decydują reguły domenowe i publiczne API klas.
Aggregation vs composition w praktyce projektowania
W najprostszym ujęciu obie relacje opisują układ „całość ma części”. Różnica polega na sile tego związku. Przy agregacji obiekt nadrzędny przechowuje lub wykorzystuje inne obiekty, ale nie musi być ich właścicielem. Przy kompozycji część jest znacznie mocniej związana z całością i zwykle nie ma sensu poza nią.
Agregacja oznacza luźne posiadanie
Dobrym przykładem jest drużyna i zawodnicy. Drużyna może mieć listę zawodników, ale zawodnik istnieje także poza konkretną drużyną. Może zmienić klub, zostać wypożyczony albo być użyty w innym kontekście. Usunięcie drużyny nie powinno usuwać zawodnika.
W UML agregację pokazuje się pustym rombem umieszczonym przy klasie reprezentującej całość. W praktyce programistycznej wiele zespołów świadomie pomija ten symbol, ponieważ zwykła asocjacja często wystarcza i nie narzuca dodatkowych założeń dotyczących własności.
Kompozycja oznacza silną własność
Przy kompozycji część należy do jednej, konkretnej całości. Pozycja zamówienia jest częścią zamówienia, a akapit może być częścią konkretnego dokumentu. Bez obiektu nadrzędnego część traci swoje znaczenie biznesowe.
W UML kompozycję oznacza wypełniony romb. Część nie powinna jednocześnie należeć do dwóch kompozytów, a jej cykl życia jest kontrolowany przez właściciela. To nie jest jednak automatyczne „kasowanie obiektu” przez język programowania. W C# pamięć zwalnia garbage collector, natomiast zasady usuwania wynikają z modelu domenowego i warstwy persystencji.
Najważniejsza różnica to własność i cykl życia
Najprościej rozstrzygam ten dylemat, zadając trzy pytania. Kto tworzy część? Kto może ją zmienić lub przenieść? Co powinno się z nią stać po usunięciu obiektu nadrzędnego? Odpowiedzi często od razu pokazują, czy mamy do czynienia z agregacją, czy kompozycją.
| Kryterium | Agregacja | Kompozycja |
|---|---|---|
| Samodzielne istnienie części | Zwykle możliwe | Zwykle nie ma sensu |
| Własność | Luźna lub współdzielona | Silna, przypisana do jednej całości |
| Tworzenie części | Część może powstać niezależnie | Najczęściej tworzy ją właściciel |
| Usunięcie całości | Część nadal może istnieć | Część zwykle powinna zostać usunięta lub unieważniona |
| Przenoszenie części | Naturalne | Ograniczone regułami domeny |
| Przykład | Zespół i pracownik | Zamówienie i pozycja zamówienia |
Trzeba przy tym zachować ostrożność z przykładami. Ten sam zestaw klas może oznaczać różne relacje w różnych systemach. Pracownik może być niezależnym obiektem agregowanym przez zespół, ale w prostym systemie kadrowym rekord pracownika może być częścią konkretnego dokumentu zatrudnienia. Nie nazwa klas decyduje o relacji, tylko reguły biznesowe.
Warto też odróżnić kompozycję UML od ogólnego hasła „composition over inheritance”. To drugie oznacza budowanie zachowania z obiektów zamiast rozbudowywania hierarchii dziedziczenia. Jest to szersza technika projektowa. Kompozycja w UML opisuje natomiast przede wszystkim relację własności między częściami i całością.
Przykłady, które dobrze pokazują różnicę
Zespół i zawodnik jako agregacja
Zawodnik może istnieć bez zespołu, ponieważ ma własną tożsamość, historię i cykl życia. Zespół jedynie utrzymuje relację do zawodników. Taki model pasuje również do projektów, użytkowników i ról, a także kursów i zapisanych na nie studentów.
public sealed class Team
{
private readonly List _players = new();
public IReadOnlyCollection Players => _players;
public void AddPlayer(Player player)
{
if (!_players.Contains(player))
{
_players.Add(player);
}
}
public void RemovePlayer(Player player)
{
_players.Remove(player);
}
}
public sealed class Player
{
public Player(Guid id, string name)
{
Id = id;
Name = name;
}
public Guid Id { get; }
public string Name { get; }
} Obiekt Player może zostać utworzony poza klasą Team, a później przypisany do innej drużyny. Sama lista nie robi jeszcze z relacji agregacji, ale dobrze pokazuje jej typowy kształt. Zawodnik ma własną tożsamość i może przeżyć zmianę właściciela.
Zamówienie i pozycje jako kompozycja
Pozycja zamówienia zwykle nie jest samodzielnym bytem. Ma znaczenie dlatego, że należy do konkretnego zamówienia, przechowuje jego kontekst i nie powinna być przypadkowo współdzielona z innym zamówieniem.
public sealed class Order
{
private readonly List _lines = new();
public IReadOnlyCollection Lines => _lines;
public void AddProduct(Product product, int quantity)
{
if (quantity <= 0)
{
throw new ArgumentOutOfRangeException(nameof(quantity));
}
_lines.Add(new OrderLine(
product.Id,
product.Name,
product.Price,
quantity));
}
}
public sealed record OrderLine(
Guid ProductId,
string ProductName,
decimal UnitPrice,
int Quantity); W tym modelu zamówienie tworzy pozycję i wystawia na zewnątrz tylko odczyt kolekcji. To ogranicza przypadkowe modyfikacje oraz wzmacnia kontrolę właściciela. Produkt jest niezależnym obiektem, ale jego konkretna pozycja na zamówieniu już nie.
W bazie danych trzeba tę regułę odwzorować osobno. Sama kompozycja w kodzie nie zagwarantuje usunięcia rekordów potomnych. Potrzebne mogą być ograniczenia kluczy obcych, kaskadowe usuwanie albo jawna obsługa transakcji. W EF Core podobny model można dodatkowo zaznaczyć jako typ należący do właściciela, ale decyzja nadal powinna wynikać z domeny, a nie z wygody mapowania.
Jak zapisać właściwy wybór w C# i .NET
W C# agregację często implementuję przez przekazanie gotowych obiektów do konstruktora lub metody. Klasa nadrzędna zna kontrakt zależności, ale nie musi zarządzać całym jej życiem. Tak działa wiele relacji między serwisami, użytkownikami, zespołami i zasobami.
Kompozycję wzmacniam przez ukrycie konstruktorów części, ograniczenie setterów oraz operacje wykonywane przez właściciela. Zamiast pozwalać dowolnemu kodowi dodać pozycję, adres czy element konfiguracji, udostępniam metody, które pilnują reguł.
public sealed class Document
{
private readonly List _paragraphs = new();
public IReadOnlyList Paragraphs => _paragraphs;
public void AddParagraph(string text)
{
if (string.IsNullOrWhiteSpace(text))
{
throw new ArgumentException("Treść nie może być pusta.", nameof(text));
}
_paragraphs.Add(Paragraph.Create(text));
}
}
public sealed class Paragraph
{
private Paragraph(string text)
{
Text = text;
}
public string Text { get; }
internal static Paragraph Create(string text)
=> new(text);
} To nadal nie jest magiczna gwarancja kompozycji. Jeśli inna część systemu może przechwycić obiekt Paragraph, zapisać go niezależnie i używać po usunięciu dokumentu, model staje się słabszy. Enkapsulacja pomaga utrzymać decyzję projektową, ale nie zastępuje świadomych reguł w repozytorium i bazie danych.
Podobna ostrożność przydaje się przy dependency injection. Wstrzykiwanie zależności jest formą składania obiektów i poprawia testowalność, ale nie oznacza automatycznie kompozycji UML. Serwis może zostać wstrzyknięty do wielu klientów, więc jego cykl życia jest niezależny. To typowy przykład luźnej zależności, a nie relacji całość-część.
Typowe błędy przy rozróżnianiu obu relacji
Traktowanie każdego pola kolekcji jako kompozycji
Fakt, że klasa ma właściwość typu List, niczego jeszcze nie przesądza. Lista może przechowywać niezależne obiekty, na przykład użytkowników przypisanych do projektu. Może też zawierać elementy należące wyłącznie do właściciela. Struktura kodu jest wskazówką, nie dowodem.
Mylenie agregacji z dziedziczeniem
Relacja „ma” nie jest tym samym co relacja „jest”. Samochód ma silnik, więc rozważamy relację części i całości. Samochód jest pojazdem, więc mówimy o dziedziczeniu lub generalizacji. Pomieszanie tych dwóch pytań prowadzi do hierarchii klas, które szybko stają się trudne do zmiany.
Wymuszanie kompozycji tam, gdzie potrzebna jest współdzielona niezależność
Silne zamykanie obiektów wewnątrz właściciela bywa kuszące, ale nie zawsze jest dobre. Jeśli ten sam obiekt ma być używany przez kilka modułów albo przenoszony między kontekstami, kompozycja stworzy niepotrzebne ograniczenia. W takim przypadku lepsza jest asocjacja lub agregacja.
Przeczytaj również: Data fabric w praktyce - warstwy, wzorce i wdrożenie w Azure
Wiara, że UML rozwiąże problem za zespół
Diagram pomaga porozumieć się co do intencji, ale nie zastąpi decyzji o transakcjach, usuwaniu i odpowiedzialności. W dużych systemach szczególnie ważne jest ustalenie granic agregatów domenowych. Jeżeli zespół nie potrafi opisać cyklu życia obiektu słowami, sam romb na diagramie niewiele pomoże.
Moja praktyczna reguła jest prosta. Wybieram kompozycję, gdy część nie ma własnej tożsamości poza całością i właściciel powinien kontrolować wszystkie operacje. Wybieram agregację, gdy element żyje samodzielnie, ma własny identyfikator albo może zmienić kontekst użycia.
Najlepszy test przed dodaniem rombu do diagramu
Przed oznaczeniem relacji odpowiadam na jedno konkretne pytanie: czy część może sensownie istnieć po usunięciu całości? Jeśli tak, zaczynam od zwykłej asocjacji lub agregacji. Jeśli nie, sprawdzam, czy właściciel tworzy część, kontroluje jej modyfikację i odpowiada za jej usunięcie. Wtedy kompozycja jest uzasadniona.
Nie traktuję przy tym pustego i wypełnionego rombu jako celu samego w sobie. Najważniejszy jest czytelny model, szczelne granice odpowiedzialności i kod, który nie pozwala łatwo złamać reguł domeny. Dobrze dobrana relacja sprawia, że późniejsze decyzje w C#, EF Core i bazie danych wynikają z jednego spójnego założenia, zamiast być zbiorem przypadkowych wyjątków.
