Wzorce projektowe bez bólu: 7 przykładów w Pythonie i TypeScript

0
25
Rate this post

Nawigacja:

Po co w ogóle wzorce projektowe programiście Pythona i TypeScriptu?

Wspólny język zamiast złotego młotka

Wzorce projektowe to przede wszystkim słownictwo, a dopiero później konkretne schematy kodu. Zespół, który potrafi powiedzieć „zróbmy tu strategię” albo „to wygląda na adapter”, szybciej się dogaduje, szczególnie jeśli miesza się w nim Python, TypeScript i różne poziomy doświadczenia.

Nie są to recepty idealne na każdy ból. Te same problemy w Pythonie często da się rozwiązać krótkim idiomem, a w TypeScripcie – czytelnym interfejsem i typowaniem. Ważny jest rozpoznany problem, a nie ślepe dopasowywanie definicji z książki GoF („Gang of Four”) do każdego fragmentu kodu.

Kiedy wzorce projektowe naprawdę pomagają:

  • gdy projekt rośnie i pojawia się wiele podobnych, ale nieidentycznych fragmentów logiki,
  • gdy trzeba zapanować nad zależnościami pomiędzy modułami w kilku usługach i aplikacjach (web, worker, front),
  • gdy kilka zespołów dotyka tego samego kodu i trzeba szybko tłumaczyć, „co się tu właściwie dzieje”.

Definicja vs rozpoznawanie problemu

Znajomość definicji typu „Strategy pozwala enkapsulować algorytmy i wymieniać je w czasie działania” jest mało użyteczna, jeśli nie prowadzi do wniosków w konkretnym kodzie. Kluczowe pytanie brzmi: czy rozpoznajesz sytuację, w której dany wzorzec faktycznie pomaga?

Rozróżnienie jest dość ostre:

  • tryb teoretyczny: „Strategy to interfejs + implementacje + kontekst, który je wykorzystuje”,
  • tryb praktyczny: „w tym module jest 12 if-ów wybierających sposób liczenia ceny – to aż prosi się o strategie”.

W projektach produkcyjnych realny zysk z wzorców pojawia się dopiero wtedy, gdy łączy się nazwę wzorca z typowym „code smell” – sygnałem, że coś zaczyna być trudne w utrzymaniu (rozrastające się warunki, duplikacja, klasy-„bąble” robiące wszystko naraz).

Od książki GoF do Pythona i TypeScriptu

Oryginalne wzorce projektowe GoF powstawały z myślą o językach takich jak C++ czy Java z lat 90. Od tamtej pory:

  • Python wprowadził dekoratory, moduły, bogate typowanie opcjonalne, metaklasy i protokoły.
  • TypeScript dołożył system typów, interfejsy, union types, klasy, dekoratory (eksperymentalne) i moduły ES.

Część wzorców „rozpuściła się” w samych językach: funkcje wyższego rzędu zastępują proste strategie, moduły stają się naturalnymi singletonami, dekoratory upraszczają wiele zastosowań dekoratora z GoF. Inne wzorce, szczególnie strukturalne (Adapter, Facade), pozostają bardzo aktualne w świecie mikroserwisów i bogatych API.

Co wiemy: wzorce projektowe redukują chaos w większych systemach i ułatwiają komunikację. Czego nie wiemy od razu: które z nich naprawdę są potrzebne w konkretnym zespole, w konkretnym staku (Python backend, TypeScript frontend czy Node). Odpowiedź da dopiero codzienny kod, a nie teoretyczna lista „trzeba znać 23 wzorce GoF”.

Kontekst technologiczny: Python vs TypeScript a projektowanie

Dynamiczny Python i statyczny TypeScript

Python i TypeScript różnią się fundamentalnie pod względem typowania i ducha języka:

  • Python – typowanie dynamiczne, silne, z opcjonalnym statycznym sprawdzaniem (mypy, pyright). Elastyczność, duck-typing, proste funkcje jako obiekty pierwszej klasy.
  • TypeScript – nadzbiór JavaScriptu z rozbudowanym statycznym systemem typów: interfejsy, klasy, typy unii, generics. Co jest typowo sprawdzane przy kompilacji, a nie w runtime.

Ten kontrast bezpośrednio wpływa na to, jak implementuje się wzorce projektowe. W Pythonie wiele rzeczy można zrealizować poprzez konwencję i duck-typing („ma metodę send, więc traktujmy to jak klienta API”), a w TypeScripcie często opłaca się wyrazić to jasno w interfejsie i dać kompilatorowi możliwość złapania niezgodności.

Wzorce wbudowane w idiomy języka

Część wzorców projektowych jest tak naturalna dla danego języka, że mówienie o „wzorcu” bywa przesadą:

  • Python: dekoratory funkcji i klas realizują wiele przypadków wzorca Decorator bez dodatkowej struktury. Moduły pełnią rolę Singletona. Funkcje wyższego rzędu i zamknięcia pokrywają dużą część Strategy.
  • TypeScript: interfejsy i klasy w stylu „konfiguruj przez konstruktor” tworzą naturalne miejsce na wiele wzorców kreacyjnych i strukturalnych. Union types plus narrowing upraszczają wiele wzorców, które w językach bez unii byłyby rozbudowanymi hierarchiami klas.

