Stop using POM for complex test suites. Do this instead

dmitryaqa1 pts0 comments

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...

await page cart test click layer

Related Articles