Kod, który da się zmieniać: dziedziczenie, polimorfizm, SOLID i przejmowanie cudzego systemu

Praktyczny warsztat budowania i przejmowania kodu: kiedy używać dziedziczenia, jak projektować polimorfizm, stosować SOLID bez przesady oraz bezpiecznie refaktoryzować nieznany system.

Poniżej znajduje się pełna treść artykułu. Quizy i symulacje wymagają włączenia JavaScript.

Praktyczny warsztat architektoniczny

Kod jest dobry dopiero wtedy, gdy następna zmiana nie wymaga odwagi.

Przejmiemy nieznany moduł kontroli wdrożeń. Najpierw odtworzymy jego kontrakt, potem oddzielimy dziedziczenie od kompozycji, użyjemy polimorfizmu i przeprowadzimy zmianę przez testy oraz produkcyjny odbiór.

01Zrozumzachowanie i ryzyko
02Zabezpiecztest charakteryzujący
03Rozdzielkontrakt i skutki uboczne
04Zmieńmały pionowy krok
05Potwierdźmetryka i rollback

Najłatwiej napisać kod, którego nikt jeszcze nie używa. Trudniej dopisać funkcję do systemu, który zarabia, wysyła wiadomości, przetwarza płatności i ma za sobą kilka lat decyzji podejmowanych pod presją czasu.

Ten materiał nie jest zbiorem definicji. Przejdziemy przez konkretną sytuację: przejmujesz moduł odpowiedzialny za kontrolę wdrożenia. Są klasy bazowe, nadpisane metody, duży switch, kilka wyjątków rzucanych jako „normalny” przebieg i testy, które sprawdzają głównie mocki. Masz dodać nową kontrolę bezpieczeństwa, ale nie chcesz przypadkiem zatrzymać produkcji.

1. Mała zmiana w nieznanym kodzie

Zadanie brzmi: „dodaj kontrolę, która przed wdrożeniem sprawdzi, czy sekrety produkcyjne pochodzą z vaulta”. W repozytorium znajdujesz klasę ReleaseCheckBase, po której dziedziczy osiem innych klas. Jedna nadpisuje metodę i zwraca null, druga rzuca NotSupportedException, a trzecia wymaga ustawienia chronionego pola w klasie bazowej.

KontraktCo dziś obiecuje moduł?

Nie to, co miało działać, lecz to, na czym faktycznie polegają konsumenci.

Skutki uboczneGdzie system dotyka świata?

Baza, sieć, pliki, kolejka, chmura, e-mail i telemetria.

DowódCo chronią testy?

Zachowanie biznesowe czy tylko kolejność wywołań mocków?

Najmniejszy ruchGdzie powstanie bezpieczny szew?

Adapter, mały interfejs, handler albo feature flag zamiast wielkiego przepisywania.

Decyzja 1 z 4

Co robisz jako pierwsze?

Masz dwie godziny, nieznany moduł i zadanie produkcyjne.

Porządkuję foldery i nazwy

Odtwarzam przepływ i zabezpieczam zachowanie

Piszę moduł od nowa

Wybierz ruch i sprawdź jego konsekwencję.

2. Dziedziczenie to silna umowa

Dziedziczenie tworzy relację znacznie silniejszą niż współdzielenie kodu. Klasa potomna dostaje cykl życia, stan, założenia i przyszłe zmiany klasy bazowej. Dlatego pytanie nie brzmi: „czy te klasy mają wspólne pola?”, ale: „czy każdy potomek może być użyty wszędzie tam, gdzie oczekujemy typu bazowego, bez niespodzianek?”.

public abstract class FileStore
{
    public abstract Task<byte[]> Read(string key, CancellationToken ct);
    public abstract Task Save(string key, byte[] data, CancellationToken ct);
}

public sealed class ReadOnlyArchiveStore : FileStore
{
    public override Task<byte[]> Read(string key, CancellationToken ct)
        => archive.Read(key, ct);

