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

0
130
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. Single