The Human Side of Requirements as Code: When Developers and Product Owners Write ACs Together
The compiler doesn't understand your business. But it forces the people who do to agree on what words mean — and then it refuses to let anyone silently change the agreement.
The Real Feature Isn't the Type System
The TypeScript requirements tracking system has abstract classes, decorators, a compliance scanner, and a self-tracking meta-requirement. Those are implementation details. The real feature is this:
The developer and the Product Owner sit together and name the acceptance criteria.
Not in Jira. Not in a wiki. Not in a Confluence page that nobody reads after sprint planning. They name them in the code — as method names on an abstract class — and the compiler enforces that every test uses exactly those names.
This changes the collaboration dynamic fundamentally.
What Happens in Practice
Before: The Telephone Game
A typical requirement lifecycle without this system:
- PM writes a user story in Jira: "As a user, I want to navigate between pages so that I can browse content."
- PM adds acceptance criteria in a text field: "Given I click a sidebar item, the corresponding page loads with a smooth transition."
- Developer reads the story, interprets it, writes a test:
test('clicking TOC item loads page', ...) - Months later, someone asks: "Does this test cover the AC about smooth transitions?" Nobody knows. The test name says "loads page" but the AC says "smooth transition." Are they the same? Is the transition tested? The Jira AC says "smooth" — what counts as smooth? 80ms? 200ms? Any fade at all?
The connection between the AC and the test is a human interpretation — undocumented, uncheckable, forgotten.
After: The Naming Session
With requirements as code:
- PM and developer sit together (or screen-share, or pair in a PR).
- Developer creates the feature class, PM provides the AC descriptions:
// PM: "The first thing is: when you click something in the sidebar, the page loads."
// Dev: "Let's call it tocClickLoadsPage — TOC is our term for the sidebar."
// PM: "TOC? Oh, Table of Contents. Yes, that works."
export abstract class NavigationFeature extends Feature {
readonly id = 'NAV';
readonly title = 'SPA Navigation + Deep Links';
readonly priority = Priority.Critical;
/** Clicking a TOC entry loads the corresponding page. */
abstract tocClickLoadsPage(): ACResult;
// PM: "And the back button should take you to the previous page."
// Dev: "backButtonRestores?"
// PM: "Yes. Restores the previous state."
/** The browser back button restores the previous page state. */
abstract backButtonRestores(): ACResult;
// PM: "Users should be able to share links. If I copy the URL and send it to someone,
// they should see the same page."
// Dev: "That's two things: the URL must be bookmarkable, and deep links must work."
// PM: "Right. Two ACs."
/** Navigating directly to a URL loads the correct page. */
abstract deepLinkLoads(): ACResult;
/** Every page state produces a bookmarkable URL. */
abstract bookmarkableUrl(): ACResult;
}// PM: "The first thing is: when you click something in the sidebar, the page loads."
// Dev: "Let's call it tocClickLoadsPage — TOC is our term for the sidebar."
// PM: "TOC? Oh, Table of Contents. Yes, that works."
export abstract class NavigationFeature extends Feature {
readonly id = 'NAV';
readonly title = 'SPA Navigation + Deep Links';
readonly priority = Priority.Critical;
/** Clicking a TOC entry loads the corresponding page. */
abstract tocClickLoadsPage(): ACResult;
// PM: "And the back button should take you to the previous page."
// Dev: "backButtonRestores?"
// PM: "Yes. Restores the previous state."
/** The browser back button restores the previous page state. */
abstract backButtonRestores(): ACResult;
// PM: "Users should be able to share links. If I copy the URL and send it to someone,
// they should see the same page."
// Dev: "That's two things: the URL must be bookmarkable, and deep links must work."
// PM: "Right. Two ACs."
/** Navigating directly to a URL loads the correct page. */
abstract deepLinkLoads(): ACResult;
/** Every page state produces a bookmarkable URL. */
abstract bookmarkableUrl(): ACResult;
}The PM is coding — literally. Not metaphorically. The PM dictates, the developer types. The PM sees the method name appear on screen, reads the JSDoc, says "yes" or "no, change it." The file is committed under both their names. The PM wrote the requirements in the code, through the developer's keyboard. This is pair-programming applied to specifications.
The method names become the shared vocabulary. When the PM says "is
bookmarkableUrlworking?", the developer knows exactly which test to check. When the developer says "I'm implementingbackButtonRestores", the PM knows exactly which AC is being worked on.The compiler enforces the vocabulary. If someone writes
@Implements<NavigationFeature>('backButtonWorks')instead of'backButtonRestores', it doesn't compile. The team agreed onbackButtonRestores— the compiler holds them to it.
Why DDD Is a Language Everyone Can Speak
Domain-Driven Design works as a collaboration language because it deliberately uses business words, not technical words. The tactical patterns — Entity, Value Object, Aggregate, Service — describe business concepts, not implementation details. When you say "the Order Aggregate has an invariant that total must be positive," the PM understands "orders can't have negative totals." When you say "the NavigationFeature has an AC called tocClickLoadsPage," the PM understands "clicking the sidebar loads a page."
This is why DDD succeeds where other architectural patterns fail at cross-team communication:
| Language | Developer hears | PM hears | Shared? |
|---|---|---|---|
| "Microservice endpoint" | REST controller, HTTP handler | Nothing meaningful | No |
| "Database migration" | ALTER TABLE, schema change | Nothing meaningful | No |
| "React component" | JSX, props, state management | Nothing meaningful | No |
| "Order Aggregate" | Class with invariants and domain logic | Business rules for orders | Yes |
| "NavigationFeature" | Abstract class with 8 AC methods | 8 things navigation must do | Yes |
| "tocClickLoadsPage" | A test that verifies click → load | "Clicking the sidebar loads the page" | Yes |
DDD removes the translation layer. The code speaks business, not technology. When feature classes use DDD naming conventions — Feature, AcceptanceCriterion, Priority, Satisfied/Failed — both parties understand the structure without understanding the implementation.
The abstract class is the specification, written in a language that both parties authored together. The PM doesn't need to understand abstract, extends, or ACResult. They need to understand:
- Feature name: "Navigation" — yes, that's what we're building
- AC names: "tocClickLoadsPage" — yes, that's what it should do
- JSDoc: "Clicking a TOC entry loads the corresponding page." — yes, that's what I mean
- Compliance report: "NAV: 8/8 (100%)" — yes, all 8 things are tested
Four levels of understanding, none of which require programming knowledge. DDD made this possible by insisting that the code speaks the same language as the business — and the requirements system proves it works in practice.
This Is Ubiquitous Language — For Real
Eric Evans coined "Ubiquitous Language" in the DDD blue book: a shared language between developers and domain experts, used in code, conversations, and documentation. Most teams treat it as an aspiration. They write a glossary, put it in a wiki, and forget about it.
Requirements as code makes it mechanical:
| Ubiquitous Language Principle | How It's Enforced |
|---|---|
| Same terms in code and conversation | AC method names ARE the terms. tocClickLoadsPage is what the PM says and what the code says. |
| Language changes propagate everywhere | Renaming an AC method → compile errors in every test referencing it. No silent drift. |
| No translation layer between business and code | The PM reads abstract tocClickLoadsPage(): ACResult and understands it. No ORM, no DTO, no service layer to decode. |
| Ambiguity is surfaced immediately | "Should we call it loginWithEmail or authenticateUser?" — the naming session forces the team to resolve ambiguity now, not later. |
| New team members learn the vocabulary from the code | requirements/features/navigation.ts reads like a specification. Every AC has a JSDoc description written by the PM. |
The critical difference from a glossary: the compiler enforces it. A glossary can drift. A wiki page can go stale. An abstract method on a TypeScript class cannot be misspelled — the build fails.
The Naming Session
The most valuable moment in this system is not when the scanner runs or when the tests pass. It's the naming session — the 15-minute conversation where the developer and PM decide what to call each acceptance criterion.
What Happens During a Naming Session
- PM describes the behavior in natural language: "Users should be able to pick a color theme."
- Developer proposes a method name:
"rightClickOpensPalette". - PM pushes back: "It's not just about opening. It's about picking. And it should persist."
- Developer splits it:
rightClickOpensPalette,swatchChangesAccent,accentPersists. - PM validates: "Yes. Three things. Open, pick, persist."
- Developer writes the abstract class, PM reviews the JSDoc descriptions.
- Both agree: this is what the feature means. The compiler will hold us to it.
What This Conversation Produces
Not just a feature definition. It produces:
- Shared understanding — PM and developer agree on scope before coding starts
- Precise vocabulary — "accent" not "color", "palette" not "picker", "persists" not "saves"
- Explicit boundaries — PM says "persist" means "survives browser reload." Developer knows exactly what to test
- A checkable contract — if the PM later asks "does the accent persist across sessions?", the answer is in the compliance report:
ACCENT: accentPersists → ✓ covered by test - Documentation that can't drift — the feature class IS the specification. The tests ARE the verification. The scanner IS the audit
What Happens Without the Naming Session
The developer writes a test called 'accent color works'. What does "works" mean? Does it mean:
- Opens the palette?
- Changes the color?
- Persists after reload?
- Adapts when switching dark/light?
- Doesn't break in high-contrast mode?
"Works" is not a ubiquitous language term. It's a vague word that hides five distinct behaviors. The naming session forces the split — and the compiler prevents merging them back into vagueness.
The PM Can Read the Feature File
This is the acid test: can a non-developer understand the feature definition?
export abstract class HireModalFeature extends Feature {
readonly id = 'HIRE';
readonly title = 'Hire Modal';
readonly priority = Priority.Medium;
/** Clicking "Available for Hire" opens the contact modal. */
abstract clickOpensModal(): ACResult;
/** The subject input is focused with default text selected. */
abstract subjectFocusedAndSelected(): ACResult;
/** Pressing Escape closes the modal. */
abstract escapeClosesModal(): ACResult;
/** The close button (×) closes the modal. */
abstract closeButtonCloses(): ACResult;
}export abstract class HireModalFeature extends Feature {
readonly id = 'HIRE';
readonly title = 'Hire Modal';
readonly priority = Priority.Medium;
/** Clicking "Available for Hire" opens the contact modal. */
abstract clickOpensModal(): ACResult;
/** The subject input is focused with default text selected. */
abstract subjectFocusedAndSelected(): ACResult;
/** Pressing Escape closes the modal. */
abstract escapeClosesModal(): ACResult;
/** The close button (×) closes the modal. */
abstract closeButtonCloses(): ACResult;
}A PM reads this and understands:
- There are 4 things this feature must do
- Each has a name and a description
- If one is missing a test, the compliance report will show it
They don't need to understand TypeScript. They don't need to understand abstract, extends, or ACResult. They read the JSDoc comments and the method names — those are written in their language, because they helped write them.
What the Compiler Enforces in Human Terms
When we say "the compiler enforces the ubiquitous language," this is what it means in practice:
Scenario 1: PM Renames an AC
PM: "We renamed 'subjectFocusedAndSelected' to 'subjectAutoFocused' because we dropped the text selection behavior."
Developer renames the method in the feature class. Every test with @Implements<HireModalFeature>('subjectFocusedAndSelected') becomes a compile error. The developer updates each test — and in doing so, reviews whether the test still matches the new AC definition. Maybe the test asserted text selection and now shouldn't.
Without this system: the PM renames the AC in Jira. The test keeps running with the old name. Nobody notices the drift until a bug report says "the text is supposed to be selected" — but the AC was changed months ago.
Scenario 2: PM Adds a New AC
PM: "We need a new criterion: the modal should have a mailto: link that opens the email client."
Developer adds abstract mailtoOpensClient(): ACResult to the feature class. The compliance scanner immediately shows HIRE: 4/5 (80%) — missing: mailtoOpensClient. The team knows exactly what's untested. The PM can ask "is mailtoOpensClient done?" and get a binary answer.
Without this system: the PM adds a line in Jira. The developer might write a test, might not. Nobody knows until someone manually checks — if they check at all.
Scenario 3: Developer Misunderstands an AC
Developer writes @Implements<NavigationFeature>('deepLinkLoads') on a test that checks window.location.href but doesn't actually navigate to the page. The test passes, but it doesn't truly implement the AC.
The type system can't catch this — keyof T only checks the name, not the implementation quality. But the naming session makes it less likely: because the PM described what "deep link loads" means during the naming session, the developer has context. And because the AC name is specific (deepLinkLoads, not urlWorks), the test's scope is constrained.
This is the honest limitation: the compiler enforces that the link exists, not that the link is correct. The naming session increases the probability that the link is correct. The compliance scanner ensures that the link is present. But ultimately, the quality of the test is a human responsibility.
The Collaboration Lifecycle
1. PM writes user story (high-level)
"Users should be able to navigate between pages"
│
▼
2. Naming session (PM + Dev, 15 min)
"Let's name the ACs: tocClickLoadsPage, backButtonRestores, ..."
│
▼
3. Developer writes feature class
abstract class NavigationFeature { abstract tocClickLoadsPage(): ACResult; ... }
│
▼
4. PM reviews the JSDoc descriptions
"Yes, that's what I meant. Ship it."
│
▼
5. Developer writes tests with @Implements
@Implements<NavigationFeature>('tocClickLoadsPage')
│
▼
6. Compiler validates references
tsc --noEmit → all @Implements reference real ACs
│
▼
7. Scanner checks completeness
NAV: 8/8 ACs covered (100%)
│
▼
8. PM asks: "Is navigation done?"
Developer: "Run 'f' in the workflow. 100%. Here's the report."1. PM writes user story (high-level)
"Users should be able to navigate between pages"
│
▼
2. Naming session (PM + Dev, 15 min)
"Let's name the ACs: tocClickLoadsPage, backButtonRestores, ..."
│
▼
3. Developer writes feature class
abstract class NavigationFeature { abstract tocClickLoadsPage(): ACResult; ... }
│
▼
4. PM reviews the JSDoc descriptions
"Yes, that's what I meant. Ship it."
│
▼
5. Developer writes tests with @Implements
@Implements<NavigationFeature>('tocClickLoadsPage')
│
▼
6. Compiler validates references
tsc --noEmit → all @Implements reference real ACs
│
▼
7. Scanner checks completeness
NAV: 8/8 ACs covered (100%)
│
▼
8. PM asks: "Is navigation done?"
Developer: "Run 'f' in the workflow. 100%. Here's the report."Step 2 is where the value is created. Steps 3-7 are where it's preserved. Step 8 is where it pays off.
Why This Matters More Than the Technology
You could implement this system in any language — TypeScript, C#, Java, Python, Rust. The compiler enforcement varies in strength, but the naming session works the same way everywhere. The act of sitting with your PM, choosing a method name together, and knowing the compiler will hold you both to that choice — that's the feature.
The abstract classes are a vehicle. The decorators are plumbing. The scanner is bookkeeping. The naming session is where two people from different worlds agree on what a word means — and the machine makes sure neither of them forgets.
That's not a technical achievement. That's a communication protocol enforced by a compiler.
The Cost of Not Doing This
When requirements and tests live in separate worlds:
- PM: "Is feature X done?" Developer: "I think so. Let me check the tests." (checks 15 test files, tries to match test names to Jira ACs, gives up, says "probably yes")
- Bug report: "Back button doesn't work after deep link." Developer: "We tested navigation..." (but nobody tested the interaction between back button and deep links because the ACs were vague)
- Sprint review: "We completed 8 stories." PM: "But are all acceptance criteria actually verified?" Silence.
When they live in the same file:
- PM: "Is feature X done?" Developer:
npm run work -- --compliance. "NAV: 8/8. Yes." - Bug report: "Back button doesn't work after deep link." Developer: "We have
backButtonRestoresanddeepLinkLoadsas separate ACs. Let me check if their interaction is tested. We might need a new AC:backButtonAfterDeepLink." - Sprint review: "We completed 8 stories." PM: "Show me the compliance report." Developer shows 100%. PM trusts the number because they helped write the AC names.
In Summary
The technology is TypeScript. The architecture is DDD. The enforcement is the compiler. But the practice that makes it work is the simplest thing in software engineering: two people agreeing on what a word means, and a machine making sure they don't forget.
The naming session is 15 minutes. The compiler enforcement is permanent. The shared vocabulary outlasts the sprint, the quarter, and the team.
That's the human side of requirements as code.