Repository Pattern vs Atomic Query Construction Design Pattern



This content originally appeared on DEV Community and was authored by Raheel Shan

Recently, I have written an article, Atomic Query Construction Design Pattern, and explained how it works. Today, I am going to tell how it differs from repository pattern.

In this article I will use Product as resource for many use cases.

Repository Pattern – The Basics

In the Repository Pattern, we usually have a handful of CRUD methods:

class ProductRepository 
{
    public function create(array $data) { /* ... */ }
    public function update(Product $product, array $data) { /* ... */ }
    public function delete(Product $product) { /* ... */ }
    public function find(int $id): ?Product { /* ... */ }
    public function all(): Collection { /* ... */ }
}

That’s clean and straightforward. But in real-world projects, things rarely end there. While these methods cover plenty of scenarios, developers often go a step further—expanding repositories with specialized methods tailored to specific business needs.

Where Repository Pattern Starts Breaking

Sooner or later, you need to fetch “active products”, or “products by category”, or “products by store”. What usually happens? People start stacking method after method in the same class:

class ProductRepository 
{
    // base methods
    public function create(array $data) { /* ... */ }
    public function update(Product $product, array $data) { /* ... */ }
    public function delete(Product $product) { /* ... */ }
    public function find(int $id): ?Product { /* ... */ }
    public function all(): Collection { /* ... */ }

    // new fetch methods
    public function getActiveProducts() { 
        return Product::where('active', 1)->get();
    }

    public function getProductsByCategory(int $categoryId) { 
        return Product::where('category_id', $categoryId)->get();
    }

    public function getActiveProductsByCategory(int $categoryId) {
        return Product::where('active', 1)
                      ->where('category_id', $categoryId)
                      ->get();
    }

    public function getProductsByStore(int $storeId) { 
        return Product::where('store_id', $storeId)->get();
    }

    public function getActiveProductsByStore(int $storeId) { 
        return Product::where('active', 1)
                      ->where('store_id', $storeId)
                      ->get();
    }

    // and so on...
}

This leads to repositories with dozens of methods, violating the original intent of the pattern.

The Repository Dilemma

If developers want to stick to original 5 methods of repository pattern, they push logic into services, helpers, and other classes. This scatters logic across multiple classes and obscures a single source of truth. Repeated conditions like where('active', 1) creep in everywhere, making the code harder to navigate and maintain.  

The Role of Parameters in Repositories

Another important aspect is how repositories often rely on parameters to decide what data to fetch. In theory, this keeps things flexible. But in practice, convenience usually wins—developers create dedicated methods named after specific queries (like getActiveProductsByStore). While easier to recall, this approach quickly leads to duplication and unnecessary bloat. So parameters act as decision maker for creating new methods.  

How Atomic Query Construction (AQC) Solves The Problem

AQC tackles these challenges by breaking responsibilities into focused, dedicated classes. Each AQC class accepts parameters, dynamically constructs the query, and returns the results. Instead of scattering queries across multiple repository methods or service classes, AQC centralizes them in a single, structured place.

The mindset also shifts: rather than creating dozens of hard-coded methods, you define all possible conditions within one class. Queries are then built dynamically based on the parameters provided—keeping things flexible, consistent, and far easier to maintain.

So instead of single class with 5 methods, we now have 5 classes each with a single handle() method.

 

└── AQC/
    └── Product/
        └── CreateProduct.php
        └── UpdateProduct.php
        └── GetProducts.php
        └── GetProduct.php           
        └── DeleteProduct.php

Take the GetProducts class, for example:

  

namespace App\AQC\Product;

use App\Models\Product;

class GetProducts
{
    public static function handle($params = [], $paginate = true, $scenario = 'default')
    {
        $productObj = Product::latest('id');

        //apply only when requested
        if (isset($params['active'])) {
            $productObj->where('active', $params['active']);
        }

        // apply only when requested
        if (isset($params['store_id'])) {
              $productObj->where('store_id', $params['store_id']);
        }

        // add more conditions for different use cases

        switch ($scenario) {
            case 'minimal':
                $productObj->select(['id', 'name']);
                break;
            case 'compact':
                $productObj->select(['id', 'name', 'price', 'image']);
                break;
            case 'admin':
                $productObj->select(['id', 'name', 'price', 'sku', 'image', 'stock', 'cost']);
                break;
            default:
                $productObj->select('*');
        }

        return $paginate
            ? $productObj->paginate(Product::PAGINATE)
            : $productObj->get();
    }
}