Wzorce nie znikają, ale ich kształt w kodzie się zmienia. W Pythonie często wystarczy prosty słownik strategii lub funkcja-fabryka, w TypeScripcie dochodzi do tego opis w systemie typów i integracja z narzędziami (IDE, lint, kompilator).

Frameworki a wzorce: nie kopiować tego, co już jest

Django, FastAPI, Flask po stronie Pythona oraz NestJS, Angular, React po stronie TypeScriptu intensywnie korzystają ze wzorców: Factory, Dependency Injection, Template Method, Observer, Facade. Duży margines błędu powstaje wtedy, gdy projekt duplikuje te same mechanizmy własnymi implementacjami.

Kilka przykładów:

  • Django – już używa wzorców jak Template Method (generic views), Factory (ORM tworzący obiekty), Singleton (ustawienia). Dodawanie własnej wielopoziomowej hierarchii fabryk nad modelami często tylko komplikuje struktury.
  • FastAPI – ma wbudowany mechanizm „dependency injection” funkcjami. To praktyczne zastosowanie kilku wzorców (Provider, Factory). Tworzenie obok tego własnego, ręcznego kontenera DI bywa powielaniem wysiłku.
  • NestJS – to de facto framework oparty na DI, modułach, kontrolerach i serwisach. Wiele wzorców strukturalnych jest tu gotowych. Dodatkowe „fabryki serwisów” nad serwisami rzadko pomagają.

Punktem wyjścia powinno być pytanie: czy idiom/framework już tego nie rozwiązuje? Dopiero jeśli odpowiedź brzmi „nie”, sięgnięcie po klasyczny wzorzec ma sens.

Programista szkicuje na białej tablicy plan projektu w biurze
Źródło: Pexels | Autor: Startup Stock Photos

Jak czytać i „tłumaczyć” wzorce projektowe na konkretny kod

Struktura opisu wzorca ważniejsza niż UML

Użyteczny opis wzorca zwykle składa się z kilku elementów:

  • Problem – typowa sytuacja, w której kod „zaczyna boleć” (np. wiele warunków, trudne testowanie, silna zależność od konkretnej biblioteki).
  • Siły – ograniczenia, z którymi trzeba się pogodzić: stabilność API, wymagania biznesowe, potrzeba rozszerzalności.
  • Rozwiązanie – ogólna struktura: jakie obiekty, jakie role, kto z kim rozmawia.
  • Konsekwencje – co się poprawia, a co komplikuje (np. więcej małych klas, rozproszenie logiki).

UML może pomóc, ale w Pythonie i TypeScripcie ważniejsze jest, by przełożyć tę strukturę na czytelne moduły, pliki i nazwy. Wzorzec, który na diagramie wygląda klarownie, w projekcie o dziwnej strukturze katalogów może być trudny do odczytania.

Ten sam wzorzec w Pythonie i TypeScript

Weźmy prosty przykład – Strategy. W Pythonie:

  • strategią może być klasa z metodą calculate,
  • albo po prostu funkcja o odpowiednim podpisie,
  • albo obiekt spełniający protokół z typing (Protocol).

W TypeScripcie:

  • strategię można opisać interfejsem PricingStrategy,
  • wdrożyć ją klasami,
  • albo zdefiniować jako typ funkcji (type PricingStrategy = (input: ...) => number).

Cel jest ten sam: łatwa wymiana algorytmu. Forma dopasowuje się do języka i jego narzędzi. To „tłumaczenie” jest kluczowe: znajdowanie odpowiednika struktury wzorca w idiomie konkretnego języka, a nie kopiowanie czystej wersji z książki wbrew składni i zwyczajom.

Od „code smell” do wzorca, a nie odwrotnie

Praktyczna metoda pracy z wzorcami w Pythonie i TypeScript:

  1. Najpierw namierz „smell” w kodzie: powtarzalne if-y, rozrastające się klasy, funkcje na 200 linii, powtarzane bloki logiki.
  2. Następnie zadaj pytanie: „czy to przypomina problem, który jakiś wzorzec opisuje?”.
  3. Dopasuj prostą, idiomatyczną formę wzorca w danym języku.
  4. Sprawdź, czy kod po refaktoryzacji jest naprawdę czytelniejszy i łatwiejszy do testowania.

Przykład typowego kandydata – powtarzające się if typ == ....

Przykład „smell” w Pythonie

def send_notification(kind: str, data: dict) -> None:
    if kind == "email":
        # logika wysyłki maila
        ...
    elif kind == "sms":
        # logika wysyłki SMS
        ...
    elif kind == "push":
        # logika wysyłki powiadomienia push
        ...
    else:
        raise ValueError(f"Unknown kind: {kind}")

Każdy nowy kanał wymaga nowego elif, testy mieszają się ze sobą, funkcja rośnie. To klasyczna sytuacja, w której Strategy lub prosta mapa strategii (słownik) porządkują logikę.

Podobny „smell” w TypeScripcie

function sendNotification(kind: string, data: any): void {
  if (kind === "email") {
    // logika wysyłki maila
  } else if (kind === "sms") {
    // logika wysyłki SMS
  } else if (kind === "push") {
    // logika wysyłki powiadomienia push
  } else {
    throw new Error(`Unknown kind: ${kind}`);
  }
}

