Working with Relational Data in Atomic Query Construction Design Pattern
Muhammad Raheel
March 15th, 2026 · 8 min read
In the previous parts of this series, I introduced the Atomic Query Construction (AQC) design pattern and how it helps organize data access in a structured and predictable way.
So far, everything looked clean because we were dealing with single models. No relationships.
Today I am going to talk about how we can achieve relational data in the AQC design pattern, and this is the final article in this series.
The Problem with Traditional Relationship Handling
In a typical Laravel application, relational queries are written wherever they are needed:
User::with('profiles')->where(...)->get();
This shows up in:
- controllers
- services
- random helper classes
- sometimes even Blade (because why not ruin everything)
At first, it works and feels flexible.
Over time, it becomes a mess:
- Different developers load different relationships
- Constraints vary across the codebase
- Some queries forget eager loading entirely
You no longer have a single definition of what a “User with Profiles” actually means.
AQC Approach to Relationships
AQC doesn’t allow this flexibility. Instead, AQC enforces a simple rule:
So:
handles usersGetUsers- if profiles are needed,
handles that internallyGetUsers - No external layer decides how relationships are loaded
class GetUsers
{
public function handle(array $params = [])
{
$query = User::query();
$this->filters($query, $params);
$this->relationships($query, $params);
return $query->get();
}
private function relationships($query, $params)
{
if (!empty($params['with_profiles'])) {
$query->with('profiles');
}
}
}
Controller stays clean:
GetUsers::handle([
'with_profiles' => true
]);
One entry point. One place to reason about the query. No query logic outside.
The Rule That Makes This Work
This is where AQC becomes strict:
Even for relationships. So this will never happen:
GetUsers → calls → GetProfiles
Instead:
directly handlesGetUsersprofilesis only used when profiles are the primary concernGetProfiles
Why This Rule Matters
Allowing AQC classes to call each other introduces:
- hidden dependencies
- execution flow becomes harder to track
- accidental complexity
- debugging becomes annoying
By preventing this, AQC ensures:
- Predictability – One class, one query flow
- Clarity – No hidden orchestration
- Control—Relationships are always explicit
Handling Complexity Internally
Queries will grow. Filters will increase. Conditions will pile up.
AQC doesn’t stop that. It just forces all of it into one place.
class GetUsers
{
public function handle(array $params = [])
{
$query = User::query();
$this->applyFilters($query, $params);
$this->applyRelationships($query, $params);
return $query->get();
}
private function applyFilters($query, $params)
{
if (!empty($params['active'])) {
$query->where('is_active', true);
}
}
private function applyRelationships($query, $params)
{
if (!empty($params['with_profiles'])) {
$query->with('profiles');
}
}
}
Instead of spreading logic across the application, everything is:
- grouped
- readable
- controlled
Yes, the class grows, but only in one place, and that’s the trade.
What About Reuse?
AQC intentionally avoids cross-class reuse in queries.
Yes, this can lead to duplication, but that’s okay.
Because AQC prioritizes:
- clarity over cleverness
- ownership over abstraction
If you’re going:
- User → Profile → handled in
GetUsers - Profile → User → handled in
GetProfiles
Each direction has its own entry point.
Testing AQC Queries
Since all query logic is centralized, testing becomes more focused and straightforward.
AQC classes can expose query debugging:
$query->toSql();
This helps:
- inspect generated SQL
- validate conditions
- debug complex filters
However, testing should not rely only on SQL strings.
You should also test:
- returned data
- applied conditions
- edge cases
What This Pattern Actually Does
AQC doesn’t reduce complexity.
It just stops it from spreading everywhere.
Instead of:
- queries in multiple layers
- inconsistent relationship handling
- random eager loading
You get:
- one entry point per model
- controlled relationships
- predictable queries
Final Thought
This approach is strict on purpose. And it works because it forces discipline. Break the rules, and you’re back to the same mess you were trying to fix. It is not the most flexible approach. It is not the most reusable approach. But it is consistent, controlled, and maintainable.