Each condition is optional and only applied when the corresponding parameter exists. The result is a single class that can handle multiple scenarios.

Now, instead of writing a new method every time, you just call:

// Get active products
$products = GetProducts::handle(['active' => true]);

// Get category products
$products = GetProducts::handle(['category_id' => 5]);

// Get store products
$products = GetProducts::handle(['store_id' => 12]);

The same single method handles all cases.

Flexibility: The Killer Strength of AQC

This is where AQC truly shines. Instead of adding new methods for every scenario, AQC relies on parameters—each one unlocking exponential query flexibility.

For example, start with just one condition active. You can fetch active products. Add store_id, and suddenly you can fetch products by store or active products by store. Introduce category_id, and now you can mix and match: products by category, active products by category, active products of category of specific store and more. Add brand_id, and the combinations multiply even further—all without writing a single extra method.

In a traditional repository, each of these scenarios would typically demand its own dedicated method. With AQC, it’s just another parameter.

namespace App\AQC\Product;

use App\Models\Product;

class GetProducts
{
    public static function handle($params = [], $paginate = true, $scenario = 'default')
    {
        $productObj = Product::latest('id');

        // apply only when requested
        if (isset($params['active'])) {
            $productObj->where('active', $params['active']);
        }

        // apply only when requested
        if (isset($params['store_id'])) {
            $productObj->where('store_id', $params['store_id']);
        }

        // apply only when requested
        if (isset($params['category_id'])) {
            $productObj->where('category_id', $params['category_id']);
        }

        // apply only when requested
        if (isset($params['brand_id'])) {
            $productObj->where('brand_id', $params['brand_id']);
        }

        // add more conditions for different use cases

        switch ($scenario) {
            case 'minimal':
                $productObj->select(['id', 'name']);
                break;
            case 'compact':
                $productObj->select(['id', 'name', 'price', 'image']);
                break;
            case 'admin':
                $productObj->select(['id', 'name', 'price', 'sku', 'image', 'stock', 'cost']);
                break;
            default:
                $productObj->select('*');
        }

        return $paginate
            ? $productObj->paginate(Product::PAGINATE)
            : $productObj->get();
    }
}

Here’s how you call them.

// Get active products
$products = GetProducts::handle(['active' => true]);

// Get category products
$products = GetProducts::handle(['category_id' => 5]);

// Get store products
$products = GetProducts::handle(['store_id' => 12]);

// Get category products but active
$products = GetProducts::handle(['category_id' => 5, 'active' => true]);

// Get store products but active
$products = GetProducts::handle(['store_id' => 12, 'active' => true]);

// Get Store products but of only specific category and must be active
$products = GetProducts::handle(['store_id' => 12, 'category_id' => 5, 'active' => true]);

Here’s the multiplication effect:

This means each new condition multiplies flexibility without multiplying methods. In a repository, you’d have to write a new method for each of those combinations, a nightmare.  

But Can You Do The Same in Repository?

Yes, Of course the same pattern can be implemented in repository pattern. Let see how.

<?php

namespace App\Repositories;

use App\Models\Product;
use Illuminate\Database\Eloquent\ModelNotFoundException;

