Skip to main content

Architecture Layers: Infrastructure vs Domain

Overview

The MicroORM is designed with a clear separation between Infrastructure Layer (raw data access) and **Domain Layer ** (entity-aware operations). Understanding when to use each layer is crucial for writing maintainable code.

Architectural Foundation

This architecture follows Martin Fowler's Enterprise Application Architecture patterns, specifically:

Repository Pattern

"Mediates between the domain and data mapping layers using a collection-like interface for accessing domain objects."

— Martin Fowler, Patterns of Enterprise Application Architecture

In MicroORM:

  • The Repository class acts as a collection-like interface
  • Isolates domain objects from database access code
  • Provides a clean separation between domain and data layers
  • Handles entity lifecycle (CRUD operations)

Data Mapper Pattern

"A layer of software that separates the in-memory objects from the database. Its responsibility is to transfer data between the two and also to isolate them from each other."

— Martin Fowler, Patterns of Enterprise Application Architecture

In MicroORM:

  • The Mapper class defines the relationship between entities and database tables
  • Translates between domain objects (entities) and database rows
  • Keeps domain objects independent of database schema
  • Allows field name mapping, transformations, and type conversions

Why These Patterns Matter:

  • Testability: Mock repositories without touching the database
  • Flexibility: Change database schema without changing domain objects
  • Separation of Concerns: Domain logic stays pure, database logic stays isolated
  • Maintainability: Clear boundaries make code easier to understand and modify

Active Record Pattern (Alternative Approach)

"An object that wraps a row in a database table or view, encapsulates the database access, and adds domain logic on that data."

— Martin Fowler, Patterns of Enterprise Application Architecture

In MicroORM:

  • The ActiveRecord trait provides static methods like get(), save(), delete(), etc.
  • Each Active Record instance represents a single database row
  • Database operations are directly available on domain objects
  • Simpler than Repository/Data Mapper for straightforward CRUD applications

Pattern Comparison:

Repository + Data Mapper           Active Record
├─ Domain objects are pure ├─ Domain objects know about DB
├─ More layers/complexity ├─ Fewer layers/simpler
├─ Better for complex domains ├─ Better for simple domains
├─ Easier to test (DI) ├─ Harder to test (static methods)
└─ More flexible └─ More convenient

Choosing Your Pattern:

  • Use Active Record for: Simple apps, prototypes, CRUD-heavy applications
  • Use Repository + Data Mapper for: Complex domain logic, Domain-Driven Design, enterprise applications

See Active Record Documentation for detailed usage examples.

The Two Layers

Infrastructure Layer

Purpose: Raw database access without entity knowledge Key Methods: Query::buildAndGetIterator(), Query::build() Returns: Raw database rows (associative arrays)

Domain Layer

Purpose: Entity-aware data access with automatic mapping Key Methods: Repository::getIterator(), Repository::getByQuery() Returns: Domain entities (objects)

When to Use Each Layer

Use Infrastructure Layer (Query::buildAndGetIterator()) For:

Migration Scripts

// Migration: Export data for backup
$query = Query::getInstance()
->table('users')
->where('created_at < :cutoff', ['cutoff' => '2020-01-01']);

$rows = $query->buildAndGetIterator(DatabaseExecutor::using($driver))->toArray();
file_put_contents('backup.json', json_encode($rows));

Utility/Admin Tools

