DEV Community

Cover image for The Repository Pattern and AQC — Part 1 of 3: Cleaning Up the Internals
Raheel Shan
Raheel Shan

Posted on

The Repository Pattern and AQC — Part 1 of 3: Cleaning Up the Internals

The Series

This is the first article in a three-part series on migrating from a traditional Repository Pattern implementation toward Atomic Query Construction. Each article represents a stage in that migration:

  • Part 1 (this article): AQC is adopted internally. The repository interface stays the same. Each method privately builds its own $params and delegates to AQC. Callers notice nothing.
  • Part 2: Parameter definition moves out of the repository and up to the service layer. The repository shrinks to a pure wrapper and its redundancy becomes visible.
  • Part 3: Two paths forward — drop the repository entirely and call AQC directly, or keep the repository and discipline it with AQC practices without using AQC classes at all.

If you are working with an existing codebase and a team that depends on a familiar repository interface, Part 1 is your entry point. It is the lowest-risk way to introduce AQC, and it delivers immediate benefits even before you take the pattern further.


The Problem This Stage Solves

A traditional Repository Pattern implementation for a product domain might look like this:

interface ProductRepositoryInterface
{
    public function getAllProducts();
    public function getProductById($productId);
    public function getActiveProducts();
    public function createProduct(array $productData);
    public function updateProduct($productId, array $productData);
    public function deleteProduct($productId);
    public function getProductsByCategory($categoryId);
    public function updateStock($productId, $quantity);
}
Enter fullscreen mode Exit fullscreen mode

Eight methods. Each has its own implementation. And inside those implementations, query logic is written directly — Eloquent calls, where clauses, orderBy, eager loading — repeated and scattered across methods. When getActiveProducts and getProductsByCategory both need to eager-load category and inventory, that eager-load is written twice. When the ordering changes, two methods need updating. When a new condition is added to "active" products, every method that deals with active products needs to be found and updated.

This is the internal problem. The interface bloat — too many methods — is a separate concern, and we will address it in Part 2. The problem this article solves is the query duplication and scattered logic inside the existing methods.

AQC eliminates this without touching the repository's public interface at all.


The Approach

Each repository method remains publicly named and individually responsible for its domain operation. But instead of writing Eloquent query logic directly, each method privately constructs a $params array that describes what it needs, and delegates the actual query construction and execution to a dedicated AQC class.

The repository method owns two things:

  1. Deciding what parameters apply to this specific operation
  2. Calling the AQC class with those parameters

The AQC class owns one thing:

  1. Building and executing the query from whatever parameters it receives

The caller owns nothing new. The interface is unchanged.


Step 1 — The AQC Classes

We create one AQC class per CRUD operation. Each class contains all possible conditions for that operation. Repository methods activate the conditions they need by including the relevant keys in $params.

GetProducts:

namespace App\AQC\Product;

