I’ve shipped Playwright tests in nine production systems over the last two years. Every single one of them started with classic Page Object Model. Every single one of them, by month four, had at least one Page Object that crossed 1,500 lines.
That’s not a slogan. That’s a pattern I watched repeat across B2B platforms, e-commerce, CRM, logistics, education, and automotive wholesale. Same language (TypeScript), same tooling (Playwright + axe-core), same outcome: POM scales until it doesn’t, and the day it breaks is usually the day a junior tries to add their first test and can’t find where the locator lives.
This article is what I extracted from those two years. Not a framework, not a library - a discipline. Four layers, three zero-rules, MIT licensed. The interactive version with toggle cards lives at cdat.sdet.it. This piece is for people who want the long-form reasoning.
Why POM breaks at scale
Page Object Model has been the de facto standard for over a decade. Most teams adopt it without thinking, the way you’d put pants on before leaving the house. And on small projects it works. The problems show up at scale, predictably, in four shapes.
1. God objects
A CheckoutPage class that started life with a fill-form-and-submit method ends up with fifty locators, thirty methods, and 1,200 lines of logic. The class has eaten the whole feature. Cyclomatic complexity is a vibe at this point. Tests reach into it for everything because everything is in there.
2. Mixed responsibilities
The same class holds locators, business logic, assertions, retry loops, fixture setup, and occasionally a console.log somebody forgot to delete. When something fails, the failure could come from any of those layers, and you can’t tell which because they share a method.
class LoginPage {
async login(user: string, pass: string) {
await this.usernameInput.fill(user);
await this.passwordInput.fill(pass);
await this.submitButton.click();
await expect(this.dashboard).toBeVisible(); // Assertion in page object?
}
}
That last line is the smell. The page object now has an opinion about what success looks like, which means a test that wants to assert something else has to either wrap the method or copy-paste it.
3. Poor reusability
When the partial flow is what you want - fill the form but don’t submit, or submit and stay on the page - POM forces you to either expose half the internals or duplicate the method. Both are bad. Neither has a clean answer.
4. Maintenance nightmare
When a selector changes, you grep. When the business rule changes, you grep harder. When both change in the same week, you ship a regression because the page object’s split personality made it easy to update one half without the other.
What I tried first
Before CDAT I went through the usual menu. Two of them deserve a mention because both are real and one of them is genuinely good for some shapes.
Screenplay Pattern. Five abstractions: Actors, Abilities, Tasks, Questions, Interactions. The cleanliness is real, the cost is real too. A simple login becomes:
const actor = Actor.named('User');
actor.attemptsTo(
Navigate.to(LoginPage),
Enter.theValue('user').into(UsernameField),
Enter.theValue('pass').into(PasswordField),
Click.on(SubmitButton),
Wait.until(Dashboard, isVisible())
);
It reads beautifully. It also requires every team member to internalize five layers, and on most projects that’s overkill. I shipped it once. The retro was unanimous: too many files for too little benefit.
Facade + Delegation. I wrote about this in Facade and Delegation Pattern - splitting a 1,500-line SaleActions class into a thin facade that delegates to focused sub-modules. It works. It’s still my recommendation when you can’t change the framework but you can refactor inside it. CDAT is the next step: stop splitting one bad pattern, start with four good ones.
The four layers
CDAT is structurally simple. Four files per feature, each with a single job, each with explicit dependency rules.
features/
├── login/
│ ├── components.ts # C - Locators only
│ ├── data.ts # D - Types & test data
│ ├── actions.ts # A - Business logic
│ └── test.ts # T - Scenarios & assertions
Layer dependency rules:
Components → nothing
Data → nothing
Actions → Components + Data
Tests → Components + Data + Actions
Lower layers never depend on higher layers. That single rule is what gives you the reusability POM cannot.
Components - locators only
export class LoginComponents {
readonly usernameInput: Locator;
readonly passwordInput: Locator;
readonly submitButton: Locator;
constructor(private readonly page: Page) {
this.usernameInput = page.getByLabel('Username');
this.passwordInput = page.getByLabel('Password');
this.submitButton = page.getByRole('button', { name: 'Sign in' });
}
}
No business logic. No waits. No assertions. If you have a click() here, you’ve already started a CheckoutPage god-object in disguise.
Data - types and constants
export interface LoginCredentials {
username: string;
password: string;
rememberMe?: boolean;
}
export const VALID_USER: LoginCredentials = {
username: 'testuser',
password: 'Password123!',
};
Pure data. No locators. No DOM. This file is what your AI assistant reads first when it tries to understand the feature, and the file you import in unit tests too if you have any.
Actions - business logic, no assertions
export class LoginActions {
private readonly components: LoginComponents;
constructor(page: Page) {
this.components = new LoginComponents(page);
}
async fillUsername(username: string): Promise<void> {
await Cdat.waitAndFill(this.components.usernameInput, username);
}
async login(credentials: LoginCredentials): Promise<void> {
await this.fillUsername(credentials.username);
await this.fillPassword(credentials.password);
await this.clickSubmit();
}
// State getters return data, they don't assert
async getErrorMessage(): Promise<string> {
return Cdat.waitForText(this.components.errorMessage);
}
}
Actions compose atomic steps into business flows. No expect() calls - those belong in tests. State getters return data, never assertions.
Tests - scenarios and assertions
test('Given valid credentials, When login, Then dashboard shown', async ({ page }) => {
const actions = new LoginActions(page);
await page.goto('/login');
await actions.login(VALID_USER);
await expect(page).toHaveURL(/\/dashboard/);
});
Every expect() in the codebase lives here. Scenario name reads as Given-When-Then. The test file is the only place where business intent and assertion live together.
This is vertical slice architecture - you organize by feature (login/, cart/, checkout/), not by layer (pages/, actions/, tests/). When you delete a feature, you delete a folder. When you onboard a junior, you point at one folder and say “everything you need is here”.
Three zero rules
Four layers tell you where code goes. Three zero-rules tell you how to write it. They sound dogmatic. They’re enforced because every time I let one slide, I paid for it later.
Zero any
// ❌ Bad
async getProductData(): Promise<any> {
return this.fetchProduct();
}
// ✅ Good
async getProductData(): Promise<ProductData> {
return this.fetchProduct();
}
any defeats TypeScript. A test that uses product.proce (typo) compiles and runs and silently asserts nothing useful. ESLint rule @typescript-eslint/no-explicit-any: error makes this non-negotiable.
Zero waitForTimeout
// ❌ Bad
await page.waitForTimeout(5000);
await button.click();
// ✅ Good
await Cdat.waitAndClick(button);
Hardcoded timeouts are the number one cause of flaky tests. Too short → random fail. Too long → slow suite. CI is slower than your laptop, so what works locally fails on Monday morning. Smart waits target the actual condition, not a stopwatch.
The Cdat utility class (10 lines, zero dependencies, ships with the pattern) bundles the common cases: waitAndClick, waitAndFill, waitForState, checkState, waitForText. Five methods cover ~95% of what people reach for waitForTimeout to do.
Zero else
// ❌ Bad - pyramid of doom
async processCheckout(data: CheckoutData) {
if (data.email) {
if (data.address) {
if (data.payment) {
await this.submitOrder();
return true;
} else { return false; }
} else { return false; }
} else { return false; }
}
// ✅ Good - early returns
async processCheckout(data: CheckoutData): Promise<void> {
if (!data.email) throw new Error('Email is required');
if (!data.address) throw new Error('Shipping address is required');
if (!data.payment) throw new Error('Payment method is required');
await this.submitOrder();
}
Early returns flatten the code. Each precondition is explicit. The happy path lives at the bottom of the function with zero indentation. Debugging is faster because the failure tells you which guard fired, not which branch you fell through.
What CDAT is not
This is where I want to be honest, because the worst case studies oversell.
CDAT is a structure for E2E tests in Playwright. It’s not:
- A BDD framework. If you want Gherkin features and step definitions, use Cucumber. CDAT lives below that - you can layer Cucumber on top, but the pattern is for the test code itself.
- A visual regression tool. CDAT pairs well with Playwright’s screenshot diffing or third-party tools, but the pattern doesn’t help you decide what to snapshot.
- A unit-testing pattern. Unit tests don’t have a DOM, so the components layer disappears. Use Vitest or Jest with their own conventions.
- Magic. Discipline scales. Patterns don’t refactor themselves. If your team won’t enforce the zero-rules, you’ll end up with a 1,500-line
actions.tsinstead of a 1,500-line page object.
CDAT is also opinionated about TypeScript. There’s a JavaScript version that works, but the type safety is half the point. If your project is JS-only, the zero-any rule is moot, and you lose roughly a third of the benefit.
Nine systems, two years
The pattern’s track record, anonymized but real:
| System type | LOC | Tests | Months in production |
|---|---|---|---|
| B2B Platform A | 12K | 340 | 14 |
| E-commerce | 18K | 520 | 18 |
| CRM | 9K | 280 | 12 |
| Event Management | 7K | 210 | 10 |
| Education | 11K | 350 | 15 |
| Invoicing | 6K | 180 | 11 |
| Logistics | 14K | 410 | 16 |
| Automotive Wholesale | 13K | 380 | 13 |
| B2B Platform B | 10K | 300 | 9 |
That’s 3000+ tests in production across the portfolio - averaging ~330 per system, ranging from 180 (Invoicing) to 520 (E-commerce 1). The pattern doesn’t push you to write more tests; it just stops the ones you do write from rotting.
What changed across all of them, qualitatively:
- Junior onboarding dropped from “two to three weeks until they can ship a test” to “one to three days”. The folder structure is self-documenting; the four files per feature give them a template they can copy and edit.
- Flakiness fell to near-zero. Removing
waitForTimeoutis the single highest-leverage change. The remaining flakes are usually genuine product bugs. - Refactor friction dropped because the dependency graph is one-way. Change a selector? Edit
components.ts. Change a flow? Editactions.ts. The test file rarely needs to move. - PR review time dropped because reviewers know which file holds which concern. “This assertion doesn’t belong here” becomes a one-line comment instead of a discussion.
I’m not going to pretend the pattern made every project succeed. Two of the nine had unrelated business reasons that killed them. The other seven are still running CDAT-shaped tests today.
Migration: one feature at a time
If you have a POM codebase and you want to move, the migration path I’ve used five times:
- Pick a small feature - login, search, anything self-contained.
- Extract
components.tsfirst - copy locators out of the page object, keep them asreadonly. - Extract
data.ts- pull credentials, URLs, error message constants. - Rewrite as
actions.ts- move methods, strip outexpect()calls into a separate test file. - Run both - keep the old POM test passing while the new CDAT test ships. Delete the POM file once you trust the new one.
- Repeat.
The pattern doesn’t require a big-bang rewrite. The first feature takes a day. The fifth takes an hour. By the tenth, your team has internalized it and the rest of the codebase falls out naturally.
The full migration playbook with worked examples lives in the Migration Guide.
Resources
Everything CDAT-shaped is at cdat.sdet.it:
- Quick Start - minimum-viable login feature in 4 files
- The 4 Layers - every layer with full code
- Three Zero Rules - interactive bad/good toggles
- Examples - basic, advanced, and POM→CDAT migration
- GitHub - MIT licensed, examples,
@cdat/utilspackage
There’s also an MCP server at https://cdat.sdet.it/mcp if you want to ask Claude or Cursor about CDAT directly. Add it to your client config:
{
"mcpServers": {
"cdat": {
"url": "https://cdat.sdet.it/mcp"
}
}
}
The site itself uses CDAT for its own E2E tests - 124 of them across chromium, firefox, webkit, and mobile-chrome. Self-referential evidence the pattern scales to projects where you don’t get to write the tests first.
When to use it, when to skip
Use CDAT when:
- You’re starting a new Playwright project of any size.
- You’re migrating from Selenium or Cypress and want a structure that won’t repeat the POM problems.
- Your team is more than two people, or it will be within a year.
Skip CDAT when:
- You’re writing five tests against a static landing page. POM is fine. Anything is fine.
- You’re working in a JavaScript-only project and don’t plan to introduce TypeScript. You’ll lose half the benefit.
- Your team has internalized Screenplay and ships fast with it. Don’t break what works.
The pattern is a tool, not a religion. The reason I use it across nine systems is that it’s the only thing I’ve found that survives both rapid feature work and the fourteen-month mark when the original author has moved teams. Both matter. Both are hard to optimize for at the same time.
If you’ve shipped Playwright at scale and have a different answer that works, I want to read it. The whole point of putting two years in a single article is that someone else’s two years might be the next iteration.