Back to Blog
Technology
July 31, 2025
4 min read
742 words

The Page Object Model (POM) is an Anti-Pattern. Why We Moved to Component-Based Architecture.

We deleted our 5,000-line 'LoginPage.java'. It was a God Class that violated every SOLID principle. Here is why the industry standard 'Page Object Model' is actually a trap, and how the Screenplay Pattern saves you.

The Page Object Model (POM) is an Anti-Pattern. Why We Moved to Component-Based Architecture.

If you are an Automation Engineer, you know this file. It sits in your repository, growing like a tumor. It started as a clean representation of your Login Page. It had a username field, a password field, and a submit button.

But then, Marketing added a "Remember Me" checkbox. Security added a "Forgot Password" flow. Product added "Login with Google", "Login with SSO", and "Login with Magic Link".

Today, your LoginPage.java (or .ts, or .py) is 5,000 lines long. It imports 15 different helpers. It has methods like loginWithGoogleAndThenCancel(). It is a God Class.

We deleted it.

At XQA, we declared the Page Object Model (POM) an Anti-Pattern. It violates the Single Responsibility Principle. It encourages Inheritance over Composition. And it is the primary reason why test suites become unmaintainable after Year 2.

Here is the architectural autopsy of POM, and what we replaced it with.

The "Inheritance Trap"

The standard advice for POM is: "Create a BasePage class, and have all pages inherit from it."


class BasePage {
    protected driver: WebDriver;
    
    waitForElement(locator: By) { ... }
    click(locator: By) { ... }
}

class LoginPage extends BasePage { ... }
class DashboardPage extends BasePage { ... }
    

This looks innocent. But it is deadly.

The problem is "Is-A" vs "Has-A".

Does a LoginPage have a driver? Or is it a driver wrapper? In this model, every page becomes a leaky abstraction over Selenium. When you need to add a custom wait strategy for a specific component (e.g., a React Virtualized List), you end up hacking the BasePage.

Suddenly, your BasePage has 50 methods. waitForElement, waitForElementVisible, waitForElementClickable, waitForElementToStopMoving. Every page inherits this bloat.

Section 1: The Violation of SRP (Single Responsibility Principle)

What is the responsibility of a Page Object?

  1. Locating elements? (By.id('user'))
  2. Interacting with elements? (element.click())
  3. Defining business logic? (loginAsAdmin())
  4. Navigating? (driver.get('/login'))

In 99% of frameworks, it does all four.

This coupling means that if the HTML changes, you edit the Page Object. If the business flows change, you edit the Page Object. If the navigation URL changes, you edit the Page Object.

The Fix: Components, Not Pages.

Modern web apps (React, Vue, Angular) are not built as "Pages." They are built as Components. Your test automation should mirror the application architecture.

We broke our monolithic Pages into small, reusable Components:

  • LoginForm (Reusable on Login Page, Modal, and Checkout).
  • NavBar (Reusable everywhere).
  • ToastNotification (Reusable everywhere).

Section 2: The Screenplay Pattern (The Real Solution)

We didn't just stop at components. We adopted the Screenplay Pattern (popularized by Serenity BDD).

In POM, you write: loginPage.login("user", "pass").

In Screenplay, you write: Actor.named("James").attemptsTo(Login.withCredentials("user", "pass")).

Why is this better? Decoupling.

  • Actors are the "Who".
  • Tasks (Login) are the "What".
  • Interactions (Click, Type) are the "How".

The Code Evidence:


// The Task is isolated. It can be composed.
export class Login implements Task {
    performAs(actor: Actor) {
        return actor.attemptsTo(
            Enter.theValue(user).into(LoginForm.USERNAME),
            Enter.theValue(pass).into(LoginForm.PASSWORD),
            Click.on(LoginForm.SUBMIT_BUTTON)
        );
    }
}
    

If the Login UI changes from a Page to a Modal, we only update the LoginForm locators. The Login tasks remain untouched. The tests remain untouched.

Section 3: Handling State and Asynchrony

POM encourages "Method Chaining" which hides state.

loginPage.enterUser().enterPass().submit().waitForDashboard();

This assumes a happy path. But what if the "Enter User" triggers an async validation spinner? POM makes it hard to inject "Wait" logic in just one place without polluting the chain.

With Component Architecture, each component owns its "Readiness".


class LoginForm {
    async enterUser(val: string) {
        await this.usernameField.wait(Until.present);
        await this.usernameField.type(val);
        await this.validationSpinner.wait(Until.gone); // Encapsulated Logic
    }
}
    

This keeps the test code clean and pushes the complexity down to the atomic level where it belongs.

Section 4: The Refactoring Story

When we audited our legacy POM suite, we found:

  • Duplication: The "Logout" method was copied across 12 different Page Objects because developers didn't know where to put it.
  • Flakiness: 60% of test failures were due to missing waits inside massive methods like fillRegistrationForm().
  • Onboarding Time: New SDETs took 3 weeks to understand the inheritance hierarchy.

The Result of the Shift:

We moved to a strict Component Object model (inspired by Playwright's recommendation).

Our code size dropped by 40%. Our reliable test rate went from 85% to 99%. And most importantly, we stopped debugging NullPointerExceptions in BasePage.

Conclusion

The Page Object Model was invented in 2013 for static HTML pages with full reloads. It does not work for Single Page Applications (SPAs) in 2026.

Stop building God Classes. Start building Components.

If your class has "Page" in the name, delete it.

Tags:TechnologyTutorialGuide
X

Written by XQA Team

Our team of experts delivers insights on technology, business, and design. We are dedicated to helping you build better products and scale your business.