class ProductRepository
{
    /**
     * Fetch products with dynamic filters and scenarios.
     */
    public function findAll(array $params = [], bool $paginate = true, string $scenario = 'default')
    {
        $query = Product::latest('id');

        // Apply filters only when present
        if (isset($params['active'])) {
            $query->where('active', $params['active']);
        }

        if (isset($params['store_id'])) {
            $query->where('store_id', $params['store_id']);
        }

        if (isset($params['category_id'])) {
            $query->where('category_id', $params['category_id']);
        }

        if (isset($params['brand_id'])) {
            $query->where('brand_id', $params['brand_id']);
        }

        // Add more conditions as needed...

        // Handle scenarios (projection logic)
        switch ($scenario) {
            case 'minimal':
                $query->select(['id', 'name']);
                break;

            case 'compact':
                $query->select(['id', 'name', 'price', 'image']);
                break;

            case 'admin':
                $query->select(['id', 'name', 'price', 'sku', 'image', 'stock', 'cost']);
                break;

            default:
                $query->select('*');
        }

        return $paginate
            ? $query->paginate(Product::PAGINATE)
            : $query->get();
    }

    /**
     * Find a single product by ID.
     */
    public function find(int $id): Product
    {
        return Product::findOrFail($id);
    }

    /**
     * Create a new product.
     */
    public function create(array $data): Product
    {
        return Product::create($data);
    }

    /**
     * Update an existing product.
     */
    public function update(int $id, array $data): Product
    {
        $product = $this->find($id);
        $product->update($data);

        return $product;
    }

    /**
     * Delete a product by ID.
     */
    public function delete(int $id): bool
    {
        $product = $this->find($id);
        return $product->delete();
    }
}

And here’s how we use it.

$productRepo = new \App\Repositories\ProductRepository();

// Get active products
$products = $productRepo->findAll(['active' => true]);

// Get category products
$products = $productRepo->findAll(['category_id' => 5]);

// Get store products
$products = $productRepo->findAll(['store_id' => 12]);

// Get category products but active
$products = $productRepo->findAll(['category_id' => 5, 'active' => true]);

// Get store products but active
$products = $productRepo->findAll(['store_id' => 12, 'active' => true]);

// Get Store products but of only specific category and must be active
$products = $productRepo->findAll(['store_id' => 12, 'category_id' => 5, 'active' => true]);

Although we have done this but defining this in repository class method makes the class quite large and we may have to scroll sometimes. Similiar to the findAll() other methods can have the same where conditions making the class go large.

Do We Have To Define All Where Conditions?

A question may rise here. should we need to define all columns into optional where conditions in advance?

The simple answer is No. We only write conditions that are actually needed somewhere in our system. If we don’t need discounted products, we don’t write that condition.

 But when we do add one, we add it once and immediately unlock all combinations.

Beyond GetProducts: Other Operations

AQC isn’t limited to fetch queries. Consider other operations:

  • DeleteProduct: Delete by id (single record) or by category_id (multiple records).
  • UpdateProduct: Apply updates conditionally based on role, context, or parameters.
  • InsertProduct: Even inserts can vary by parameters — e.g., saving different sets of data depending on user roles.

In each case, optional conditions driven by parameters make classes both flexible and maintainable.

Repository vs AQC – A Balanced View

It’s worth noting that some of AQC’s strengths can be mirrored inside a well-designed repository. You could, in theory, build flexible queries with parameters inside repository methods. But in practice:

  • Repositories often grow too large (God-classes).
  • Or they fragment into multiple services/helpers, scattering logic.

AQC avoids both extremes by extracting logic into small, dedicated, parameter-driven classes grouped under a common namespace. This makes the codebase cleaner, more readable, and easier to maintain.

AQC Summery

  1. No Repetition – You write each condition only once, not scattered across multiple methods.
  2. Scales with Complexity – Adding one condition unlocks exponential combinations.
  3. Single Source of Truth – All product fetching logic stays in one class.
  4. Cleaner API – Consumers only call handle(), passing parameters.

Repository vs AQC: Quick Comparison

Final Thoughts

At the end of the day, I don’t see AQC as a replacement for the repository pattern — I see it as an evolution. Repository was great for CRUD and simple lookups, but it starts to struggle as real-world scenarios pile up. AQC gives us that missing flexibility without exploding our classes into a mess of methods. One method, multiple conditions, endless combinations. That’s the kind of control and clarity I prefer in my projects.

I hope this approach may help develop better code. I am open for suggestions and improvements.

If you found this post helpful, consider supporting my work — it means a lot.

Support my work


This content originally appeared on DEV Community and was authored by Raheel Shan