Why the Atomic Query Construction (AQC) Design Pattern Works Best with Onion Architecture
Muhammad Raheel
March 15th, 2026 · 4 min read
Introduction
When building modern applications, one of the most common architectural challenges is managing database interactions cleanly across multiple layers. Developers often end up scattering query logic across controllers, services, repositories, and CQRS handlers — leading to duplicated code, inconsistency, and maintenance headaches.
Atomic Query Construction (AQC) is a design pattern that solves this problem by introducing a single, disciplined layer for all database interactions. It is based on three well-established principles: the Single Responsibility Principle, the Query Object Pattern, and the Composition Pattern. Every AQC class does exactly one thing, is named clearly after that thing, and composes query conditions dynamically based on parameters passed to it.
In this article, we will explore how AQC fits into an onion architecture and how it serves as a unified interface for multiple external layers — web, API, and WebSocket — in a Laravel application.
What is the AQC Layer?
The AQC layer is a wrapper around your domain model. In Laravel terms, it sits outside your Eloquent model and acts as the only place where queries are written. No query logic lives in controllers, routes, services, or repositories. The AQC layer is the single source of truth for all database interactions.
The rules of AQC are simple but strict:
- A class must never use a constructor.
- Each class must have only one public method called handle.
- The handle method must always receive an array as its parameter.
- The array shapes the query through conditional logic inside the class.
- No query snippet should ever be repeated across the application.
For every model in your application, you will have exactly five AQC classes — no more, no less. For a Product model, these would be GetProducts, GetProduct, SaveProduct, UpdateProduct, and DeleteProduct. Each class handles one operation, and all complexity for that operation lives within it.
Here is what the five AQC classes for a Product model look like as a directory structure:
app/
└── AQC/
└── Product/
├── GetProducts.php
├── GetProduct.php
├── SaveProduct.php
├── UpdateProduct.php
└── DeleteProduct.php
AQC in Onion Architecture
Onion architecture organises an application into concentric layers, where the domain sits at the core and external concerns live on the outside. The AQC layer wraps the domain model and sits just outside it, forming a clean boundary between your domain and everything else.
In a typical Laravel application built with onion architecture, you might have three external layers that need to interact with the database:
- Web Layer — responsible for rendering HTML content in the browser, driven by your web.php routes file.
- API Layer — serving mobile apps or third-party clients through RESTful endpoints, driven by your api.php routes file.
- WebSocket Layer — handling real-time interactions and event-driven communication.
Each of these layers has different concerns and different triggers, but they all need the same thing: data. Instead of each layer implementing its own query logic, all three talk to the AQC layer using the same consistent contract — pass an array of parameters to the handle method and get results back.
The Power of Parameter-Driven Composition
The real strength of AQC becomes clear when you look at how a single class can serve many different needs through parameter composition. Consider this GetProducts class:
<?php
namespace App\AQC\Product;
use App\Models\Product;
class GetProducts
{
public function handle($params = [])
{
$query = Product::latest('id');
$this->applyFilters($query, $params);
$this->selectColumns($query, $params);
$this->applySorting($query, $params);
return isset($params['paginate'])
? $this->handlePagination($query, $params)
: $query->get();
}
private function applyFilters($query, $params)
{
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']);
}
}
private function selectColumns($query, $params)
{
if (isset($params['columns']) && count($params['columns']) > 0) {
$query->select($params['columns']);
} else {
$query->select('*');
}
}
private function applySorting($query, $params)
{
if (isset($params['sortBy']) && isset($params['type'])) {
$query->orderBy($params['sortBy'], $params['type']);
}
}
private function handlePagination($query, $params)
{
return $query->paginate(Product::PAGINATE);
}
}
The parameters passed to handle act as switches. Each parameter activates or deactivates a specific condition inside the class. The same class can serve all of these scenarios:
Fetch all products with no filters:
(new GetProducts)->handle([]);
Fetch products filtered by category:
(new GetProducts)->handle(['category_id' => 3]);
Fetch products filtered by brand with specific columns:
(new GetProducts)->handle([
'brand_id' => 5,
'columns' => ['id', 'name', 'price']
]);
Fetch a paginated, sorted list filtered by both category and brand:
(new GetProducts)->handle([
'category_id' => 3,
'brand_id' => 5,
'sortBy' => 'price',
'type' => 'asc',
'paginate' => true
]);
All of this from one class, one method, one entry point. The flexibility is enormous, and not a single line of query logic is repeated anywhere in the application.
All Three External Layers Talking to AQC
Now let us see how the web, API, and WebSocket layers all access the same AQC class with different parameters depending on their needs.
The web layer fetches paginated products for a browser view:
// routes/web.php
use App\AQC\Product\GetProducts;
Route::get('/products', function (Request $request) {
$products = (new GetProducts)->handle([
'category_id' => $request->category_id,
'paginate' => true
]);
return view('products.index', compact('products'));
});
The API layer fetches a lightweight product list for a mobile app, returning only the columns it needs:
// routes/api.php
use App\AQC\Product\GetProducts;
Route::get('/products', function (Request $request) {
$products = (new GetProducts)->handle([
'brand_id' => $request->brand_id,
'columns' => ['id', 'name', 'price', 'thumbnail']
]);
return response()->json($products);
});
The WebSocket layer fetches the latest products in real time to broadcast to connected clients:
// Inside a WebSocket event handler
use App\AQC\Product\GetProducts;
$products = (new GetProducts)->handle([
'sortBy' => 'created_at',
'type' => 'desc'
]);
broadcast(new ProductsUpdated($products));
Three layers, three different needs, one AQC class. Notice that none of these layers know anything about how the query is built. They only know what parameters to pass. The AQC class handles the rest.
AQC Replaces Repository, Service, and CQRS Layers
One of the most significant architectural benefits of AQC is what it eliminates. In traditional Laravel applications, developers often reach for repository classes, service classes, or CQRS patterns to organise database interactions. Each of these adds a layer of abstraction that must be maintained, tested, and understood by the team.
When AQC is present, none of these are necessary for database access. AQC is the repository. AQC is the query handler. It can be called directly from a controller, from a route closure, from a service class if one exists for other reasons, or even from within a CQRS command if that pattern is used elsewhere. Here is an example showing AQC being called from a controller, a service, and a repository — demonstrating that it integrates with whatever structure you already have:
// From a Controller
class ProductController extends Controller
{
public function index(Request $request)
{
return (new GetProducts)->handle($request->all());
}
}
// From a Service class
class ProductService
{
public function getFeaturedProducts()
{
return (new GetProducts)->handle([
'category_id' => Category::FEATURED,
'columns' => ['id', 'name', 'price']
]);
}
}
// From a Repository class
class ProductRepository
{
public function paginated()
{
return (new GetProducts)->handle(['paginate' => true]);
}
}
In all three cases, the query logic lives in exactly one place — the AQC class. The controller, service, and repository are simply callers. They pass parameters and receive results. They do not know, and do not need to know, how the query is constructed.
Conclusion
Atomic Query Construction brings discipline, simplicity, and consistency to database interactions in Laravel applications. By wrapping your domain model in a clean AQC layer and enforcing strict rules around how queries are written and accessed, you eliminate duplication, reduce architectural complexity, and create a single source of truth that every layer of your application can rely on.
When paired with onion architecture, AQC sits naturally as that boundary between your domain and the outside world — serving your web, API, and WebSocket layers equally, through the same clean and consistent interface. Whether you are calling it from a controller, a service, a repository, or a WebSocket handler, the contract never changes: pass an array, get your data back.