Tutaj również aż prosi się o wydzielenie interfejsu lub typu funkcji i zestawu strategii. Różnica polega na tym, że TypeScript może dodatkowo chronić nas typami (np. type NotificationKind = "email" | "sms" | "push"), co ogranicza ryzyko literówek i nieobsłużonych przypadków.

Wzorzec 1 – Factory / Simple Factory: porządkowanie tworzenia obiektów

Kiedy fabryka ma sens

Fabryka (Simple Factory, czasem Factory Method) porządkuje logikę, która:

  • rozsiana jest po wielu miejscach (new KlientApi(...) w kilku modułach),
  • zawiera warunki decydujące, jaki konkretnie obiekt stworzyć,
  • łączy się z konfiguracją, środowiskiem, flagami feature’ów.

Bez fabryki kod tworzący obiekty:

  • duplikuje się,
  • jest trudny do zmiany (nowy typ obiektu = zmiana w wielu plikach),
  • bywa ciężki w testowaniu (trzeba stubować wiele miejsc naraz).

Prosta fabryka w Pythonie: funkcja czy klasa?

W Pythonie nie trzeba od razu tworzyć klasy fabryki. Najczęściej wystarczy:

  • funkcja-fabryka,
  • albo moduł z funkcjami generującymi obiekty.

Przykład: wybór klienta API w Pythonie

Załóżmy, że aplikacja ma mówić do różnych backendów: REST, GraphQL, lub używać klienta-podróbki w testach.

from typing import Protocol

class ApiClient(Protocol):
    def get_user(self, user_id: str) -> dict:
        ...

class RestApiClient:
    def __init__(self, base_url: str) -> None:
        self.base_url = base_url

    def get_user(self, user_id: str) -> dict:
        # wywołanie HTTP GET /users/{user_id}
        ...

class GraphqlApiClient:
    def __init__(self, endpoint: str) -> None:
        self.endpoint = endpoint

    def get_user(self, user_id: str) -> dict:
        # zapytanie GraphQL
        ...

class MockApiClient:
    def get_user(self, user_id: str) -> dict:
        return {"id": user_id, "name": "Test User"}

