Red-green-domain TDD cycle with strict phase boundaries and domain modeling review
From sdlcnpx claudepluginhub jwilger/claude-code-plugins --plugin sdlcThis skill uses the workspace's default tool permissions.
Executes pre-written implementation plans: critically reviews, follows bite-sized steps exactly, runs verifications, tracks progress with checkpoints, uses git worktrees, stops on blockers.
Dispatches parallel agents to independently tackle 2+ tasks like separate test failures or subsystems without shared state or dependencies.
Guides idea refinement into designs: explores context, asks questions one-by-one, proposes approaches, presents sections for approval, writes/review specs before coding.
Version: 1.0.0 Portability: Universal
!if [ -f Cargo.toml ]; then echo "Language: Rust | Test runner: cargo test"; elif [ -f package.json ]; then if grep -q vitest package.json 2>/dev/null; then echo "Language: TypeScript/JavaScript | Test runner: npx vitest"; elif grep -q jest package.json 2>/dev/null; then echo "Language: TypeScript/JavaScript | Test runner: npx jest"; else echo "Language: TypeScript/JavaScript | Test runner: npm test"; fi; elif [ -f pyproject.toml ] || [ -f setup.py ]; then echo "Language: Python | Test runner: pytest"; elif [ -f go.mod ]; then echo "Language: Go | Test runner: go test ./..."; elif [ -f mix.exs ]; then echo "Language: Elixir | Test runner: mix test"; else echo "Language: unknown | Test runner: unknown (configure manually)"; fi
Defines a disciplined TDD workflow with three distinct phases (Red, Green, Domain) and strict boundaries between them. Enforces domain modeling review at natural checkpoints to prevent primitive obsession and invalid state representation.
Purpose: Ensure tests drive design, maintain clear separation of concerns, and build domain-rich implementations from the start.
Scope:
The Cycle: Every feature is built through alternating phases with domain review at natural checkpoints.
Why this matters: Traditional TDD (red → green → refactor) often skips domain modeling, leading to primitive obsession and anemic models. Adding domain review phases catches these issues early.
The Four Steps:
How to apply:
Example Cycle:
RED: Write test for User.authenticate(email, password)
↓
DOMAIN: Review test, create Email type (not String), Password type, AuthError enum
↓
GREEN: Implement User.authenticate() using domain types
↓
DOMAIN: Review implementation, check for primitive obsession
↓
CYCLE COMPLETE (move to next test)
The Principle: Each phase can only edit specific file types. This creates mechanical enforcement of separation of concerns.
Why this matters: Without boundaries, developers drift into "test and implement simultaneously" which defeats TDD's design benefits. Mechanical boundaries prevent drift.
Phase Ownership:
RED Phase - Test Files Only
*_test.*, *.test.*, tests/, spec/)DOMAIN Phase - Type Definitions Only
unimplemented!(), todo!(), raise NotImplementedErrorGREEN Phase - Implementation Bodies Only
How to apply:
Example:
// RED phase writes test (test file only)
#[test]
fn user_can_authenticate_with_valid_credentials() {
let user = User::new(Email::from("user@example.com"), Password::from("secret"));
let result = user.authenticate("secret");
assert!(result.is_ok());
}
// DOMAIN phase creates types (type definition files only)
struct User { email: Email, password_hash: PasswordHash }
struct Email(String); // Newtype
struct Password(String); // Newtype
enum AuthError { InvalidPassword, UserNotFound }
impl User {
fn new(email: Email, password: Password) -> Self {
unimplemented!("GREEN phase will implement")
}
fn authenticate(&self, password: &str) -> Result<(), AuthError> {
unimplemented!("GREEN phase will implement")
}
}
// GREEN phase implements bodies (implementation only)
impl User {
fn new(email: Email, password: Password) -> Self {
User {
email,
password_hash: PasswordHash::from(password)
}
}
fn authenticate(&self, password: &str) -> Result<(), AuthError> {
if self.password_hash.verify(password) {
Ok(())
} else {
Err(AuthError::InvalidPassword)
}
}
}
// DOMAIN phase reviews: ✓ No primitive obsession, types are meaningful
The Principle: Each test verifies exactly one behavior with one assertion.
Why this matters: Multiple assertions hide which expectation failed. When a test with 5 assertions fails, you don't know which of the 5 expectations is wrong without investigating.
How to apply:
assertExample:
# ❌ Bad: Multiple assertions (which one fails?)
def test_user_registration():
user = register_user("alice", "alice@example.com", "password123")
assert user.name == "alice"
assert user.email == "alice@example.com"
assert user.password is not None
assert user.is_active == True
assert user.created_at is not None
# ✓ Good: One assertion per test (failure is obvious)
def test_user_registration_sets_name():
user = register_user("alice", "alice@example.com", "password123")
assert user.name == "alice"
def test_user_registration_sets_email():
user = register_user("alice", "alice@example.com", "password123")
assert user.email == "alice@example.com"
def test_user_registration_hashes_password():
user = register_user("alice", "alice@example.com", "password123")
assert user.password_hash is not None
def test_user_registration_activates_by_default():
user = register_user("alice", "alice@example.com", "password123")
assert user.is_active == True
The Principle: Write the simplest code that makes the test pass. No more, no less.
Why this matters: Over-engineering during green phase adds complexity that isn't tested. YAGNI (You Aren't Gonna Need It) prevents speculative complexity.
How to apply:
Example:
// Test (RED phase)
test('user can register with email', () => {
const user = registerUser('alice@example.com');
expect(user.email).toBe('alice@example.com');
});
// ❌ Bad: Over-engineered (not tested)
function registerUser(email: string, options?: {
role?: 'admin' | 'user',
metadata?: Record<string, any>,
sendWelcomeEmail?: boolean
}): User {
const user = new User(email);
user.role = options?.role ?? 'user';
user.metadata = options?.metadata ?? {};
if (options?.sendWelcomeEmail !== false) {
sendEmail(user.email, 'Welcome!');
}
return user;
}
// ✓ Good: Minimal (exactly what test requires)
function registerUser(email: string): User {
return new User(email);
}
The Principle: After RED and after GREEN, pause for domain modeling review before continuing.
Why this matters: Traditional TDD often produces anemic models with primitive obsession. Systematic domain review catches these issues at natural breakpoints.
What Domain Review Checks:
After RED (reviewing test):
String where Email should exist)After GREEN (reviewing implementation):
How to apply:
Example (Primitive Obsession):
# RED phase writes test
def test_user_email_must_be_valid
user = User.new(email: "invalid-email")
expect(user).to_not be_valid
end
# DOMAIN review flags: "email" is a String, should be Email type
# DOMAIN creates Email type with validation
class Email
def initialize(value)
raise ArgumentError unless value.match?(EMAIL_REGEX)
@value = value
end
def to_s = @value
end
class User
def initialize(email:)
@email = email # Now expects Email object, not String
end
end
# GREEN implements validation in Email constructor
# DOMAIN reviews: ✓ Parse-don't-validate principle applied
unimplemented!() / todo!() / raise NotImplementedErrorunimplemented!() stubsRationale: These boundaries ensure tests drive design, domain modeling happens systematically, and implementations stay minimal.
Scenario: Implementing user authentication.
Acceptance Criteria:
Cycle 1 (AC #1 - Registration):
RED:
Write test_user_can_register_with_email_and_password()
Test fails: User type doesn't exist
STOP ← Return control
DOMAIN (after red):
Review test: Email and Password are strings (primitive obsession!)
Create: Email type, Password type, User type
All methods use unimplemented!()
STOP ← Return control
GREEN:
Implement User::new(email: Email, password: Password)
Test now compiles and passes
STOP ← Return control
DOMAIN (after green):
Review implementation: ✓ No primitive obsession
Review implementation: ✓ Password is hashed, not stored plaintext
No violations found
STOP ← Cycle complete
Move to Cycle 2 (AC #2)
Scenario: Domain review catches primitive obsession.
RED writes:
test('order calculates total price', () => {
const order = new Order();
order.addItem('Widget', 10.99, 2);
order.addItem('Gadget', 5.50, 1);
expect(order.total).toBe(27.48);
});
DOMAIN reviews:
DOMAIN creates types:
class Money {
constructor(private readonly amount: number, readonly currency: string = 'USD') {
if (amount < 0) throw new Error('Money cannot be negative');
}
add(other: Money): Money {
if (this.currency !== other.currency) throw new Error('Currency mismatch');
return new Money(this.amount + other.amount, this.currency);
}
multiply(factor: number): Money {
return new Money(this.amount * factor, this.currency);
}
}
class Quantity {
constructor(private readonly value: number) {
if (value < 0) throw new Error('Quantity cannot be negative');
if (!Number.isInteger(value)) throw new Error('Quantity must be whole number');
}
getValue(): number { return this.value; }
}
class OrderItem {
constructor(
readonly name: string,
readonly price: Money,
readonly quantity: Quantity
) {}
subtotal(): Money {
return this.price.multiply(this.quantity.getValue());
}
}
class Order {
private items: OrderItem[] = [];
addItem(name: string, price: number, quantity: number): void {
this.items.push(new OrderItem(
name,
new Money(price),
new Quantity(quantity)
));
}
total(): Money {
return this.items.reduce(
(sum, item) => sum.add(item.subtotal()),
new Money(0)
);
}
}
GREEN implements (already done by domain! Just fills stubs if any).
DOMAIN reviews GREEN: ✓ Domain-rich model, invalid states impossible.
Scenario: Test reveals complex behavior needing sub-tests.
Initial test (too broad):
#[test]
fn order_validation_catches_all_errors() {
let order = Order::new();
let result = order.validate();
assert!(result.is_err());
}
Realization: "All errors" is vague. Need specific tests.
Drill-down approach:
#[ignore])test_order_validation_fails_when_emptytest_order_validation_fails_when_no_shipping_addresstest_order_validation_fails_when_payment_invalidWorks well with:
Prerequisites:
Problem: "Let me write all the tests for this feature"
Solution: ONE test, then complete the cycle. Multiple tests = multiple assumptions = unclear failures.
Problem: "This is simple, we don't need to model it"
Solution: "Simple" features often have implicit domain concepts. Always review. Primitive obsession starts with "simple" code.
Problem: Adding error handling, logging, flexibility not required by test
Solution: Write ONLY what makes the test pass. Future tests will drive additional features.
Problem: One test verifies 5 different things
Solution: Split into 5 tests. Shared setup can be extracted to helper functions.
Problem: "While writing the test, I'll just quickly add this type" (RED doing DOMAIN's job)
Solution: STOP. File boundaries prevent drift. If you're blocked, return control to workflow coordinator.
Problem: Not actually running tests, assuming outcome
Solution: ALWAYS run tests. Assumptions cause bugs. Evidence prevents them.
RED Phase:
@Test
public void testOrderTotalWithMultipleItems() {
Order order = new Order();
order.addItem("Widget", 10.99, 2);
order.addItem("Gadget", 5.50, 1);
assertEquals(27.48, order.getTotal(), 0.01);
}
DOMAIN Review (after red):
❌ Price is double (primitive obsession)
❌ Total is double (primitive obsession)
❌ No currency information
❌ Floating point arithmetic for money (precision errors!)
Create Money type instead.
DOMAIN Phase:
public class Money {
private final long amountInCents; // Avoid floating point!
private final String currency;
public Money(String amount, String currency) {
this.amountInCents = new BigDecimal(amount)
.multiply(new BigDecimal("100"))
.longValue();
this.currency = currency;
}
public Money add(Money other) {
if (!this.currency.equals(other.currency)) {
throw new CurrencyMismatchException();
}
long sum = this.amountInCents + other.amountInCents;
return new Money(sum, this.currency);
}
public Money multiply(int quantity) {
return new Money(this.amountInCents * quantity, this.currency);
}
public BigDecimal toDecimal() {
return new BigDecimal(amountInCents).divide(new BigDecimal("100"));
}
}
public class Order {
private List<OrderItem> items = new ArrayList<>();
public void addItem(String name, String price, int quantity) {
// Implementation will be added in GREEN phase
throw new UnsupportedOperationException("Not implemented yet");
}
public Money getTotal() {
throw new UnsupportedOperationException("Not implemented yet");
}
}
GREEN Phase:
public class Order {
private List<OrderItem> items = new ArrayList<>();
public void addItem(String name, String price, int quantity) {
Money itemPrice = new Money(price, "USD");
items.add(new OrderItem(name, itemPrice, quantity));
}
public Money getTotal() {
Money total = new Money("0.00", "USD");
for (OrderItem item : items) {
total = total.add(item.getSubtotal());
}
return total;
}
}
class OrderItem {
private String name;
private Money price;
private int quantity;
public OrderItem(String name, Money price, int quantity) {
this.name = name;
this.price = price;
this.quantity = quantity;
}
public Money getSubtotal() {
return price.multiply(quantity);
}
}
DOMAIN Review (after green):
✓ Money type prevents floating point errors
✓ Currency is explicit
✓ Invalid states impossible (negative money throws exception)
✓ Domain-rich model (Money has behavior, not just data)
APPROVED
RED Phase:
def test_user_registration_rejects_invalid_email():
with pytest.raises(InvalidEmailError):
User(email="not-an-email", password="secret123")
DOMAIN Review (after red):
❌ Email is string (primitive obsession)
❌ Validation logic will leak into User class
❌ Need Email type that validates on construction
DOMAIN Phase:
import re
class Email:
"""Email type that enforces validity at construction."""
EMAIL_REGEX = re.compile(r'^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$')
def __init__(self, value: str):
if not self.EMAIL_REGEX.match(value):
raise InvalidEmailError(f"Invalid email: {value}")
self._value = value
def __str__(self) -> str:
return self._value
def __eq__(self, other) -> bool:
return isinstance(other, Email) and self._value == other._value
class Password:
"""Password type (placeholder for hashing)."""
def __init__(self, plaintext: str):
self._hash = self._hash_password(plaintext)
def _hash_password(self, plaintext: str) -> str:
raise NotImplementedError("GREEN phase will implement")
def verify(self, plaintext: str) -> bool:
raise NotImplementedError("GREEN phase will implement")
class User:
def __init__(self, email: Email, password: Password):
raise NotImplementedError("GREEN phase will implement")
GREEN Phase:
import hashlib
class Password:
def __init__(self, plaintext: str):
self._hash = self._hash_password(plaintext)
def _hash_password(self, plaintext: str) -> str:
return hashlib.sha256(plaintext.encode()).hexdigest()
def verify(self, plaintext: str) -> bool:
return self._hash == self._hash_password(plaintext)
class User:
def __init__(self, email: Email, password: Password):
self.email = email
self.password = password
DOMAIN Review (after green):
✓ Parse-don't-validate: Email validates at boundary
✓ Invalid emails cannot exist in system
✓ User class doesn't need validation logic
✓ Password is hashed, not stored as plaintext
APPROVED
RED Phase:
test('published article has author and content', () => {
const article = new Article();
article.status = 'published';
expect(article.author).not.toBeNull();
expect(article.content).not.toBeNull();
});
DOMAIN Review (after red):
❌ Status is string (enum of states)
❌ Can create published article without author/content (invalid state!)
❌ Need type-state pattern: Draft vs Published
DOMAIN Phase:
// Use type-state pattern to make invalid states unrepresentable
interface Draft {
kind: 'draft';
content?: string;
// No author required for drafts
}
interface Published {
kind: 'published';
content: string; // Required
author: string; // Required
publishedAt: Date; // Required
}
type Article = Draft | Published;
function createDraft(): Draft {
return { kind: 'draft' };
}
function publish(draft: Draft, author: string): Published {
if (!draft.content) {
throw new Error('Cannot publish draft without content');
}
return {
kind: 'published',
content: draft.content,
author,
publishedAt: new Date()
};
}
GREEN Phase: (Already complete - domain created full implementation)
DOMAIN Review (after green):
✓ Cannot create published article without author/content
✓ Type system enforces invariants
✓ Invalid states are literally impossible
✓ No runtime checks needed (compile-time safety)
APPROVED
Watch for these thoughts - they indicate you're about to violate TDD discipline:
| If you're thinking... | The truth is... | Correct action |
|---|---|---|
| "Let me write a few tests at once to be efficient" | Multiple tests = multiple assumptions = unclear failures | Write ONE test, verify it fails, STOP |
| "The domain type isn't needed for this test" | Primitive obsession starts small. Using String instead of Email is a slippery slope | Use domain types from the start |
| "I'll test the edge case later" | "Later" means "never" in TDD. Tests drive design NOW | Write the edge case test now |
| "This is a simple test, I don't need to run it" | If you didn't watch it fail, you don't know it tests anything | Run EVERY test and verify failure |
| "I know what the failure will look like" | Assumptions cause bugs. Evidence prevents them | Run the test, observe actual output |
| "The acceptance criteria don't need exact coverage" | Acceptance criteria ARE the requirements. Missing one = incomplete work | Map EVERY criterion to a test |
| "Let me quickly add this implementation to see if the test works" | You're drifting toward "test after" - the cardinal sin | STOP. Complete RED phase first |
| "This is just data, it doesn't need domain modeling" | Anemic models start with "just data". Behavior belongs with data | Review for missing behavior |
| "We can add domain types later when we need them" | Later = never. Refactoring away from primitives is painful | Model the domain NOW |
When you catch yourself thinking these things, STOP and return to the protocol.
Use this checklist to verify you're following TDD constraints:
RED Phase:
DOMAIN Phase (after red):
unimplemented!(), todo!())GREEN Phase:
DOMAIN Phase (after green):
Source Documentation:
Related Skills:
External Resources:
Extraction Source: sdlc plugin (tdd-constraints.md, red.md, green.md, domain.md) Extraction Date: 2026-02-04 Last Updated: 2026-02-04 Compatibility: Universal (all languages and test frameworks) License: MIT