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.
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.
Nie to, co miało działać, lecz to, na czym faktycznie polegają konsumenci.
Baza, sieć, pliki, kolejka, chmura, e-mail i telemetria.
Zachowanie biznesowe czy tylko kolejność wywołań mocków?
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.
Odczyt, zapis i usuwanie trafiają do każdego potomka, nawet gdy nie mają sensu.
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.
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
Jawna kompozycja
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
Ile niezależnych powodów ma ten moduł, żeby się zmienić?
Czy nowy przypadek wymaga edycji stabilnego centrum?
Czy każda implementacja zachowuje obietnicę kontraktu?
Czy klient zależy tylko od zdolności, której potrzebuje?
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
„System blokuje release, jeśli produkcyjny sekret nie pochodzi z zatwierdzonego źródła”. Nie zaczynaj od nazwy klasy.
Sukces, blokada, brak danych, timeout, anulowanie. Przykłady odsłaniają kontrakt.
Czysta reguła i osobny adapter providera dają szybsze testy oraz czytelniejsze błędy.
Przekaż dane potrzebne do decyzji, nie cały klient chmury ani kontener usług.
Reguła, wejście, audyt, metryka i test. Dopiero druga implementacja potwierdzi wspólną abstrakcję.
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.
EvaluateRelease mówi więcej niż ProcessData.
Mniej ukrytego stanu oznacza mniej warunków do pamiętania.
Negatywna decyzja biznesowa powinna być jawnym wynikiem.
Nazwa i zależności mówią, że kod dotyka sieci albo bazy.
Nie kopiuj tej samej walidacji do kontrolera, joba i frontendu.
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.
Zapisz wersje, konfigurację, migracje i minimalny smoke test.
Wejścia, wyjścia, dane, integracje, kolejki i harmonogramy.
Co nigdy nie może się wydarzyć?
Dodaj testy charakteryzujące obecne zachowanie.
Adapter, handler, interfejs albo feature flag.
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 dziedziczenia | Ukryty stan i kruche zależności | Test kontraktowy dla potomków |
switch po typie | Pozorny polimorfizm | Rejestr implementacji za kontraktem |
NotSupportedException | Naruszenie LSP | Podział kontraktu na zdolności |
| Serwis z wieloma SDK | Wiele powodów do zmiany | Oddzielenie decyzji od adapterów |
| Dużo mocków, brak integracji | Nieznany kontrakt systemu | Test charakteryzujący na granicy |
Utils i Common | Brak właściciela odpowiedzialności | Przeniesienie operacji do właściwego pojęcia |
| Boolean sterujący metodą | Dwie operacje pod jedną nazwą | Osobne komendy albo strategie |
| Logowanie całych obiektów | Ryzyko PII i sekretów | Jawny model danych telemetrycznych |
10. Te same decyzje w różnych językach
Składnia się zmienia, ale granice odpowiedzialności i kontrakty zachowują znaczenie.
| Zamiar | C# | Java/Kotlin | TypeScript | Python |
|---|---|---|---|---|
| Mały kontrakt | interface | interface | interfejs lub typ strukturalny | Protocol / ABC |
| Zamknięty zestaw wyników | record + enum | sealed class | discriminated union | dataclass + enum |
| Składanie zachowań | konstruktor | konstruktor | funkcje lub obiekty | jawne zależności |
| Test kontraktowy | wspólny fixture | contract test | wspólny zestaw testów | parametrized tests |
| Adapter infrastruktury | implementacja portu | implementacja portu | obiekt spełniający typ | obiekt 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
Gotowe do uruchomienia.
Checklista przed zmianą
- Potrafię opisać zachowanie jednym zdaniem.
- Znam niezmiennik, którego nie wolno naruszyć.
- Wiem, które wejście uruchamia ten kod.
- Widzę wszystkie skutki uboczne.
- Mam test obecnego zachowania.
- Wybrałem kompozycję albo dziedziczenie świadomie.
- Każda implementacja spełnia ten sam kontrakt.
- Nowa abstrakcja odpowiada realnej osi zmiany.
- Logi i metryki pozwolą sprawdzić produkcję.
- 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
Jaką decyzję biznesową chroni ten kod?
Czy typy można podmienić bez wyjątków od kontraktu?
Gdzie kończy się reguła, a zaczyna infrastruktura?
Jaki test chroni zachowanie podczas refactoringu?
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