Skip to main content

Query Infrastructure: Implementation Guide

Overview

The Query Infrastructure provides a standardized way for modules to read data from other modules without creating tight coupling. This guide covers the complete implementation process from creating your first query interface to advanced usage patterns.

Core Principle

Dependency Inversion: Consumer modules depend on abstractions (interfaces) defined in a shared kernel, while provider modules implement these interfaces within their own domain.


Architecture Components

1. Base Contracts (Shared Foundation)

Located in app/Contracts/:

app/Contracts/QueryInterface.php
<?php

declare(strict_types=1);

namespace App\Contracts;

interface QueryInterface
{
// Marker interface - no methods required
// All query interfaces must extend this
}

2. Domain Layer (Contracts)

Located in app/Domains/{Context}/:

This layer contains:

  • Query interfaces
  • DTOs (Data Transfer Objects)
  • Domain IDs (Value Objects)
  • DTO Collections
Example Structure
app/Domains/User/
├── UserQuery.php # Interface
├── UserDTO.php # Data Transfer Object
├── UserDTOCollection.php # DTO Collection
├── UserId.php # Domain ID (Value Object)
└── UserIdCollection.php # ID Collection

3. Implementation Layer

Located in app/Core/{Module}/Services/Query/ or app/Modules/{Module}/Services/Query/:

This layer contains:

  • Concrete implementations of query interfaces
  • Repository usage
  • Entity to DTO mapping logic

4. Service Locator

Located in app/Services/QueryServiceLocator.php:

Manages registration and resolution of query services.

5. Service Provider

Located in app/Providers/QueryServiceProvider.php:

Registers all query interfaces and their implementations with Laravel's service container.


Step-by-Step Implementation

Step 1: Create Domain ID (Value Object)

app/Domains/Order/OrderId.php
<?php

declare(strict_types=1);

namespace App\Domains\Order;

use App\Contracts\DomainId;

final class OrderId extends DomainId
{
// Inherits all functionality from DomainId
// Can add domain-specific methods if needed
}
Key Points
  • Extends DomainId base class
  • Use final keyword to prevent inheritance
  • Provides type safety for order IDs
  • Validates that ID is positive integer
app/Domains/Order/OrderIdCollection.php
<?php

declare(strict_types=1);

namespace App\Domains\Order;

use App\Contracts\BaseCustomCollection;

final class OrderIdCollection extends BaseCustomCollection
{
public function __construct(array $items = [])
{
// Validate all items are OrderId instances
foreach ($items as $item) {
if (!$item instanceof OrderId) {
throw new \InvalidArgumentException(
'All items must be instances of OrderId'
);
}
}

parent::__construct($items);
}

/**
* Convert collection to array of ID values
*/
public function toValues(): array
{
return array_map(
fn(OrderId $id) => $id->value,
$this->items
);
}
}

Step 3: Create Data Transfer Object (DTO)

app/Domains/Order/OrderDTO.php
<?php

declare(strict_types=1);

namespace App\Domains\Order;

use App\Contracts\DataTransferObject;
use App\Domains\User\UserId;
use DateTimeInterface;

final class OrderDTO implements DataTransferObject
{
public function __construct(
public readonly OrderId $id,
public readonly string $orderNumber,
public readonly UserId $userId,
public readonly int $totalAmount,
public readonly string $status,
public readonly DateTimeInterface $createdAt,
public readonly ?DateTimeInterface $completedAt = null,
) {
}

public function toArray(): array
{
return [
'id' => $this->id->value,
'order_number' => $this->orderNumber,
'user_id' => $this->userId->value,
'total_amount' => $this->totalAmount,
'status' => $this->status,
'created_at' => $this->createdAt->format('Y-m-d H:i:s'),
'completed_at' => $this->completedAt?->format('Y-m-d H:i:s'),
];
}

/**
* Check if order is completed
*/
public function isCompleted(): bool
{
return $this->status === 'completed';
}

/**
* Check if order is pending
*/
public function isPending(): bool
{
return $this->status === 'pending';
}
}
Key Points
  • All properties are public readonly (immutable)
  • Use type hints for all properties
  • Required parameters first, optional parameters last
  • Can include domain-specific helper methods
  • Implements toArray() for serialization

Step 4: Create DTO Collection

app/Domains/Order/OrderDTOCollection.php
<?php

declare(strict_types=1);

namespace App\Domains\Order;

use App\Contracts\BaseCustomCollection;

