Share

From Bloated Repositories to Atomic Query Builder Pattern

Modern PHP applications often suffer from a specific type of architectural decay: the Repository God-Class. As business requirements evolve, the ProductRepository or OrderRepository becomes a dumping ground for every specialized query, filter combination, and join logic in the system. This method-driven approach does not scale. Adding a single filter often requires a new method, leading to a combinatorial explosion of code that is impossible to test or maintain.   

The Atomic Query Construction (AQC) pattern – or Atomic Query Builder – solves this by treating each query as a first-class citizen. By encapsulating data access into single-purpose, parameter-driven classes, you eliminate method bloat and restore the Single Responsibility Principle (SRP) to your persistence layer.


The Failure Modes of Monolithic Repositories

The traditional Repository pattern starts as a clean abstraction over a collection of domain objects . However, in production-scale Symfony or Laravel projects, the “Collection” metaphor breaks down under the weight of complex filtering, reporting, and specialized joins.

Engineers frequently encounter these three failure modes:

  1. Method Multiplication: If you have 5 optional filters (e.g., active, category, store, price_range, stock_status), there are 2n - 1 potential combinations. In a repository, you either write 31 separate methods or leak the QueryBuilder to the service layer to handle the logic dynamically.
  2. Leaky Abstractions: To avoid method bloat, developers often return a QueryBuilder instance from the repository. This forces the calling Service to know about the database schema, defeating the purpose of the repository as an abstraction.
  3. Dependency Circularity: A SalesRepository might need to check product stock, while the ProductRepository needs to check recent sales. This often leads to circular dependencies or “Repository Overlap Insanity”.
MetricMonolithic RepositoryAtomic Query Builder (AQC)
Class ComplexityO(n) where n = methods O(1) per class (SRP)
Query FlexibilityHard-coded / StrictParameter-driven / Dynamic 
Testing IsolationHigh (must mock large interface) Low (unit test a single class) 
Dependency WeightHeavy (injected everywhere) Light (on-demand injection) 

Defining the Atomic Query Construction (AQC) Pattern

AQC is an Object-Oriented approach where each database query lives in its own dedicated class. Instead of a repository with 20 methods, you have 20 classes that perform one specific action.   

Core Principles of AQC Classes

  1. Single Public Interface: Every class exposes a single handle() or execute() method.   
  2. Internal Construction: The class encapsulates the QueryBuilder or SQL logic entirely. No leaking of Doctrine or Eloquent internals.   
  3. Parameter-Driven Lifecycle: The class accepts a DTO or a structured array of parameters to shape the query dynamically.   
  4. Scenario Support: A single class can handle different projections (e.g., ‘minimal’ vs ‘full’ entity hydration) using specific parameters.   

Technical Implementation: Symfony & Doctrine

In a Symfony environment, AQC classes replace the custom EntityRepository. We move from method-based retrieval to action-based services.

The GetProducts Atomic Class

This implementation uses PHP 8.2+ features to ensure immutability and type safety.

PHP
namespace App\DataAccess\Product;

use Doctrine\ORM\EntityManagerInterface;
use Doctrine\ORM\QueryBuilder;
use App\Entity\Product;

/**
 * Atomic Query Builder for Product retrieval.
 * Handles dynamic filtering and projection scenarios.
 */
final readonly class GetProducts
{
    public function __construct(
        private EntityManagerInterface $entityManager
    ) {}

    /**
     * @param array{
     *   active?: bool,
     *   category_id?: int,
     *   store_id?: int,
     *   search?: string,
     *   scenario?: 'minimal'|'full'|'admin'
     * } $params
     * @return Product
     */
    public function handle(array $params = []): array
    {
        $qb = $this->entityManager
            ->createQueryBuilder()
            ->select('p')
            ->from(Product::class, 'p');

        $this->applyFilters($qb, $params);
        $this->applyScenario($qb, $params['scenario'] ?? 'full');

        return $qb->getQuery()->getResult();
    }

    private function applyFilters(QueryBuilder $qb, array $params): void
    {
        // Parameter-driven dynamic filtering logic
        if (isset($params['active'])) {
            $qb->andWhere('p.active = :active')
               ->setParameter('active', $params['active']);
        }

        if (isset($params['category_id'])) {
            $qb->andWhere('p.category = :catId')
               ->setParameter('catId', $params['category_id']);
        }

        if (!empty($params['search'])) {
            $qb->andWhere('p.name LIKE :search')
               ->setParameter('search', '%'. $params['search'] . '%');
        }
    }

    private function applyScenario(QueryBuilder $qb, string $scenario): void
    {
        // Selective Hydration for performance optimization
        if ($scenario === 'minimal') {
            $qb->select('PARTIAL p.{id, name, sku}');
        }
    }
}

