Programmers have long grappled with the boundary between relational and object-oriented thinking. This problem is known as the "Object-Relational Impedance Mismatch," referring to the fundamental incompatibility between the tabular data of relational databases and the hierarchical object structures of object-oriented programming.
Traditional ORMs attempted to abstract SQL away, making it invisible. This abstraction created boundaries that developers constantly felt. We want to leverage the power of databases while maintaining object-oriented principles. How to reconcile these two desires has always been a challenge.
The BDR Pattern dissolves this boundary.
In the BDR Pattern, SQL and OOP shake hands. Each performs what it does best while working in harmony.
The friction caused by boundaries is eliminated. There's no longer a need for forced abstractions or for one paradigm to pretend the other doesn't exist.
The BDR Pattern presents a new paradigm where Object-Oriented Programming and SQL work in harmony. It achieves "OOP autonomy with SQL foundation" and enables:
Core Value Propositions:
- SQL remains SQL: Complex queries, JOINs, window functions - all at maximum performance
- Objects remain objects: Autonomous domain models with rich behavior
- Leveraging both strengths: Achieving both object-oriented design and SQL performance
- Clear testing: Each component can be tested independently
Complex business logic scattered across controllers. One change requires modifications to multiple methods, and testing requires numerous mock objects.
When using ORMs, we encounter characteristics such as:
- Need to handle N+1 query problems
- Constraints in expressing complex JOINs
- Difficulty predicting generated SQL
- Creative workarounds needed for performance tuning
The BDR Pattern proposes a different approach.
It leverages the strengths of both SQL and OOP, allowing each to shine in their respective domains.
public function showOrderDetails(string $id): Response
{
$order = $this->orderRepo->getOrder($id);
return $this->render('order.html.twig', ['order' => $order]);
}
// Business logic in factories
// SQL in optimized query files
// Tests in independent layersSimple structure improves maintainability and readability.
class OrderController
{
public function show(string $id): Response
{
$order = $this->orderRepo->findById($id); // Simple data
// Business logic scattered in controller - testing nightmare!
$items = $this->inventoryService->checkStock($order->items);
$tax = $this->taxCalculator->calculate($items, $order->region);
$shipping = $this->shippingService->calculate($items, $order->region);
$canFulfill = $this->validateOrder($items, $order->status);
// Testing this controller requires mocking 6+ dependencies!
return $this->render('order.html.twig', compact('order', 'tax', 'shipping', 'canFulfill'));
}
}class OrderController
{
public function show(string $id): Response
{
// Repository returns complete domain object
$order = $this->orderRepo->getOrder($id);
// Controller only renders - no business logic
return $this->render('order.html.twig', ['order' => $order]);
}
}The BDR Pattern achieves something important: true object autonomy with SQL as the foundation.
Balancing object autonomy and SQL efficiency was traditionally considered difficult. The BDR Pattern achieves this balance. Domain objects are self-contained with their own behavior and data, while their creation is efficiently powered by SQL queries.
Critically, domain objects in the BDR Pattern are read-only and immutable. They represent a snapshot of the database at a specific point in time. These objects:
- Have no
save()methods - They don't persist themselves - Have no setters - State cannot be modified after creation
- Are query results - They represent the "read" side of your architecture
This immutability is intentional and brings important benefits:
- Thread-safe by default - Safe to share across concurrent operations
- Predictable behavior - State never changes unexpectedly
- Clear intent - Separation between queries (reading data) and commands (changing data)
// Leveraging the power of DI in domain objects
final readonly class UserDomainObject
{
public function __construct(
public string $id,
public string $name,
public string $role,
// Service injected from factory
private PermissionService $permissionService,
) {}
// Dynamic business rules through injected service
public function canEdit(Document $document): bool
{
// Impossible with ORM entities - depends on external service
// Test env: FakePermissionService (everyone can edit)
// Production: RealPermissionService (complex permission checks)
return $this->permissionService->canEdit($this, $document);
}
// Note: No save(), update(), or setter methods
// This object is a read-only snapshot
}In the BDR Pattern, objects are not mere data containers but domain objects containing business logic. They answer questions about the business domain but don't change the database themselves.
interface OrderRepositoryInterface
{
#[DbQuery('order_detail', factory: OrderDomainFactory::class)]
public function getOrder(string $id): OrderDomainObject;
#[DbQuery('active_orders', factory: OrderDomainFactory::class)]
/** @return array<OrderDomainObject> */
public function getActiveOrders(): array;
}SELECT
o.id,
o.customer_id,
o.region,
o.status,
o.created_at,
JSON_ARRAYAGG(
JSON_OBJECT(
'product_id', oi.product_id,
'name', p.name,
'quantity', oi.quantity,
'price', oi.price,
'current_stock', p.stock
)
) as items
FROM orders o
JOIN order_items oi ON o.id = oi.order_id
JOIN products p ON oi.product_id = p.product_id
WHERE o.id = :id
GROUP BY o.idfinal class OrderDomainFactory
{
public function __construct(
private TaxCalculator $taxCalculator,
private ShippingService $shippingService,
private InventoryService $inventoryService,
private BusinessRuleEngine $ruleEngine,
) {}
public function factory(
string $id,
string $customer_id,
string $region,
string $status,
string $items_json
): OrderDomainObject {
$items = json_decode($items_json, true);
// Centralize business logic in factory
$validatedItems = $this->inventoryService->validateStock($items);
$subtotal = array_sum(array_map(fn($item) => $item['price'] * $item['quantity'], $items));
$tax = $this->taxCalculator->calculate($validatedItems, $region);
$shipping = $this->shippingService->calculate($validatedItems, $region);
return new OrderDomainObject(
id: $id,
customerId: $customer_id,
region: $region,
status: $status,
items: $validatedItems,
subtotal: $subtotal,
tax: $tax,
shipping: $shipping,
total: $subtotal + $tax + $shipping,
canFulfill: count($validatedItems) === count($items) && $status === 'pending',
insufficientStockItems: $this->getInsufficientStockItems($items, $validatedItems),
ruleEngine: $this->ruleEngine,
);
}
private function getInsufficientStockItems(array $original, array $validated): array
{
// Business logic to identify items with insufficient stock
return array_filter($original, fn($item) =>
!in_array($item['product_id'], array_column($validated, 'product_id'))
);
}
}final readonly class OrderDomainObject
{
public function __construct(
public string $id,
public string $customerId,
public string $region,
public string $status,
public array $items, // Stock-validated items
public float $subtotal,
public float $tax, // Calculated by region
public float $shipping, // Calculated shipping
public float $total, // Complete total
public bool $canFulfill, // Business rule applied
public array $insufficientStockItems, // List of insufficient stock items
// Injected business rule engine - impossible with ORM
private BusinessRuleEngine $ruleEngine,
) {}
// Domain object behavior
public function getDisplayTotal(): string
{
return '$' . number_format($this->total, 2);
}
public function hasInsufficientStock(): bool
{
return count($this->insufficientStockItems) > 0;
}
public function getTaxRate(): float
{
return $this->subtotal > 0 ? ($this->tax / $this->subtotal) * 100 : 0;
}
public function isPending(): bool
{
return $this->status === 'pending';
}
public function canProcess(): bool
{
return $this->canFulfill && $this->isPending();
}
// Dynamic business rules through injected service
public function getBusinessPriority(): string
{
// Impossible with ORM entities - depends on external service
// Test environment: Relaxed thresholds (e.g., high priority at $100+)
// Production: Strict thresholds (e.g., high priority at $10,000+)
// Peak season: Different thresholds
// VIP customers: Special rules apply
return $this->ruleEngine->calculatePriority($this);
}
}One of the important advantages of the BDR Pattern is that testing becomes simple and reliable.
Common challenges in testing include:
- Integration tests taking a long time to run
- Test instability due to database state dependencies
- Complex mock setups
- Intermittently failing tests
The BDR Pattern provides a better way.
Because each layer is independent, if each is tested individually, the combination naturally works:
- SQL Query: Does it return correct data for the input?
- Factory: Does it correctly transform data into domain objects?
- Domain Object: Does it correctly implement business rules?
If these are individually correct, the combination is necessarily correct. It's a logical structure.
class UserQueryTest extends DatabaseTestCase
{
public function testUserByIdQuery(): void
{
// Prepare test data
$this->insertUser('user-1', 'Alice', 'alice@example.com', 'editor');
// Execute query
$result = $this->executeQuery('user_by_id.sql', ['id' => 'user-1']);
// Verify results
$this->assertEquals('Alice', $result[0]['name']);
$this->assertEquals('editor', $result[0]['role']);
}
}class UserDomainFactoryTest extends TestCase
{
public function testCreatesUserWithInjectedService(): void
{
// Inject fake service
$permissionService = new FakePermissionService();
$factory = new UserDomainFactory($permissionService);
// Test factory
$user = $factory->factory('user-1', 'Alice', 'alice@example.com', 'editor');
// Verify object is created correctly
$this->assertEquals('Alice', $user->name);
$this->assertEquals('editor', $user->role);
// Confirm injected service works
$document = new Document('doc-1', 'user-1');
$this->assertTrue($user->canEdit($document));
}
}class UserDomainObjectTest extends TestCase
{
public function testCanEditWithDifferentPermissionServices(): void
{
$document = new Document('doc-1', 'user-2');
// Restrictive service
$strictService = new StrictPermissionService();
$user1 = new UserDomainObject('user-1', 'Alice', 'alice@example.com', 'editor', $strictService);
$this->assertFalse($user1->canEdit($document)); // Cannot edit others' documents
// Permissive service
$relaxedService = new RelaxedPermissionService();
$user2 = new UserDomainObject('user-1', 'Alice', 'alice@example.com', 'editor', $relaxedService);
$this->assertTrue($user2->canEdit($document)); // Editors can edit all documents
}
}Because each layer is tested independently, integration issues are extremely rare. This eliminates the need for complex and fragile integration tests.
final class UserDomainFactory
{
public function factory(string $id, string $email, string $type): UserInterface
{
return match ($type) {
'free' => new FreeUser($id, $email, maxStorage: 100),
'premium' => new PremiumUser($id, $email, maxStorage: 1000),
};
}
}final class ProductDomainFactory
{
public function __construct(
private PriceService $priceService, // External API
) {}
public function factory(string $id, string $name): ProductDomainObject
{
return new ProductDomainObject(
id: $id,
name: $name,
currentPrice: $this->priceService->getCurrentPrice($id),
);
}
}final class UserDomainFactory
{
public function __construct(
private CacheInterface $cache,
private PermissionService $permissionService,
) {}
public function factory(string $id, string $name, string $role): UserDomainObject
{
// Cache expensive permission lookups
$permissions = $this->cache->remember(
"permissions_{$role}",
3600,
fn() => $this->permissionService->getPermissions($role)
);
return new UserDomainObject($id, $name, $role, $permissions);
}
}// Before: Logic scattered in controller
class ProductController
{
public function show($id)
{
$product = $this->repo->find($id);
// Identify this business logic
$product->finalPrice = $this->calculatePrice($product);
$product->inStock = $this->inventory->check($product->id);
$product->reviews = $this->reviewService->get($product->id);
return view('product', compact('product'));
}
}// After: Move logic to factory
final class ProductDomainFactory
{
public function factory($id, $basePrice, $categoryId): ProductDomainObject
{
return new ProductDomainObject(
id: $id,
finalPrice: $this->calculatePrice($basePrice, $categoryId),
inStock: $this->inventory->check($id),
reviews: $this->reviewService->get($id),
);
}
}- Start with new features - Implement new features with BDR Pattern
- Prioritize high-traffic endpoints - Greater performance improvement impact
- Leverage existing test coverage - Migrate while utilizing existing tests
- Share knowledge within the team - Share the benefits of the factory pattern
Another advantage of the BDR Pattern is creating a codebase transparent to AI tools.
Complex abstraction layers of traditional ORMs were black boxes to AI:
- Unclear what SQL would be executed
- Difficult to trace where business logic exists
- Implicit dependencies difficult to understand
In the BDR Pattern, everything is explicit:
- What data is accessed: Visible in SQL files
- How it's transformed: Clear in factory methods
- What services are used: Explicit in constructors
- Business logic flow: Traceable from query → factory → domain object
-- order_detail.sql - AI can read and understand this
SELECT
o.id,
o.region,
JSON_ARRAYAGG(
JSON_OBJECT(
'product_id', oi.product_id,
'quantity', oi.quantity,
'price', oi.price
)
) as items
FROM orders o
JOIN order_items oi ON o.id = oi.order_id
WHERE o.id = :id// Factory - AI fully understands dependencies and logic
public function __construct(
private TaxCalculator $taxCalculator, // Explicit dependency
private ShippingService $shippingService, // Explicit dependency
) {}This transparency enables AI assistants to deeply understand your codebase and provide more accurate suggestions and automation.
The BDR Pattern presents one form of domain collaboration. It not only bridges different paradigms but also dissolves boundaries between different media.
SQL (declarative, set-based) and OOP (imperative, object-based). How to combine these technologies with different characteristics has been a long-standing challenge.
The BDR Pattern provides one approach to this challenge.
The boundaries created by traditional ORMs abstracting SQL. The BDR Pattern dissolves these and creates new harmony.
The results achieved are:
- Controllers become simple - Focus on presentation
- Business logic in the right place - Placed in factories
- Testing is clear and independent - Each layer ensures quality independently
- No performance compromise - Maximize SQL performance
SQL and OOP work in harmony.
In the BDR Pattern, each excels in its own domain while building something greater together.
A: You don't. Objects in the BDR Pattern are read-only and exist for querying data. When you need to modify data:
- Make business decisions in your application layer
- Issue a Command - a clear, explicit write operation
- Execute simple write queries - UPDATE, INSERT, DELETE statements
This follows the CQRS (Command Query Responsibility Segregation) principle:
// Query side (BDR Pattern)
$order = $this->orderRepo->getOrder($id);
if ($order->canProcess()) {
// Command side (simple write)
$this->orderCommandRepo->markAsProcessed($id, new DateTime());
}
// orderCommandRepo might use simple SQL:
// UPDATE orders SET status = 'processed', processed_at = :timestamp WHERE id = :idThe separation is intentional:
- Queries can be complex, with JOINs and aggregations
- Commands should be simple and focused on changing state
- Domain logic lives in the query objects, not in the database writes
A: Yes, specifically the Query (read) side. The BDR Pattern is a powerful implementation of CQRS's query side.
CQRS separates read and write responsibilities:
- Query side (BDR Pattern): Complex reads with rich domain objects containing business logic
- Command side: Simple, focused writes that change state
The BDR Pattern handles the complex part (queries) by combining:
- SQL's power for data retrieval
- Factories for transformation and enrichment
- Domain objects for business logic
Meanwhile, the command side remains straightforward:
- Direct UPDATE/INSERT/DELETE statements
- Event sourcing (if needed)
- Simple validation before writes
This separation makes both sides simpler and more maintainable.
A: Yes, without proper strategy. This is essentially an N+1 problem variant. Here are strategies to mitigate:
1. Batch Requests
final class ProductDomainFactory
{
private array $priceCache = [];
public function factory(string $id, string $name): ProductDomainObject
{
// Prices fetched in batch before factory calls
$price = $this->priceCache[$id] ?? $this->priceService->getPrice($id);
return new ProductDomainObject($id, $name, $price);
}
public function warmPriceCache(array $productIds): void
{
// Fetch all prices in one API call
$this->priceCache = $this->priceService->getPrices($productIds);
}
}2. Lazy Loading
final readonly class ProductDomainObject
{
private ?float $currentPrice = null;
public function getCurrentPrice(): float
{
// Only fetch when actually needed
return $this->currentPrice ??= $this->priceService->getPrice($this->id);
}
}3. Strategic Data Loading
// List view: Don't load expensive data
#[DbQuery('product_list_simple', factory: ProductListFactory::class)]
public function getProductList(): array;
// Detail view: Load everything including external data
#[DbQuery('product_detail', factory: ProductDetailFactory::class)]
public function getProduct(string $id): ProductDomainObject;The key is being intentional about when and how you load data. The factory pattern gives you complete control over this strategy.
- Object-Relational Mapping is the Vietnam of Computer Science - Jeff Atwood (2006)