    public override Task Save(string key, byte[] data, CancellationToken ct)
        => throw new NotSupportedException("Archiwum jest tylko do odczytu.");
}

ReadOnlyArchiveStore nie jest pełnoprawnym FileStore, bo część obietnicy typu bazowego przestaje działać. Kod wywołujący musi znać konkretny typ albo łapać wyjątek, czyli polimorfizm staje się pozorny.

PrzedJeden szeroki kontrakt

Odczyt, zapis i usuwanie trafiają do każdego potomka, nawet gdy nie mają sensu.

→
PoZdolności zamiast etykiet

IFileReader i IFileWriter opisują dokładnie to, czego potrzebuje konsument.

public interface IFileReader
{
    Task<byte[]> Read(string key, CancellationToken ct);
}

public interface IFileWriter
{
    Task Save(string key, byte[] data, CancellationToken ct);
}

public sealed class ArchiveStore : IFileReader { /* ... */ }
public sealed class BlobStore : IFileReader, IFileWriter { /* ... */ }

Kiedy dziedziczenie jest dobrym wyborem?

  • Relacja „jest rodzajem” jest prawdziwa w domenie, nie tylko podobna w kodzie.
  • Kontrakt bazowy jest mały, stabilny i ma test kontraktowy.
  • Potomek nie osłabia wyniku i nie wymaga dodatkowych warunków wejściowych.
  • Klasa bazowa nie udostępnia dużej ilości chronionego, mutowalnego stanu.
  • Każda implementacja zachowuje semantykę błędów, anulowania i wartości pustych.
Dobry przypadek: stabilny szkielet procesu

Template Method może być właściwy, gdy kolejność kroków jest częścią stabilnego kontraktu, a rozszerzany fragment jest mały i precyzyjnie opisany. Nadal potrzebujesz testu wspólnego dla wszystkich potomków. Jeżeli potomkowie zaczynają omijać kroki albo zmieniać kolejność, kompozycja będzie czytelniejsza.

Decyzja 2 z 4

Implementacja rzuca „operacja nieobsługiwana”. Co to zwykle oznacza?

Wystarczy opisać wyjątek w dokumentacji

Kontrakt jest zbyt szeroki

Potrzebujemy jeszcze jednej klasy bazowej

Sprawdź, czy relacja zachowuje zasadę Liskov.

3. Polimorfizm: jedna decyzja, wiele poprawnych wykonań

Polimorfizm daje wartość wtedy, gdy kod wywołujący zna kontrakt, ale nie musi znać konkretnych typów. Nie powinien mieć obok drugiego mechanizmu wyboru schowanego w switch.

public interface IReleaseCheck
{
    string Code { get; }
    Task<CheckResult> Evaluate(ReleaseContext context, CancellationToken ct);
}

public sealed class SecretSourceCheck : IReleaseCheck
{
    public string Code => "secret_source";

    public async Task<CheckResult> Evaluate(
        ReleaseContext context,
        CancellationToken ct)
    {
        var bindings = await context.Secrets.GetBindings(ct);
        var invalid = bindings
            .Where(x => x.Environment == "production" && !x.FromVault);

        return invalid.Any()
            ? CheckResult.Block(Code, "Sekrety produkcyjne muszą pochodzić z vaulta.")
            : CheckResult.Pass(Code);
    }
}
public sealed class ReleaseGate
{
    private readonly IReadOnlyList<IReleaseCheck> checks;

    public ReleaseGate(IEnumerable<IReleaseCheck> checks)
        => this.checks = checks.ToList();

    public async Task<ReleaseDecision> Evaluate(
        ReleaseContext context,
        CancellationToken ct)
    {
        var results = new List<CheckResult>();

        foreach (var check in checks)
            results.Add(await check.Evaluate(context, ct));

        return ReleaseDecision.From(results);
    }
}

