iwantcoding.com
🔥 Daily 👥 Rooms 🏆 Top Log in Sign up

Repository

The Repository pattern hides the data store behind a collection-like interface. Callers ask "give me the user with this id" instead of "SELECT * FROM users WHERE id = ?". The win: business code is testable with an in-memory repo, the storage layer can swap (Postgres -> Mongo -> Elastic) without touching the rest, and queries get named in one place.

Repository for a domain entity + tests

EXAMPLE
<?php
// ============================================================
// Domain entity — owns business rules, knows NOTHING about storage
// ============================================================
final class Order {
    public function __construct(
        public string $id,
        public string $customer,
        public int $totalCents,
        public string $status = 'new',
        public ?DateTimeImmutable $paidAt = null,
    ) {}

    public function pay(DateTimeImmutable $at): void {
        if ($this->status !== 'new') throw new DomainException('already past new');
        $this->status = 'paid';
        $this->paidAt = $at;
    }
}

// ============================================================
// Repository interface — the collection-like contract
// ============================================================
interface OrderRepository {
    public function findById(string $id): ?Order;
    public function findOpenForCustomer(string $customerId): array;     // Order[]
    public function save(Order $order): void;
}

// ============================================================
// Production implementation — talks to PDO
// ============================================================
final class PdoOrderRepository implements OrderRepository {
    public function __construct(private PDO $pdo) {}

    public function findById(string $id): ?Order {
        $st = $this->pdo->prepare('SELECT * FROM orders WHERE id = :id');
        $st->execute(['id' => $id]);
        $row = $st->fetch(PDO::FETCH_ASSOC);
        return $row ? $this->hydrate($row) : null;
    }

    public function findOpenForCustomer(string $customerId): array {
        $st = $this->pdo->prepare(
            'SELECT * FROM orders WHERE customer_id = :c AND status IN (\'new\', \'paid\') ORDER BY created_at DESC'
        );
        $st->execute(['c' => $customerId]);
        return array_map([$this, 'hydrate'], $st->fetchAll(PDO::FETCH_ASSOC));
    }

    public function save(Order $order): void {
        $this->pdo->prepare(
            'INSERT INTO orders (id, customer_id, total_cents, status, paid_at)
             VALUES (:id, :c, :t, :s, :p)
             ON DUPLICATE KEY UPDATE status = VALUES(status), paid_at = VALUES(paid_at)'
        )->execute([
            'id' => $order->id, 'c' => $order->customer,
            't' => $order->totalCents, 's' => $order->status,
            'p' => $order->paidAt?->format('Y-m-d H:i:s'),
        ]);
    }

    private function hydrate(array $row): Order {
        return new Order(
            id: $row['id'],
            customer: $row['customer_id'],
            totalCents: (int) $row['total_cents'],
            status: $row['status'],
            paidAt: $row['paid_at'] ? new DateTimeImmutable($row['paid_at']) : null,
        );
    }
}

// ============================================================
// In-memory implementation — used in tests
// ============================================================
final class InMemoryOrderRepository implements OrderRepository {
    /** @var array<string, Order> */
    private array $store = [];

    public function findById(string $id): ?Order { return $this->store[$id] ?? null; }
    public function findOpenForCustomer(string $customerId): array {
        return array_values(array_filter($this->store, fn(Order $o) =>
            $o->customer === $customerId && in_array($o->status, ['new','paid'])));
    }
    public function save(Order $order): void { $this->store[$order->id] = $order; }
}

// ============================================================
// Service layer — uses the repository through the interface only
// ============================================================
final class PayOrder {
    public function __construct(private OrderRepository $repo, private Clock $clock) {}

    public function __invoke(string $orderId): Order {
        $o = $this->repo->findById($orderId)
             ?? throw new RuntimeException('order not found');
        $o->pay($this->clock->now());
        $this->repo->save($o);
        return $o;
    }
}

// ============================================================
// Test — no DB required, runs in 1ms
// ============================================================
$repo = new InMemoryOrderRepository();
$repo->save(new Order('o1', 'alice', 4995));
$svc  = new PayOrder($repo, new FixedClock(new DateTimeImmutable('2026-06-18 09:00:00')));
$res  = $svc('o1');
assert($res->status === 'paid');

interface Clock { public function now(): DateTimeImmutable; }
final class FixedClock implements Clock {
    public function __construct(private DateTimeImmutable $now) {}
    public function now(): DateTimeImmutable { return $this->now; }
}

Why it matters

Keep repository methods as named, intention-revealing queries: `findOpenForCustomer`, not `findBy(["status" => ["new","paid"]])`. Generic "find by" methods leak SQL-ish thinking into your domain code and make every consumer responsible for understanding the storage layer; named methods read as business sentences and constrain the data layer to one place.

Tip: Tweak the snippet with Try it Yourself », then sit the quiz at the bottom of the page.

Example

Example
// Hides the data source behind a clean domain API.
class UserRepo {
    constructor(db) { this.db = db; }
    async byEmail(email) { return await this.db('users').where({ email }).first(); }
    async create(u)      { return await this.db('users').insert(u).returning('*'); }
}
Try it Yourself »

Discussion

Loading…