Why flat test architectures fail: Moving beyond POM to a 3-layer BDR approach

Why flat test architectures fail: Moving beyond POM to a 3-layer BDR approach PRO IMPLEMENTATION
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
@Stepdecorator 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
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 muchtest('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();});When this test fails, your report shows:
✗ Test: User can complete purchase - goto - fill - fill - click - click - click - fill - clickWhich 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
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 Flowclass CartFlow { async addProduct(product: Product) {...} }class CheckoutFlow { async placeOrder(address: Address) {...} }class PaymentFlow { async pay(card: Card) {...} }
// Spec assembles them for different scenariostest('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);});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)
Section titled “Layer 1: Technical (Page Objects)”Job: Encapsulate raw Playwright interactions. Know about selectors. Know nothing else.
export class CartPage { constructor(private page: Page) {}
// Exposes WHAT can be done, not HOW the business uses it get checkoutButton(): Locator { return this.page.getByTestId('checkout-submit'); }
async clickCheckout() { await this.checkoutButton.click(); }}What it must NOT do:
// WRONG: POM containing business logicasync proceedToCheckoutAndVerify() { await this.checkoutButton.click(); // This is business logic — it doesn't belong here await expect(this.page).toHaveURL('/payment');}Why? Because the URL /payment is a business rule, not a UI detail. If the business decides to show a modal instead of navigating — your POM shouldn’t need to change.
Layer 2: Action (Flows)
Section titled “Layer 2: Action (Flows)”Job: Orchestrate business processes using Page Objects. Know about business rules. Know nothing about selectors.
export class CheckoutFlow { // Dependency Injection: receives ready Page Object instances constructor( private cartPage: CartPage, private paymentPage: PaymentPage, ) {}
async completePurchase(orderData: OrderData) { await test.step('WHEN: User proceeds to checkout', async () => { await this.cartPage.clickCheckout(); // Business rule: payment form must appear await expect(this.paymentPage.form).toBeVisible(); });
await test.step('WHEN: User fills payment details', async () => { // Data comes from outside — no hardcoded values in Flows await this.paymentPage.fillDetails(orderData.card); await this.paymentPage.submit(); }); }}What it must NOT do:
// WRONG: Flow reaching into selectorsasync completePurchase(orderData: OrderData) { // This bypasses the POM entirely — now Flow is coupled to selectors await this.page.getByTestId('checkout-submit').click();}Why does this matter? If checkout-submit becomes checkout-btn, you now have to find and fix this in every Flow that touches it — instead of fixing it once in CartPage.
Layer 3: Specification (Tests)
Section titled “Layer 3: Specification (Tests)”Job: Express business intent. Read like a user story. Know nothing about implementation.
test('User can complete a purchase', async ({ checkoutFlow }) => { await BDR.Given('the user has items in their cart', async () => { await checkoutFlow.addProductToCart(testProduct); });
await BDR.When('the user completes the purchase', async () => { await checkoutFlow.completePurchase(testOrderData); });
await BDR.Then('the order is confirmed', async () => { await checkoutFlow.verifyOrderConfirmation(); });});A non-engineer can read this and understand exactly what’s being tested. That’s the goal.
What it must NOT do:
// WRONG: Test reaching into POM directlytest('User can complete a purchase', async ({ page }) => { // Test now knows about selectors — living documentation is broken await page.getByTestId('checkout-submit').click();});The boundary violation cascade
Section titled “The boundary violation cascade”Here’s what actually happens when teams blur the boundaries:
Month 1: “It’s just one selector in the Flow, it’s fine.”
Month 2: The selector changes. You fix it in the POM — but the Flow breaks too. Two places to fix instead of one.
Month 3: A new developer adds business logic to the POM because “that’s where the page stuff is”. Now the POM has assertions.
Month 6: Every layer knows about every other layer. Changing anything breaks everything. Nobody knows where to look when a test fails.
The three-layer rule isn’t aesthetic. It’s the thing that keeps your test suite maintainable at scale.
What the report looks like with proper layering
Section titled “What the report looks like with proper layering”With this architecture, your Allure report becomes a business document:
✓ User can complete a purchase ✓ GIVEN: The user has items in their cart 📊 Cart Contents: [Laptop Pro x1, $1200] ✓ WHEN: User proceeds to checkout ✓ WHEN: User fills payment details 📊 Payment Data: [Card: **** 4242, Amount: $1200] ✓ THEN: Order is confirmed 📊 Order Summary: [ID: #12345, Status: confirmed]When a test fails:
✗ User can complete a purchase ✓ GIVEN: The user has items in their cart ✗ WHEN: User proceeds to checkout 📊 Cart State before click: [button status: disabled, reason: stock_unavailable] ❌ Expected payment form to be visibleThirty seconds from opening the report to understanding the failure. No code diving required.
Fixtures: the dependency injection container
Section titled “Fixtures: the dependency injection container”The glue that makes all this work without boilerplate is Playwright’s fixture system:
import { test as base } from '@playwright/test';import { CartPage } from '../pom/CartPage';import { PaymentPage } from '../pom/PaymentPage';import { CheckoutFlow } from '../flows/CheckoutFlow';
type Fixtures = { cartPage: CartPage; paymentPage: PaymentPage; checkoutFlow: CheckoutFlow;};
export const test = base.extend<Fixtures>({ cartPage: async ({ page }, use) => { await use(new CartPage(page)); }, paymentPage: async ({ page }, use) => { await use(new PaymentPage(page)); }, // Flow receives its Page Objects automatically via DI checkoutFlow: async ({ cartPage, paymentPage }, use) => { await use(new CheckoutFlow(cartPage, paymentPage)); },});Your test declares what it needs — Playwright provides it. Fresh instance per test, no shared state, no manual wiring.
Anti-patterns and how to spot them
Section titled “Anti-patterns and how to spot them”Anti-pattern 1: The God Test The test does everything: setup, interaction, assertion, cleanup — all with raw Playwright calls. Sign: test file is 100+ lines.
Anti-pattern 2: The Smart POM
Page Object contains assertions, navigation logic, or business rules. Sign: expect() calls inside a POM method.
Anti-pattern 3: The Leaky Flow
Flow accesses page directly or imports locators. Sign: this.page.getBy... inside a Flow class.
Anti-pattern 4: The Copy-Paste Chain Same setup code (login, navigate, seed data) repeated across test files. Sign: changing one thing requires a grep-and-replace.
The rule in one sentence
Section titled “The rule in one sentence”Each layer talks only to the layer directly below it. Spec → Flow → POM. Never skip a level. Never reach up.
Follow this and your test suite stays maintainable. Violate it and you’ll be rewriting everything in six months.
Try it
Section titled “Try it”This architecture is implemented in the BDR Playwright template — ready to clone and use:
- BDR Methodology — full architecture docs and guides
- Playwright BDR Template — working implementation
I’m open to QA Automation roles — remote, contract, or full-time. dmitryAQA@outlook.com | @DmitryMeAQA