final class OrderDTOCollection extends BaseCustomCollection
{
public function __construct(array $items = [])
{
// Validate all items are OrderDTO instances
foreach ($items as $item) {
if (!$item instanceof OrderDTO) {
throw new \InvalidArgumentException(
'All items must be instances of OrderDTO'
);
}
}

parent::__construct($items);
}

/**
* Filter completed orders
*/
public function completed(): self
{
$completed = array_filter(
$this->items,
fn(OrderDTO $order) => $order->isCompleted()
);

return new self(array_values($completed));
}

/**
* Filter pending orders
*/
public function pending(): self
{
$pending = array_filter(
$this->items,
fn(OrderDTO $order) => $order->isPending()
);

return new self(array_values($pending));
}

/**
* Get total amount of all orders
*/
public function totalAmount(): int
{
return array_reduce(
$this->items,
fn(int $carry, OrderDTO $order) => $carry + $order->totalAmount,
0
);
}
}
Key Points
  • Type-safe collection for OrderDTO
  • Can include domain-specific filtering methods
  • Immutable operations (return new instances)
  • Provides meaningful business operations

Step 5: Create Query Interface

app/Domains/Order/OrderQuery.php
<?php

declare(strict_types=1);

namespace App\Domains\Order;

use App\Contracts\QueryInterface;
use App\Domains\User\UserId;

interface OrderQuery extends QueryInterface
{
/**
* Get order by ID (throws exception if not found)
*/
public function getById(OrderId $id): OrderDTO;

/**
* Find order by ID (returns null if not found)
*/
public function findById(OrderId $id): ?OrderDTO;

/**
* Find multiple orders by IDs
*/
public function findByIds(OrderIdCollection $ids): OrderDTOCollection;

/**
* Check if order exists
*/
public function exists(OrderId $id): bool;

/**
* Find order by order number
*/
public function findByOrderNumber(string $orderNumber): ?OrderDTO;

/**
* Get all orders for a user
*/
public function getOrdersByUser(UserId $userId): OrderDTOCollection;

/**
* Get completed orders for a user
*/
public function getCompletedOrdersByUser(UserId $userId): OrderDTOCollection;

/**
* Get pending orders for a user
*/
public function getPendingOrdersByUser(UserId $userId): OrderDTOCollection;
}
Key Points
  • Extends QueryInterface
  • Only read-only methods (no create/update/delete)
  • Use Value Objects for parameters (OrderId, UserId)
  • Return DTOs or DTO Collections
  • Use get* for methods that throw exceptions
  • Use find* for methods that return null
  • Document each method with PHPDoc

Step 6: Create Query Implementation

app/Modules/Orders/Services/Query/OrderQueryImplementation.php
<?php

declare(strict_types=1);

namespace App\Modules\Orders\Services\Query;

use App\Domains\Order\OrderDTO;
use App\Domains\Order\OrderDTOCollection;
use App\Domains\Order\OrderId;
use App\Domains\Order\OrderIdCollection;
use App\Domains\Order\OrderQuery;
use App\Domains\User\UserId;
use App\Modules\Orders\Entities\Order;
use App\Modules\Orders\Repositories\OrderRepository;

final class OrderQueryImplementation implements OrderQuery
{
public function __construct(
private readonly OrderRepository $repository
) {
}

public function getById(OrderId $id): OrderDTO
{
$order = $this->repository->findOrFail($id->value);

return $this->toDTO($order);
}

public function findById(OrderId $id): ?OrderDTO
{
$order = $this->repository->find($id->value);

return $order ? $this->toDTO($order) : null;
}

public function findByIds(OrderIdCollection $ids): OrderDTOCollection
{
if ($ids->isEmpty()) {
return OrderDTOCollection::empty();
}

$orders = $this->repository
->newQuery()
->whereIn('id', $ids->toValues())
->get();

$dtos = array_map(
fn(Order $order) => $this->toDTO($order),
$orders->all()
);

return new OrderDTOCollection($dtos);
}

public function exists(OrderId $id): bool
{
return $this->repository
->newQuery()
->where('id', $id->value)
->exists();
}

public function findByOrderNumber(string $orderNumber): ?OrderDTO
{
$order = $this->repository
->newQuery()
->where('order_number', $orderNumber)
->first();

return $order ? $this->toDTO($order) : null;
}

public function getOrdersByUser(UserId $userId): OrderDTOCollection
{
$orders = $this->repository
->newQuery()
->where('user_id', $userId->value)
->orderBy('created_at', 'desc')
->get();

$dtos = array_map(
fn(Order $order) => $this->toDTO($order),
$orders->all()
);

return new OrderDTOCollection($dtos);
}

public function getCompletedOrdersByUser(UserId $userId): OrderDTOCollection
{
$orders = $this->repository
->newQuery()
->where('user_id', $userId->value)
->where('status', 'completed')
->orderBy('completed_at', 'desc')
->get();

$dtos = array_map(
fn(Order $order) => $this->toDTO($order),
$orders->all()
);

return new OrderDTOCollection($dtos);
}

public function getPendingOrdersByUser(UserId $userId): OrderDTOCollection
{
$orders = $this->repository
->newQuery()
->where('user_id', $userId->value)
->where('status', 'pending')
->orderBy('created_at', 'desc')
->get();

$dtos = array_map(
fn(Order $order) => $this->toDTO($order),
$orders->all()
);

return new OrderDTOCollection($dtos);
}

/**
* Convert Order entity to OrderDTO
*/
private function toDTO(Order $order): OrderDTO
{
return new OrderDTO(
id: new OrderId($order->id),
orderNumber: $order->order_number,
userId: new UserId($order->user_id),
totalAmount: $order->total_amount,
status: $order->status,
createdAt: $order->created_at,
completedAt: $order->completed_at,
);
}
}
Key Points
  • Implements the query interface
  • Uses repository for data access
  • Private toDTO() method for entity mapping
  • Returns DTOs, never entities
  • Uses Value Objects for IDs
  • Handles empty collections gracefully

