Scaling Requirements as Code: From Hand-Written Features to Generated Adapters
At 20 features, you write abstract classes by hand. At 200, you generate them from Jira. At 2,000, you generate them from a database. The compiler enforcement never changes — only the authoring tool does.
The Scaling Question
The TypeScript requirements tracking system works beautifully at the scale of this website: 20 features, 112 acceptance criteria, one developer. But what happens when the project grows?
- 100 features × 10 ACs each = 1,000 abstract methods across 100 files. That's 100 imports to manage, 1,000 decorator references to keep in sync.
- Multiple teams each owning different features, updating requirements in Jira or Linear, not in TypeScript files.
- PMs and stakeholders who define acceptance criteria in tools they know — not in code.
The hand-written approach breaks down. But the type-safe chain doesn't have to.
The Insight: Generate the Types, Keep the Enforcement
The compiler doesn't care whether a .ts file was written by a human or a script. abstract class Jira456Feature { abstract adminCanAssignRoles(): ACResult; } enforces keyof T the same way regardless of origin.
The solution: adapters that read requirements from external sources and generate the TypeScript feature classes. The generated files are committed to git. The compiler validates all @Implements references. The scanner checks coverage. Nothing in the enforcement layer changes.
┌──────────────────┐ ┌───────────────────┐ ┌─────────────────────┐
│ External Sources │ │ Adapter / Codegen │ │ Type-Safe Artifacts │
│ │────>│ │────>│ │
│ Jira API │ │ sync-requirements │ │ requirements/ │
│ Linear API │ │ --source=jira │ │ features/*.ts │
│ Azure DevOps │ │ --source=linear │ │ (abstract classes │
│ PostgreSQL │ │ --source=db │ │ with ACs) │
│ YAML files │ │ --source=file │ │ │
│ Git log │ │ │ │ DO NOT EDIT header │
└──────────────────┘ └───────────────────┘ └─────────────────────┘
│
▼
tsc --noEmit
(@Implements validated)
│
▼
Compliance Scanner
(same as now)┌──────────────────┐ ┌───────────────────┐ ┌─────────────────────┐
│ External Sources │ │ Adapter / Codegen │ │ Type-Safe Artifacts │
│ │────>│ │────>│ │
│ Jira API │ │ sync-requirements │ │ requirements/ │
│ Linear API │ │ --source=jira │ │ features/*.ts │
│ Azure DevOps │ │ --source=linear │ │ (abstract classes │
│ PostgreSQL │ │ --source=db │ │ with ACs) │
│ YAML files │ │ --source=file │ │ │
│ Git log │ │ │ │ DO NOT EDIT header │
└──────────────────┘ └───────────────────┘ └─────────────────────┘
│
▼
tsc --noEmit
(@Implements validated)
│
▼
Compliance Scanner
(same as now)How It Stays Type-Safe with a Database
This is the key question: how do you get compiler enforcement from a database?
The answer: you don't query the database at compile time. You query it at sync time — a script reads the database and writes .ts files. The .ts files are the bridge between the dynamic world (database rows change anytime) and the static world (TypeScript's type system checks at compile time).
PM updates Jira: Feature JIRA-456 has 3 ACs
↓
Developer runs: npm run sync --source=jira
↓
Codegen writes:
abstract class Jira456Feature {
abstract adminCanAssignRoles(): ACResult;
abstract userSeesOnlyPermitted(): ACResult;
abstract roleChangesImmediate(): ACResult;
}
↓
Developer writes test:
@Implements<Jira456Feature>('adminCanAssignRoles')
↓
PM removes AC-2 from Jira, developer re-syncs
↓
Codegen regenerates (AC-2 gone):
abstract class Jira456Feature {
abstract adminCanAssignRoles(): ACResult;
abstract roleChangesImmediate(): ACResult;
}
↓
Developer's @Implements<Jira456Feature>('userSeesOnlyPermitted')
→ COMPILE ERROR: 'userSeesOnlyPermitted' not assignable to keyof Jira456Feature
↓
Developer removes the orphan test
↓
Chain stays consistentPM updates Jira: Feature JIRA-456 has 3 ACs
↓
Developer runs: npm run sync --source=jira
↓
Codegen writes:
abstract class Jira456Feature {
abstract adminCanAssignRoles(): ACResult;
abstract userSeesOnlyPermitted(): ACResult;
abstract roleChangesImmediate(): ACResult;
}
↓
Developer writes test:
@Implements<Jira456Feature>('adminCanAssignRoles')
↓
PM removes AC-2 from Jira, developer re-syncs
↓
Codegen regenerates (AC-2 gone):
abstract class Jira456Feature {
abstract adminCanAssignRoles(): ACResult;
abstract roleChangesImmediate(): ACResult;
}
↓
Developer's @Implements<Jira456Feature>('userSeesOnlyPermitted')
→ COMPILE ERROR: 'userSeesOnlyPermitted' not assignable to keyof Jira456Feature
↓
Developer removes the orphan test
↓
Chain stays consistentThe database is the authoring tool. The generated .ts files are the enforcement tool. The compiler is the gatekeeper. None of these roles change with scale.
The Adapter Pattern
Every external source implements the same interface:
// scripts/adapters/base.ts
export interface ExternalRequirement {
id: string;
title: string;
priority: 'critical' | 'high' | 'medium' | 'low';
acceptanceCriteria: { name: string; description: string }[];
sourceUrl?: string;
parent?: string; // Epic / parent feature ID
labels?: string[];
lastUpdated: string; // ISO date
}
export interface RequirementAdapter {
name: string;
fetch(): Promise<ExternalRequirement[]>;
}// scripts/adapters/base.ts
export interface ExternalRequirement {
id: string;
title: string;
priority: 'critical' | 'high' | 'medium' | 'low';
acceptanceCriteria: { name: string; description: string }[];
sourceUrl?: string;
parent?: string; // Epic / parent feature ID
labels?: string[];
lastUpdated: string; // ISO date
}
export interface RequirementAdapter {
name: string;
fetch(): Promise<ExternalRequirement[]>;
}YAML File Adapter (simplest — no API needed)
For small-to-medium projects that don't use Jira:
# requirements.yaml
- id: AUTH
title: User Authentication
priority: critical
acceptanceCriteria:
- name: loginWithEmail
description: User can log in with email and password
- name: sessionPersists
description: Session persists across browser refresh
- name: logoutClearsSession
description: Logout clears all session data# requirements.yaml
- id: AUTH
title: User Authentication
priority: critical
acceptanceCriteria:
- name: loginWithEmail
description: User can log in with email and password
- name: sessionPersists
description: Session persists across browser refresh
- name: logoutClearsSession
description: Logout clears all session dataThe adapter reads this file and produces the same ExternalRequirement[] as any other source.
Jira Adapter
// scripts/adapters/jira.ts
export class JiraAdapter implements RequirementAdapter {
name = 'jira';
constructor(
private baseUrl: string, // https://myorg.atlassian.net
private project: string, // PROJ
private auth: string, // API token
) {}
async fetch(): Promise<ExternalRequirement[]> {
// JQL: project = PROJ AND type in (Story, Feature) AND status != Done
// Parse: summary → title
// Parse: acceptance criteria custom field → AC list
// Map: Jira priority → our enum
// Map: labels → tags
}
}// scripts/adapters/jira.ts
export class JiraAdapter implements RequirementAdapter {
name = 'jira';
constructor(
private baseUrl: string, // https://myorg.atlassian.net
private project: string, // PROJ
private auth: string, // API token
) {}
async fetch(): Promise<ExternalRequirement[]> {
// JQL: project = PROJ AND type in (Story, Feature) AND status != Done
// Parse: summary → title
// Parse: acceptance criteria custom field → AC list
// Map: Jira priority → our enum
// Map: labels → tags
}
}Linear Adapter
// scripts/adapters/linear.ts
export class LinearAdapter implements RequirementAdapter {
name = 'linear';
async fetch(): Promise<ExternalRequirement[]> {
// GraphQL API
// Issues with labels → features
// Sub-issues or checklist items → ACs
// Team assignment → labels
}
}// scripts/adapters/linear.ts
export class LinearAdapter implements RequirementAdapter {
name = 'linear';
async fetch(): Promise<ExternalRequirement[]> {
// GraphQL API
// Issues with labels → features
// Sub-issues or checklist items → ACs
// Team assignment → labels
}
}Database Adapter
// scripts/adapters/database.ts
export class DatabaseAdapter implements RequirementAdapter {
name = 'database';
async fetch(): Promise<ExternalRequirement[]> {
// SQL: SELECT f.id, f.title, f.priority, ac.name, ac.description
// FROM features f
// JOIN acceptance_criteria ac ON ac.feature_id = f.id
// WHERE f.status != 'archived'
}
}// scripts/adapters/database.ts
export class DatabaseAdapter implements RequirementAdapter {
name = 'database';
async fetch(): Promise<ExternalRequirement[]> {
// SQL: SELECT f.id, f.title, f.priority, ac.name, ac.description
// FROM features f
// JOIN acceptance_criteria ac ON ac.feature_id = f.id
// WHERE f.status != 'archived'
}
}Git Log Adapter (supplementary)
This one doesn't produce features — it enriches them with commit tracing:
// scripts/adapters/git.ts
export class GitLogAdapter {
// Scans git log for commit messages referencing feature IDs
// Output: { featureId: 'NAV', commits: ['abc123'], authors: ['serard'] }
// Used by the compliance report to show: "NAV: last touched 2 days ago by serard"
}// scripts/adapters/git.ts
export class GitLogAdapter {
// Scans git log for commit messages referencing feature IDs
// Output: { featureId: 'NAV', commits: ['abc123'], authors: ['serard'] }
// Used by the compliance report to show: "NAV: last touched 2 days ago by serard"
}The Code Generator
The generator reads any adapter's output and writes .ts files:
// scripts/sync-requirements.ts
function generateFeatureFile(req: ExternalRequirement, source: string): string {
const className = toClassName(req.id);
const acMethods = req.acceptanceCriteria.map(ac =>
` /** ${ac.description} */\n abstract ${toCamelCase(ac.name)}(): ACResult;`
).join('\n\n');
return `// Generated by: npx tsx scripts/sync-requirements.ts --source=${source}
// Source: ${req.id} "${req.title}" (synced: ${new Date().toISOString()})
// DO NOT EDIT — regenerate with: npm run sync:requirements
import { Feature, Priority, type ACResult } from '../base';
export abstract class ${className} extends Feature {
readonly id = '${req.id}';
readonly title = '${req.title}';
readonly priority = Priority.${capitalize(req.priority)};
readonly source = '${source}';
readonly sourceUrl = '${req.sourceUrl ?? ''}';
${acMethods}
}
`;
}// scripts/sync-requirements.ts
function generateFeatureFile(req: ExternalRequirement, source: string): string {
const className = toClassName(req.id);
const acMethods = req.acceptanceCriteria.map(ac =>
` /** ${ac.description} */\n abstract ${toCamelCase(ac.name)}(): ACResult;`
).join('\n\n');
return `// Generated by: npx tsx scripts/sync-requirements.ts --source=${source}
// Source: ${req.id} "${req.title}" (synced: ${new Date().toISOString()})
// DO NOT EDIT — regenerate with: npm run sync:requirements
import { Feature, Priority, type ACResult } from '../base';
export abstract class ${className} extends Feature {
readonly id = '${req.id}';
readonly title = '${req.title}';
readonly priority = Priority.${capitalize(req.priority)};
readonly source = '${source}';
readonly sourceUrl = '${req.sourceUrl ?? ''}';
${acMethods}
}
`;
}Every generated file has:
- A
DO NOT EDITheader with the regeneration command - The source system name and URL (clickable in the IDE)
- The sync timestamp
- Standard abstract class structure — identical to hand-written features
What Gets Committed to Git
Generated files must be committed. This is non-negotiable for three reasons:
tscworks without API credentials. A new developer clones the repo, runstsc --noEmit— it works. No Jira token needed.- CI type-checks without network access. The build server doesn't need to reach Jira to validate
@Implementsreferences. - Git diff shows exactly what changed. When a PM removes an AC from Jira and you re-sync, the diff shows which abstract method was removed — and the compiler shows which tests broke.
requirements/features/
├── navigation.ts ← hand-written (local features)
├── scroll-spy.ts ← hand-written (local features)
├── jira-456.ts ← GENERATED (synced from Jira)
├── jira-789.ts ← GENERATED (synced from Jira)
├── linear-auth-42.ts ← GENERATED (synced from Linear)
└── .sync-state.json ← last sync timestamp per sourcerequirements/features/
├── navigation.ts ← hand-written (local features)
├── scroll-spy.ts ← hand-written (local features)
├── jira-456.ts ← GENERATED (synced from Jira)
├── jira-789.ts ← GENERATED (synced from Jira)
├── linear-auth-42.ts ← GENERATED (synced from Linear)
└── .sync-state.json ← last sync timestamp per sourceHand-written and generated features coexist. The compiler treats them identically.
Developer Workflow
# Pull latest requirements from Jira
npm run work -- --sync --source=jira
# See what would change without writing
npm run work -- --sync --source=jira --dry-run
# Pull from a local YAML file (no API needed)
npm run work -- --sync --source=file
# Check compliance after sync
npm run work -- --compliance
# Full cycle: sync → type-check → compliance
npm run work -- --sync --source=jira && npx tsc --noEmit && npm run work -- --compliance# Pull latest requirements from Jira
npm run work -- --sync --source=jira
# See what would change without writing
npm run work -- --sync --source=jira --dry-run
# Pull from a local YAML file (no API needed)
npm run work -- --sync --source=file
# Check compliance after sync
npm run work -- --compliance
# Full cycle: sync → type-check → compliance
npm run work -- --sync --source=jira && npx tsc --noEmit && npm run work -- --complianceInteractive workflow menu:
--- workflow ---
...
f Feature compliance report
y Sync requirements (from external source)
...--- workflow ---
...
f Feature compliance report
y Sync requirements (from external source)
...Scaling Roadmap
| Scale | Authoring | Enforcement | Scanner |
|---|---|---|---|
| 1-20 features | Hand-written .ts |
tsc + keyof T |
Full report on one screen |
| 20-100 | YAML file → codegen | Same | Filter by label/team |
| 100-500 | Jira/Linear → codegen | Same | Filter + team dashboards |
| 500+ | Database → codegen | Same | Dashboard UI + trend charts |
At every scale:
- The generated
.tsfiles have the same structure @Implements<Feature>('ac')is validated bytsc- The compliance scanner cross-references features × tests
- Self-tracking (REQ-TRACK) verifies the infrastructure
The only thing that changes is where the feature definitions come from — and that's the adapter's job.
What This Enables
Bidirectional Tracing
With the Git log adapter, the compliance report can show:
✓ JIRA-456 User Roles 3/3 ACs (100%)
Source: https://myorg.atlassian.net/browse/JIRA-456
Last synced: 2026-03-20
Commits: abc123 (serard, 2026-03-18), def456 (alice, 2026-03-19)
Tests: test/e2e/user-roles.spec.ts (3 @Implements) ✓ JIRA-456 User Roles 3/3 ACs (100%)
Source: https://myorg.atlassian.net/browse/JIRA-456
Last synced: 2026-03-20
Commits: abc123 (serard, 2026-03-18), def456 (alice, 2026-03-19)
Tests: test/e2e/user-roles.spec.ts (3 @Implements)From Jira → to generated class → to test decorator → to git commits → back to Jira. Full traceability, compiler-checked at every link.
Drift Detection
The sync script can detect drift:
⚠ JIRA-456: Jira has 4 ACs, generated file has 3
New AC: "adminCanRevokeRoles" (added in Jira on 2026-03-22)
Action: re-sync to update generated file, then write test⚠ JIRA-456: Jira has 4 ACs, generated file has 3
New AC: "adminCanRevokeRoles" (added in Jira on 2026-03-22)
Action: re-sync to update generated file, then write testStale Feature Detection
⚠ JIRA-123: Last synced 45 days ago. Jira shows status "Done".
Action: archive or remove generated file?⚠ JIRA-123: Last synced 45 days ago. Jira shows status "Done".
Action: archive or remove generated file?The Philosophy, Restated
The C# Requirements DSL established the principle: requirements should be types, not metadata. The TypeScript implementation proved it works outside .NET.
This scaling plan extends it: the authoring tool doesn't matter — the type system is the enforcement layer. Whether a PM writes an AC in Jira, a developer writes it in YAML, or a script reads it from a database — it becomes an abstract method on a TypeScript class, and the compiler refuses to build until every @Implements reference is valid.
The database doesn't make it less type-safe. The code generation makes it more scalable. The chain stays unbroken.