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
$paramsand 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);
}
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:
- Deciding what parameters apply to this specific operation
- Calling the AQC class with those parameters
The AQC class owns one thing:
- 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();
}
}
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();
}
}
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',
]);
}
}
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;
}
}
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;
}
}
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],
]);
}
}
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$paramsand 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
$paramscombinations 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.