ReleaseGate nie zna kontroli sekretów, migracji ani kontraktu API. Zna jeden kontrakt i składa wyniki. Nowe zachowanie jest nową implementacją, a nie kolejną gałęzią stabilnego centrum.

WejścieReleaseContextśrodowisko, artefakt, zależności
01Secrets
02Database
03Contract
WynikReleaseDecisionpass, warn albo block

4. Kompozycja zamiast głębokiego drzewa

W drzewie dziedziczenia zmiana klasy bazowej rozchodzi się pionowo przez potomków. W kompozycji obiekt dostaje małe współpracujące elementy i jawnie pokazuje, czego potrzebuje.

Głębokie drzewo

BaseReleaseCheck
CloudReleaseCheck
AzureReleaseCheck
ProductionAzureReleaseCheck

Jawna kompozycja

ReleaseGate
IReleaseCheck[]IClockIAuditWriterIReleasePolicy

Kompozycja nie oznacza „interfejs dla każdej klasy”. Granice wprowadzaj tam, gdzie istnieje zmienność, skutek uboczny albo potrzebna jest kontrolowana podmiana w teście.

5. SOLID bez religii

SSingle Responsibility

Ile niezależnych powodów ma ten moduł, żeby się zmienić?

OOpen/Closed

Czy nowy przypadek wymaga edycji stabilnego centrum?

LLiskov Substitution

Czy każda implementacja zachowuje obietnicę kontraktu?

IInterface Segregation

Czy klient zależy tylko od zdolności, której potrzebuje?

DDependency Inversion

Czy reguła zależy od pojęcia, czy od konkretnego SDK?

Jeżeli ReleaseService waliduje politykę, wywołuje Azure, zapisuje audyt, formatuje e-mail i generuje HTML, ma wiele powodów do zmiany. Rozdzielenie nie służy estetyce. Ogranicza obszar, który trzeba zrozumieć oraz przetestować podczas następnej funkcji.

6. Jak zbudować nowy moduł od zera

01
Nazwij wynik

„System blokuje release, jeśli produkcyjny sekret nie pochodzi z zatwierdzonego źródła”. Nie zaczynaj od nazwy klasy.

02
Zapisz przykłady

Sukces, blokada, brak danych, timeout, anulowanie. Przykłady odsłaniają kontrakt.

03
Oddziel decyzję od I/O

Czysta reguła i osobny adapter providera dają szybsze testy oraz czytelniejsze błędy.

04
Wybierz najwęższy kontrakt

Przekaż dane potrzebne do decyzji, nie cały klient chmury ani kontener usług.

05
Zbuduj pionowy wycinek

Reguła, wejście, audyt, metryka i test. Dopiero druga implementacja potwierdzi wspólną abstrakcję.

06
Nazwij dowód odbioru

Test, log korelacyjny, metryka, komunikat operatora i sposób wycofania.

Decyzja 3 z 4

Masz tylko jedną implementację i brak potwierdzonej zmienności. Co robisz?

Od razu buduję system pluginów

Zaczynam prosto i zostawiam czytelny szew

Łączę regułę bezpośrednio z SDK

Wybierz poziom abstrakcji adekwatny do wiedzy, którą masz dziś.

7. Czysty kod nie oznacza krótkiego kodu

Funkcja może mieć dwanaście linii i mieszać trzy odpowiedzialności. Inna może mieć trzydzieści linii, ale prowadzić czytelnika przez jeden spójny algorytm. Zamiast liczyć linie, oceń przepływ decyzji.

Nazwy pokazują zamiar

EvaluateRelease mówi więcej niż ProcessData.

Dane płyną w jednym kierunku

Mniej ukrytego stanu oznacza mniej warunków do pamiętania.

Wyjątki są wyjątkowe

Negatywna decyzja biznesowa powinna być jawnym wynikiem.

Skutki uboczne są widoczne

