DEV Community

Cover image for Repository Pattern vs Atomic Query Construction (AQC) Design Pattern
Raheel Shan
Raheel Shan

Posted on • Edited on • Originally published at raheelshan.com

Repository Pattern vs Atomic Query Construction (AQC) Design Pattern

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 { /* ... */ }
}
Enter fullscreen mode Exit fullscreen mode

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...
}
Enter fullscreen mode Exit fullscreen mode

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 code. 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
Enter fullscreen mode Exit fullscreen mode

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();
    }
}
Enter fullscreen mode Exit fullscreen mode

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]);
Enter fullscreen mode Exit fullscreen mode

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();
    }
}
Enter fullscreen mode Exit fullscreen mode

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]);
Enter fullscreen mode Exit fullscreen mode

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();
    }
}
Enter fullscreen mode Exit fullscreen mode

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]);
Enter fullscreen mode Exit fullscreen mode

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

Top comments (4)

Collapse
 
nothingimportant34 profile image
No Name

I don't know man, I think this will quickly get very messy. I think clearly named, organized, single purpose methods are better solution here. If you want to prevent duplication, you can use custom query builders (if using doctrine)

Collapse
 
raheelshan profile image
Raheel Shan • Edited

Thanks for the feedback! You’re right. if all the conditions are written in one place, dynamic query building can definitely get messy. That’s exactly why in AQC I recommend breaking the class down into smaller methods so the logic stays organized:

one private method for filters,
one private method for selections,
one private method for sorting/pagination,
and a single public handle() method to tie it all together.

Here’s a simplified example of how I usually structure it:

namespace App\AQC\Product\GetAllProducts;

use App\Models\Product;

class GetProducts
{
    public static function handle($params = [])
    {
        $products = [];

        $query = Product::latest('id');

        self::applyFilters($query , $params);

        self::selectColumns($query , $params);

        self::applySorting($query , $params);

        if(isset($params['paginate'])){
            $products = self::handlePagination($query , $params);
        } else {
            $products = $query->get();
        }

        // transform the results if required

        return $products;
    }

    private static function applyFilters($query , $params)
    {
        // apply requested filters
        if (isset($params['category_id']) && $params['category_id'] > 0) {
            $query->where('category_id', $params['category_id']);
        }

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

        if (isset($params['keyword'])) {
            $query->where('name', 'like', '%' . $params['keyword'] . '%')
                        ->orWhere('sku', 'like', '%' . $params['keyword'] . '%')
                        ->orWhere('description', 'like', '%' . $params['keyword'] . '%');
        }

        if (isset($params['low_stock'])) {
            $query->where('stock', '<=' , 'low_stock_point');
        }

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

        if (isset($params['expired'])) {
            $query->whereDate('expired_date', '<', today())->get();
        }

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

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

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

        return $query;
    }

    private static function selectColumns($query , $params)
    {
        $scenario = isset($params['scenario']) ? $params['scenario'] : 'default';
        // select columns based on requested scenario
        switch ($scenario) {
            case 'minimal':
                $query->select(['id', 'name']);
                break;
            case 'compact':
                $query->select(['id', 'name', 'price', 'image']);
                break;
            case 'export':
                $query->select(['id', 'name', 'price', 'sku', 'cost']);
                break;
            case 'admin':
                $query->select(['id', 'name', 'price', 'sku', 'image', 'stock', 'cost']);
                break;
            default:
                $query->select('*');
        }

        return $query;
    }

    private static function applySorting($query , $params)
    {
        // Apply sorting when requested otherwise do it on it by default
        if(isset($params['sortBy']) && isset($params['type'])){

            $sortBy = $params['sortBy'];
            $type = $params['type'];    
            $query->orderBy($sortBy, $type);
        }

        return $query;        
    }

    private static function handlePagination($query , $params)
    {
        // if paginaton is requested by api check per_page and page otherwise do the default pagination
        if(isset($params['per_page']) && isset($params['page'])){
            return $query->paginate($params['per_page'], ['*'], 'page', $params['page']);
        }else{
            return $query->paginate(Product::PAGINATE);
        }
    } 
}
Enter fullscreen mode Exit fullscreen mode

By breaking things up like this, the code stays clean, you can easily add/remove conditions without touching everything else, and the handle() method always remains readable.

I agree with you that clearly named repository methods or custom query builders are also good solutions. I just find that when projects have lots of possible query combinations, this AQC breakdown keeps things under control without turning into one big mess.

Collapse
 
rzulty profile image
Piotr Grzegorzewski

There is beauty in the simplicity of AQC, but is achieved by putting everything into an undefined params array. Moreover, you then have this set of isset checks that build a query. Also, you assume that to retrieve a product you only need to query one table (in other words that business entity is the same as ORM entity).
From my point of view it looks great until it doesn't ;)
So as long as the params array is described in the DocBlock, and the class is split if a number of conditions becomes unclear, and you only need to query a limited set of tables, this is great.

Collapse
 
raheelshan profile image
Raheel Shan

Yes, the params array should definitely be documented — I agree with you on that. For now, since AQC is still in its initial form, I’ve kept it focused on querying a single table. Later on, we can evolve it further by adding methods for joins and handling related data.