// Admin tool: Generate report with raw data
$query = QueryRaw::getInstance("
SELECT DATE(created_at) as date, COUNT(*) as count
FROM users
GROUP BY DATE(created_at)
");

$stats = $query->buildAndGetIterator(DatabaseExecutor::using($driver))->toArray();

Testing Query Building Logic

// Test: Verify SQL generation
public function testQueryBuilder()
{
$query = Query::getInstance()
->table('users')
->where('status = :status', ['status' => 'active']);

$sql = $query->build($driver)->getSql();
$this->assertEquals('SELECT * FROM users WHERE status = :status', $sql);
}

Working Without a Repository

// Standalone script without entity models
$query = Query::getInstance()
->table('logs')
->where('level = :level', ['level' => 'ERROR']);

$errors = $query->buildAndGetIterator(DatabaseExecutor::using($driver))->toArray();

Use Domain Layer (Repository methods) For:

Application/Business Logic

// Application: Get active users
$query = $userRepo->queryInstance()
->where('status = :status', ['status' => 'active']);

$users = $userRepo->getIterator($query)->toEntities(); // Returns User[] objects

Standard CRUD Operations

// Business logic: Find user by email
$user = $repository->getByFilter(['email' => '[email protected]']);

// Update user
$user->setName('New Name');
$repository->save($user);

Complex Queries with Entity Transformation

// Multi-table JOIN with automatic entity mapping
$query = Query::getInstance()
->table('users')
->join('info', 'users.id = info.user_id')
->where('users.status = :status', ['status' => 'active']);

// Returns array of [User, Info] entity pairs
$results = $userRepo->getByQuery($query, [$infoMapper]);

Architecture Comparison

┌─────────────────────────────────────────────────────────────────────────┐
│ INFRASTRUCTURE LAYER │
├─────────────────────────────────────────────────────────────────────────┤
│ │
│ Query::buildAndGetIterator(DatabaseExecutor $executor) │
│ ├─ Returns: GenericIterator (raw arrays) │
│ ├─ No entity knowledge │
│ ├─ Stateless execution │
│ └─ Use for: migrations, utilities, admin tools │
│ │
└─────────────────────────────────────────────────────────────────────────┘


│ build()

┌────────────────────────────────────────────────────────────────────────┐
│ DOMAIN LAYER │
├────────────────────────────────────────────────────────────────────────┤
│ │
│ Repository::getIterator(QueryBuilderInterface $query) │
│ ├─ Returns: GenericIterator (with entity transformation) │
│ ├─ Uses mapper for automatic transformation │
│ ├─ Integrated with repository lifecycle │
│ └─ Use for: application logic, business operations │
│ │
│ Repository::getByQuery(QueryBuilderInterface $query, array $mappers) │
│ ├─ Returns: Entity[] or Array<int, Entity[]> │
│ ├─ Handles single-mapper (efficient) and multi-mapper (JOINs) │
│ ├─ Intelligent entity boundary detection │
│ └─ Use for: complex queries, JOIN operations │
│ │
└────────────────────────────────────────────────────────────────────────┘

Multi-Mapper Logic: Why It Belongs in Repository

The Problem

When executing a JOIN query, the result set contains columns from multiple tables:

SELECT users.*, info.*
FROM users
JOIN info ON users.id = info.user_id

Result:

| id | name      | id | user_id | property |
|----|-----------|----|---------│----------|
| 1 | John Doe | 1 | 1 | 30.4 |

This row contains data for two entities: User and Info. The Query layer doesn't know where one entity ends and another begins.

The Solution: Repository Intelligence

Repository::getByQuery() intelligently handles this by accepting multiple mappers for JOIN queries:

// Setup: Create mappers for both entities
$userMapper = new Mapper(User::class, 'users', 'id');
$infoMapper = new Mapper(Info::class, 'info', 'id');

$userRepository = new Repository(DatabaseExecutor::using($driver), $userMapper);

// Build a JOIN query
$query = Query::getInstance()
->table('users')
->join('info', 'users.id = info.user_id')
->where('users.status = :status', ['status' => 'active']);

// Execute with multiple mappers - Repository handles the complexity!
$results = $userRepository->getByQuery($query, [$infoMapper]);

// Results structure:
// [
// [$userEntity1, $infoEntity1], // First row mapped to User and Info
// [$userEntity2, $infoEntity2], // Second row mapped to User and Info
// ...
// ]

foreach ($results as [$user, $info]) {
echo $user->getName() . " has property: " . $info->getProperty() . "\n";
}

What happens internally:

  1. Repository detects multiple mappers (User + Info)
  2. Executes the query and gets raw rows
  3. For each row, creates two separate entities:
    • Uses $userMapper to extract User fields → creates User object
    • Uses $infoMapper to extract Info fields → creates Info object
  4. Returns array of entity pairs

Single Mapper (Optimized Path):

// When you only need one entity type, it's more efficient
$query = Query::getInstance()
->table('users')
->where('status = :status', ['status' => 'active']);

$users = $userRepository->getByQuery($query); // No additional mappers
// Returns: [User, User, User, ...] - Just User entities

Why This Logic Belongs in Repository, Not Query:

  1. Entity mapping is domain logic, not data access logic
  2. Multi-mapper queries need entity boundaries - only the Repository knows which columns belong to which entity
  3. Query layer stays agnostic - keeps infrastructure concerns separate from domain concerns
  4. Repository is the guardian - it mediates between raw database rows and domain entities

Best Practices

✅ DO: Use Repository for Application Code

class UserService
{
public function __construct(private Repository $userRepository) {}

public function getActiveUsers(): array
{
$query = $this->userRepository->queryInstance()
->where('status = :status', ['status' => 'active']);

return $this->userRepository->getIterator($query)->toEntities();
}
}

❌ DON'T: Use Query Directly in Application Code

// BAD: Mixing infrastructure and domain concerns
class UserService
{
public function getActiveUsers(): array
{
$query = Query::getInstance()
->table('users')
->where('status = :status', ['status' => 'active']);

$rows = $query->buildAndGetIterator(DatabaseExecutor::using($driver))->toArray();

// Now you have to manually map arrays to entities!
return array_map(fn($row) => new User($row), $rows);
}
}

✅ DO: Use Query for Infrastructure Code

// GOOD: Utility script for data export
class DataExporter
{
public function exportToJson(string $table, string $filename): void
{
$query = Query::getInstance()->table($table);
$rows = $query->buildAndGetIterator(DatabaseExecutor::using($driver))->toArray();

file_put_contents($filename, json_encode($rows, JSON_PRETTY_PRINT));
}
}

Summary

AspectInfrastructure LayerDomain Layer
MethodsQuery::buildAndGetIterator()Repository::getIterator()
Repository::getByQuery()
ReturnsRaw arraysDomain entities
MapperNot requiredRequired
Entity TransformNoYes (automatic)
Multi-Mapper JOINsNot supportedSupported
Use CasesMigrations, utilities, testingApplication logic, CRUD
CouplingLow (stateless)High (repository lifecycle)

Key Takeaway: Use Repository methods in your application code. Reserve Query methods for infrastructure-level operations where entity transformation is not needed or not desired.

See Also