Skip to Content
Course content

77: Practice Exercise: Building a Repository Pattern for Data Access

Click on the "Edit" button in the top corner of the screen to edit your slide content.

I was looking back at some old code I wrote for a client—a basic inventory system—and I noticed something that made me cringe. My controllers were bloated. Every time I wanted to fetch a product, I had a PDO instance right there in the action method, writing raw SQL and mapping results to an array. It worked, but it was a nightmare to test and even harder to change.

The 'Quick and Dirty' approach

Usually, when we start, we do something like this. I'll show you the snippet I found in that old project:

public function showProduct($id) {
    $stmt = $this->pdo->prepare("SELECT * FROM products WHERE id = ?");
    $stmt->execute([$id]);
    $product = $stmt->fetch();
    
    // Now I'm doing business logic and DB access in one place...
    if (!$product) {
        throw new Exception("Product not found");
    }
    return $product;
}

At first glance, it's fine. It's fast. But here is where I hit a wall: what happens if I want to add a caching layer? Or what if I decide to move these products from MySQL to a JSON API or MongoDB? I'd have to hunt down every single controller method that mentions SELECT * FROM products and rewrite them. That's a recipe for introducing bugs.

Moving the plumbing out of the way

I figured I needed a way to hide the "how" of data retrieval. I don't care how the product gets from the disk to my variable; I just want the product. So, I tried creating a ProductRepository class. I basically just copy-pasted the SQL logic into a method.

class ProductRepository {
    private $pdo;

    public function __construct(PDO $pdo) {
        $this->pdo = $pdo;
    }

    public function findById(int $id): ?Product {
        $stmt = $this->pdo->prepare("SELECT * FROM products WHERE id = ?");
        $stmt->execute([$id]);
        $data = $stmt->fetch();

        return $data ? new Product($data['name'], $data['price']) : null;
    }
}

Now the controller looks much cleaner. It just calls $this->repo->findById($id). I feel better, right? Not quite. I realized I've just moved the problem. My controller is now dependent on the ProductRepository class. If I want to write a unit test for the controller, I still need a real database connection because the repository demands a PDO instance.

The Interface epiphany

This is where I had to stop and rethink. The controller shouldn't depend on a class; it should depend on a contract. If I define an interface, the controller doesn't care if the data comes from MySQL, a CSV file, or a hard-coded array in a test suite.

I'll define what a "Product Repository" is supposed to be able to do, without saying how it does it:

interface ProductRepositoryInterface {
    public function findById(int $id): ?Product;
    public function save(Product $product): void;
}

Now, I can create my SqlProductRepository that implements this interface. But here's the magic: I can also create an InMemoryProductRepository for my tests. No database, no PDO, just a simple array. It's lightning fast and doesn't require any setup.

class InMemoryProductRepository implements ProductRepositoryInterface {
    private array $products = [];

    public function findById(int $id): ?Product {
        return $this->products[$id] ?? null;
    }

    public function save(Product $product): void {
        $this->products[$product->getId()] = $product;
    }
}

Connecting the dots

The final piece of the puzzle is how I use this in the actual app. Instead of saying new SqlProductRepository($pdo) inside my controller, I type-hint the interface in the constructor. This is dependency inversion in action.

class ProductController {
    private ProductRepositoryInterface $repository;

    public function __construct(ProductRepositoryInterface $repository) {
        $this->repository = $repository;
    }

    public function show($id) {
        $product = $this->repository->findById($id);
        // ... logic continues
    }
}

I've effectively decoupled my business logic from my storage logic. If the boss comes to me next week and says, "We're moving to DynamoDB," I don't touch a single line of code in my controllers. I just write a new DynamoProductRepository, swap it in the configuration, and I'm done. That's the power of the Repository pattern—it turns your data source into a plug-in.




📋 Practical Task

Exercise: Implementing a User Management Repository

You are tasked with refactoring a User system. Currently, the user data is being fetched directly via PDO in the service layer. You need to implement a Repository pattern to decouple the data access.

Requirements:

  • Create a UserRepositoryInterface that defines two methods: findByEmail(string $email): ?User and save(User $user): void.
  • Create a MysqlUserRepository class that implements this interface using a provided PDO instance.
  • Create a MockUserRepository class that implements the same interface but uses an internal array to store and retrieve User objects (for testing purposes).
  • Write a small bootstrap script that demonstrates switching between the MysqlUserRepository and the MockUserRepository while passing them into a UserService class that depends on the UserRepositoryInterface.

Note: You may assume a simple User entity class with getId(), getEmail(), and setName() methods already exists.

Rating
0 0

There are no comments for now.

to be the first to leave a comment.