Nazwa i zależności mówią, że kod dotyka sieci albo bazy.

Reguła ma jednego właściciela

Nie kopiuj tej samej walidacji do kontrolera, joba i frontendu.

Telemetria ma własny model

Nie loguj całych obiektów z sekretami i danymi prywatnymi.

Najlepsza nazwa nie opisuje konstrukcji języka. Opisuje decyzję, którą kod podejmuje.

8. Jak przejąć kod po kimś

Nie zaczynaj od poprawiania formatowania i przenoszenia katalogów. Najpierw zbuduj mapę zachowania. To jest moment, w którym cierpliwość ma większą wartość niż tempo pisania.

1Uruchom

Zapisz wersje, konfigurację, migracje i minimalny smoke test.

2Zmapuj

Wejścia, wyjścia, dane, integracje, kolejki i harmonogramy.

3Nazwij niezmienniki

Co nigdy nie może się wydarzyć?

4Zabezpiecz

Dodaj testy charakteryzujące obecne zachowanie.

5Znajdź szew

Adapter, handler, interfejs albo feature flag.

6Zmień i zmierz

Mały commit, kontrolowany release i obserwacja.

[Fact]
public async Task Legacy_gate_blocks_when_any_required_check_fails()
{
    var checks = new IReleaseCheck[]
    {
        StubCheck.Pass("contract"),
        StubCheck.Block("secrets", "missing vault binding")
    };

    var decision = await new ReleaseGate(checks)
        .Evaluate(Context.Valid(), CancellationToken.None);

    decision.Allowed.Should().BeFalse();
    decision.Blockers.Should().ContainSingle(x => x.Code == "secrets");
}

Test charakteryzujący nie twierdzi, że stary kod jest idealny. Potwierdza, że rozumiemy jego aktualne zachowanie. To pas bezpieczeństwa podczas porządkowania struktury.

Decyzja 4 z 4

Refactoring i nowa funkcja dotykają tych samych plików. Jak ograniczysz ryzyko?

Łączę wszystko w jeden duży pull request

Rozdzielam ochronę, refactoring i funkcję

Najpierw zmieniam kod, testy dopiszę później

Wybierz strategię, która ułatwi review i wycofanie zmiany.

9. Czerwone flagi w odziedziczonym systemie

SygnałCo może oznaczaćPierwszy bezpieczny ruch
Głębokie drzewo dziedziczeniaUkryty stan i kruche zależnościTest kontraktowy dla potomków
switch po typiePozorny polimorfizmRejestr implementacji za kontraktem
NotSupportedExceptionNaruszenie LSPPodział kontraktu na zdolności
Serwis z wieloma SDKWiele powodów do zmianyOddzielenie decyzji od adapterów
Dużo mocków, brak integracjiNieznany kontrakt systemuTest charakteryzujący na granicy
Utils i CommonBrak właściciela odpowiedzialnościPrzeniesienie operacji do właściwego pojęcia
Boolean sterujący metodąDwie operacje pod jedną nazwąOsobne komendy albo strategie
Logowanie całych obiektówRyzyko PII i sekretówJawny model danych telemetrycznych

10. Te same decyzje w różnych językach

Składnia się zmienia, ale granice odpowiedzialności i kontrakty zachowują znaczenie.

ZamiarC#Java/KotlinTypeScriptPython
Mały kontraktinterfaceinterfaceinterfejs lub typ strukturalnyProtocol / ABC
Zamknięty zestaw wynikówrecord + enumsealed classdiscriminated uniondataclass + enum
Składanie zachowańkonstruktorkonstruktorfunkcje lub obiektyjawne zależności
Test kontraktowywspólny fixturecontract testwspólny zestaw testówparametrized tests
Adapter infrastrukturyimplementacja portuimplementacja portuobiekt spełniający typobiekt spełniający Protocol