Step 7: Register in QueryServiceProvider

app/Providers/QueryServiceProvider.php
<?php

declare(strict_types=1);

namespace App\Providers;

use App\Domains\Order\OrderQuery;
use App\Modules\Orders\Services\Query\OrderQueryImplementation;
use App\Services\QueryServiceLocator;
use Illuminate\Support\ServiceProvider;

class QueryServiceProvider extends ServiceProvider
{
private array $queryServices = [
'users' => UserQuery::class,
'carts' => CartQuery::class,
'orders' => OrderQuery::class, // ← Add this
// ... other queries
];

private array $implementations = [
UserQuery::class => UserQueryImplementation::class,
CartQuery::class => CartQueryImplementation::class,
OrderQuery::class => OrderQueryImplementation::class, // ← Add this
// ... other implementations
];

public function register(): void
{
// Register QueryServiceLocator as singleton
$this->app->singleton(QueryServiceLocator::class, function () {
$locator = new QueryServiceLocator();

foreach ($this->queryServices as $context => $interface) {
$locator->register($context, $interface);
}

return $locator;
});

// Bind interfaces to implementations
foreach ($this->implementations as $interface => $implementation) {
$this->app->bind($interface, $implementation);
}
}

public function boot(): void
{
// Boot logic if needed
}
}
Registration Notes
  • Add context name to $queryServices array
  • Add interface-to-implementation mapping
  • Context name should be descriptive (e.g., 'orders', 'users')
  • Laravel will automatically resolve dependencies

Usage Patterns

Best for: When you know which query interface you need at compile time.

<?php

namespace App\Modules\Accounting\Services;

use App\Domains\Order\OrderQuery;
use App\Domains\Order\OrderId;
use App\Domains\User\UserQuery;
use App\Domains\User\UserId;

final class InvoiceService
{
public function __construct(
private readonly OrderQuery $orderQuery,
private readonly UserQuery $userQuery,
) {
}

public function generateInvoice(int $orderId): InvoiceDTO
{
// Get order data
$order = $this->orderQuery->getById(new OrderId($orderId));

// Get user data
$user = $this->userQuery->getById($order->userId);

// Generate invoice
return new InvoiceDTO(
orderNumber: $order->orderNumber,
customerName: $user->fullName,
totalAmount: $order->totalAmount,
// ...
);
}
}
Advantages
  • ✅ Type-safe (IDE autocomplete works)
  • ✅ Easy to test (can mock interfaces)
  • ✅ Clear dependencies
  • ✅ Compile-time validation

Best Practices

1. Interface Design

interface OrderQuery extends QueryInterface
{
// Clear, descriptive method names
public function getById(OrderId $id): OrderDTO;
public function findByOrderNumber(string $orderNumber): ?OrderDTO;
public function getCompletedOrdersByUser(UserId $userId): OrderDTOCollection;
}

2. DTO Design

final class OrderDTO implements DataTransferObject
{
public function __construct(
public readonly OrderId $id, // Value Objects
public readonly string $orderNumber, // Primitives
public readonly UserId $userId, // Related VOs
public readonly DateTimeInterface $createdAt, // Interfaces
) {
}

// Helper methods are OK
public function isCompleted(): bool
{
return $this->status === 'completed';
}
}

3. Implementation Patterns

