Zaawansowane techniki obiektowe

Rok akademicki: 2026/27
Instrukcja laboratoryjna – Część 1: Wprowadzenie, Fundamenty Architektoniczne, Kontenery DI (IoC) i Wzorce Kreacyjne
Prowadzący: Tomasz Goluch
Wersja: 1.3


1. Wprowadzenie i Środowisko Laboratoryjne

1.1. Cel i Wymagania Środowiskowe

Celem niniejszej części laboratorium jest opanowanie fundamentów architektonicznych nowoczesnego programowania obiektowego, zrozumienie taksonomii wzorców projektowych Bandy Czworga (GoF – Gang of Four), przyswojenie zasad SOLID, dogłębne zrozumienie mechanizmu odwrócenia sterowania (IoC) i wstrzykiwania zależności (DI) z wykorzystaniem kontenerów Autofac oraz Microsoft.Extensions.DependencyInjection, a także praktyczna implementacja wzorców kreacyjnych (konstrukcyjnych) w języku C# (standard C# 12 / .NET 8+).

Laboratorium odbywa się na maszynach fizycznych (komputery laboratoryjne albo własne laptopy), z wykorzystaniem preferowanego edytora lokalnego (np. Visual Studio 2022, Visual Studio Code z rozszerzeniem C# Dev Kit, JetBrains Rider). Nie ma żadnych przeciwwskazań, aby laboratorium uruchomić w oparciu o kontenery Docker, szczególnie z wykorzystaniem Docker Compose do orkiestracji usług pomocniczych.


1.2. Geneza, Istota i Ekosystem Bibliotek Wzorców w .NET

Wzorce projektowe (ang. Design Patterns) to sprawdzone w praktyce inżynierskiej, sformalizowane szablony rozwiązań powtarzalnych problemów programistycznych i architektonicznych. Koncepcja ta została zaadaptowana do inżynierii oprogramowania przez Ericha Gammę, Richarda Helma, Ralpha Johnsona oraz Johna Vlissidesa (autorów fundamentalnej monografii z 1994 roku, zwanych odtąd Gang of Four – GoF).

Wzorzec projektowy nie jest gotowym algorytmem ani biblioteką klas, którą można bezrefleksyjnie wkleić do projektu. Jest to opis relacji, odpowiedzialności i sposobu komunikacji pomiędzy klasami oraz współpracującymi obiektami, zapewniający:

  1. Zwiększenie reużywalności kodu poprzez separację logiki stabilnej od zmiennej.
  2. Ujednolicenie słownictwa technicznego w zespole inżynierskim (np. pojęcie „Fabryka” natychmiast definiuje odpowiedzialność modułu).
  3. Elastyczność na zmiany wymagań biznesowych dzięki programowaniu do abstrakcji (interfejsów), a nie do konkretnych implementacji.

Mimo że wzorzec sam w sobie jest szablonem myślowym, w nowoczesnym ekosystemie platformy .NET istnieje szereg dojrzałych bibliotek i wbudowanych mechanizmów, które implementują konkretne wzorce projektowe lub bezpośrednio wspierają ich realizację produkcyjną:

Wzorce Kreacyjne w bibliotekach .NET:

Wzorce Strukturalne w bibliotekach .NET:

Wzorce Czynnościowe (Behawioralne) w bibliotekach .NET:


1.3. Podwójna Taksonomia Wzorców Projektowych (GoF)

Wzorce projektowe dzielimy przynajmniej pod względem dwóch zasadniczych kryteriów: przeznaczenia oraz zakresu:

                           ┌────────────────────────────────────────┐
                           │      WZORCE PROJEKTOWE (GoF)           │
                           └──────────────────┬─────────────────────┘
                                              │
         ┌────────────────────────────────────┼────────────────────────────────────┐
         ▼                                    ▼                                    ▼
┌──────────────────┐                 ┌──────────────────┐                 ┌──────────────────┐
│    KREACYJNE     │                 │   STRUKTURALNE   │                 │   BEHAWIORALNE   │
│ (KONSTRUKCYJNE)  │                 │                  │                 │  (CZYNNOŚCIOWE)  │
├──────────────────┤                 ├──────────────────┤                 ├──────────────────┤
│ • Singleton      │                 │ • Adapter        │                 │ • Łańcuch Odpow. │
│ • Metoda Wytwórcza│                │ • Dekorator      │                 │ • Obserwator     │
│ • Fabryka Abstr. │                 │ • Fasada         │                 │ • Polecenie (Cmd)│
│ • Budowniczy     │                 │ • Kompozyt       │                 │ • Metoda Szablon.│
│ • Prototyp       │                 │ • Most (Bridge)  │                 │ • Mediator       │
│                  │                 │ • Pełnomocnik    │                 │ • Stan / Pamiątka│
│                  │                 │ • Pyłek          │                 │ • Strategia      │
└────────┬─────────┘                 └────────┬─────────┘                 └────────┬─────────┘
         │                                    │                                    │
         └────────────────────────────────────┼────────────────────────────────────┘
                                              ▼
                             ┌─────────────────────────────────┐
                             │       ZAKRES REALIZACJI         │
                             ├─────────────────────────────────┤
                             │ • Klasowe (dziedziczenie / ISA) │
                             │ • Obiektowe (kompozycja / HAS-A)│
                             └─────────────────────────────────┘

Pierwsze kryterium: Przeznaczenie wzorców

Drugie kryterium: Zakres wzorców


2. Fundamenty Architektoniczne: Zasady SOLID

Wzorce projektowe stanowią praktyczną implementację fundamentalnych reguł inżynierii oprogramowania sformułowanych przez Roberta C. Martina (SOLID):

🦆 Dla dociekliwych: Zasada Podstawienia Liskov a pułapka potocznego „IS-A” – Kaczka i Kaczka Elektryczna (Kliknij, aby rozwinąć)

Częstym błędem architektonicznym jest utożsamianie dziedziczenia z potocznym rozumieniem relacji „jest czymś” (is-a) w języku naturalnym. W świecie rzeczywistym powiemy: „kaczka elektryczna to też kaczka (tylko na baterie)”. Jednak w programowaniu obiektowym podtypowanie musi być behawioralne – klasa pochodna musi bezwzględnie dotrzymywać kontraktu semantycznego klasy bazowej.

1. Naiwna hierarchia klas (naruszenie kontraktu)

Rozważmy klasę bazową modelującą ptaka oraz klasę pochodną modelującą zabawkę:

public class Kaczka
{
    public virtual void Kwacz() => Console.WriteLine("Kwa, kwa!");
    public virtual void Plywaj() => Console.WriteLine("Kaczka pływa po stawie.");
}

public class KaczkaElektryczna : Kaczka
{
    public bool Wlaczona { get; private set; }

    public void Wlacz() => Wlaczona = true;
    public void Wylacz() => Wlaczona = false;

    public override void Kwacz()
    {
        // Naruszenie LSP: wzmocnienie warunków wstępnych (preconditions)
        if (!Wlaczona)
            throw new InvalidOperationException("Włącz zasilanie przed kwakaniem!");

        Console.WriteLine("Elektroniczne: Pii-kwa!");
    }

    public override void Plywaj()
    {
        if (!Wlaczona)
            Console.WriteLine("Kaczka stoi w miejscu – brak napędu.");
        else
            Console.WriteLine("Silniczek napędza śrubę.");
    }
}

2. Konsekwencje dla konsumenta (Code Smell)

Kod operujący na abstrakcji bazy nie ma pojęcia o stanie zasilania:

public void PoranekNaStawie(IEnumerable<Kaczka> kaczki)
{
    foreach (var kaczka in kaczki)
    {
        // Zgłosi InvalidOperationException dla wyłączonej KaczkiElektrycznej!
        kaczka.Kwacz();
    }
}

Aby uchronić się przed awarią, programista jest zmuszony do weryfikacji typu w czasie wykonania (RTTI):

if (kaczka is KaczkaElektryczna ke)
    ke.Wlacz();

kaczka.Kwacz();

Obecność instrukcji typu is / as lub rzutowań w kodzie klienckim konsumującym abstrakcję to podręcznikowy objaw złamania LSP.

3. Czy ukrycie wywołania Wlacz() wewnątrz Kwacz() rozwiązuje problem?

Kuszącym odruchem inżynierskim bywa automatyczne spełnienie warunku pod maską:

public override void Kwacz()
{
    if (!Wlaczona)
        Wlacz(); // Próba zamiecenia problemu pod dywan

    Console.WriteLine("Elektroniczne: Pii-kwa!");
}

Rozwiązanie to nadal łamie LSP, zamieniając jawny wyjątek w trudny do wykrycia błąd logiczny:

  1. Niejawne efekty uboczne (Side Effects): Klient wywołuje z pozoru bezpieczną metodę emisji dźwięku, a nieświadomie zmienia globalny stan maszyny (włącza silnik, rozpoczyna pobór prądu).
  2. Problem wyczerpania zasobów: Co w sytuacji, gdy bateria jest rozładowana? Rzucenie BatteryDepletedException z metody Kwacz() ponownie łamie kontrakt bazy, która nie dopuszcza awarii zasilania.

4. Poprawne rozwiązanie architektoniczne

Prawdziwa kaczka to byt biologiczny, a kaczka elektryczna to urządzenie elektromechaniczne imitujące ptaka. Rozwiązaniem jest eliminacja dziedziczenia na rzecz segregacji interfejsów (ISP) oraz kompozycji:

public interface IKwaczacy
{
    void Kwacz();
}

public interface IPlywajacy
{
    void Plywaj();
}

// Kaczka biologiczna implementuje zachowania bezpośrednio
public class Kaczka : IKwaczacy, IPlywajacy
{
    public void Kwacz() => Console.WriteLine("Kwa, kwa!");
    public void Plywaj() => Console.WriteLine("Kaczka unosi się na wodzie.");
}

// Kaczka elektryczna realizuje zachowania przez kompozycję
public class KaczkaElektryczna : IKwaczacy, IPlywajacy
{
    private readonly ISoundEmitter _soundEmitter;
    public bool Wlaczona { get; set; }

    public KaczkaElektryczna(ISoundEmitter soundEmitter)
    {
        _soundEmitter = soundEmitter;
    }

    public void Kwacz()
    {
        if (Wlaczona)
            _soundEmitter.EmitSound("Pii-kwa!");
    }

    public void Plywaj()
    {
        if (Wlaczona)
            Console.WriteLine("Silniczek napędza śrubę.");
    }
}

🧪 Dla dociekliwych: Typologia atrap testowych – Dummy, Stub, Spy, Mock i Fake (Kliknij, aby rozwinąć)

W żargonie programistycznym pojęcia „mock” i „stub” bywają mylnie stosowane zamiennie. Zgodnie z klasyfikacją Gerarda Meszarosa (xUnit Test Patterns) wszystkie te mechanizmy tworzą rodzinę dublerów testowych (Test Doubles), jednak realizują zupełnie inne cele weryfikacyjne:

                               ┌─────────────────────────────┐
                               │   DUBLER TESTOWY            │
                               │   (Test Double)             │
                               └──────────────┬──────────────┘
                                              │
         ┌──────────────────┬─────────────────┼──────────────────┬──────────────────┐
         ▼                  ▼                 ▼                  ▼                  ▼
  ┌─────────────┐    ┌─────────────┐   ┌─────────────┐    ┌─────────────┐    ┌─────────────┐
  │    DUMMY    │    │    STUB     │   │     SPY     │    │    MOCK     │    │    FAKE     │
  ├─────────────┤    ├─────────────┤   ├─────────────┤    ├─────────────┤    ├─────────────┤
  │ Wypełniacz  │    │ Sztywne     │   │ Cichy       │    │ Weryfikator │    │ Uproszczona │
  │ parametrów  │    │ odpowiedzi  │   │ rejestrator │    │ interakcji  │    │ implement.  │
  │ (np. null)  │    │ (State-based│   │ wywołań     │    │ (Behavior-  │    │ biznesowa   │
  │             │    │  testing)   │   │             │    │  testing)   │    │ (np. In-Mem)│
  └─────────────┘    └─────────────┘   └─────────────┘    └─────────────┘    └─────────────┘
  1. Dummy (Wypełniacz):

    • Obiekt powoływany wyłącznie po to, aby zaspokoić sygnaturę metody lub konstruktora (np. gdy wymagana jest instancja loggera, ale test w ogóle go nie dotyczy).
    • Nigdy nie bierze czynnego udziału w teście — często przekazuje się go jako null lub pustą instancję.
  2. Stub (Zaślepka stanu):

    • Zwraca z góry przygotowane, stałe odpowiedzi na wywołania testowanego modułu (State Verification).
    • Nie bada liczby wywołań ani parametrów — dostarcza jedynie dane potrzebne do przejścia danej ścieżki w kodzie.
    • Przykład (Moq): mockRepo.Setup(r => r.GetById(1)).Returns(new User { IsActive = true });
  3. Spy (Szpieg):

    • Odmiana Stuba, która oprócz zwracania wartości dyskretnie zlicza wywołania i rejestruje przekazane argumenty do późniejszej asercji w teście.
  4. Mock (Atrapa zachowania):

    • Służy do weryfikacji interakcji (Behavior Verification), a nie samego stanu obiektu.
    • Definiuje sztywne oczekiwania: określona metoda musi zostać wywołana dokładnie określoną liczbę razy z konkretnymi argumentami. Brak interakcji oznacza błąd asercji.
    • Przykład (Moq):
      emailSenderMock.Verify(s => s.Send(It.IsAny<string>(), "Awaria"), Times.Once());
  5. Fake (Falsyfikat):

    • Posiada w pełni funkcjonalną implementację biznesową, która z przyczyn wydajnościowych lub infrastrukturalnych nie nadaje się na produkcję.
    • Klasyczny przykład: Baza w pamięci RAM (InMemoryDatabase), słownik ConcurrentDictionary udający bazę danych lub symulator bramki płatności FakePaymentGateway.

3. Idea Wstrzykiwania Zależności (DI) i Kontenery IoC

3.1. Zasada Odwrócenia Sterowania (IoC) a Wstrzykiwanie Zależności (DI)

W tradycyjnym programowaniu to klasa nadrzędna decyduje, kiedy utworzyć obiekt zależności za pomocą słowa kluczowego new. Wzorzec Inversion of Control (IoC) odwraca ten przepływ: obiekt nie tworzy swoich zależności, lecz są mu one dostarczane z zewnątrz przez framework lub kontener.

Dependency Injection (DI) jest konkretnym wzorcem projektowym realizującym koncepcję IoC. Wstrzykiwanie zależności polega na przekazaniu instancji implementującej żądany interfejs bezpośrednio do odbiorcy.

Wyróżniamy trzy główne techniki wstrzykiwania:

  1. Wstrzykiwanie przez konstruktor (Constructor Injection):
    • Podstawowy i rekomendowany standard architektoniczny.
    • Zależności są niemutowalne (zapisywane w polach readonly), co gwarantuje, że obiekt od momentu powstania znajduje się w stanie w pełni zainicjalizowanym i poprawnym.
    • Działa jako wskaźnik naruszenia zasady SRP: konstruktor przyjmujący więcej niż 4–5 parametrów sygnalizuje, że klasa skupia zbyt wiele odpowiedzialności i wymaga podziału.
  2. Wstrzykiwanie przez właściwość (Property Injection):
    • Stosowane głównie dla zależności opcjonalnych oraz komponentów brzegowych, w których instancję powołuje zewnętrzny framework.
    • Umożliwia dynamiczną podmianę zależności w czasie życia nosiciela (np. zmiana strategii algorytmu lub przełączenie na implementację awaryjną w locie).
    • Wady: ryzyko wywołania obiektu w stanie niekompletnym (gdy właściwość nie została jeszcze zainicjalizowana) oraz brak domyślnego bezpieczeństwa wątkowego przy modyfikacji właściwości na instancjach współdzielonych.
  3. Wstrzykiwanie przez metodę (Method Injection):
    • Stosowane, gdy dana zależność nie opisuje stałego stanu obiektu, lecz jest potrzebna wyłącznie na czas wykonania jednej, konkretnej operacji (np. przekazanie strategii kalkulacji, kontekstu operacji lub tokena autoryzacji).
    • Pozwala uniknąć przechowywania rzadko używanej zależności w polach klasy przez cały cykl jej życia.

3.2. Zaawansowany Kontener IoC: Autofac

W platformie .NET w przypadku wielu wystąpień operatora new z tą samą klasą tworzymy jej fabrykę albo stosujemy kontener DI, np. natywny Microsoftu Microsoft.Extensions.DependencyInjection bądź VS MEF albo zewnętrznego dostawcy taki jak: AutoFac, Unity, ewentualnie własny.

W celu porównania bibliotek można wykorzystać stronę: https://dotnet.libhunt.com/categories/1810-ioc. Jak widać, AutoFac prowadzi pod względem liczby gwiazdek, obserwatorów, forków, częstości wydań oraz jakości kodu.

A. Podstawowa konfiguracja imperatywna

Instalacja pakietu NuGet:

dotnet add package Autofac

Model domenowy:

public interface ILogger
{
    void Log(string message);
}

public class ConsoleLogger : ILogger
{
    public void Log(string message) => Console.WriteLine($"[LOG]: {message}");
}

public class Worker
{
    private readonly ILogger _logger;

    // Wstrzykiwanie przez konstruktor
    public Worker(ILogger logger)
    {
        _logger = logger;
    }

    public void Work()
    {
        _logger.Log("Rozpoczęto zadanie.");
        _logger.Log("Zakończono zadanie.");
    }
}

Budowa i rejestracja w kontenerze:

using Autofac;

// 1. Tworzymy budowniczego kontenera
var builder = new ContainerBuilder();

// 2. Rejestrujemy komponenty
builder.RegisterType<ConsoleLogger>().As<ILogger>();
builder.RegisterType<Worker>();

// 3. Budujemy kontener (IContainer)
using IContainer container = builder.Build();

// 4. Rozwiązujemy zależność
var worker = container.Resolve<Worker>();
worker.Work();

B. Techniki wstrzykiwania w Autofac

1. Wstrzykiwanie przez konstruktor (Standard domyślny)

Podstawowy wariant zaprezentowany w punkcie 3.2.A – Autofac automatycznie analizuje parametry konstruktora i przekazuje zarejestrowane implementacje.

2. Wstrzykiwanie przez właściwość (PropertiesAutowired)

Pozwala kontenerowi przypisać zarejestrowane usługi do publicznych właściwości z akcesorem set po utworzeniu instancji:

// Rejestracja pod typem własnym (as self) z włączonym skanowaniem właściwości
builder.RegisterType<PropertyWorker>().PropertiesAutowired();
3. Wstrzykiwanie przez metodę (OnActivated / w punkcie wywołania)

Autofac nie wywołuje metod automatycznie na podstawie konwencji, dlatego wstrzykiwanie przez metodę realizuje się na dwa sposoby:

4. Komponenty nazwane, klucze i indeksy (Named, Keyed & IIndex)

Stosowane w sytuacji, gdy pod jeden interfejs rejestrujemy wiele implementacji i zachodzi potrzeba ich jednoznacznego rozróżnienia:

⚠️ Architektoniczna pułapka: Antywzorzec Service Locator
Ręczne wywoływanie metod container.ResolveNamed<T>() lub container.ResolveKeyed<T>() wewnątrz klas biznesowych uzależnia kod domenowy od API kontenera IoC i stanowi antywzorzec Service Locator. Klasy domenowe nie powinny wiedzieć o istnieniu kontenera.

C. Zarządzanie Czasem Życia (Lifetime Scopes) w Autofac

Strategia w Autofac Odpowiednik w Microsoft DI Opis działania
InstancePerDependency() Transient Domyślna strategia. Nowa instancja jest powoływana przy każdym zapytaniu.
SingleInstance() Singleton Dokładnie jedna instancja w całym procesie kontenera.
InstancePerLifetimeScope() Scoped Jedna instancja w ramach danego zasięgu (ILifetimeScope). Kluczowe w aplikacjach webowych (jedno żądanie HTTP = jeden zasięg).
InstancePerMatchingLifetimeScope(tag) Brak wbudowanego Jedna współdzielona instancja w ramach zasięgu oznaczonego konkretnym identyfikatorem (tagiem).

3.3. Standardowy Kontener: Microsoft.Extensions.DependencyInjection

Platforma .NET udostępnia natywny, silnie zoptymalizowany pod kątem wydajności kontener IoC, stanowiący domyślny fundament potoku aplikacji w ASP.NET Core oraz szablonach Worker Service (.NET Generic Host).

A. Podstawowa konfiguracja imperatywna i cykle życia

W przeciwieństwie do dwuetapowego budowania w Autofac (ContainerBuilder -> Build()), natywny kontener konfiguruje kolekcję deskryptorów usług IServiceCollection, a następnie kompiluje zoptymalizowanego dostawcę ServiceProvider:

using Microsoft.Extensions.DependencyInjection;

// 1. Inicjalizacja kolekcji usług
var services = new ServiceCollection();

// 2. Rejestracja z określeniem cyklu życia
services.AddTransient<ILogger, ConsoleLogger>();
services.AddTransient<Worker>(); // Rejestracja pod typem własnym (as self)

services.AddScoped<IOperationScoped, Operation>();
services.AddSingleton<IOperationSingleton, Operation>();

// 3. Kompilacja dostawcy usług (IServiceProvider)
using ServiceProvider provider = services.BuildServiceProvider();

// 4. Rozwiązanie grafu zależności
var worker = provider.GetRequiredService<Worker>();
worker.Work();

B. Usługi kluczowane (.NET 8+) i techniki wstrzykiwania

Wbudowany kontener Microsoftu celowo nie wspiera automatycznego wstrzykiwania do właściwości (PropertiesAutowired) ani metod inicjalizujących na zdarzeniach. Zamiast tego opiera się na wstrzykiwaniu przez konstruktor oraz – od wersji .NET 8 – natywnym mechanizmie usług kluczowanych (Keyed Services), będących bezpośrednim odpowiednikiem komponentów nazwanych i kluczowanych z Autofac.

1. Rejestracja usług kluczowanych (Keyed Services)

Kluczem identyfikującym może być ciąg znaków (string) lub dowolny silnie typowany obiekt, w tym enum:

public enum StorageType { Local, Cloud }

// A. Rejestracja z kluczem tekstowym (odpowiednik Named z Autofac):
services.AddKeyedSingleton<ILogger, ConsoleLogger>("console");
services.AddKeyedSingleton<ILogger, FileLogger>("file");

// B. Rejestracja ze silnie typowanym kluczem enum:
services.AddKeyedScoped<IStorage, LocalStorage>(StorageType.Local);
services.AddKeyedScoped<IStorage, CloudStorage>(StorageType.Cloud);
2. Wstrzykiwanie w konstruktorze konsumenta ([FromKeyedServices])

Do wskazania kontenerowi, którą z zarejestrowanych implementacji należy wstrzyknąć, służy dedykowany atrybut [FromKeyedServices]:

// Wstrzykiwanie za pomocą konstruktora pierwotnego (Primary Constructor - C# 12):
public class BackupManager(
    [FromKeyedServices(StorageType.Cloud)] IStorage cloudStorage,
    [FromKeyedServices("file")] ILogger fileLogger)
{
    public void Execute()
    {
        fileLogger.Log("Rozpoczęto backup...");
        cloudStorage.Save();
    }
}
3. Bezpośrednie pobranie z dostawcy (GetRequiredKeyedService)

Pobieranie instancji z poziomu kodu konfiguracyjnego lub fabryki:

var fileLogger = provider.GetRequiredKeyedService<ILogger>("file");
var cloudStorage = provider.GetRequiredKeyedService<IStorage>(StorageType.Cloud);
🌐 Dla dociekliwych: Scenariusze wstrzykiwania i czasy życia w kontrolerach ASP.NET Core MVC (Kliknij, aby rozwinąć)

W technologii ASP.NET Core architektura MVC opiera się w całości na kontenerze Microsoft.Extensions.DependencyInjection.

1. Scenariusze wstrzykiwania zależności do kontrolera

A. Wstrzykiwanie przez konstruktor (Constructor Injection – wariant domyślny)

Rekomendowane podejście w przypadku serwisów wykorzystywanych przez większość akcji kontrolera. Kontener automatycznie tworzy graf zależności przy każdym żądaniu HTTP:

public class OrderController : Controller
{
    private readonly IOrderService _orderService;
    private readonly ILogger<OrderController> _logger;

    public OrderController(IOrderService orderService, ILogger<OrderController> logger)
    {
        _orderService = orderService;
        _logger = logger;
    }
}
B. Wstrzykiwanie do akcji metodą ([FromServices])

Stosowane w sytuacji, gdy usługa jest kosztowna w inicjalizacji lub wykorzystywana wyłącznie w jednej, rzadko wywoływanej akcji. Eliminuje narzut powoływania instancji przy innych żądaniach trafiających do kontrolera:

[HttpPost]
public async Task<IActionResult> ExportToPdf(
    int orderId,
    [FromServices] IPdfReportGenerator pdfGenerator)
{
    var pdf = await pdfGenerator.GenerateAsync(orderId);
    return File(pdf, "application/pdf");
}
C. Wstrzykiwanie usług kluczowanych ([FromKeyedServices] – C# 12 / .NET 8+)

Umożliwia precyzyjne wskazanie implementacji zarejestrowanej pod wskazanym identyfikatorem w akcji lub konstruktorze:

public IActionResult ProcessPayment(
    decimal amount,
    [FromKeyedServices("PayPal")] IPaymentGateway paymentGateway)
{
    paymentGateway.Charge(amount);
    return Ok();
}
D. Wstrzykiwanie bezpośrednio do widoków Razor (@inject)

Pozwala na dostarczanie serwisów pomocniczych (lokalizatorów tekstów, konfiguracji UI) bezpośrednio w szablonach .cshtml z pominięciem kontrolera:

@inject IConfiguration Configuration
<p>Wersja systemu: @Configuration["AppVersion"]</p>

2. Cykle życia obiektów w cyklu żądania HTTP

Klient HTTP ──► [ Żądanie 1 ] ──────────────────────────────────────────► Odpowiedź HTTP
                  │
                  ├─► Kontroler MVC (Transient) ────► niszczony po zakończeniu akcji
                  ├─► Usługa A (Transient) ─────────► niszczona po wywołaniu
                  └─► DbContext / UoW (Scoped) ─────► współdzielony w Żądaniu 1,
                                                      niszczony przy końcu Żądania 1

Klient HTTP ──► [ Żądanie 2 ] ──────────────────────────────────────────► Odpowiedź HTTP
                  │
                  └─► DbContext / UoW (Scoped) ─────► NOWA, odizolowana instancja!

[ Stan Aplikacji w Pamięci ] ───────────────────────────────────────────► Współdzielony
  └─► Cache / ThreadSafeSingleton (Singleton) ──────► jedna instancja dla Żądania 1, 2 itd.
  1. Transient (AddTransient):

    • Nowy obiekt jest alokowany za każdym razem, gdy dowolny komponent zażąda zależności w konstruktorze lub akcji.
    • Jeżeli kontroler i dwa wstrzyknięte serwisy odwołują się do tego samego interfejsu ITransientService, w ramach jednego żądania HTTP powstaną 3 niezależne instancje.
  2. Scoped (AddScoped) – podstawa aplikacji webowych:

    • Dokładnie jedna instancja na cały czas trwania jednego żądania HTTP (tworzona w zasięgu IServiceScopeFactory.CreateScope()).
    • Zapewnia spójność stanu transakcyjnego — instancja DbContext w Entity Framework Core jest zawsze rejestrowana jako Scoped, co pozwala współdzielić kontekst bazy i śledzenie encji w obrębie całego requestu.
    • Po wysłaniu odpowiedzi do klienta kontener automatycznie zwalnia wszystkie obiekty implementujące IDisposable.
  3. Singleton (AddSingleton):

    • Jedna instancja powoływana raz i współdzielona pomiędzy wszystkimi wątkami z puli oraz wszystkimi żądaniami HTTP.
    • Wymaga bezwzględnego zapewnienia bezpieczeństwa wątkowego.
    • ⚠️ Zagrożenie uwięzionej zależności (Captive Dependency): Wstrzyknięcie obiektu Scoped (np. DbContext) do obiektu typu Singleton prowadzi do uwięzienia zasobu w pamięci na cały czas życia aplikacji, uniemożliwiając jego zwolnienie i wywołując błędy współbieżnego dostępu do bazy.
  4. Cykl życia samego kontrolera MVC:

    • W ASP.NET Core kontrolery są domyślnie rejestrowane jako Transient — powoływane w momencie odebrania żądania i natychmiast utylizowane po odesłaniu wyniku IActionResult. Nie należy zapisywać w nich stanu pomiędzy wywołaniami.

4. Wzorce Kreacyjne (Konstrukcyjne)

Wzorce kreacyjne hermetyzują proces tworzenia obiektów, uniezależniając aplikację od konkretnych klas implementacyjnych.


4.1. Singleton (Zakres Obiektowy)

Jest to najprostszy wzorzec kreacyjny, dlatego od niego rozpoczniemy. Ze względu na wykorzystywanie w nim składników statycznych klasy, takich jak metody, pola, właściwości, problemy z testami jednostkowymi oraz z potencjalnymi problemami, jakie mogą wystąpić podczas przetwarzania współbieżnego, często uważany jest za antywzorzec.

Przykładowa implementacja:

public sealed class Singleton
{
    private Singleton() { }
    private static Singleton _instance;

    public static Singleton GetInstance()
    {
        if (_instance == null)
        {
            _instance = new Singleton();
        }
        return _instance;
    }

    public void someBusinessLogic()
    {
        // ...
    }
}

W celu zapobieżenia utworzenia nowej instancji singletonu z kodu jego konstruktor musi być prywatny. Poniższy kod zwróci błąd CS0122 Element niedostępny z powodu swojego dostępu:

Singleton s0 = new Singleton();

Do pobrania instancji singletonu służy metoda statyczna GetInstance. Przy pierwszym jej wołaniu tworzony jest nowy obiekt, który jest przypisywany do pola statycznego _instance. Każde kolejne wołanie spowoduje zwrócenie tej samej, wcześniej utworzonej instancji. Jest to najprostsze rozwiązanie, ale jest nieodporne na niewłaściwe wykorzystanie mechanizmu refleksji, serializacji oraz na dostęp współbieżny i może np. spowodować powstanie dwóch lub większej liczby instancji singletonu.

Poniższy kod testuje działanie singletonu:

Singleton s1 = Singleton.GetInstance();
Singleton s2 = Singleton.GetInstance();
CheckReferencesAreEquals(s1, s2);

static void CheckReferencesAreEquals(Singleton s1, Singleton s2)
{
    if (s2 != null)
    {
        if (s1 == s2)
        {
            Console.WriteLine("Success");
        }
        else
        {
            Console.WriteLine("Fail");
        }
    }
    else
    {
        Console.WriteLine("Success, s2 is null");
    }
}

Podatność 1: Złamanie zabezpieczeń za pomocą Refleksji

Poniższy kod pokazuje, jak można błędnie utworzyć dwie instancje singletonu, wykorzystując mechanizm refleksji:

Singleton s2 = Activator.CreateInstance(typeof(Singleton), true) as Singleton;

Metoda statyczna CreateInstance statycznej klasy Activator jako pierwszy parametr, który jest typu Type, pobiera informacje o typie, którego obiekt ma utworzyć. Drugi parametr to wartość logiczna nonPublic i w przypadku przekazania true zmienia zachowanie domyślne funkcji, pozwalając na wykorzystanie niepublicznego konstruktora.

Podatność 2: Złamanie zabezpieczeń za pomocą Serializacji

Poniższy kod również błędnie tworzy dwie instancje singletonu, tylko z wykorzystaniem mechanizmu serializacji:

DataContractJsonSerializer jsonSerializer = new DataContractJsonSerializer(typeof(Singleton)); // by serializing
MemoryStream memoryStream = new();
jsonSerializer.WriteObject(memoryStream, s1);
memoryStream.Position = 0;
Singleton s2 = jsonSerializer.ReadObject(memoryStream) as Singleton;

Rozwiązanie obronne w konstruktorze:

W obydwu przypadkach rozwiązaniem jest sprawdzenie w konstruktorze, czy pole z instancją obiektu nie zostało jeszcze zainicjalizowane i wskazuje na null. Jeśli tak nie jest, to znaczy, że konstruktor został nielegalnie wywołany i oświadczamy to, rzucając odpowiedni wyjątek TargetInvocationException (opakowujący wyjątek zgłoszony w konstruktorze):

private Singleton()
{
    if (_instance != null)
    {
        throw new InvalidOperationException("Cannot create new instance, " +
            "please use getInstance method instead.");
    }
}

Podatność 3: Współbieżny dostęp do metody GetInstance

Kolejnym problemem może być współbieżny dostęp do metody GetInstance. Poniższy kod uruchamia 10 000 przypadków polegających na współbieżnym uruchomieniu 2 wołań tej metody.

Metody Task.Factory.StartNew albo bardziej zwięzła jej forma Task.Run pozwalają na współbieżne uruchomienie kodu przekazanego w postaci delegata Action uruchamiającego metodę GetInstance. Metoda Task.WaitAll czeka, aż wszystkie przekazane jako parametry zadania Task zostaną zakończone. Jest ona wykonywana w bloku try - catch, ponieważ może zwrócić agregowany wyjątek AggregateException, a liczba zagregowanych wyjątków e.InnerException.Data.Count będzie równa liczbie nieuprawnionych prób dostępu do metody GetInstance:

int testSuccess = 0;
int testFail = 0;
int testException = 0;

for (int i = 0; i < 10000; i++)
{
    ClearStaticSingletonField();
    Singleton firstInstanceForTestBreakWithThreads = null;
    Singleton secondInstanceForTestBreakWithThreads = null;

    var producer1 = Task.Run(() =>
    {
        firstInstanceForTestBreakWithThreads = Singleton.GetInstance();
    });

    var producer2 = Task.Factory.StartNew(() =>
    {
        secondInstanceForTestBreakWithThreads = Singleton.GetInstance();
    });

    try
    {
        Task.WaitAll(producer1, producer2);
    }
    catch (AggregateException e)
    {
        testException += e.InnerException.Data.Count;
    }

    if (firstInstanceForTestBreakWithThreads == secondInstanceForTestBreakWithThreads)
    {
        testSuccess++;
    }
    else
    {
        testFail++;
    }
}

Console.WriteLine($"testSuccess: {testSuccess}, testFail: {testFail}, testException: {testException}");

static void ClearStaticSingletonField()
{
    var field = typeof(Singleton).GetField("_instance",
                        BindingFlags.Static |
                        BindingFlags.NonPublic);
    field.SetValue(null, null);
}

Przykładowe wyjście:

testSuccess: 9988, testFail: 12, testException: 0

Widać, że statystycznie taki scenariusz generuje błędy. Rozwiązaniem jest wykorzystanie słowa kluczowego lock w celu uniknięcia równoczesnego użycia wspólnego zasobu, którym w naszym przypadku jest konstruktor:

private static readonly object _lock = new object();

public static Singleton GetInstance()
{
    lock (_lock)
    {
        if (_instance == null)
        {
            _instance = new Singleton();
        }
    }
    return _instance;
}

Singleton a Zasada SRP

Każdy singleton udostępnia pewną logikę biznesową, która jest zaimplementowana w postaci metod publicznych, takich jak someBusinessLogic. Jak widać, taki singleton ma dwie odpowiedzialności:

  1. Zarządza tworzeniem instancji klasy.
  2. Wykonuje pierwotną odpowiedzialność klasy – metoda someBusinessLogic.

Zatem łamie pierwszą zasadę SOLID – zasadę pojedynczej odpowiedzialności (SRP). Rozwiązaniem będzie wydzielenie logiki biznesowej singletonu do innych modułów/klas.

Nowoczesna implementacja z użyciem Lazy<T>

W platformie .NET najbardziej eleganckim rozwiązaniem jest klasa Lazy<T>, gwarantująca leniwą inicjalizację bezpieczną wątkowo bez manualnego lock:

public sealed class ModernSingleton
{
    private static readonly Lazy<ModernSingleton> _lazyInstance =
        new Lazy<ModernSingleton>(() => new ModernSingleton());

    private ModernSingleton() { }

    public static ModernSingleton Instance => _lazyInstance.Value;
}

Alternatywa w nowoczesnym .NET: Microsoft DI i Autofac

Zamiast implementować mechanizm kontroli instancji wewnątrz samej klasy domenowej, rejestrujemy czystą klasę POCO w kontenerze z czasem życia Singleton:

// 1. Rejestracja w Microsoft.Extensions.DependencyInjection:
services.AddSingleton<IEngineTelemetryService, EngineTelemetryService>();

// 2. Rejestracja w Autofac:
builder.RegisterType<EngineTelemetryService>()
       .As<IEngineTelemetryService>()
       .SingleInstance();
Dlaczego podejście oparte na kontenerze DI jest lepsze?
  1. Pełna zgodność z zasadą SRP (SOLID): Klasa realizuje wyłącznie swoją odpowiedzialność domenową. Nie zarządza swoim cyklem życia, nie zawiera pól statycznych ani metod fabrykujących.
  2. Bezpieczeństwo współbieżne bez manualnych blokad: Kontenery DI automatycznie gwarantują alokację typu thread-safe przy pierwszym żądaniu, bez ryzyka wyścigów pamięci.
  3. Izolacja testów jednostkowych: Klasa ma publiczny konstruktor – w testach jednostkowych można powołać jej nową instancję operatorem new lub bez problemu przekazać atrapę (Mock<IEngineTelemetryService>).
  4. Brak podatności na refleksję i serializację: Ponieważ instancja nie jest „wymuszana” przez ukryty konstruktor, refleksja nie może zepsuć globalnego stanu aplikacji.

4.2. Metoda Wytwórcza (Factory Method – Zakres Klasowy)

Klasy, aby mogły korzystać z udostępnianych przez inne klasy funkcjonalności, muszą tworzyć obiekty tych klas. Domyślnie w tym celu wykorzystywany jest konstruktor klasy poprzedzony słowem kluczowym new. Takie rozwiązanie powoduje powstanie silnych zależności pomiędzy klasami, a zgodnie z piątą zasadą SOLID – Dependency Inversion Principle (DIP) – moduły wyższego poziomu powinny zależeć od abstrakcji, a nie od modułów niskiego poziomu.

W celu rozluźnienia tych zależności możemy wykorzystać wzorzec metody wytwórczej. Proces tworzenia obiektów (nazywanych teraz produktami) za pomocą new jest przenoszony do ciała tej metody, co ukrywa ten proces przed klientem oraz pozwala na łatwą jego zmianę (zmiany dokonujemy tylko raz w ciele metody, a nie we wszystkich wołaniach new w klasach klienckich).

Zgodnie z zasadą DIP należy użyć abstrakcji, a w C# mamy do dyspozycji interfejsy i klasy abstrakcyjne. Zgodnie ze wzorcem wprowadzamy dwie abstrakcje: produktu i kreatora.

Interfejs IProduct definiujący metodę Operation wraz z implementującą go konkretną klasą produktu:

public interface IProduct
{
    string Operation();
}

public class ConcreteProduct1 : IProduct
{
    public string Operation()
    {
        return "{Result of ConcreteProduct1}";
    }
}

Interfejs ICreator definiujący metodę wytwórczą FactoryMethod zwracającą produkt implementujący IProduct wraz z implementującą go konkretną klasą wytwórcy:

Jeśli wykorzystamy klasę abstrakcyjną albo interfejs w wersji C# 8.0 albo wyższej, to możemy w kreatorze umieścić domyślną implementację metody wytwórczej FactoryMethod (tutaj niezrealizowane, zaimplementowana jest za to wspólna dla wszystkich kreatorów logika w metodzie SomeOperation):

public interface ICreator
{
    public IProduct FactoryMethod();

    public string SomeOperation()
    {
        // Call the factory method to create a Product object.
        var product = FactoryMethod();
        // Now, use the product.
        var result = "Creator: The same creator's code has just worked with "
            + product.Operation();
        return result;
    }
}

Podstawową odpowiedzialnością kreatora nie jest tworzenie produktów. Zazwyczaj zawiera on pewną podstawową logikę biznesową, która opiera się na produktach zwracanych przez metodę FactoryMethod. Podklasy mogą pośrednio zmieniać tę logikę biznesową, nadpisując metodę FactoryMethod i zwracając z niej inny typ produktu:

public class ConcreteCreator1 : ICreator
{
    public IProduct FactoryMethod()
    {
        return new ConcreteProduct1();
    }
}

Użycie w kodzie:

ICreator c;
c = new ConcreteCreator1();
//c = new ConcreteCreator2();
Console.WriteLine(c.SomeOperation());

W celu zmiany logiki biznesowej zmieniamy jedynie obiekt kreatora – jego metody same powołają odpowiednie produkty, na których jest oparta.

💡 Ponowne użycie obiektów a oszczędność zasobów:
Z pomocą metody wytwórczej można w miejsce tworzenia nowych obiektów dokonać ponownego użycia już istniejących. Jest to szczególnie przydatne w sytuacji, kiedy pracujemy z dużymi obiektami wykorzystującymi takie zasoby systemowe jak połączenia z bazą danych, systemy plików oraz zasoby sieciowe – pozwoli to na ich zaoszczędzenie.

Alternatywa w nowoczesnym .NET: Microsoft DI i Autofac

W nowoczesnym C# nie tworzy się odrębnych podklas kreatorów dla każdego nowego typu produktu. Odpowiedzialność wytwórczą przejmują wbudowane delegaty fabrykujące Func<T> lub serwisy kluczowane (.NET 8):

// Wariant A: Delegat Func<IProduct> w Microsoft DI:
services.AddTransient<ConcreteProduct1>();
services.AddTransient<Func<IProduct>>(sp => () => sp.GetRequiredService<ConcreteProduct1>());

// Wariant B: Usługi kluczowane w .NET 8 (Keyed Services):
services.AddKeyedTransient<IProduct, ConcreteProduct1>("v1");
services.AddKeyedTransient<IProduct, ConcreteProduct2>("v2");

// Wariant C: Automatyczna fabryka niejawna w Autofac (wystarczy zarejestrować typ):
builder.RegisterType<ConcreteProduct1>().As<IProduct>();
// Autofac automatycznie obsłuży zależność 'Func<IProduct>' wstrzykiwaną do konsumenta!

Konsument (dawny Kreator) przyjmuje delegat fabrykujący w konstruktorze:

public class OrderProcessor
{
    private readonly Func<IProduct> _factory;
    public OrderProcessor(Func<IProduct> factory) => _factory = factory;

    public void Process()
    {
        var product = _factory(); // Odpowiednik FactoryMethod()
        Console.WriteLine(product.Operation());
    }
}
Dlaczego podejście oparte na kontenerze DI jest lepsze?
  1. Eliminacja eksplozji klas (Class Explosion): Dodanie produktu ConcreteProduct3 nie wymaga pisania nowej klasy ConcreteCreator3. Wystarczy zarejestrować typ w kontenerze.
  2. Ścisłe programowanie do abstrakcji (DIP): Konsument zależy wyłącznie od interfejsu delegata Func<IProduct>, a nie od hierarchii dziedziczenia kreatorów.
  3. Maksymalna testowalność: W teście jednostkowym nie trzeba tworzyć sztucznych klas testowych dziedziczących po kreatorze – wystarczy przekazać lambdę: new OrderProcessor(() => mockProduct.Object).

4.3. Fabryka Abstrakcyjna (Abstract Factory – Zakres Obiektowy)

Jest to rozszerzenie koncepcji metody wytwórczej o możliwość tworzenia całego zbioru/rodziny współpracujących ze sobą produktów. Interfejs IAbstractFactory deklaruje zestaw metod, które zwracają różne produkty abstrakcyjne. Tworzą one rodzinę produktów powiązanych tematycznie bądź koncepcyjnie.

Produkty jednej rodziny zazwyczaj mogą ze sobą współpracować. Rodzina produktów może mieć kilka wariantów, ale produkty należące do różnych wariantów są ze sobą niezgodne.

Zbiór interfejsów IAbstractProductA i IAbstractProductB wraz z implementującym go zbiorem konkretnych klas produktów: ConcreteProductA1 i ConcreteProductB1:

public interface IAbstractProductA
{
    string UsefulFunctionA();
}

public class ConcreteProductA1 : IAbstractProductA
{
    public string UsefulFunctionA()
    {
        return "The result of the product A1.";
    }
}

public interface IAbstractProductB
{
    string UsefulFunctionB();
    string AnotherUsefulFunctionB(IAbstractProductA collaborator);
}

public class ConcreteProductB1 : IAbstractProductB
{
    public string UsefulFunctionB()
    {
        return "The result of the product B1.";
    }

    public string AnotherUsefulFunctionB(IAbstractProductA collaborator)
    {
        var result = collaborator.UsefulFunctionA();
        return $"The result of the B1 collaborating with the ({result})";
    }
}

Zauważmy, że produkt implementujący IAbstractProductB definiuje metodę AnotherUsefulFunctionB, która współpracuje z obiektem implementującym IAbstractProductA. To właśnie fabryka abstrakcyjna dba o to, aby wszystkie tworzone przez nią produkty były tej samej wersji, a zatem były kompatybilne. Konkretne fabryki produkują rodzinę produktów należących do jednego wariantu i gwarantują, że powstałe produkty są kompatybilne.

Interfejs IAbstractFactory wraz z implementującą go konkretną klasą fabryki:

public interface IAbstractFactory
{
    IAbstractProductA CreateProductA();
    IAbstractProductB CreateProductB();
}

public class ConcreteFactory1 : IAbstractFactory
{
    public IAbstractProductA CreateProductA() => new ConcreteProductA1();
    public IAbstractProductB CreateProductB() => new ConcreteProductB1();
}

Użycie w kodzie:

//var factory = new ConcreteFactory1();
var factory = new ConcreteFactory2();
var productA = factory.CreateProductA();
var productB = factory.CreateProductB();

Console.WriteLine(productB.UsefulFunctionB());
Console.WriteLine(productB.AnotherUsefulFunctionB(productA));

Alternatywa w nowoczesnym .NET: Microsoft DI i Autofac

Zamiast tworzyć rozbudowane interfejsy fabryk z metodami CreateProductA() i CreateProductB(), spójność całej rodziny produktów definiuje się na poziomie rejestracji kontenera:

// Wariant A: Wstrzykiwanie kluczowane w Microsoft DI (.NET 8):
services.AddKeyedTransient<IAbstractProductA, ConcreteProductA1>("Variant1");
services.AddKeyedTransient<IAbstractProductB, ConcreteProductB1>("Variant1");

services.AddKeyedTransient<IAbstractProductA, ConcreteProductA2>("Variant2");
services.AddKeyedTransient<IAbstractProductB, ConcreteProductB2>("Variant2");

// Wariant B: Moduły Autofac jako Fabryki Abstrakcyjne:
public class Variant1Module : Module
{
    protected override void Load(ContainerBuilder builder)
    {
        builder.RegisterType<ConcreteProductA1>().As<IAbstractProductA>();
        builder.RegisterType<ConcreteProductB1>().As<IAbstractProductB>();
    }
}
// Zmiana rodziny produktów to zmiana jednej linijki w Composition Root:
builder.RegisterModule<Variant1Module>();

Konsument wstrzykuje bezpośrednio potrzebne mu produkty, nie martwiąc się o ich powoływanie:

public class ClientWorkflow
{
    public ClientWorkflow(IAbstractProductA prodA, IAbstractProductB prodB) { /* ... */ }
}
Dlaczego podejście oparte na kontenerze DI jest lepsze?
  1. Zredukowana odpowiedzialność klienta: Klient biznesowy nie musi wiedzieć o istnieniu fabryki ani manualnie wywoływać metod tworzących – otrzymuje od razu wstrzyknięty, w pełni kompatybilny zestaw narzędzi.
  2. Brak ryzyka pomieszania wariantów: Rejestracja w module gwarantuje, że pod interfejsy IAbstractProductA i IAbstractProductB zawsze podstawione zostaną komponenty z tej samej rodziny.
  3. Czystość kodu: Drastyczny spadek liczby interfejsów i klas w solucji.

4.4. Budowniczy (Builder – Zakres Obiektowy)

Pozwala rozdzielić proces tworzenia złożonego obiektu od jego reprezentacji. Jest stosowany, kiedy mamy do czynienia ze złożonymi obiektami, które wymagałyby utworzenia dużej liczby podklas. Proces tworzenia obiektu podzielony jest na kilka mniejszych etapów, a każdy z nich może być implementowany na wiele sposobów. Ten sam proces konstrukcji może prowadzić do powstania różnych reprezentacji. Finalnie metoda Build inicjalizuje proces budowy.

Takie podejście pozwala również wyeliminować problem tzw. teleskopowych konstruktorów. Powstaje on, gdy udostępniamy zbyt wiele konstruktorów, które mają 4 lub więcej parametrów. Trudno wtedy zapamiętać, jaka jest wymagana kolejność parametrów, a także jaki konkretny konstruktor należy użyć w danej sytuacji. Warto zadbać o to, aby implementacja Buildera nie wymagała konstruowania obiektu w określonej kolejności. Zapobiega to możliwości pomyłki czy wystąpienia efektów ubocznych, które wynikają z różnej kolejności etapów konstrukcji obiektów.

Wyróżniamy następujące elementy tego wzorca:

Można połączyć go ze wzorcem Most, gdzie dyrektor to abstrakcja, zaś konkretni budowniczowie to implementacje (powiemy o tym więcej w opisie Mostu w sekcji wzorce strukturalne).

Kod klasy Product (produktu):

public class Product
{
    private List<object> _parts = new List<object>();

    public void Add(string part)
    {
        this._parts.Add(part);
    }

    public string ListParts()
    {
        string str = string.Empty;
        for (int i = 0; i < this._parts.Count; i++)
        {
            str += this._parts[i] + ", ";
        }
        str = str.Remove(str.Length - 2); // removing last ", "
        return "Product parts: " + str + "\n";
    }
}

Interfejs IBuilder (budowniczy) specyfikujący metody tworzenia różnych części obiektów produktu:

public interface IBuilder
{
    void BuildPartA();
    void BuildPartB();
    void BuildPartC();
}

Klasa konkretnego budowniczego implementująca interfejs IBuilder:

public class ConcreteBuilder : IBuilder
{
    private Product _product = new Product();

    public void Reset()
    {
        this._product = new Product();
    }

    public void BuildPartA()
    {
        this._product.Add("PartA1");
    }

    public void BuildPartB()
    {
        this._product.Add("PartB1");
    }

    public void BuildPartC()
    {
        this._product.Add("PartC1");
    }

    public Product Build()
    {
        Product result = this._product;
        this.Reset();
        return result;
    }
}

Należy podkreślić, że wszystkie etapy produkcji działają na tej samej instancji produktu. Konkretni budowniczowie powinni udostępniać metodę Build pozwalającą na zbudowanie i pobieranie wynikowego obiektu. Dzieje się tak, ponieważ, w przeciwieństwie do innych wzorców kreacyjnych, różni konkretni budowniczowie mogą wytwarzać niepowiązane produkty, które nie korzystają z tego samego interfejsu. Dlatego też takich metod nie można zadeklarować w podstawowej abstrakcji Buildera (przynajmniej w statycznie typowanym języku programowania).

Zazwyczaj po zwróceniu klientowi wyniku końcowego oczekuje się, że instancja buildera będzie gotowa do rozpoczęcia produkcji innego produktu. Dlatego powszechną praktyką jest na końcu metody Build wywoływać metodę Reset. Jednak takie zachowanie nie jest obowiązkowe i alternatywą jest, aby buildery czekały na jawne wywołanie resetu z kodu klienta.

Kod klasy dyrektora:

public class Director
{
    private IBuilder _builder;

    public IBuilder Builder
    {
        set { _builder = value; }
    }

    // The Director can construct several product variations using the same
    // building steps.
    public void BuildMinimalViableProduct()
    {
        this._builder.BuildPartA();
    }

    public void BuildFullFeaturedProduct()
    {
        this._builder.BuildPartA();
        this._builder.BuildPartB();
        this._builder.BuildPartC();
    }
}

Dyrektor jest odpowiedzialny, aby zapewnić poprawną kolejność kroków budowy. Jest to pomocne podczas produkcji produktów zgodnie z określoną konfiguracją. Klasa Dyrektora jest opcjonalna, ponieważ klient może bezpośrednio kontrolować budowniczych.

Użycie w kodzie:

var director = new Director();
var builder = new ConcreteBuilder();
director.Builder = builder;

Console.WriteLine("Standard basic product:");
director.BuildMinimalViableProduct();
Console.WriteLine(builder.Build().ListParts());

Console.WriteLine("Standard full featured product:");
director.BuildFullFeaturedProduct();
Console.WriteLine(builder.Build().ListParts());

// Użycie buildera bez dyrektora:
Console.WriteLine("Custom product:");
builder.BuildPartA();
builder.BuildPartC();
Console.Write(builder.Build().ListParts());

Praktyczne zastosowanie w ekosystemie .NET: WebApplicationBuilder

Praktycznym zastosowaniem buildera jest klasa WebApplicationBuilder dostępna na platformie .NET od wersji 6.0, służąca do budowania i konfigurowania aplikacji internetowych (należy zauważyć, że większość konfiguracji została przeniesiona do odpowiadających im warstw zgodnie z duchem czystej architektury):

var builder = WebApplication.CreateBuilder(args);
builder.Services.AddOpenTelemetry(builder.Configuration);
builder.Services.AddApplication();
builder.Services.AddInfrastructure(builder.Configuration);
builder.Services.AddWeb(builder.Configuration);

builder.Configuration.AddJsonFile("users.json", optional: false, reloadOnChange: false)
                     .AddEnvironmentVariables();

builder.WebHost.ConfigureKestrel(options =>
{
    options.Limits.MaxResponseBufferSize = 2_147_483_648; // 2 GiB
});

var app = builder.Build();

Rozszerzenie: Fluent Step Builder

Rozszerzeniem tego wzorca jest Fluent Builder, który zmusza programistę do tworzenia obiektów w kolejności, poprzez wywoływanie każdego settera po kolei, aż do ustawienia wszystkich wymaganych atrybutów. W tym celu należy utworzyć łańcuch interfejsów, w którym każda metoda interfejsu będzie zwracać kolejny typ interfejsu. Jak widać, pozwala to na rezygnację z dyrektora bez utraty jego zalet.

Oto przykład budujący obiekt Email, który będzie zawierał wszystkie informacje potrzebne do wysłania wiadomości e-mail:

interface IEmailTo
{
    IEmailSubject SetTo(string to);
}

interface IEmailSubject
{
    IEmailContent SetSubject(string subject);
}

interface IEmailContent
{
    IEmailCreator SetContent(string content);
}

interface IEmailCreator
{
    IEmailCreator SetBCC(string bcc);
    IEmailCreator SetCC(string cc);
    Email Build();
}

Alternatywa w nowoczesnym .NET: Microsoft DI i Autofac

W ekosystemie .NET klasycznego Dyrektora zastępuje się konfiguracją funkcyjną (Action/Options Pattern), a instancje budowniczych rejestruje się w kontenerze z czasem życia uniemożliwiającym kolizje stanu:

// Wariant A: Konfiguracja za pomocą lambdy (wzorzec powszechny w .NET Core / ASP.NET):
services.AddTransient<Product>(sp =>
{
    var configurator = new FluentProductConfigurator();
    configurator.AddPartA().AddPartB(); // Kroki montażu zdefiniowane w Composition Root
    return configurator.Build();
});

// Wariant B: Autofac z cyklem InstancePerDependency:
// Klasyczny builder jest mutowalny. Aby uniknąć wyścigów wątków, kontener gwarantuje
// świeżą, odizolowaną instancję buildera przy każdym wstrzyknięciu:
builder.RegisterType<ConcreteBuilder>().As<IBuilder>().InstancePerDependency();

// Automatyczne wstrzyknięcie buildera do Dyrektora przez właściwość:
builder.RegisterType<Director>().PropertiesAutowired();
Dlaczego podejście oparte na kontenerze DI jest lepsze?
  1. Eliminacja stanu mutowalnego dzielonego między wątkami: Klasyczny builder trzyma pole _product w pamięci. Zarejestrowanie go jako InstancePerDependency / Transient całkowicie eliminuje błędy współbieżnego nadpisywania stanu montowanego obiektu.
  2. Rezygnacja z klasy Dyrektora: W nowoczesnym C# rolę Dyrektora przejmuje czytelne wyrażenie lambda lub metoda rozszerzająca IServiceCollection, co redukuje narzut ceremonialnego kodu.

4.5. Prototyp (Prototype – Zakres Obiektowy)

Pozwala na klonowanie istniejących obiektów bez potrzeby dodawania zależności do klasy kopiowanego obiektu – wystarcza sama abstrakcja (zazwyczaj interfejs). Zazwyczaj realizacja tego wzorca polega na definicji interfejsu implementującego pojedynczą metodę Clone wspólną dla wszystkich prototypów.

Rozróżniamy dwa rodzaje klonowania, których zachowanie różni się podczas kopiowania typu referencyjnego oryginalnego wystąpienia:

W .NET mamy do dyspozycji:

  1. Metodę MemberwiseClone realizującą płytką kopię przez utworzenie nowego obiektu, a następnie skopiowanie niestatycznych pól bieżącego obiektu do nowego obiektu;
  2. Interfejs ICloneable. W jego przypadku nie jest określone, jaką kopię (płytką, głęboką czy coś pomiędzy) wykonuje operacja klonowania. Nie wymaga się również skopiowania wszystkich wartości właściwości oryginalnego wystąpienia do nowego wystąpienia.

Rozważmy kod klasy Person, pozwalającej zarówno na płytkie, jak i głębokie kopiowanie, oraz wykorzystywanej przez nią klasy IdInfo:

public class Person
{
    public int Age;
    public DateTime BirthDate;
    public string Name;
    public IdInfo IdInfo;

    public Person ShallowCopy()
    {
        return (Person)this.MemberwiseClone();
    }

    public Person DeepCopy()
    {
        Person clone = (Person)this.MemberwiseClone();
        clone.IdInfo = new IdInfo(IdInfo.IdNumber);
        clone.Name = new string(Name); // lub clone.Name = Name (string jest w C# niezmienny)
        return clone;
    }
}

public class IdInfo
{
    public int IdNumber;

    public IdInfo(int idNumber)
    {
        this.IdNumber = idNumber;
    }
}

Użycie w kodzie:

Person p1 = new Person();
p1.Age = 42;
p1.BirthDate = Convert.ToDateTime("1977-01-01");
p1.Name = "Jack Daniels";
p1.IdInfo = new IdInfo(666);

// Perform a shallow copy of p1 and assign it to p2.
Person p2 = p1.ShallowCopy();

// Make a deep copy of p1 and assign it to p3.
Person p3 = p1.DeepCopy();

// Display values of p1, p2 and p3.
Console.WriteLine("Original values of p1, p2, p3:");
Console.WriteLine("   p1 instance values: ");
DisplayValues(p1);
Console.WriteLine("   p2 instance values:");
DisplayValues(p2);
Console.WriteLine("   p3 instance values:");
DisplayValues(p3);

// Change the value of p1 properties and display the values of p1, p2 and p3.
p1.Age = 32;
p1.BirthDate = Convert.ToDateTime("1900-01-01");
p1.Name = "Frank";
p1.IdInfo.IdNumber = 7878;

Console.WriteLine("\nValues of p1, p2 and p3 after changes to p1:");
Console.WriteLine("   p1 instance values: ");
DisplayValues(p1);
Console.WriteLine("   p2 instance values (reference values have changed):");
DisplayValues(p2); // Referencja IdInfo współdzielona – wartość uległa zmianie!
Console.WriteLine("   p3 instance values (everything was kept the same):");
DisplayValues(p3); // Całkowita izolacja pamięci – wartość IdNumber nienaruszona!

static void DisplayValues(Person p)
{
    Console.WriteLine("      Name: {0:s}, Age: {1:d}, BirthDate: {2:MM/dd/yy}",
        p.Name, p.Age, p.BirthDate);
    Console.WriteLine("      ID#: {0:d}", p.IdInfo.IdNumber);
}

Alternatywa w nowoczesnym .NET: Microsoft DI i Autofac

Zamiast manualnego implementowania MemberwiseClone() i narażania się na błędy płytkiej kopii (Shallow Copy), ideę prototypu w kontenerach realizuje się poprzez cykl Transient lub dedykowane serwisy szablonowe:

// Wariant A: Cykl Transient w Microsoft DI jako czysty prototyp:
// Każde zapytanie do kontenera zwraca całkowicie nowy, odizolowany obiekt w pamięci:
services.AddTransient<Person>(sp => new Person
{
    Age = 18,
    Name = "Default Template",
    IdInfo = new IdInfo(0)
});

// Wariant B: Rejestr szablonów (Master Template Cache):
// Kosztowny w alokacji obiekt wzorcowy rejestrujemy jako Singleton,
// a kontener udostępnia fabrykę wydającą bezpieczne kopie głębokie (DeepCopy):
services.AddSingleton<IPrototypeRegistry<Person>>(new PersonPrototypeRegistry(masterTemplate));

// Wariant C: Autofac z automatyczną fabryką dynamiczną:
// Pozwala w locie sparametryzować klonowany obiekt:
builder.Register<Func<int, Person string,>>(c => (id, name) => new Person
{
    Name = name,
    IdInfo = new IdInfo(id)
});
Dlaczego podejście oparte na kontenerze DI jest lepsze?
  1. Eliminacja ryzyka wycieku referencji (Reference Mutation): W klasycznym MemberwiseClone() zmiana pola referencyjnego w klonie uszkadza dane w prototypie pierwotnym. W kontenerze DI instancje są z definicji odizolowane na stercie.
  2. Czysta domena (brak metody Clone() w klasach biznesowych): Klasy nie muszą implementować interfejsów klonujących ani znać szczegółów kopiowania swoich pól, co sprzyja hermetyzacji i zasadzie pojedynczej odpowiedzialności.
  3. Współbieżność: Równoległe pobieranie egzemplarzy z kontenera w setkach wątków (Parallel.ForEachAsync) jest w 100% bezpieczne wątkowo.

4.6. Podsumowanie Wzorców Kreacyjnych i Ewolucja w Architekturze

Podsumowując wzorce kreacyjne: pierwszym wzorcem wdrażanym w projektach zazwyczaj jest Metoda wytwórcza jako najprostsza. W kolejnym kroku, w zależności od potrzeb, projekty mogą ewoluować w bardziej elastyczne, ale też bardziej skomplikowane wzorce: Fabrykę abstrakcyjną, Prototyp lub Budowniczego (które mogą być implementowane jako Singletony).

Budowniczy koncepcyjnie jest powiązany ze strukturalnym wzorcem Most, który zaleca dzielenie dużych klas poprzez stosowanie agregacji względem dziedziczenia. Można też wykorzystać go do rekurencyjnego tworzenia drzew obiektów opisywanych przez wzorzec strukturalny Kompozyt.

Zestawienie Wzorców GoF z Nowoczesnymi Kontenerami IoC/DI:

Klasyczny Wzorzec GoF Odpowiednik w Kontenerze IoC/DI Korzyść architektoniczna z migracji na kontener
Singleton Rejestracja .SingleInstance() (Autofac) lub AddSingleton() (MS DI). Usunięcie globalnego stanu statycznego, pełna testowalność z użyciem mocków, zgodność z SRP.
Metoda Wytwórcza Rejestracja Func<TService> lub automatyczne fabrykowanie zależności w konstruktorze. Brak konieczności tworzenia dedykowanych klas podrzędnych kreatorów dla każdego typu.
Fabryka Abstrakcyjna Usługi nazwane (Named<T>), kluczowane (AddKeyedScoped) lub fabryki z parametrami. Uniknięcie rozbudowanych hierarchii fabryk; dodanie nowego wariantu to rejestracja w kontenerze.
Budowniczy Fluentyczna rejestracja komponentów (builder.Register(...)), WebApplicationBuilder. Konfiguracja procesu budowy grafu obiektów w jednym punkcie wejścia aplikacji (Composition Root).
Prototyp Rejestracja z cyklem InstancePerDependency() / Transient lub dedykowane serwisy klonujące. Bezpieczne zarządzanie świeżymi obiektami w pamięci przez kontener.

5. Zadania Laboratoryjne

Zadania laboratoryjne do Części 1 znajdują się w osobnym dokumencie:
📄 Zadanie Laboratoryjne do Części 1: Architektura Asynchronicznego Systemu Dowodzenia Flotą Orbitalną (Wzorce Kreacyjne, Wielozadaniowość i IoC/DI)


6. Literatura i Źródła

  1. Refactoring Guru: Wzorce projektowe w C# – https://refactoring.guru/pl/design-patterns/csharp
  2. Infotraining: Wzorce projektowe w C-Sharp – https://infotraining.bitbucket.io/cs-dp/
  3. Krzysztof Owsiany / StormIT: Singleton – 7 błędów, które popełniasz wykorzystując singleton – https://stormit.pl/singleton/
  4. Sławomir Kowalski: Wzorce projektowe w C# – https://medium.com/@sawomirkowalski
  5. Maciej Szczepaniak: Wzorce projektowe – czym są i dlaczego warto je znać? – https://devszczepaniak.pl/wzorce-projektowe/
  6. Dokumentacja Autofac: Configuration & Lifetime Scopes – https://autofac.readthedocs.io
  7. Awesome .NET / LibHunt: Kategoria IoC i Dependency Injection – https://dotnet.libhunt.com/categories/1810-ioc