Najważniejsze pytania są uniwersalne: gdzie jest decyzja, gdzie jest skutek uboczny, jaki jest kontrakt i jak udowodnimy, że podmiana zachowania jest bezpieczna?

11. Test polimorfizmu

✓

Każda implementacja zwraca wynik z własnym kodem.

✓

Każda respektuje anulowanie operacji.

✓

Żadna nie zwraca null zamiast wyniku.

✓

Żadna nie modyfikuje wejściowego kontekstu.

✓

Brak danych ma jawny, wspólny status.

✓

Komunikat nie ujawnia sekretów ani PII.

✓

Błąd providera różni się od blokady biznesowej.

✓

Wspólny test kontraktowy działa dla każdego typu.

12. Nie przesadzaj z architekturą

Nie twórz interfejsu wyłącznie dlatego, że klasa istnieje. Nie dodawaj fabryki, jeśli konstruktor i kontener DI rozwiązują wybór. Nie buduj systemu pluginów dla dwóch stabilnych przypadków. Nie dziel metody tylko po to, żeby każda miała pięć linii.

Abstrakcja jest opłacalna, kiedy zmniejsza koszt realnej zmiany albo izoluje realny skutek uboczny. Dobra architektura jest trochę nudna: zależności są jawne, przepływ da się prześledzić, a nowa osoba potrafi przewidzieć miejsce kolejnej zmiany.

Interaktywny przepływ

Przeprowadź zmianę przez pięć bramek bezpieczeństwa

Uruchom animację i zobacz kolejność, która ogranicza ryzyko podczas przejmowania nieznanego kodu.

Uruchom przepływ

1Obserwujwejścia i skutki uboczne
2Zabezpiecztest obecnego zachowania
3Znajdź szewkontrakt i adapter
4Podmieńmały pionowy krok
5Potwierdźmetryka i rollback

Gotowe do uruchomienia.

Checklista przed zmianą

  1. Potrafię opisać zachowanie jednym zdaniem.
  2. Znam niezmiennik, którego nie wolno naruszyć.
  3. Wiem, które wejście uruchamia ten kod.
  4. Widzę wszystkie skutki uboczne.
  5. Mam test obecnego zachowania.
  6. Wybrałem kompozycję albo dziedziczenie świadomie.
  7. Każda implementacja spełnia ten sam kontrakt.
  8. Nowa abstrakcja odpowiada realnej osi zmiany.
  9. Logi i metryki pozwolą sprawdzić produkcję.
  10. Rollback nie wymaga cofania destrukcyjnej migracji.

Najważniejsza myśl

Dobry kod nie jest kodem, którego nie da się zepsuć. Jest kodem, w którym ryzyko zmiany widać odpowiednio wcześnie. Dziedziczenie może dobrze modelować stabilny kontrakt. Polimorfizm może usunąć centralne instrukcje wyboru. SOLID może pomóc nazwać sprzężenia. Żadna z tych technik nie zastąpi jednak zrozumienia zachowania, testu i obserwacji po wdrożeniu.

Kiedy zaczynasz od zera, buduj od wyniku i granic. Kiedy przejmujesz cudzy system, najpierw obserwuj, potem zabezpiecz zachowanie, znajdź szew i dopiero wtedy zmieniaj strukturę.

Do zabrania do projektu

Pięć pytań ważniejszych od wybranego języka

01

Jaką decyzję biznesową chroni ten kod?

02

Czy typy można podmienić bez wyjątków od kontraktu?

03

Gdzie kończy się reguła, a zaczyna infrastruktura?

04

Jaki test chroni zachowanie podczas refactoringu?

05

Jaki sygnał potwierdzi zmianę po wdrożeniu?

Masz kod, który trudno bezpiecznie zmieniać?

Zacznij od mapy zachowania i ryzyka, nie od przepisywania systemu.

Krótki opis modułu, awarii albo planowanej zmiany wystarczy, żeby ustalić pierwszy techniczny krok.

Opisz problem techniczny