DEV Community

Raheel Shan
Raheel Shan

Posted on • Originally published at raheelshan.com

Write One Query Class Instead of Many: The AQC Design Pattern

 When I introduced the Atomic Query Construction (AQC) design pattern, a few people asked: "Is this something new? Or is it just a renamed version of something that already exists?"

Fair question. And the honest answer is: it's both.

AQC isn't pulling ideas out of thin air. It's a deliberate combination of three well-established software design principles, packaged into a focused, practical approach to managing query logic. Once you see those foundations clearly, you'll understand not just what AQC does — but why it works.

The three pillars are:

  1. Query Object Pattern
  2. Single Responsibility Principle (SRP)
  3. Pipeline / Composition Pattern

Let me walk through each one.

1. Query Object Pattern

The Query Object pattern is a lesser-known but well-documented pattern from Martin Fowler's Patterns of Enterprise Application Architecture. The idea is straightforward: represent a database query as an object. Instead of scattering query logic across controllers, services, and helpers you encapsulate it in a dedicated class.

class ActiveProducts
{
    public function get()
    {
        return Product::query()
          ->where('active',1)
          ->paginate();
    }
}
Enter fullscreen mode Exit fullscreen mode

In AQC, the same idea is implemented. A dedicated class is created for specific type for operation. Fetch products for example. See, the class accepts parameters and returns results. The caller doesn't care how the query is built. It just calls the class and gets what it needs.

<?php

namespace App\AQC\Product;

use App\Models\Product;

class GetProducts
{
    public static function handle($params = [], $columns = ['*'])
    {
        $query = Product::latest('id');

        // other code goes here

        return $query
            ? $query->paginate(Product::PAGINATE)
            : $query->get();
    }
}
Enter fullscreen mode Exit fullscreen mode

This is a query object. It has one job. It accepts inputs. It builds the query internally. It returns results.

Instead of putting query logic inside controllers, repositories, or helpers, the query lives in its own class. Not a method buried in a repository. Not a static helper in some utility class. The query is the object. It has a name, a namespace, and a clear contract.

What AQC adds on top of the raw Query Object pattern is the use of dynamic parameters and scenarios to handle multiple use cases within a single class. Instead of creating a separate class for **GetActiveProducts** and **GetProductsByCategory**, you have one **GetProducts** class that handles both and every combination through parameters.

You call it from a controller:

// Admin panel
$columns = ['*'];
$products = GetProducts::handle([], $columns);

// Frontend filtered by category
$params = ['category_id' => 5, 'active' => true];
$products = GetProducts::handle($params , $columns);

// Mobile API - out of stock report
$params = ['out_of_stock' => 5];
$products = GetProducts::handle($params , $columns);
Enter fullscreen mode Exit fullscreen mode

Same class. Same method. Different parameters, different results. That's the Query Object pattern doing its job.

Again, this is framework agnostic. The pattern doesn't care whether you're using Eloquent, Doctrine, SQLAlchemy, or raw PDO. The concept is the same: encapsulate the query in an object.

2. Single Responsibility Principle (SRP)

SRP is one of the five SOLID principles. Robert C. Martin's definition is simple: a class should have one, and only one, reason to change.

Most developers have heard this. But in practice, it gets violated constantly — especially with query logic. A service class ends up with **getActiveProducts()**, **getProductsByCategory()**, **getExpiredProducts()**, and fifteen other methods. The class has dozens of reasons to change. That's the opposite of SRP.

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

AQC enforces SRP at a structural level. Every query gets its own class. One class, one job.

app/
└── AQC/
    └── Product/
        └── CreateProduct.php
        └── UpdateProduct.php
        └── GetAllProducts.php
        └── GetProduct.php           
        └── DeleteProduct.php
Enter fullscreen mode Exit fullscreen mode

GetProducts only knows how to fetch a list of products. **StoreProduct** only knows how to persist a new product. They don't bleed into each other. When a requirement changes — say, the listing query needs a new filter — you go to exactly one class and change exactly one thing.

  

<?php

namespace App\AQC\Product;

use App\Models\Product;

class GetProducts
{
    public static function handle($params = [], $columns)
    {
        $query = Product::latest('id');

        // other code goes here
        // always return products (more than one)

        return $query->get();
    }
}
Enter fullscreen mode Exit fullscreen mode

That's SRP in practice.

This might sound trivial, but it's the reason AQC scales. As your application grows, you add new classes, you don't bloat existing ones. The structure stays flat, discoverable, and predictable regardless of the size of your codebase.

And this isn't Laravel-specific. Whether you're working in Node.js, Python, or .Net, the principle holds. One class, one query, one reason to change.

3. Pipeline / Composition Pattern

This is the one people notice last, but it's what makes AQC genuinely powerful.

Look at the **GetProducts** class above. The **handle()** method doesn't do everything in one monolithic block. Internally, it's building the query in stages:

  • Apply filters
  • Select columns based on scenario
  • Apply sorting
  • Handle pagination

Each step is a distinct concern. In a more explicit implementation, these stages become private methods that each receive the query, add their piece, and pass it along.

<?php

namespace App\AQC\Product;

use App\Models\Product;

class GetProducts
{
    public static function handle($params = [], $columns)
    {
        $query = Product::latest('id');

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

        return isset($params['paginate'])
            ? self::handlePagination($query, $params)
            : $query->get();    
    }