def create_api_client(kind: str) -> ApiClient:
    if kind == "rest":
        return RestApiClient(base_url="https://api.example.com")
    elif kind == "graphql":
        return GraphqlApiClient(endpoint="https://gql.example.

Domknięcie przykładu fabryki w Pythonie

def create_api_client(kind: str) -> ApiClient:
    if kind == "rest":
        return RestApiClient(base_url="https://api.example.com")
    elif kind == "graphql":
        return GraphqlApiClient(endpoint="https://gql.example.com")
    elif kind == "mock":
        return MockApiClient()
    else:
        raise ValueError(f"Unknown client kind: {kind}")

Fakty:

  • wybór implementacji jest w jednym miejscu,
  • reszta systemu zależy wyłącznie od protokołu ApiClient,
  • nowy typ klienta wymaga dopisania jednej gałęzi w fabryce.

Interpretacja jest prosta: w małym projekcie taka funkcja wystarcza na długo, a przy rozroście można ją rozbić na moduły lub zamienić na rejestr strategii.

Analogiczna fabryka w TypeScripcie: typy na pierwszej linii

W TypeScripcie fabryka szybko zderza się z typami. Dobrze, jeśli od razu je wykorzystuje:

type ApiClientKind = "rest" | "graphql" | "mock";

interface ApiClient {
  getUser(userId: string): Promise<Record<string, unknown>>;
}

class RestApiClient implements ApiClient {
  constructor(private readonly baseUrl: string) {}

  async getUser(userId: string) {
    // HTTP GET `${this.baseUrl}/users/${userId}`
    return {};
  }
}

class GraphqlApiClient implements ApiClient {
  constructor(private readonly endpoint: string) {}

  async getUser(userId: string) {
    // zapytanie GraphQL
    return {};
  }
}

class MockApiClient implements ApiClient {
  async getUser(userId: string) {
    return { id: userId, name: "Test User" };
  }
}

function createApiClient(kind: ApiClientKind): ApiClient {
  switch (kind) {
    case "rest":
      return new RestApiClient("https://api.example.com");
    case "graphql":
      return new GraphqlApiClient("https://gql.example.com");
    case "mock":
      return new MockApiClient();
  }
}

Co wiemy?

  • kompilator pilnuje, by wszystkie wartości z unii ApiClientKind były obsłużone,
  • konsumenci widzą tylko interfejs ApiClient, a nie konkretne klasy,
  • testy mogą wstrzykiwać MockApiClient lub nadpisać fabrykę.

W większym systemie tę funkcję często zastępuje konfiguracja modułów (NestJS), ale struktura pozostaje: jeden punkt tworzenia, wspólny interfejs.

Fabryka jako warstwa nad konfiguracją

Gdy konfiguracja staje się głównym sterownikiem zachowania, fabryka zamienia się w cienką warstwę tłumaczącą dane konfiguracyjne na obiekty.

from dataclasses import dataclass
from typing import Literal

ClientKind = Literal["rest", "graphql", "mock"]

@dataclass
class ApiConfig:
    kind: ClientKind
    base_url: str | None = None
    endpoint: str | None = None

def create_api_client_from_config(cfg: ApiConfig) -> ApiClient:
    if cfg.kind == "rest":
        if not cfg.base_url:
            raise ValueError("base_url required for REST client")
        return RestApiClient(base_url=cfg.base_url)
    if cfg.kind == "graphql":
        if not cfg.endpoint:
            raise ValueError("endpoint required for GraphQL client")
        return GraphqlApiClient(endpoint=cfg.endpoint)
    return MockApiClient()

Taki układ pojawia się regularnie w narzędziach CLI, mikrousługach czy integracjach zewnętrznych API. W TypeScripcie odpowiednikiem bywa fabryka biorąca Config z pliku .json lub z otoczenia procesów (np. w aplikacjach Node).

Programistka pisze Use APIs na białej tablicy podczas planowania software
Źródło: Pexels | Autor: ThisIsEngineering

Wzorzec 2 – Singleton bez magii: kiedy współdzielenie stanu ma sens

Po co w ogóle Singleton w Pythonie i TypeScripcie?

Singleton pojawia się wszędzie tam, gdzie jakiś obiekt:

  • jest kosztowny w utworzeniu (połączenie z bazą, ciężka konfiguracja),
  • powinien być współdzielony (cache, globalna konfiguracja, logger),
  • musi być spójny w ramach jednego procesu lub requestu.

Problem w praktyce zwykle nie brzmi „potrzebuję Singletona”, lecz raczej „mamy pięć instancji klienta bazy, każda z własnymi ustawieniami”. Singleton uporządkowuje taki bałagan, ale tylko wtedy, gdy jest użyty świadomie.

Antywzorzec: „magiczny” Singleton

Klasyczne, podręcznikowe implementacje z nadpisywaniem __new__ w Pythonie albo statycznym polem w TypeScripcie łatwo zamieniają się w globalny stan trudny do testowania.

class BadSingleton:
    _instance: "BadSingleton | None" = None

    def __new__(cls) -> "BadSingleton":
        if cls._instance is None:
            cls._instance = super().__new__(cls)
        return cls._instance

Kod wygląda efektownie, ale testy zaczynają odczuwać skutki: stan trudno zresetować, kolejność uruchamiania przypadków ma znaczenie. Z perspektywy projektowej to koszt, który nie zawsze się zwraca.

Prostsza droga w Pythonie: moduł jako Singleton

W Pythonie moduł jest ładowany raz na proces. To naturalny, prosty „Singleton bez klasy”.

# config.py
from functools import lru_cache
from pydantic import BaseSettings

class Settings(BaseSettings):
    db_url: str
    debug: bool = False

@lru_cache(maxsize=1)
def get_settings() -> Settings:
    return Settings()  # wczytuje z env / pliku

W innym miejscu kodu:

from .config import get_settings

def handle_request():
    settings = get_settings()
    ...

Fakty:

  • Settings tworzone jest leniwie przy pierwszym użyciu,
  • każde kolejne wywołanie zwraca tę samą instancję,
  • w testach lru_cache można wyczyścić (get_settings.cache_clear()) i wstrzykiwać inne źródła konfiguracji.

To praktyczny, idiomatyczny Singleton – bez specjalnych konstrukcji w klasie.

Singleton w TypeScripcie: moduł ES i statyczne instancje

W TypeScripcie (Node, bundlery) podobną rolę pełni moduł ES. Import jest cache'owany, więc eksportowana instancja klasy jest współdzielona.

// config.ts
export interface Settings {
  dbUrl: string;
  debug: boolean;
}

class SettingsImpl implements Settings {
  dbUrl: string;
  debug: boolean;

  constructor() {
    this.dbUrl = process.env.DB_URL ?? "postgres://localhost";
    this.debug = process.env.DEBUG === "1";
  }
}

export const settings: Settings = new SettingsImpl();

Użycie:

// service.ts
import { settings } from "./config";

export async function handleRequest() {
  if (settings.debug) {
    console.log("Debug mode");
  }
}

Nie ma tu „oficjalnej” etykiety Singleton, ale zachowanie jest identyczne: jedna instancja na proces, leniwe stworzenie przy pierwszym imporcie. Różnica względem Pythona jest taka, że w TypeScripcie łatwiej podmienić instancję przez DI (np. w NestJS) lub helper testowy.

Singleton per żądanie: kontekst aplikacji

W aplikacjach webowych częściej potrzebna jest jedna instancja na request niż na cały proces. Wtedy zamiast globalnego Singletona stosuje się coś w rodzaju „request-scoped Singleton”.

Przykład w FastAPI:

from fastapi import Depends, FastAPI
from sqlalchemy.orm import Session

app = FastAPI()

def get_db() -> Session:
    db = SessionLocal()
    try:
        yield db
    finally:
        db.close()

@app.get("/users/{user_id}")
def get_user(user_id: int, db: Session = Depends(get_db)):
    # w obrębie jednego requestu `db` jest współdzielone
    ...

FastAPI pilnuje cyklu życia obiektu. Z punktu widzenia logiki biznesowej to Singleton w obrębie jednego żądania, ale pozbawiony globalnego stanu.

Analogiczny wzorzec w NestJS:

@Injectable({ scope: Scope.REQUEST })
export class RequestContextService {
  constructor(@Inject(REQUEST) private readonly req: Request) {}

  getRequestId(): string {
    return this.req.headers["x-request-id"] as string;
  }
}

Framework utrzymuje jedną instancję serwisu na żądanie HTTP. Strategia jest ta sama: współdzielić kosztowne lub kontekstowe zasoby, ale zamknąć je w rozsądnym zakresie.

Kiedy unikać Singletona

Są sytuacje, w których współdzielony stan robi więcej szkody niż pożytku:

  • logika domenowa zależna od sekwencji wywołań (łatwo o błędy w konkurencji),
  • testy równoległe (globalny stan utrudnia izolację),
  • wieloprocesowe aplikacje (Singleton w jednym procesie nie widzi danych z innych).

Jeśli obiekt często zmienia stan i zależy od danych użytkownika, bezpieczniejszy bywa wstrzykiwany serwis bez globalnego współdzielenia. Singleton sprawdza się lepiej przy stanie konfiguracyjnym i zasobach typu cache, a gorzej przy zasadach biznesowych.

Wzorzec 3 – Strategy: zamiana if-ów na wymienne algorytmy

Strategia jako odpowiedź na rozrastające się warunki

Do Strategii prowadzi zwykle obserwacja jednego zjawiska: ta sama operacja jest wykonywana na różne sposoby, a wybór konkretnego wariantu opiera się na parametrach, typie klienta, flagach. Kod zaczyna gubić się w if/elif, a pojawienie się nowego przypadku wymaga modyfikacji starej funkcji.

Strategia to prosta struktura:

  • interfejs „co trzeba zrobić” (np. calculate_price),
  • zestaw implementacji „jak to zrobić”,
  • mechanizm wyboru odpowiedniej strategii.

Strategia w Pythonie: od klas do funkcji i słowników

Klasyczna wersja z hierarchią klas w Pythonie bywa zbyt ciężka. Często wystarcza lekki interfejs oparty na protokole i funkcjach.

from typing import Protocol

class PricingStrategy(Protocol):
    def __call__(self, base: float) -> float:
        ...

def regular_price(base: float) -> float:
    return base

def vip_price(base: float) -> float:
    return base * 0.9

def black_friday_price(base: float) -> float:
    return base * 0.7

STRATEGIES: dict[str, PricingStrategy] = {
    "regular": regular_price,
    "vip": vip_price,
    "black_friday": black_friday_price,
}

def calculate_price(base: float, kind: str) -> float:
    try:
        strategy = STRATEGIES[kind]
    except KeyError:
        raise ValueError(f"Unknown pricing kind: {kind}")
    return strategy(base)

Fakty:

  • każda strategia to zwykła funkcja spełniająca protokół,
  • słownik pełni rolę prostego rejestru Strategii,
  • dodanie nowego wariantu sprowadza się do dopisania funkcji i wpisu do STRATEGIES.

Taka struktura pojawia się w systemach rabatów, walidacji, generowania raportów – wszędzie tam, gdzie „rodzaj” operacji wybierany jest na podstawie danych wejściowych.

Strategia w TypeScripcie: interfejs lub typ funkcji

W TypeScripcie wybór często pada na typ funkcji, bo integruje się dobrze z systemem typów.

type PricingKind = "regular" | "vip" | "black_friday";

type PricingStrategy = (base: number) => number;

const regularPrice: PricingStrategy = (base) => base;
const vipPrice: PricingStrategy = (base) => base * 0.9;
const blackFridayPrice: PricingStrategy = (base) => base * 0.7;

const STRATEGIES: Record<PricingKind, PricingStrategy> = {
  regular: regularPrice,
  vip: vipPrice,
  black_friday: blackFridayPrice,
};

export function calculatePrice(base: number, kind: PricingKind): number {
  return STRATEGIES[kind](base);
}

Co wiemy?

  • kompilator nie pozwoli wywołać calculatePrice z nieznanym rodzajem strategii,
  • nowy rodzaj wymaga dopisania do typu unii i do obiektu STRATEGIES,
  • brak ręcznego if/else zmniejsza ryzyko pomyłek i „zapomnianych” przypadków.

Strategia zagnieżdżona w obiektach domenowych

Często Strategia ląduje bezpośrednio w obiekcie domenowym, a nie w oddzielnej funkcji. Przykład z praktyki: aplikacja do rozliczania opłat, gdzie różne typy produktów mają różne algorytmy ustalania marży.

from dataclasses import dataclass
from typing import Callable

MarginStrategy = Callable[[float], float]

@dataclass
class Product:
    name: str
    base_price: float
    margin_strategy: MarginStrategy

    def final_price(self) -> float:
        return self.margin_strategy(self.base_price)

def high_margin(base: float) -> float:
    return base * 1.5

def low_margin(base: float) -> float:
    return base * 1.1

Strategia jest tu parametrem produktu. Kod nie musi już nigdzie indziej pytać „jaki to typ produktu?”, bo obiekt niesie ze sobą sposób obliczania ceny.

Strategia w aplikacji frontowej: TypeScript + React

W frontendzie Strategia często przyjmuje postać mapy sposobów renderowania lub walidowania. Np. różne typy pól formularza:

Strategia w React: mapa komponentów zamiast if-ów

Frontendy szybko pokazują, czy Strategia została dobrze zastosowana. Przypadek typowy: wiele typów pól formularza i rosnący komponent z warunkami renderowania.

type FieldType = "text" | "number" | "date";

interface FieldConfig {
  name: string;
  label: string;
  type: FieldType;
}

interface FieldProps {
  value: string;
  onChange: (value: string) => void;
  config: FieldConfig;
}

const TextField: React.FC<FieldProps> = ({ value, onChange, config }) => (
  <label>
    {config.label}
    <input
      type="text"
      value={value}
      onChange={(e) => onChange(e.target.value)}
    />
  </label>
);

const NumberField: React.FC<FieldProps> = ({ value, onChange, config }) => (
  <label>
    {config.label}
    <input
      type="number"
      value={value}
      onChange={(e) => onChange(e.target.value)}
    />
  </label>
);

const DateField: React.FC<FieldProps> = ({ value, onChange, config }) => (
  <label>
    {config.label}
    <input
      type="date"
      value={value}
      onChange={(e) => onChange(e.target.value)}
    />
  </label>
);

const FIELD_RENDERERS: Record<FieldType, React.FC<FieldProps>> = {
  text: TextField,
  number: NumberField,
  date: DateField,
};

export const DynamicField: React.FC<FieldProps> = (props) => {
  const Renderer = FIELD_RENDERERS[props.config.type];
  return <Renderer {...props} />;
};

Fakty:

  • każdy typ pola ma własny komponent – osobną Strategię renderowania,
  • DynamicField jest jedynie koordynatorem, a nie listą warunków,
  • dodanie nowego pola wymaga dopisania komponentu i wpisu w FIELD_RENDERERS.

Co to daje w praktyce? Mniejszy komponent nadrzędny, prostsze testowanie pojedynczych Strategii i czytelny punkt wejścia do konfiguracji formularza.

Strategia wybierana w runtime: konfiguracja zamiast kodu

Strategia nie musi być na stałe zaszyta w kodzie. W wielu systemach wybór algorytmu odbywa się na podstawie danych z bazy lub pliku konfiguracyjnego.

import json
from typing import Callable

ShippingStrategy = Callable[[float], float]

def standard_shipping(weight: float) -> float:
    return 10 + weight * 2

def express_shipping(weight: float) -> float:
    return 20 + weight * 3.5

STRATEGIES: dict[str, ShippingStrategy] = {
    "standard": standard_shipping,
    "express": express_shipping,
}

def load_config(path: str) -> dict:
    with open(path) as f:
        return json.load(f)

def calculate_shipping_cost(weight: float, config: dict) -> float:
    strategy_name = config.get("shipping_strategy", "standard")
    try:
        strategy = STRATEGIES[strategy_name]
    except KeyError:
        raise ValueError(f"Unknown shipping strategy: {strategy_name}")
    return strategy(weight)

Z perspektywy biznesu oznacza to jedną rzecz: zmiana sposobu liczenia kosztów wysyłki może odbyć się bez wdrażania nowej wersji aplikacji – wystarczy zmodyfikować konfigurację. Kod jest przygotowany na ten ruch, bo Strategia została wydzielona.

Wzorzec 4 – Adapter: godzenie starych i nowych interfejsów

Skąd bierze się potrzeba Adaptera

Najczęstszy scenariusz jest prozaiczny: istniejący kod używa jakiegoś interfejsu, a nowa biblioteka albo serwis dostarcza podobną funkcjonalność, lecz pod innym API. Przepisywanie całej aplikacji do nowego interfejsu jest kosztowne, więc powstaje warstwa pośrednia – Adapter.

Konstrukcja jest powtarzalna:

  • jest oczekiwany interfejs (np. PaymentGateway), którego używa reszta systemu,
  • jest zastępcza lub nowa implementacja o innym kształcie API,
  • Adapter owija nową implementację i „tłumaczy” wywołania na oczekiwany kształt.

Co wiemy? Adapter nie dodaje funkcji biznesowej – jego rolą jest korekta kształtu danych i sygnatur metod.

Adapter w Pythonie: stare API spotyka nowe SDK

Przykład z migracji bramki płatności. Stary kod opiera się na interfejsie:

from typing import Protocol

class PaymentGateway(Protocol):
    def charge(self, amount: int, currency: str, token: str) -> str:
        """Zwraca identyfikator transakcji lub rzuca wyjątek."""
        ...

Nowe SDK dostawcy wygląda inaczej:

# nowy_sdk.py
class NewPaymentClient:
    def create_payment(self, payload: dict) -> dict:
        # wysyła żądanie HTTP, zwraca dane JSON
        ...

Adapter łączy oba światy:

from typing import Protocol
from .nowy_sdk import NewPaymentClient

class PaymentGateway(Protocol):
    def charge(self, amount: int, currency: str, token: str) -> str:
        ...

class NewPaymentAdapter:
    def __init__(self, client: NewPaymentClient) -> None:
        self._client = client

    def charge(self, amount: int, currency: str, token: str) -> str:
        payload = {
            "amount": amount,
            "currency": currency.lower(),
            "source": token,
        }
        response = self._client.create_payment(payload)
        if response.get("status") != "success":
            raise RuntimeError("Payment failed")
        return response["transaction_id"]

Reszta aplikacji nadal woła gateway.charge(...). Różnice w parametrach, formacie odpowiedzi i obsłudze błędów ukrywa Adapter. W testach można podstawiać atrapę PaymentGateway bez konieczności dotykania SDK.

Adapter w TypeScripcie: integracja z zewnętrznym API

W ekosystemie TypeScriptu Adapter często siedzi na granicy z backendem HTTP. Załóżmy, że istnieje interfejs klienta użytkowników:

export interface UserDto {
  id: string;
  fullName: string;
  email: string;
}

export interface UsersClient {
  getUser(id: string): Promise<UserDto>;
}

Nowe API backendowe zwraca inne pola i inne nazwy:

interface RawUserApiResponse {
  user_id: string;
  first_name: string;
  last_name: string;
  contact: {
    email: string;
  };
}

Adapter odczytuje odpowiedź HTTP i dopasowuje ją do oczekiwanego interfejsu:

import axios from "axios";
import type { UsersClient, UserDto } from "./types";

export class HttpUsersClientAdapter implements UsersClient {
  constructor(private readonly baseUrl: string) {}

  async getUser(id: string): Promise<UserDto> {
    const response = await axios.get<RawUserApiResponse>(
      `${this.baseUrl}/users/${id}`
    );

    const raw = response.data;

    const user: UserDto = {
      id: raw.user_id,
      fullName: `${raw.first_name} ${raw.last_name}`,
      email: raw.contact.email,
    };

    return user;
  }
}

Fakty:

  • komponenty w React/Next korzystają z typowanego UsersClient,
  • zmiany w strukturze odpowiedzi HTTP dotykają tylko Adaptera,
  • testy mogą podmienić UsersClient na fałszywą implementację bez warstwy HTTP.

To typowy obrazek z projektów migracyjnych: stare API frontendowe zostaje, natomiast pod spodem podmieniany jest sposób jego realizacji.

Adapter jako „tłumacz” pomiędzy warstwami

Adapter nie musi dotyczyć tylko zewnętrznych bibliotek. Równie często pełni rolę tłumacza między warstwą domenową a bazą danych czy modelem ORM.

from dataclasses import dataclass

@dataclass
class User:
    id: int
    email: str
    is_active: bool

# uproszczony model ORM (np. SQLAlchemy)
class UserModel:
    id: int
    email_address: str
    active: bool

class UserRepository:
    def __init__(self, session):
        self._session = session

    def get(self, user_id: int) -> User | None:
        row = self._session.query(UserModel).get(user_id)
        if row is None:
            return None
        return User(id=row.id, email=row.email_address, is_active=row.active)

    def save(self, user: User) -> None:
        row = self._session.query(UserModel).get(user.id) or UserModel()
        row.email_address = user.email
        row.active = user.is_active
        self._session.add(row)
        self._session.commit()

Repozytorium działa tu jako Adapter między obiektem domenowym User a modelem ORM. Komunikat jest prosty: logika biznesowa sięga po spójną reprezentację użytkownika, szczegóły bazy danych zostają w warstwie dostępu do danych.

Adapter funkcyjny: drobna korekta bez klas

Nie każdy Adapter musi być klasą. Prosta funkcja mapująca też pełni tę rolę, szczególnie w TypeScripcie.

interface LegacyOrder {
  id: string;
  total: number;
  created_at: string; // ISO
}

interface Order {
  id: string;
  totalCents: number;
  createdAt: Date;
}

export function adaptLegacyOrder(input: LegacyOrder): Order {
  return {
    id: input.id,
    totalCents: Math.round(input.total * 100),
    createdAt: new Date(input.created_at),
  };
}

Takie małe Adaptery porządkują granice między „światem zewnętrznym” (API, stare moduły) a „światem wewnętrznym” (typy domenowe). W codziennej pracy pojawiają się przy migracjach schematu, refaktoringach modeli oraz integracjach z systemami partnerskimi.

Adapter a testowanie: kontrolowany punkt styku

Na poziomie testów Adapter staje się naturalnym miejscem, w którym symuluje się zachowanie zewnętrznych zależności. Zamiast stubować surowe wywołania HTTP czy metod SDK, podmienia się cały Adapter.

class FakePaymentGateway:
    def __init__(self) -> None:
        self.charges: list[tuple[int, str, str]] = []

    def charge(self, amount: int, currency: str, token: str) -> str:
        self.charges.append((amount, currency, token))
        return "test-transaction-id"

def test_checkout_uses_gateway():
    gateway = FakePaymentGateway()
    order = create_sample_order()

    # funkcja checkout przyjmuje PaymentGateway (interfejs)
    checkout(order, payment_gateway=gateway)

    assert gateway.charges[0][0] == order.total_amount

Punkt styku z zewnętrznym światem jest jeden – interfejs PaymentGateway. To on reprezentuje oczekiwany kontrakt, pozostawiając implementacji (Adapterowi) swobodę w korzystaniu z konkretnej biblioteki.

Najczęściej zadawane pytania (FAQ)

Po co mi wzorce projektowe, skoro w Pythonie i TypeScripcie mogę „po prostu pisać kod”?

Wzorce projektowe przede wszystkim dają wspólny język w zespole. Łatwiej powiedzieć „zróbmy tu Strategię” niż przez 10 minut tłumaczyć, że chodzi o rozbicie 12 if-ów wybierających algorytm liczenia ceny. Przy mieszanym składzie (Python backend, TypeScript frontend) takie skróty mocno przyspieszają rozmowę.

Same definicje niewiele zmieniają. Zysk pojawia się wtedy, gdy umiesz powiązać nazwę wzorca z konkretnym problemem w kodzie: rosnącą klasą‑„bąblem”, duplikacją warunków, trudnym w testowaniu modułem. Wtedy wzorzec staje się narzędziem, a nie teorią.

Kiedy warto stosować wzorce projektowe w projektach Python + TypeScript?

Wzorce zaczynają być przydatne, gdy projekt rośnie: przybywa podobnych, ale nieidentycznych fragmentów logiki, kolejne usługi dotykają tych samych modułów, a zależności pomiędzy komponentami robią się niejasne. Typowy sygnał to moment, w którym nowa funkcja wymaga przeklikania się przez kilka plików, żeby zrozumieć przepływ danych.

Drugi częsty przypadek to praca wielu zespołów na jednym kodzie. Jasno nazwane wzorce (Adapter, Facade, Strategy) porządkują strukturę i ułatwiają onboarding nowych osób. Pytanie kontrolne, które warto sobie zadać: czy problem zrozumienia kodu wynika z brakującej nazwy/struktury, czy z samej logiki biznesowej?

Czy klasyczne wzorce GoF mają sens w nowoczesnym Pythonie i TypeScripcie?

Część wzorców GoF została wchłonięta przez sam język i frameworki. W Pythonie dekoratory realizują wiele zastosowań Decoratora, moduły zachowują się jak naturalne Singletony, a funkcje wyższego rzędu pokrywają sporą część Strategii. W TypeScripcie interfejsy, typy unii i klasy uprościły wiele dawnych, rozbudowanych hierarchii.

Wciąż aktualne pozostają zwłaszcza wzorce strukturalne (Adapter, Facade) i te, które pomagają poradzić sobie z zależnościami między modułami. To one dobrze pasują do świata mikroserwisów, API i podziału na backend w Pythonie oraz frontend/Node w TypeScripcie.

Jak inaczej stosuje się wzorce projektowe w Pythonie, a inaczej w TypeScripcie?

Python skłania do rozwiązań opartych na dynamice i duck-typingu. Strategia może być zwykłą funkcją, obiektem spełniającym protokół albo wpisem w słowniku strategii. Często nie trzeba definicji klasy – wystarczy uzgodniony „kształt” parametru lub modułu.

W TypeScripcie ten sam pomysł zwykle opisuje się interfejsem lub typem funkcji, a kompilator pilnuje zgodności implementacji. Strategia staje się np. interface PricingStrategy albo type PricingStrategy = (input: ...) => number. Co wiemy dzięki temu? Łatwiej złapać niezgodności na etapie kompilacji i lepiej współpracować z IDE.

Jak rozpoznać, że w projekcie brakuje wzorca, a nie po prostu refaktoryzacji?

Pierwszy krok to zauważenie „code smells”: powtarzające się bloki if/else, klasy, które „robią wszystko”, funkcje mające po kilkaset linii, trudne do sensownego przetestowania moduły. To symptomy, a nie diagnoza. Kolejne pytanie brzmi: czy ten problem przypomina typowy przypadek, dla którego opisano konkretny wzorzec?

Jeśli odpowiedź jest twierdząca („ciągle dokładam if-y do wyboru algorytmu” → Strategy, „wszędzie wisi klient konkretnej biblioteki” → Adapter/Facade), wtedy wzorzec daje gotowy szkielet refaktoryzacji. Jeśli nie – często wystarczy prostsze uporządkowanie funkcji, modułów i nazw, bez wyciągania „ciężkiej artylerii” w postaci wzorców.

Czy trzeba ręcznie implementować wzorce, jeśli używam Django, FastAPI, Reacta czy NestJS?

Wiele frameworków ma wzorce wbudowane. Django korzysta z Template Method w generic views i z fabryk w ORM, FastAPI udostępnia prosty mechanizm dependency injection funkcjami, NestJS jest zbudowany wokół DI, modułów i serwisów. W takich środowiskach dodatkowe, własne hierarchie fabryk czy kontenery DI często tylko dublują istniejące rozwiązania.

Praktyczna zasada: najpierw sprawdź, czy problemu nie da się rozwiązać idiomem lub mechanizmem frameworka. Dopiero gdy brak pasującego narzędzia, warto sięgnąć po klasyczny opis wzorca i dostosować go do konkretnego języka i stosu technologicznego.

Jak „tłumaczyć” opis wzorca na realny kod w projekcie?

Punktem wyjścia jest zawsze problem, nie diagram UML. Najpierw nazwij sytuację, która boli (np. trudne testowanie przez sztywne zależności od zewnętrznego API). Potem sprawdź, jakie „siły” działają w projekcie: stabilność API, tempo zmian biznesowych, ograniczenia technologiczne.

Dopiero potem wybierz wzorzec i przełóż go na idiomy języka: w Pythonie może to być moduł z funkcjami‑strategiami lub prosty słownik strategii, w TypeScripcie – interfejs plus klasy lub typ funkcji. Kluczowe pytanie kontrolne: czy po wprowadzeniu wzorca kod stał się bardziej zrozumiały dla innych osób w zespole? Jeśli nie, implementacja wzorca jest prawdopodobnie zbyt „książkowa” albo nieadekwatna do kontekstu.