Why this works

  • Encapsulation: The calling service doesn’t know how products are filtered. It just passes the category_id.   
  • Performance: The applyScenario method allows you to use Doctrine’s PARTIAL hydration or switch to getArrayResult() for heavy read operations without changing the service logic .
  • Scalability: Adding a brand_id filter involves adding 3 lines to applyFilters. This immediately makes “active products by brand” and “brand products by category” available without new methods.   

Advanced Data Access: Handling Writes and Transactions

AQC isn’t limited to reads. “Command” classes handle state changes, ensuring transactional integrity.

The StoreProduct Command Class

PHP
namespace App\DataAccess\Product;

use Doctrine\ORM\EntityManagerInterface;
use App\Entity\Product;

final readonly class StoreProduct
{
    public function __construct(
        private EntityManagerInterface $entityManager
    ) {}

    public function handle(array $data): Product
    {
        $product = new Product();
        $product->setName($data['name']);
        $product->setPrice($data['price']);
        
        $this->entityManager->persist($product);
        $this->entityManager->flush(); // Explicit commit for atomic operation

        return $product;
    }
}

Transactional Boundaries (The Unit of Work Problem)

One critique of AQC is the management of transactions across multiple commands. If CommandA and CommandB must succeed together, having flush() inside each class breaks atomicity.   

Architectural Solution: Inject the EntityManager into a high-level Service or use Symfony Messenger’s doctrine_transaction middleware . The AQC classes should perform the persist() and remove() calls, while the flush() is managed by the transaction coordinator .


Trade-offs and Architectural Constraints

While AQC reduces class bloat, it introduces other overheads that engineers must manage.

The “Cons” of Atomic Construction

  1. Too many files: You move from one ProductRepository file to five or ten files in a DataAccess/Product/ directory. This requires strict directory discipline.   
  2. Boilerplate: Each class requires its own constructor and Dependency Injection overhead .
  3. Discovery: New developers may find it harder to “see” all available queries for an entity compared to scrolling through a single repository file.   
FeatureRepositoryAtomic Query Builder
Logic ReuseInternal method calls Composition of classes 
MockingMock the whole Interface Mock only the used Query 
Type SafetyInterface-based Parameter/DTO-based 
Complexity GrowthExponential (2n)Linear (+1 class)

AI-Native Alignment: The Semantic Signal Advantage

From an architectural standpoint, AQC is significantly more compatible with Large Language Models (LLMs) used for code generation and refactoring.   

Token Efficiency and Signal-to-Noise Ratio

LLMs operate within a finite context window. When an LLM analyzes a 2,000-line repository to fix a bug in a specific query, 95% of the file is “noise” – unrelated methods that consume tokens and increase hallucination risk.   

AQC provides a high signal-to-noise ratio. When an AI agent needs to modify the “Get Active Products” logic, it only reads the GetProducts class. Research indicates that “Healthy” modular code (high CodeHealth scores) results in 30% fewer semantic breakages during AI-driven refactoring compared to monolithic structures.   

AI Reliability MetricMonolithic RepoAtomic Query Builder
Context Window UsageHigh (Inefficient)Low (Surgical) 
Hallucination RiskHigh (Method Noise)Minimal (Context Focus) 
Refactoring Success Rate~53% ~64% 

Troubleshooting & Edge Cases

1. The “Too Many Files” Myth

Teams often fear a DataAccess folder with 100+ files. Solution: Group by Domain/Resource. src/DataAccess/Order/src/DataAccess/User/. Use action-based naming: FindGetStoreCalculate.

2. Parameter Validation Gaps

Since handle() often takes an array, you lose the type safety of method arguments. Solution: Use PHP 8.1+ readonly DTOs as parameters instead of associative arrays.   

PHP
public function handle(ProductQueryParameters $params): array 
{
    // Now you have IDE completion and strict types
}

3. Performance Overhead of Injection

Injecting five AQC classes into a controller might seem heavier than injecting one Repository. Solution: Symfony’s autowiring and lazy-loading services mitigate this overhead. In practice, the memory impact of loading 5 small files is irrelevant compared to parsing one 5,000-line “God Repository”

Strategic Implementation Plan

For architects looking to move away from monolithic repositories, follow this refactoring path:

  1. Audit the “Papercuts”: Identify the repository methods that are duplicated or have 3+ optional arguments.   
  2. Extract the Read Side First: Move complex findBy methods into Get atomic classes. Keep the repository for basic CRUD if necessary, but route complex queries to AQC.   
  3. Enforce Scenario-Based Projections: Implement a scenario parameter to handle PARTIAL hydration. This is the single biggest performance win for read-heavy Symfony apps .
  4. Standardize the Interface: Ensure every AQC class uses the handle() method name to allow for future interface-based decorators (e.g., CachingDecorator) .