    private static function applyFilters($query, $params) { /* ... */ }
    private static function selectColumns($query, $params) { /* ... */ }
    private static function applySorting($query, $params) { /* ... */ }
    private static function handlePagination($query, $params) { /* ... */ }
}
Enter fullscreen mode Exit fullscreen mode

This is a pipeline. The query object passes through a sequence of stages. Each stage does one thing. Each stage is independently readable, testable, and modifiable.

The Composition pattern takes this further — you build complex behavior by composing smaller, focused pieces rather than writing one massive function. In AQC, you're composing query conditions. In more advanced implementations, you could compose multiple AQC classes together when building complex reporting queries.

A class that does ten things in one method is a class that will break in ten different ways. Split the stages. Compose the result.

This is also why AQC classes are naturally easy to unit test. You're not testing a sprawling 200-line method. You're testing a pipeline where each stage has a clear input and output. You can test the filter stage independently. You can test the pagination stage independently. That's composition paying dividends.

The Underlying Power of Composition Pattern

One of the most powerful aspects of composition in AQC is how filters combine dynamically based on the parameters passed to the query. Each filter is applied conditionally, which means the query does not follow a fixed path. Instead, every parameter acts like a switch that may or may not add a constraint to the query. When multiple parameters are provided, the resulting query becomes a combination of all applicable filters. In practical terms, this creates a flexible query system where a single query class can support many possible filter combinations without needing dozens of specialized query methods. The behavior is similar to a Cartesian combination of conditions: any subset of parameters can be used together, and the query naturally adapts by composing only the relevant constraints. This keeps the code simple while still allowing a wide range of filtering scenarios.

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

In a typical Repository or naive Query Object setup, every meaningful filter combination often turns into a separate method or class. Instead of one flexible query that reacts to parameters, developers end up writing many rigid query variants to cover different filtering needs. 

Consider the above applyFilters(), it would have been turned into the classes or repository methods listed below.

  • getProductsByCategory()

  • getProductsByBrand()

  • getProductsByCategoryAndBrand()

  • searchProductsByKeyword()

  • searchProductsByKeywordAndCategory()

  • searchProductsByKeywordAndBrand()

  • searchProductsByKeywordCategoryAndBrand()

  • getLowStockProducts()

  • getOutOfStockProducts()

  • getExpiredProducts()

  • getFeaturedProducts()

  • getTrendingProducts()

  • getProductsByCategoryAndLowStock()

  • getProductsByBrandAndLowStock()

  • getProductsByCategoryBrandAndLowStock()

  • getFeaturedProductsByCategory()

  • getTrendingProductsByBrand()

  • getExpiredProductsByCategory()

  • getLowStockProductsByBrand()

  • getProductsRelatedToProduct()

  • getFeaturedProductsByCategoryAndBrand()

  • searchFeaturedProductsByKeyword()

  • searchTrendingProductsByKeyword()

Why These Three, Together

Individually, none of these patterns is a complete solution.

SRP tells you to split things apart — but doesn't tell you how to organize query logic specifically.

The Query Object pattern gives you the structural template — but doesn't enforce clean internal organization.

The Pipeline / Composition pattern gives you internal clarity — but doesn't tell you how to structure the class's public contract.

Together, they give you AQC:

  • SRP defines the boundary: one class, one query.
  • The Query Object pattern defines the structure: a named class that encapsulates query construction.
  • The Pipeline pattern defines the internals: stages that compose to produce the final result.

The result is a pattern that's modular, reusable, testable, and predictable. Every query has a home. Every home has a consistent contract. Every contract is built in composable stages.


A Note on Class Naming and Static Methods

One thing that may look slightly unconventional in this pattern is the class naming. In many object-oriented conventions, class names typically represent entities such as **Product**, **Order**, or **User**. In AQC, however, the class name often represents an action or intent, such as **GetProducts**, GetOrders, or **GetUsers**. While this may feel unusual at first, it is not entirely new. Many established patterns, including Query Objects, Actions, and Command patterns, follow the same idea where a class represents a specific operation rather than a domain entity.

Another stylistic choice in AQC is the use of static methods. Some developers prefer static entry points for simplicity, while others prefer instance methods for better dependency handling or testability. The pattern itself does not require one approach over the other. Teams are free to implement AQC using static or non-static methods depending on their coding standards and architectural preferences.  

The Framework Agonist Pattern

I use Laravel, so the examples above use Eloquent. But AQC is not a Laravel pattern. It's a software design pattern that happens to be demonstrated with Laravel.

You can apply the same thinking in Node.js, Python, .Net Framework or any language you use.

The three underlying principles — SRP, Query Object, Pipeline/Composition are language-agnostic. AQC is simply an opinionated application of those three principles to the specific problem of managing query logic across a growing application.


Final Thoughts

When people see AQC for the first time, it looks deceptively simple. Just a class with a handle() method. But the simplicity is intentional — it's what you get when three well-understood patterns come together cleanly.

SRP says: give every query a dedicated home. Query Object says: make the query a first-class object with a clear contract. Pipeline/Composition says: build that query in stages, each one doing exactly one thing.

That's AQC. Not magic. Not reinvention. Just three good ideas in the right combination.

If you've been using these principles in your work already, AQC will feel like familiar ground with a concrete name and structure. If you haven't, this is a good place to start.

Hope this article gives you new way of thinking and writing queries. Let me know what you think of it.

Top comments (0)