final class OrderQueryImplementation implements OrderQuery
{
public function __construct(
private readonly OrderRepository $repository
) {
}

public function getById(OrderId $id): OrderDTO
{
$order = $this->repository->findOrFail($id->value);
return $this->toDTO($order);
}

private function toDTO(Order $order): OrderDTO
{
return new OrderDTO(
id: new OrderId($order->id),
orderNumber: $order->order_number,
// ...
);
}
}

4. Naming Conventions

Method PrefixBehaviorReturn Type
get*Throws exception if not foundDTO or Collection
find*Returns null if not found?DTO or Collection
exists*Checks existencebool
count*Counts recordsint
Examples
public function getById(OrderId $id): OrderDTO;              // Throws if not found
public function findById(OrderId $id): ?OrderDTO; // Returns null if not found
public function exists(OrderId $id): bool; // true/false
public function countByUser(UserId $userId): int; // Count

5. Error Handling

public function getById(OrderId $id): OrderDTO
{
// Let repository throw ModelNotFoundException
$order = $this->repository->findOrFail($id->value);
return $this->toDTO($order);
}

public function findById(OrderId $id): ?OrderDTO
{
// Return null for not found
$order = $this->repository->find($id->value);
return $order ? $this->toDTO($order) : null;
}

Testing Strategies

Unit Testing with Mocks

tests/Unit/Modules/Accounting/Services/InvoiceServiceTest.php
<?php

namespace Tests\Unit\Modules\Accounting\Services;

use App\Domains\Order\OrderDTO;
use App\Domains\Order\OrderId;
use App\Domains\Order\OrderQuery;
use App\Domains\User\UserDTO;
use App\Domains\User\UserId;
use App\Domains\User\UserQuery;
use App\Modules\Accounting\Services\InvoiceService;
use Mockery;
use Tests\TestCase;

final class InvoiceServiceTest extends TestCase
{
public function test_generates_invoice_successfully(): void
{
// Arrange
$orderId = new OrderId(1);
$userId = new UserId(10);

$orderQuery = Mockery::mock(OrderQuery::class);
$orderQuery->shouldReceive('getById')
->once()
->with(Mockery::on(fn($id) => $id->equals($orderId)))
->andReturn(new OrderDTO(
id: $orderId,
orderNumber: 'ORD-001',
userId: $userId,
totalAmount: 10000,
status: 'completed',
createdAt: now(),
));

$userQuery = Mockery::mock(UserQuery::class);
$userQuery->shouldReceive('getById')
->once()
->with(Mockery::on(fn($id) => $id->equals($userId)))
->andReturn(new UserDTO(
id: $userId,
fullName: 'John Doe',
morphClass: 'App\Models\User',
registeredAt: now(),
mobile: null,
));

$service = new InvoiceService($orderQuery, $userQuery);

// Act
$invoice = $service->generateInvoice(1);

// Assert
$this->assertEquals('ORD-001', $invoice->orderNumber);
$this->assertEquals('John Doe', $invoice->customerName);
$this->assertEquals(10000, $invoice->totalAmount);
}
}

Integration Testing

tests/Integration/Services/Query/OrderQueryImplementationTest.php
<?php

namespace Tests\Integration\Services\Query;

use App\Domains\Order\OrderId;
use App\Domains\Order\OrderQuery;
use App\Domains\User\UserId;
use App\Modules\Orders\Entities\Order;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;

final class OrderQueryImplementationTest extends TestCase
{
use RefreshDatabase;

private OrderQuery $orderQuery;

protected function setUp(): void
{
parent::setUp();
$this->orderQuery = app(OrderQuery::class);
}

public function test_gets_order_by_id(): void
{
// Arrange
$order = Order::factory()->create([
'order_number' => 'ORD-001',
'total_amount' => 10000,
]);

// Act
$dto = $this->orderQuery->getById(new OrderId($order->id));

// Assert
$this->assertEquals($order->id, $dto->id->value);
$this->assertEquals('ORD-001', $dto->orderNumber);
$this->assertEquals(10000, $dto->totalAmount);
}

public function test_returns_null_when_order_not_found(): void
{
// Act
$dto = $this->orderQuery->findById(new OrderId(999));

// Assert
$this->assertNull($dto);
}

public function test_gets_orders_by_user(): void
{
// Arrange
$userId = 1;
Order::factory()->count(3)->create(['user_id' => $userId]);
Order::factory()->count(2)->create(['user_id' => 2]);

// Act
$orders = $this->orderQuery->getOrdersByUser(new UserId($userId));

// Assert
$this->assertCount(3, $orders);
}
}

Common Patterns and Examples

Pattern: Pagination Support

View Implementation
interface OrderQuery extends QueryInterface
{
public function paginateByUser(
UserId $userId,
int $page = 1,
int $perPage = 15
): OrderPaginationDTO;
}

