Why flat test architectures fail: Moving beyond POM to a 3-layer BDR approach | BDR Methodology<br>Skip to content
Why flat test architectures fail: Moving beyond POM to a 3-layer BDR approach
May 3, 2026<br>Dmitry<br>QA Automation Engineer
Why flat test architectures fail: Moving beyond POM to a 3-layer BDR approach PRO IMPLEMENTATION<br>Section titled “Why flat test architectures fail: Moving beyond POM to a 3-layer BDR approach ”
This is a technical deep dive into BDR’s layered architecture. For an introduction to why BDR exists and how the @Step decorator works internally, see Beyond Cucumber: A Type-Safe 4-Layer BDD Architecture with Playwright.
Note: BDR (Behavior-Driven Living Requirements) is my own architectural approach to organizing Playwright tests — a Cucumber-free alternative to BDD that I designed and documented at bdr-methodology.dev.
The problem with flat test architecture<br>Section titled “The problem with flat test architecture”
Most Playwright projects start with two layers: Page Objects and tests. It works fine at twenty tests. At two hundred, it collapses.
Here’s a typical flat architecture failure:
// The test knows too much
test('User can complete purchase', async ({ page }) => {
// Setup — copy-pasted from 40 other tests
await page.goto('/login');
await page.getByLabel('Email').fill('user@example.com');
await page.getByLabel('Password').fill('password123');
await page.getByRole('button', { name: 'Log In' }).click();
// The actual test
await page.getByTestId('add-to-cart').click();
await page.getByTestId('checkout-submit').click();
await page.getByLabel('Card Number').fill('4242424242424242');
await page.getByRole('button', { name: 'Pay' }).click();
await expect(page.getByText('Order confirmed')).toBeVisible();
});
{ // Setup — copy-pasted from 40 other tests await page.goto('/login'); await page.getByLabel('Email').fill('user@example.com'); await page.getByLabel('Password').fill('password123'); await page.getByRole('button', { name: 'Log In' }).click(); // The actual test await page.getByTestId('add-to-cart').click(); await page.getByTestId('checkout-submit').click(); await page.getByLabel('Card Number').fill('4242424242424242'); await page.getByRole('button', { name: 'Pay' }).click(); await expect(page.getByText('Order confirmed')).toBeVisible();});">
When this test fails, your report shows:
✗ Test: User can complete purchase
- goto
- fill
- fill
- click
- click
- click
- fill
- click
Which click failed? What was the state? What was being tested — login, cart, or payment? Nobody knows without reading the entire test.
Why three layers, not two<br>Section titled “Why three layers, not two”
The standard advice is “add a Flow layer”. But most teams add it for the wrong reason — DRY. They think “I keep copy-pasting the cart setup, let me extract it into a Flow.”
DRY is a nice side effect. It’s not the point.
The real reason for three layers is separation of abstraction levels . Each layer speaks a different language:
POM speaks the language of markup: “click this button”, “fill this field”, “find this element”
Flow speaks the language of business: “add product to cart”, “place order”, “process payment” — these are self-contained business entities, not just reusable helpers
Spec speaks the language of scenarios: assembles business entities like Lego to express intent
Here’s what that looks like in practice with an e-commerce app:
// Three separate business entities — each its own Flow
class CartFlow { async addProduct(product: Product) {...} }
class CheckoutFlow { async placeOrder(address: Address) {...} }
class PaymentFlow { async pay(card: Card) {...} }
// Spec assembles them for different scenarios
test('Full purchase flow', async ({ cart, checkout, payment }) => {
await cart.addProduct(laptop);
await checkout.placeOrder(address);
await payment.pay(card);
});
test('Cart total updates correctly', async ({ cart }) => {
await cart.addProduct(laptop);
await cart.addProduct(mouse);
await cart.verifyTotal(1225);
});
{ await cart.addProduct(laptop); await checkout.placeOrder(address); await payment.pay(card);});test('Cart total updates correctly', async ({ cart }) => { await cart.addProduct(laptop); await cart.addProduct(mouse); await cart.verifyTotal(1225);});">
Same building blocks, different scenarios. CartFlow exists not because you’ll reuse it (though you will), but because “managing the cart” is a real business concept with its own rules and boundaries.
This distinction matters because it changes how you design Flows. A DRY-driven Flow is shaped by what’s convenient to reuse. A business-entity Flow is shaped by what the business actually does. The second one is stable. The first one drifts.
Here’s the precise responsibility of each layer:
Layer 1: Technical (Page Objects)<br>Section titled “Layer 1: Technical (Page Objects)”
Job: Encapsulate raw Playwright interactions. Know about selectors. Know nothing...