Singleton i Builder to dwa bardzo różne wzorce kreacyjne. Singleton jest jednym z najkrótszych wzorców GoF i jednym z najczęściej nadużywanych. Builder rozwiązuje zupełnie inny problem – budowanie złożonych obiektów krok po kroku, gdy konstruktor z dziesiątkami parametrów staje się nieczytelny. Pokazuję oba z przykładami i bez owijania w bawełnę mówię kiedy Singleton to zły pomysł.
Singleton
Singleton gwarantuje że klasa ma dokładnie jedną instancję i zapewnia globalny punkt dostępu do niej.
<?php
declare(strict_types=1);
class Configuration
{
private static ?self $instance = null;
private array $data = [];
// Konstruktor prywatny - nikt nie może zrobić new Configuration()
private function __construct()
{
// Ładujemy konfigurację raz przy pierwszym użyciu
$this->data = parse_ini_file('/etc/app/config.ini', true) ?: [];
}
// Blokujemy klonowanie i deserializację
private function __clone() {}
public function __wakeup(): never
{
throw new \RuntimeException('Cannot deserialize singleton');
}
public static function getInstance(): static
{
if (static::$instance === null) {
static::$instance = new static();
}
return static::$instance;
}
public function get(string $key, mixed $default = null): mixed
{
return $this->data[$key] ?? $default;
}
public function set(string $key, mixed $value): void
{
$this->data[$key] = $value;
}
}
// Użycie - zawsze ta sama instancja
$config = Configuration::getInstance();
$config->set('debug', true);
// W innym miejscu kodu - ta sama instancja, to samo 'debug'
$sameConfig = Configuration::getInstance();
var_dump($sameConfig->get('debug')); // bool(true)
Singleton – dlaczego jest problematyczny
Singleton to jeden z najbardziej kontrowersyjnych wzorców GoF. Problemy są realne:
<?php
// Problem 1: Singleton jest trudny do testowania
// Globalna instancja "wcieka" między testami
class UserService
{
public function getUser(int $id): array
{
// Singleton ukrywa zależność - nie widać jej w konstruktorze
$db = Database::getInstance();
return $db->query("SELECT * FROM users WHERE id = {$id}");
}
}
// W teście musisz "wyczyścić" singleton między testami
// albo mockować statyczne wywołania - obie opcje są brzydkie
// Problem 2: Singleton = globalny stan = ukryte zależności
// Nie widać z zewnątrz że UserService zależy od Database
// Problem 3: Naruszenie SRP - klasa zarządza swoją instancją ORAZ ma logikę biznesową
// LEPIEJ - wstrzyknij zależność zamiast używać Singletona
class UserService
{
public function __construct(
private DatabaseInterface $db // widoczna zależność, łatwa do mockowania
) {}
public function getUser(int $id): array
{
return $this->db->query("SELECT * FROM users WHERE id = ?", [$id]);
}
}
Kiedy Singleton ma sens: Logger który musi pisać do jednego pliku, połączenie z bazą danych zarządzane przez connection pool, rejestr konfiguracji tylko do odczytu wczytywany raz przy starcie. Klucz: Singleton dla zasobów które z natury powinny być jedne, nie dla wygody dostępu do globalnego stanu.
Builder
Builder rozwiązuje problem konstruktorów z wieloma parametrami – tzw. „telescoping constructor”. Gdy obiekt ma opcjonalne atrybuty których kombinacje są różne, Builder pozwala budować go krok po kroku.
<?php
declare(strict_types=1);
// Problem - konstruktor z wieloma parametrami
class QueryBuilder_BAD
{
public function __construct(
string $table,
array $conditions = [],
array $columns = ['*'],
?int $limit = null,
?int $offset = null,
?string $orderBy = null,
string $orderDirection = 'ASC',
array $joins = [],
bool $distinct = false
) {
// Co to wszystko znaczy przy wywołaniu?
}
}
// Wywołanie jest nieczytelne
$query = new QueryBuilder_BAD('users', [['id', '>', 5]], ['id', 'email'], 10, 0, 'created_at', 'DESC', [], false);
// Kto pamięta co jest na której pozycji?
<?php
declare(strict_types=1);
// Produkt - obiekt który budujemy
class Query
{
public function __construct(
public readonly string $table,
public readonly array $columns,
public readonly array $conditions,
public readonly array $joins,
public readonly ?int $limit,
public readonly ?int $offset,
public readonly ?string $orderBy,
public readonly string $orderDirection,
public readonly bool $distinct
) {}
public function toSql(): string
{
$distinct = $this->distinct ? 'DISTINCT ' : '';
$columns = implode(', ', $this->columns);
$sql = "SELECT {$distinct}{$columns} FROM {$this->table}";
foreach ($this->joins as $join) {
$sql .= " {$join['type']} JOIN {$join['table']} ON {$join['condition']}";
}
if (!empty($this->conditions)) {
$wheres = array_map(fn($c) => implode(' ', $c), $this->conditions);
$sql .= ' WHERE ' . implode(' AND ', $wheres);
}
if ($this->orderBy) {
$sql .= " ORDER BY {$this->orderBy} {$this->orderDirection}";
}
if ($this->limit !== null) {
$sql .= " LIMIT {$this->limit}";
}
if ($this->offset !== null) {
$sql .= " OFFSET {$this->offset}";
}
return $sql;
}
}
// Builder - fluent interface do budowania Query krok po kroku
class QueryBuilder
{
private string $table = '';
private array $columns = ['*'];
private array $conditions = [];
private array $joins = [];
private ?int $limit = null;
private ?int $offset = null;
private ?string $orderBy = null;
private string $orderDirection = 'ASC';
private bool $distinct = false;
public function from(string $table): static
{
$this->table = $table;
return $this;
}
public function select(string ...$columns): static
{
$this->columns = $columns;
return $this;
}
public function distinct(): static
{
$this->distinct = true;
return $this;
}
public function where(string $column, string $operator, mixed $value): static
{
$this->conditions[] = [$column, $operator, $value];
return $this;
}
public function join(string $table, string $condition, string $type = 'INNER'): static
{
$this->joins[] = ['table' => $table, 'condition' => $condition, 'type' => $type];
return $this;
}
public function orderBy(string $column, string $direction = 'ASC'): static
{
$this->orderBy = $column;
$this->orderDirection = strtoupper($direction);
return $this;
}
public function limit(int $limit): static
{
$this->limit = $limit;
return $this;
}
public function offset(int $offset): static
{
$this->offset = $offset;
return $this;
}
public function build(): Query
{
if (empty($this->table)) {
throw new \LogicException('Table name is required');
}
return new Query(
table: $this->table,
columns: $this->columns,
conditions: $this->conditions,
joins: $this->joins,
limit: $this->limit,
offset: $this->offset,
orderBy: $this->orderBy,
orderDirection: $this->orderDirection,
distinct: $this->distinct
);
}
}
// Użycie - czytelne, każdy krok ma nazwę
$query = (new QueryBuilder())
->from('users')
->select('id', 'email', 'name')
->join('orders', 'orders.user_id = users.id', 'LEFT')
->where('users.status', '=', 'active')
->where('users.created_at', '>', '2022-01-01')
->orderBy('users.created_at', 'DESC')
->limit(20)
->offset(0)
->build();
echo $query->toSql();
// SELECT id, email, name FROM users
// LEFT JOIN orders ON orders.user_id = users.id
// WHERE users.status = active AND users.created_at > 2022-01-01
// ORDER BY users.created_at DESC LIMIT 20 OFFSET 0
Builder z Director – gdy kolejność kroków ma znaczenie
<?php
declare(strict_types=1);
// Director - enkapsuluje często używane konfiguracje buildera
class QueryDirector
{
public function __construct(
private QueryBuilder $builder
) {}
public function buildUserReport(int $page, int $perPage): Query
{
return $this->builder
->from('users')
->select('id', 'email', 'name', 'created_at')
->where('status', '=', 'active')
->orderBy('created_at', 'DESC')
->limit($perPage)
->offset(($page - 1) * $perPage)
->build();
}
public function buildAdminList(): Query
{
return $this->builder
->from('users')
->select('id', 'email', 'role', 'last_login')
->where('role', '=', 'admin')
->orderBy('last_login', 'DESC')
->build();
}
}
$director = new QueryDirector(new QueryBuilder());
$reportQuery = $director->buildUserReport(page: 2, perPage: 50);
$adminQuery = $director->buildAdminList();
Kiedy Builder ma sens?
Builder jest uzasadniony gdy: konstruktor ma więcej niż 4-5 parametrów i część z nich jest opcjonalna, obiekt musi być walidowany jako całość po złożeniu wszystkich części, ten sam proces budowania ma tworzyć różne reprezentacje (np. Query dla MySQL i PostgreSQL). Jeśli obiekt ma 2-3 proste parametry – Builder to over-engineering.
Podsumowanie
Singleton jest prosty w implementacji ale łatwy do nadużycia – globalny stan utrudnia testowanie i ukrywa zależności. Używaj go świadomie i rozważnie. Builder rozwiązuje realny problem czytelności kodu przy złożonych obiektach – fluent interface sprawia że kod budowania obiektu czyta się niemal jak zdania w języku naturalnym.