final class OrderPaginationDTO implements DataTransferObject
{
public function __construct(
public readonly OrderDTOCollection $items,
public readonly int $total,
public readonly int $currentPage,
public readonly int $perPage,
public readonly int $lastPage,
) {
}

public function toArray(): array
{
return [
'items' => array_map(
fn(OrderDTO $order) => $order->toArray(),
$this->items->toArray()
),
'total' => $this->total,
'current_page' => $this->currentPage,
'per_page' => $this->perPage,
'last_page' => $this->lastPage,
];
}
}

Pattern: Complex Queries with Filters

View Implementation
final class OrderFilterDTO
{
public function __construct(
public readonly ?string $status = null,
public readonly ?DateTimeInterface $fromDate = null,
public readonly ?DateTimeInterface $toDate = null,
public readonly ?int $minAmount = null,
public readonly ?int $maxAmount = null,
) {
}
}

interface OrderQuery extends QueryInterface
{
public function findByFilters(OrderFilterDTO $filters): OrderDTOCollection;
}

// Implementation
public function findByFilters(OrderFilterDTO $filters): OrderDTOCollection
{
$query = $this->repository->newQuery();

if ($filters->status !== null) {
$query->where('status', $filters->status);
}

if ($filters->fromDate !== null) {
$query->where('created_at', '>=', $filters->fromDate);
}

if ($filters->toDate !== null) {
$query->where('created_at', '<=', $filters->toDate);
}

if ($filters->minAmount !== null) {
$query->where('total_amount', '>=', $filters->minAmount);
}

if ($filters->maxAmount !== null) {
$query->where('total_amount', '<=', $filters->maxAmount);
}

$orders = $query->get();

$dtos = array_map(
fn(Order $order) => $this->toDTO($order),
$orders->all()
);

return new OrderDTOCollection($dtos);
}

Pattern: Nested DTOs

View Implementation
final class OrderWithItemsDTO implements DataTransferObject
{
public function __construct(
public readonly OrderId $id,
public readonly string $orderNumber,
public readonly OrderItemDTOCollection $items, // Nested collection
public readonly int $totalAmount,
) {
}

public function toArray(): array
{
return [
'id' => $this->id->value,
'order_number' => $this->orderNumber,
'items' => array_map(
fn(OrderItemDTO $item) => $item->toArray(),
$this->items->toArray()
),
'total_amount' => $this->totalAmount,
];
}
}

interface OrderQuery extends QueryInterface
{
public function getWithItems(OrderId $id): OrderWithItemsDTO;
}

Troubleshooting

Issue: Interface Not Found

Error
Interface [App\Domains\Order\OrderQuery] does not exist
Solution
  1. Verify interface file exists at correct path
  2. Check namespace matches directory structure
  3. Run composer dump-autoload
  4. Clear Laravel cache: php artisan cache:clear

Issue: Implementation Not Resolved

Error
Target [App\Domains\Order\OrderQuery] is not instantiable
Solution
  1. Verify implementation is registered in QueryServiceProvider
  2. Check interface-to-implementation binding
  3. Ensure implementation class exists
  4. Run php artisan config:clear

Issue: Type Error with Value Objects

Error
Argument #1 must be of type OrderId, int given
Solution
$order = $orderQuery->getById(1);

Issue: Circular Dependencies

Error
Circular dependency detected
Solution
  • Query interfaces should only depend on other query interfaces
  • Never create circular dependencies between modules
  • Use events for write operations instead of queries

Issue: Performance Problems

Symptoms
  • Slow query execution
  • N+1 query problems
  • High memory usage
Solutions

1. Use eager loading in implementation:

public function getWithItems(OrderId $id): OrderWithItemsDTO
{
$order = $this->repository
->newQuery()
->with('items') // Eager load
->findOrFail($id->value);

return $this->toDTO($order);
}

2. Use batch operations:

foreach ($orderIds as $id) {
$order = $orderQuery->getById(new OrderId($id));
}

3. Add caching if needed:

public function getById(OrderId $id): OrderDTO
{
return Cache::remember(
"order:{$id->value}",
3600,
fn() => $this->fetchFromDatabase($id)
);
}

Conclusion

The Query Infrastructure provides a robust, type-safe way to read data across module boundaries while maintaining loose coupling. By following this implementation guide and best practices, you can:

Achievements
  • ✅ Create maintainable, testable code
  • ✅ Enforce architectural boundaries
  • ✅ Enable independent module evolution
  • ✅ Support future scalability needs
Remember

Use queries for reading data, use events for triggering operations.