class GetProducts
{
    public function handle(array $params): Collection|LengthAwarePaginator
    {
        $query = Product::query();

        if (!empty($params['status'])) {
            $query->where('status', $params['status']);
        }

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

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

        if (!empty($params['search'])) {
            $query->where(function ($q) use ($params) {
                $q->where('name', 'like', '%' . $params['search'] . '%')
                  ->orWhere('sku', 'like', '%' . $params['search'] . '%');
            });
        }

        if (!empty($params['with'])) {
            $query->with($params['with']);
        }

        if (!empty($params['order_by'])) {
            $query->orderBy($params['order_by'], $params['order_dir'] ?? 'desc');
        } else {
            $query->orderBy('created_at', 'desc');
        }

        if (!empty($params['per_page'])) {
            return $query->paginate($params['per_page']);
        }

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

GetProduct:

namespace App\AQC\Product;

class GetProduct
{
    public function handle(array $params): ?Product
    {
        $query = Product::query();

        if (!empty($params['id'])) {
            $query->where('id', $params['id']);
        }

        if (!empty($params['sku'])) {
            $query->where('sku', $params['sku']);
        }

        if (!empty($params['with'])) {
            $query->with($params['with']);
        }

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

StoreProduct:

namespace App\AQC\Product;

class StoreProduct
{
    public function handle(array $params): Product
    {
        return Product::create([
            'name'           => $params['name'],
            'description'    => $params['description'] ?? null,
            'price'          => $params['price'],
            'sku'            => $params['sku'],
            'stock_quantity' => $params['stock_quantity'] ?? 0,
            'category_id'    => $params['category_id'] ?? null,
            'status'         => $params['status'] ?? 'active',
        ]);
    }
}
Enter fullscreen mode Exit fullscreen mode

UpdateProduct:

namespace App\AQC\Product;

class UpdateProduct
{
    public function handle(array $params): bool
    {
        $query = Product::query();

        if (!empty($params['id'])) {
            $query->where('id', $params['id']);
        }

        if (!empty($params['status'])) {
            $query->where('status', $params['status']);
        }

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

        return $query->update($params['data'] ?? []) > 0;
    }
}
Enter fullscreen mode Exit fullscreen mode

DeleteProduct:

namespace App\AQC\Product;

class DeleteProduct
{
    public function handle(array $params): bool
    {
        $query = Product::query();

        if (!empty($params['id'])) {
            $query->where('id', $params['id']);
        }

        if (!empty($params['status'])) {
            $query->where('status', $params['status']);
        }

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

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

All five classes are defined once. Every condition lives in exactly one place. No duplication anywhere.


Step 2 — The Repository Uses AQC Internally

Now the repository is rewritten to use these AQC classes internally. The public interface is identical to the original. Callers — controllers, services, tests — do not change a single line:

<?php

use App\AQC\Product\GetProducts;
use App\AQC\Product\GetProduct;
use App\AQC\Product\StoreProduct;
use App\AQC\Product\UpdateProduct;
use App\AQC\Product\DeleteProduct;

class ProductRepository implements ProductRepositoryInterface
{
    public function getAllProducts(): Collection
    {
        $aqc = new GetProducts();
        return $aqc->handle([
            'with' => ['category', 'inventory'],
        ]);
    }

    public function getProductById(int $productId): ?Product
    {
        $aqc = new GetProduct();
        return $aqc->handle([
            'id'   => $productId,
            'with' => ['category', 'inventory'],
        ]);
    }

    public function getActiveProducts(): Collection
    {
        $aqc = new GetProducts();
        return $aqc->handle([
            'status'    => 'active',
            'min_stock' => 0,
            'with'      => ['category', 'inventory'],
        ]);
    }

    public function createProduct(array $productData): Product
    {
        $aqc = new StoreProduct();
        return $aqc->handle($productData);
    }

    public function updateProduct(int $productId, array $productData): bool
    {
        $aqc = new UpdateProduct();
        return $aqc->handle([
            'id'   => $productId,
            'data' => $productData,
        ]);
    }

    public function deleteProduct(int $productId): bool
    {
        $aqc = new DeleteProduct();
        return $aqc->handle([
            'id' => $productId,
        ]);
    }

    public function getProductsByCategory(int $categoryId): Collection
    {
        $aqc = new GetProducts();
        return $aqc->handle([
            'category_id' => $categoryId,
            'with'        => ['inventory'],
        ]);
    }

    public function updateStock(int $productId, int $quantity): bool
    {
        $aqc = new UpdateProduct();
        return $aqc->handle([
            'id'   => $productId,
            'data' => ['stock_quantity' => $quantity],
        ]);
    }
}
Enter fullscreen mode Exit fullscreen mode

Study what happened to each method. getAllProducts no longer writes an Eloquent query — it builds a $params array and delegates. getActiveProducts does the same, with the conditions for "active" expressed as parameters. updateStock, which once had its own direct Eloquent logic, now delegates to UpdateProduct with a targeted $params array.

Every method is now three to six lines: construct $params, instantiate the AQC class, return the result. No raw Eloquent. No scattered where clauses. No duplication.

And crucially — the controller that calls $repository->getActiveProducts() has not changed. The service that calls $repository->updateStock($id, $quantity) has not changed. The interface is identical. The migration is invisible to callers.


What Changed and What Did Not

What changed:

  • Query logic is no longer scattered across repository methods
  • Conditions like 'status' => 'active' or eager-loading ['category', 'inventory'] are defined once in $params and handled once in the AQC class
  • Adding a new condition to "active products" means changing one line in GetProducts::handle(), not hunting down every method that touches active products
  • The AQC classes are independently testable — you can verify query behavior by passing $params combinations directly, without invoking the repository at all

What did not change:

  • The repository interface — same method names, same signatures, same return types
  • The number of repository methods — still eight
  • The caller's experience — controllers and services call the same methods they always called
  • The fact that new domain requirements still produce new repository methods

The Honest Limitation of This Stage

This approach delivers real value, but it leaves one problem untouched: the repository still grows.

When a new business requirement arrives — say, getLowStockProductsByCategory — a new method still gets added to the repository. The interface expands. The interface contract grows. And every new method follows the same pattern: build $params, call AQC, return result. The methods become predictable and clean, but they keep accumulating.

There is also a subtler issue. The repository is now making business decisions. getActiveProducts knows that "active" means status = active and stock_quantity > 0. That business knowledge lives in the repository, which is a data access layer. Should it? If the definition of "active" changes — if it now also requires verified = true — the change happens in the repository, not in the service or domain layer where business logic is supposed to live.

These are the tensions that Part 2 resolves. By moving parameter definition out of the repository and up to the service layer, the repository loses its business knowledge entirely. It becomes a pure router. And once it is a pure router, its redundancy becomes impossible to ignore.


When to Stop at This Stage

Not every codebase needs to go further. This stage is the right stopping point when:

  • Your team is not ready for the full AQC migration and needs a familiar repository interface
  • You are refactoring a large legacy codebase incrementally and cannot change callers yet
  • Your application is stable with a fixed set of domain operations that are unlikely to grow significantly
  • The primary goal was eliminating internal query duplication, not restructuring the architecture

If any of those describe your situation, this stage is a complete and valid destination. The internals are clean, the query logic is centralized, and the codebase is significantly more maintainable than before.

If you want to go further — if you want to see the repository reduced to a wrapper and ultimately made optional — that is what Part 2 is for.


Summary

Before After (Part 1)
Repository interface 8 named methods 8 named methods (unchanged)
Query logic location Scattered in each method Centralized in AQC classes
Caller impact None
Duplication High Eliminated
Business logic in repository Yes Yes (unchanged)
Repository growth Unbounded Still unbounded
AQC adoption level None Internal only

This is Part 1 of a 3-part series.
Part 2: "The Repository as a Wrapper — When AQC Makes the Repository Redundant."
Part 3: "Two Paths — Go Direct with AQC, or Discipline Your Repository with AQC Practices."


Raheel Shan is the originator of the Atomic Query Construction (AQC) design pattern. He writes about Laravel architecture, query design, and application structure at raheelshan.com, dev.to, and medium.com.

Top comments (0)