@clawhub-quochungto-93dad49abd
Build a personalized multi-week SPIN practice plan for a B2B sales rep learning the SPIN methodology. Use this skill when someone is new to SPIN and wants to...
---
name: spin-skill-practice-coach
description: "Build a personalized multi-week SPIN practice plan for a B2B sales rep learning the SPIN methodology. Use this skill when someone is new to SPIN and wants to build the skill systematically, when a rep says 'I read SPIN Selling but can't put it into practice', when someone asks 'how do I actually learn SPIN?', when a seller wants a structured practice curriculum, when a manager wants a rep to develop SPIN questioning skills without blowing key accounts, when someone asks 'how do I actually use Implication Questions in real calls?', when a rep is struggling to apply SPIN because it feels unnatural, when someone wants to build muscle memory for Problem or Need-payoff questions, or when a seller wants to know which accounts are safe to practice on. This skill applies Rackham's Four Golden Rules for skill learning — one behavior at a time, try it 3 times before judging, quantity before quality, safe situations first — plus a 4-step SPIN learning sequence, to build a personalized schedule calibrated to the user's current level and actual account portfolio. The output is a dated multi-week plan that names specific behaviors to practice in specific practice windows, not a feature list. A baseline LLM will produce tips ('read the book', 'practice in real calls', 'get feedback') — this skill produces a practice curriculum."
version: 1.0.0
homepage: https://github.com/bookforge-ai/bookforge-skills/tree/main/books/spin-selling/skills/spin-skill-practice-coach
metadata: {"openclaw":{"emoji":"🎯","homepage":"https://github.com/bookforge-ai/bookforge-skills"}}
status: draft
source-books:
- id: spin-selling
title: "SPIN Selling"
authors: ["Neil Rackham"]
chapters: [8]
tags: [sales, b2b-sales, enterprise-sales, spin-methodology, skill-development, practice-plan, learning-curriculum, questioning-techniques]
depends-on:
- spin-discovery-question-planner
execution:
tier: 1
mode: plan-only
inputs:
- type: document
description: "account-portfolio.md — list of active accounts with deal stage, size, and relationship strength. If this file does not exist, the skill interviews the user to build it."
- type: interactive
description: "SPIN level self-assessment — the skill administers a short diagnostic to determine which question types the user already uses naturally"
tools-required: [Read, Write]
tools-optional: [Grep]
mcps-required: []
environment: "Document set or interactive session. Agent produces spin-practice-plan-{user}.md — a multi-week practice schedule. Human executes the plan in real calls."
discovery:
goal: "Produce a personalized multi-week SPIN practice schedule that assigns one new behavior per practice window, names which accounts are safe vs. key, and embeds retry budgets so the user does not abandon new behaviors after one awkward attempt"
tasks:
- "Diagnose the user's current SPIN level via short self-assessment"
- "Classify the user's accounts into safe practice grounds vs. key accounts to protect"
- "Apply the Four Golden Rules to structure the practice schedule"
- "Walk through the 4-step SPIN learning sequence: more questions → Problem Qs → Implication Qs → Need-payoff Qs"
- "Produce a named, dated multi-week plan with one behavior per window and a 3-attempt retry budget"
audience:
roles: [account-executive, enterprise-sales-rep, sdr, solutions-consultant, founder-led-seller]
experience: intermediate
when_to_use:
triggers:
- "Rep is new to SPIN and wants to build the skill methodically"
- "Rep read SPIN Selling but finds the behaviors feel unnatural in actual calls"
- "Manager wants a structured learning plan for a rep without risking key accounts"
- "Seller wants to know which accounts to practice Implication Questions on first"
prerequisites:
- "Basic familiarity with SPIN question types (Situation, Problem, Implication, Need-payoff) — either from reading the book or from using spin-discovery-question-planner"
not_for:
- "Planning questions for a specific deal call (use spin-discovery-question-planner)"
- "Diagnosing why a call produced objections (use objection-source-diagnoser)"
- "Running a Plan-Do-Review cycle on a specific call (use sales-call-plan-do-review-coach)"
- "Closing-attitude introspection (use closing-attitude-self-assessment)"
environment:
codebase_required: false
codebase_helpful: false
works_offline: true
quality:
scores:
with_skill: 0
baseline: 0
delta: 0
tested_at: ""
eval_count: 0
assertion_count: 0
iterations_needed: 0
what_skill_catches:
- "Output assigns one new SPIN behavior per practice window, not multiple simultaneously"
- "Output names which specific accounts are safe practice grounds and which to protect"
- "Output requires 3 attempts before evaluating a new behavior (Rule 2)"
- "Output prioritizes quantity over quality in early practice windows (Rule 3)"
- "Output explicitly forbids practicing new behaviors on top accounts (Rule 4)"
what_baseline_misses:
- "Produces a flat list of tips with no structural methodology"
- "Does not map new behaviors to specific account types"
- "Does not include a retry budget or protect against abandoning after one awkward attempt"
- "Does not sequence the four SPIN behaviors — treats them as a flat list"
---
# SPIN Skill Practice Coach
## When to Use
You know what SPIN is. You may have even planned a few question banks with `spin-discovery-question-planner`. But when the call starts, the questions feel clunky. You sound scripted. You go back to old habits. The methodology is in your head — not yet in your hands.
This skill builds the practice infrastructure that converts knowledge into reflexes. It diagnoses where you are in the SPIN learning curve, maps your account portfolio into safe vs. key accounts, and produces a multi-week schedule with one new behavior per practice window and a built-in retry budget.
**Use this skill when:**
- You are new to SPIN and want to learn it without damaging your pipeline
- You have tried SPIN a few times and given up after it felt awkward
- You want to know which accounts are safe to experiment on — and which to protect
- You want a structured plan, not a list of tips
**This skill is NOT for:** individual call execution (use `spin-discovery-question-planner`), post-call objection diagnosis (use `objection-source-diagnoser`), or full Plan-Do-Review cycles (use `sales-call-plan-do-review-coach`).
## Context & Input Gathering
### Required Context (must have — ask if missing)
- **Account portfolio:** The user's active accounts, with some indication of size, stage, and relationship strength.
-> Check environment for: `account-portfolio.md`
-> If missing, ask: "Tell me about your current account list — roughly how many active accounts do you have, and can you characterize a few as either important/key deals or smaller/exploratory accounts?"
- **Current SPIN level:** Which question types does the user already use naturally?
-> Check environment for: self-assessment answers or prior call notes
-> If missing, administer the 5-item diagnostic in Step 1
### Observable Context (gather from environment)
- **Prior call notes or question banks:** Any `question-bank-*.md` files from `spin-discovery-question-planner`
-> If available: read to calibrate the user's actual SPIN usage, not just self-reported
-> If unavailable: rely on the Step 1 diagnostic
- **Time horizon:** How many weeks does the user want the plan to cover?
-> Default to 6 weeks if not specified
### Sufficiency Threshold
SUFFICIENT: Any description of the account portfolio + self-assessment complete
PROCEED WITH DEFAULTS: User describes only 2-3 accounts → extrapolate safe vs. key classification
MUST ASK: No account description at all (the skill cannot assign account-specific practice windows)
## Process
### Step 1: Diagnose Current SPIN Level
**ACTION:** Administer the following 5-item self-assessment. Ask the user to rate each item on a scale of 1 (never) to 5 (always):
1. "In a typical discovery call, I ask open-ended questions to understand the customer's situation." (Situation Questions baseline)
2. "I ask questions specifically about the customer's problems, difficulties, or frustrations." (Problem Questions)
3. "When a customer mentions a problem, I follow up with questions about the downstream effects and costs of that problem." (Implication Questions)
4. "I ask questions that prompt the customer to articulate why solving the problem would be valuable to them." (Need-payoff Questions)
5. "I sequence my questions — building from context to problems to consequences to solutions — rather than asking in whatever order comes to mind." (SPIN sequencing)
**WHY:** Without a baseline, the practice plan cannot know where to start. A seller who scores 1 on all five items needs to start at the very beginning (Step 1 of the SPIN learning sequence: just ask more questions of any type). A seller who scores 4-5 on Situation and Problem but 1-2 on Implication needs a plan that focuses on Implication Questions specifically. Skipping the diagnostic means assigning the wrong starting behavior — the most expensive mistake in a practice plan.
**Output:** A SPIN level summary:
```
SPIN Level Summary
Situation Questions: [score/5] — [Natural / Developing / Not yet in repertoire]
Problem Questions: [score/5] — [Natural / Developing / Not yet in repertoire]
Implication Questions: [score/5] — [Natural / Developing / Not yet in repertoire]
Need-payoff Questions: [score/5] — [Natural / Developing / Not yet in repertoire]
Sequencing discipline: [score/5] — [Natural / Developing / Not yet in repertoire]
Starting point: [Step X of the 4-step SPIN learning sequence — see Step 3]
```
### Step 2: Classify Account Portfolio — Safe vs. Key
**ACTION:** Review the user's account list. For each account (or account type), classify it into one of three categories:
- **SAFE — Practice Ground:** Small deal, early stage, strong personal relationship, or an account where a less-than-perfect call has minimal downside. These are where new behaviors get their first 3 attempts.
- **SAFE — Moderate:** Mid-tier accounts, reasonable relationship, some deal risk. New behaviors can be introduced here once they have been tried at least once on a full-safe account.
- **KEY — Protect:** High-value, late-stage, fragile relationship, or strategic deal. No new SPIN behaviors are practiced here until they are fully comfortable.
**Format:**
```
Account Safety Classification
SAFE — Practice Grounds (new behaviors start here):
- [Account name or type]: [Why safe — small deal, strong relationship, etc.]
- [Account name or type]: [...]
SAFE — Moderate (introduce after first attempt):
- [Account name or type]: [...]
KEY — Protect (only use behaviors already comfortable):
- [Account name or type]: [CRITICAL: no new behaviors here]
```
**WHY:** This is Rule 4 in action: practice in safe situations. Research consistently shows that people instinctively try new skills in high-stakes situations — precisely the wrong choice. New SPIN behaviors feel awkward (see Step 3 for why this is normal and temporary). An awkward Implication Question in a $500k deal at closing stage can cost the seller the sale. The same question in a small exploratory call costs nothing and teaches a great deal. The classification step makes Rule 4 concrete rather than abstract.
**Anti-pattern AP-12 — Practicing on Key Accounts:** If the user has no safe accounts in their portfolio (unlikely but possible), do NOT skip account classification — instead, tell the user they need to identify at least one safe practice context before starting the plan. This may mean cold prospecting calls, internal role-plays, or waiting for a new small inbound. Starting on key accounts is worse than not starting.
### Step 3: Apply the Four Golden Rules
**ACTION:** Present the Four Golden Rules to the user before building the schedule. These are the structural constraints that will govern every practice window in the plan.
For verbatim detail and the original analogies, see [references/four-golden-rules-detail.md](references/four-golden-rules-detail.md).
**Summary for plan-building:**
**Rule 1 — One behavior at a time.**
Pick one SPIN behavior. Practice it until it is comfortable. Then, and only then, add the next. Do not simultaneously practice Problem Questions AND Implication Questions AND Need-payoff Questions. That is the fastest path to abandoning all of them.
*Why it matters:* Tom Landry's single coaching principle was "work on one thing at a time, and get it right." Benjamin Franklin's system for learning virtues in his 1771 Autobiography follows the same discipline. The complexity of selling is already high — adding multiple simultaneous behavior changes overwhelms the seller and prevents any individual behavior from becoming automatic.
**Rule 2 — Try the new behavior at least 3 times before judging.**
The first attempt will feel unnatural and probably underperform. This is expected and does not mean the behavior is wrong for you. Out of 200 golfers asked whether their game improved after a professional lesson, 157 said their next round was worse — not because the lesson was bad, but because a new behavior first degrades before it improves. Give every new SPIN behavior a minimum of 3 attempts before deciding whether it works.
*Why it matters:* Abandoning a new behavior after one awkward call is the most common reason SPIN training fails. Anti-pattern AP-11 (judging on first attempt) directly causes the "I tried Implication Questions and they made the call worse" conclusion — which is almost always premature.
**Rule 3 — Quantity before quality.**
When practicing a new behavior, the goal is to use it as many times as possible per call — not to use it perfectly. Ask 8 Problem Questions per call, even if 5 of them are clumsy. Ask many Implication Questions, even if they feel blunt. The quality will improve as a byproduct of volume. Spending time worrying about how to phrase each question is a quality-first trap that slows skill acquisition dramatically.
*Why it matters:* Research on language learning shows that a quantity-first approach produces both more fluency AND better quality than a quality-first approach, in less time. The same principle applies to sales behaviors. A program that required sellers to ask only "high-quality" Problem Questions (with four sub-steps for each question) resulted in students asking an average of 1.6 Problem Questions per call — identical to the pre-training baseline.
**Rule 4 — Practice in safe situations first.**
Never try a new SPIN behavior in a key account or late-stage deal. Start with safe accounts (from Step 2). Move to moderate accounts only after 1-2 successful attempts. Bring new behaviors into key accounts only when they feel completely natural.
*Why it matters:* New behaviors are awkward and may temporarily reduce performance. This is the normal learning curve. The cost of that awkwardness is near-zero in a safe account and potentially catastrophic in a key account. The classification from Step 2 makes this rule concrete.
### Step 4: Build the Practice Plan Using the 4-Step SPIN Learning Sequence
**ACTION:** Using the user's SPIN level from Step 1 and the Four Golden Rules from Step 3, build a multi-week practice schedule. The plan starts at the appropriate step in the SPIN learning sequence and advances one behavior at a time.
**The 4-Step SPIN Learning Sequence:**
For verbatim detail and the full methodology, see [references/spin-learning-sequence.md](references/spin-learning-sequence.md).
**Step A — Ask more questions (any type).**
For sellers who have primarily been "telling" rather than asking. Goal: break the pattern of leading with features and advantages. Use any questions — most will be Situation Questions. Do not worry about quality. Just ask more. Practice windows: 2-3 weeks. Advance when questions feel as natural as telling.
**Step B — Develop Problem Questions.**
For sellers who ask questions but don't consistently probe for customer problems, difficulties, and dissatisfactions. Goal: ask about problems at least 6 times per call in the average call. Focus on quantity — don't worry about whether each question is "good." Practice windows: 2-3 weeks. Advance when 6 Problem Questions per call feels automatic.
**Step C — Plan and ask Implication Questions.**
For sellers who surface problems but don't develop their consequences. Goal: plan Implication chains before each call (using `spin-discovery-question-planner`) and then execute them in-call. This is the hardest step — expect 4-8 weeks of deliberate practice. Plan carefully; execute in volume. Implication Questions must be pre-planned — they cannot be reliably improvised.
**Step D — Ask Need-payoff Questions.**
For sellers who have developed needs through Implication Questions but still present benefits rather than asking the customer to articulate them. Goal: ask questions that get the customer talking about why a solution would be valuable — instead of you telling them. Focus on volume and variety.
**Practice Schedule Format:**
```
SPIN Practice Plan — [User Name] — Starting [Date]
Overall starting point: Step [A/B/C/D] of the SPIN learning sequence
──────────────────────────────────────────
WEEK 1-2: [BEHAVIOR NAME]
Goal: [Specific, measurable goal]
Rule 1: This is the ONLY SPIN behavior being practiced this window
Rule 2: Attempt minimum 3 times before drawing any conclusions
Rule 3: Volume goal: [X] per call — quality does not matter yet
Rule 4: Practice ONLY on these accounts: [names from safe list]
KEY accounts to protect: [names from key list — no new behaviors here]
Practice prompt: [Specific in-call instruction]
Review check: [What to note after each practice call]
──────────────────────────────────────────
WEEK 3-4: [NEXT BEHAVIOR]
[same structure]
──────────────────────────────────────────
WEEK 5-6: [NEXT BEHAVIOR — or advance to next learning step]
[same structure]
──────────────────────────────────────────
Retry budget:
- Feeling awkward is normal. Do NOT conclude a behavior is wrong until 3 attempts.
- If after 3 attempts the behavior still disrupts the call badly, return to Step 1
(re-diagnose) and check whether you are applying it in an appropriate context.
- If comfortable ahead of schedule: advance to the next behavior — don't wait.
```
**WHY:** A schedule without account specificity is abstract and does not get followed. Naming the actual safe accounts removes the decision friction at the moment of the call. Naming the protected accounts prevents the instinctive drift toward practicing in high-stakes situations. The retry budget prevents premature abandonment (AP-11).
### Step 5: Embed the Motivation Layer
**ACTION:** Before writing the final plan document, add a brief section that prepares the user for the discomfort of early practice. Use the empirical anchors from the source material.
**Framing to include in the plan:**
> "When you first try a new SPIN behavior, it will feel awkward. You may ask a clumsy Implication Question and see the customer's expression change. You may decide the new behavior is wrong for you. Almost certainly, that conclusion is premature.
>
> Out of 200 people who took golf lessons from a professional, 157 scored worse on their next round — not because the lesson was bad, but because integrating a new technique first disrupts performance before it improves it. The lesson was working. The outcome was temporary.
>
> Tom Landry's single coaching principle was 'work on one thing at a time, and get it right.' He was building a habit, not testing a hypothesis. One awkward call is not a test. Three attempts — with deliberate practice and post-call reflection — is the minimum for a fair evaluation.
>
> This plan gives you the structure to practice without pressure. The safe accounts are your range. The key accounts are the tournament. Practice on the range."
**WHY:** Without this framing, sellers abandon new behaviors after the first awkward attempt — this is anti-pattern AP-11. The golfer analogy provides an evidence-based reason to push through. The Landry and Franklin references elevate the principle from folk wisdom to validated methodology. Motivation scaffolding is not optional — it is the mechanism that keeps the plan in use past the first week.
### Step 6: Write the Practice Plan Document
**ACTION:** Compile all outputs from Steps 1-5 into a single file: `spin-practice-plan-{user}.md`. The document should be readable in under 5 minutes at the start of each practice week.
**Structure:**
1. SPIN Level Summary (from Step 1)
2. Account Safety Classification (from Step 2) — laminate this; refer to it before every call
3. The Four Golden Rules (brief version — link to references/ for depth)
4. Practice Schedule (from Step 4) — one section per practice window
5. Motivation Layer (from Step 5) — read this when it feels awkward
**WHY:** A written, dated document is the difference between a plan and an intention. Without it, the planning conversation evaporates. With it, the user has a reference they can return to each week and annotate with what they observed.
## Key Principles
- **One behavior at a time is not a suggestion — it is the constraint that makes learning possible.** The instinct to practice many things simultaneously is the fastest way to practice nothing effectively. Every practice window in the plan has exactly one target behavior.
- **The awkward phase is not evidence the behavior is wrong.** New skills degrade performance before improving it. 157 of 200 golfers scored worse after a professional lesson. The minimum sample for evaluation is three attempts in appropriate contexts. AP-11 (judging on first attempt) is the single most common cause of failed SPIN adoption.
- **Key accounts are for execution, not experimentation.** Rule 4 is absolute. Practicing a new SPIN behavior on a late-stage six-figure deal is not brave — it is expensive. Safe accounts exist precisely to absorb the awkwardness that is a normal and temporary feature of skill acquisition. AP-12 (practicing on key accounts) destroys both the skill and the deal.
- **Quantity produces quality faster than quality-first approaches do.** Ask many Problem Questions per call, even clumsy ones. The quality will emerge from volume and real-world feedback. A quality-first approach to skill learning consistently underperforms a volume approach by a wide margin — in language acquisition, in sales behavior training, and in other complex skills.
- **The practice plan is a starting point, not a constraint.** If a behavior becomes comfortable ahead of schedule, advance. If after three attempts a behavior is still disrupting calls badly, return to the SPIN level diagnostic and recalibrate. The schedule is a minimum structure, not a maximum commitment.
- **spin-discovery-question-planner is the operational complement.** This skill builds the practice curriculum; `spin-discovery-question-planner` prepares the actual question bank for each practice call. During the Implication Question practice window (Step C of the learning sequence), use `spin-discovery-question-planner` before every call on safe accounts to pre-plan Implication chains. The practice plan tells you which accounts to practice on and which behavior to focus on; the question planner tells you which specific questions to ask.
## Examples
**Scenario: New AE, just finished reading SPIN Selling, has a full pipeline**
Trigger: "I just finished SPIN Selling and want to start using it. I have about 15 accounts, a few of which are big deals. Where do I start?"
Process:
1. Diagnostic: scores 4/5 on Situation Questions (natural asking style), 2/5 on Problem Questions (asks occasionally), 1/5 on Implication Questions (never planned them), 1/5 on Need-payoff (doesn't know how to ask). Starting point: Step B (Problem Questions).
2. Account classification: 3 accounts flagged as key (major deals, late stage). 6 accounts classified as safe (small, early stage, or strong existing relationships). 6 accounts as moderate.
3. Practice schedule: Weeks 1-2: Problem Questions — target 6 per call — safe accounts only. Weeks 3-4: Problem Questions on moderate accounts — add volume not quality. Weeks 5-6: Begin Implication Question planning using spin-discovery-question-planner — safe accounts only, planned chains only, no improvised Implication Questions.
4. Key accounts: no new behaviors until week 7 minimum.
Output: `spin-practice-plan-alex-2026-04-14.md` with classified account list, 6-week schedule, retry budget.
---
**Scenario: Experienced rep, practiced SPIN for 2 weeks, gave up after "Implication Questions made calls worse"**
Trigger: "I tried SPIN for a couple of weeks. The Implication Questions felt really unnatural and I think they actually hurt a few calls. I've gone back to my old style."
Process:
1. Diagnose: this is AP-11 in action. Confirm: did the rep try Implication Questions at least 3 times in safe situations? Almost certainly not — most give up after 1-2 attempts, often on moderate or key accounts.
2. Apply Rule 2 framework: out of 200 golfers, 157 scored worse after a lesson. That doesn't mean the lesson was bad.
3. Rebuild plan from Step C (Implication Questions) — but now with explicit safe account assignment and a 3-attempt minimum before any evaluation.
4. Add the motivation layer as the first section of the plan.
Output: `spin-practice-plan-revised-2026-04-14.md` — same structure but framed as a restart with the AP-11 explanation at the top.
---
**Scenario: Sales manager building a practice plan for a new hire**
Trigger: "I'm onboarding a new AE and want to give them a structured SPIN learning plan. They have a small starter portfolio — 8 accounts, all small-to-mid size. I want them fluent in SPIN in 8 weeks."
Process:
1. Diagnostic administered on behalf of the new hire (or manager answers for them): likely starting at Step A (just ask more questions) given no prior SPIN exposure.
2. Account classification: all 8 accounts are safe by definition (small-to-mid, new rep, no existing key relationships to jeopardize). Flag one or two as "practice-priority" for dense early use.
3. 8-week schedule: Weeks 1-2: quantity questions (any type). Weeks 3-4: Problem Questions (6+ per call). Weeks 5-6: begin Implication Question pre-planning with spin-discovery-question-planner. Weeks 7-8: add Need-payoff Questions.
Output: `spin-practice-plan-new-hire-2026-04-14.md` — includes the manager review checklist (what to listen for in each phase's call recordings).
## References
- Four Golden Rules verbatim text and original analogies (Landry, Franklin, golfer study): [references/four-golden-rules-detail.md](references/four-golden-rules-detail.md)
- 4-step SPIN learning sequence verbatim text and implementation guidance: [references/spin-learning-sequence.md](references/spin-learning-sequence.md)
## License
This skill is licensed under [CC-BY-SA-4.0](https://creativecommons.org/licenses/by-sa/4.0/).
Source: [BookForge](https://github.com/bookforge-ai/bookforge-skills) — SPIN Selling by Neil Rackham.
## Related BookForge Skills
This skill depends on:
- `clawhub install bookforge-spin-discovery-question-planner` — plan the actual SPIN question bank for each practice call (the operational complement to this skill; use it during the Implication Question and Need-payoff practice windows)
Skills this one builds on:
- `clawhub install bookforge-need-type-classifier` — classify customer responses during practice calls to know whether your Implication Questions developed a need successfully
- `clawhub install bookforge-sales-call-plan-do-review-coach` — wrap a full Plan-Do-Review cycle around individual calls in your practice plan (Level 2)
Or install the full SPIN Selling skill set from GitHub: [bookforge-skills](https://github.com/bookforge-ai/bookforge-skills)
FILE:references/four-golden-rules-detail.md
# Four Golden Rules for Learning Skills
Source: SPIN Selling, Neil Rackham — Chapter 8: Turning Theory into Practice
These rules are Rackham's synthesis of Huthwaite's worldwide experience training thousands of salespeople to improve their skills. They apply to learning any skill — selling, golf, flying, language learning.
---
## Rule 1: Practice Only One Behavior at a Time
> "People who successfully learn complex skills do so by practicing one behavior at a time — not by half-practicing two, and certainly not by trying to handle 10 at once."
**The Tom Landry authority:**
Tom Landry (Dallas Cowboys head coach) was asked: "If you had to put forward just one principle for successfully learning a skill, what would it be?" He replied: "Work on one thing at a time, and get it right."
**The Benjamin Franklin authority:**
Franklin's 1771 Autobiography gives a detailed account of breaking a complex skill into its component behaviors and working on improving each one at a time.
**Application:** Pick a single SPIN behavior. Do not move to the next until the first feels comfortable. If you are practicing Problem Questions, do not simultaneously try to practice Implication Questions or cut out closing techniques.
---
## Rule 2: Try the New Behavior at Least Three Times
> "You have to try any new behavior several times before it becomes practiced enough to be both comfortable and effective. The new skill needs to be 'broken in.'"
**The golfer study:**
> "I once asked a sample of 200 people, each of whom had taken golf lessons from a professional, whether their next round was better or worse. Out of the 200, 157 said that they scored worse after the lesson than before it."
This does not mean the lesson was bad — it means new behaviors first degrade performance before improving it. The lesson was working. The score was a temporary artifact of the learning curve.
**The principle:**
> "Never judge whether a new behavior is effective until you've tried it at least three times."
**Anti-pattern AP-11 — Judging on first attempt:** The most common reason SPIN training fails. After one awkward Implication Question, the seller concludes the behavior doesn't work. This conclusion is almost always wrong and almost always results in the seller reverting to their previous, less effective style.
---
## Rule 3: Quantity Before Quality
> "When you're practicing, concentrate on quantity: use a lot of the new behavior. Don't worry about quality issues, such as whether you're using it smoothly or whether there might be a better way to phrase it. Those things get in the way of effective skills learning. Use the new behavior often enough and the quality will look after itself."
**The language learning evidence:**
Modern language training tells students to speak, speak, and speak — not to focus on tenses, pronunciation, or grammar. Students taught by the quantity method are speaking more confidently after one year than quality-first students after five years. And their quality — pronunciation and grammar — is higher than the quality-first students as well.
**The $650,000 training failure:**
One multinational company spent $650,000 building a quality-first SPIN training program — a 74-step model with sub-steps for asking each question type "correctly." The program produced walkouts during its pilot and, when tracked in the field, students were asking an average of 1.6 Problem Questions per call — identical to the pre-training baseline.
Huthwaite replaced it with a quantity-focused program for less than 10% of the cost. Students were asking 12 Problem Questions per call in final role plays.
**Application:** Volume goal during any practice window is defined in the per-window target. Quality is not assessed until the behavior is comfortable.
---
## Rule 4: Practice in Safe Situations
> "If you've just finished this book and you're about to visit your most important account, then forget everything I've written."
> "Always try out new behaviors in safe situations until they feel comfortable. Don't use important sales to practice new skills."
**The negotiation program story:**
A company president attending a negotiating-skills program told Rackham he would be negotiating the sale of his company the next day. Rackham's advice: "Forget every single thing you've heard on this program — otherwise, you'll spend the rest of your life regretting you came here."
New skills are uncomfortable and awkward. They may temporarily reduce performance. In low-stakes situations, that temporary reduction is acceptable and instructive. In high-stakes situations, it is potentially catastrophic.
**Application:** Safe situations include: small accounts, customers you know well, areas where failure has minimal downside. Practice new SPIN behaviors in these contexts until they feel completely natural. Then, and only then, deploy them in key accounts.
**Anti-pattern AP-12 — Practicing on key accounts:** Trying new behaviors in high-value, late-stage deals is the most expensive mistake in skill development. It jeopardizes the deal and does not accelerate learning — the pressure of the situation prevents the open experimentation that skill-building requires.
FILE:references/spin-learning-sequence.md
# A Strategy for Learning the SPIN Behaviors
Source: SPIN Selling, Neil Rackham — Chapter 8: Turning Theory into Practice
Rackham's four-step implementation sequence for developing SPIN questioning skills. Derived from Huthwaite's work with thousands of salespeople across training programs at major corporations.
---
## Overarching Principle: Focus on the Investigating Stage
> "Focus your efforts on the Investigating stage. Practice your questioning skills, and the other stages of the call will generally look after themselves. If you know how to develop needs — to get your customers to want the capabilities you offer — then you'll have no problem showing Benefits or Obtaining Commitment. The key selling skill is in the Investigating stage, using the SPIN questions to get your customers to feel a genuine need for your product."
The most common planning mistake is focusing on what to tell the customer (Demonstrating Capability) rather than what to ask (Investigating). Unless needs are developed first, even a perfect solution presentation has little impact — the customer does not yet want what you are offering.
---
## The 4-Step Sequence
### Step 1: Ask More Questions (Any Type)
> "First decide whether you're asking enough questions of any type. If you've built up selling patterns that involve telling — in other words if you're giving a lot of Features and Advantages — then start by just asking more questions. Most of the questions you ask will be Situation Questions, but this is fine. Just keep asking questions for a few weeks until asking feels as comfortable as telling."
**Who this is for:** Sellers who have been trained in a feature-benefit-close model and habitually lead with product information.
**Goal:** Break the telling pattern. Volume of questions is the only metric. Do not worry about question type, quality, or sequence.
**Duration:** 2-3 weeks, or until asking feels as natural as telling.
**Advance condition:** Questioning no longer requires deliberate effort — you naturally probe before presenting.
---
### Step 2: Plan and Ask Problem Questions
> "Next plan and ask Problem Questions. Aim, in the average call, to ask a customer about problems, difficulties, and dissatisfactions at least half a dozen times. Concentrate on building up the quantity of your Problem Questions; don't worry about whether or not each question is a 'good' one."
**Who this is for:** Sellers who ask questions but primarily ask for facts and context (Situation Questions) rather than probing for customer problems.
**Goal:** 6+ Problem Questions per call in the average call. Focus on quantity.
**Duration:** 2-3 weeks, or until Problem Questions are part of every call without prompting.
**Advance condition:** You reliably uncover at least 2-3 customer problems in a typical call.
---
### Step 3: Plan and Ask Implication Questions
> "If you feel you're doing an effective job of uncovering customer problems, it's time to move on to Implication Questions. These are more difficult to ask, and you may need a couple of months' practice before you become entirely comfortable with Implication Questions. Plan them carefully."
**Who this is for:** Sellers who surface customer problems but do not develop their consequences — who hear an Implied Need and then jump to a solution presentation.
**Planning method (from Rackham):**
> "When I'm planning Implication Questions, I find it's useful to imagine a customer who's saying 'So what? Yes, I've got that problem — but I don't think it's serious.' I list the arguments I'd use to convince the customer that the problem really is serious — it's causing a loss of efficiency, it's increasing her costs, and it's demotivating her better people. Then I turn each of my arguments into a question — 'What effect is the problem having on your efficiency?' and 'How much is it increasing your costs?' and 'What impact does it have on the motivation of your better people?'"
**Goal:** Pre-plan Implication chains using `spin-discovery-question-planner` before each practice call. Execute in volume — quantity over quality.
**Duration:** This is the hardest step. Expect 4-8 weeks of deliberate practice.
**Advance condition:** You can reliably build a consequence chain from a customer problem without improvisation.
---
### Step 4: Ask Need-payoff Questions
> "Finally, when you're comfortable with Situation, Problem, and Implication Questions, turn your attention to Need-payoff Questions. Instead of giving Benefits to the customer, concentrate on asking questions that get the customer to tell you the Benefits. Ask questions like these: How would that help you? What do you see as the pluses of this approach? Is there any other way our product could be useful? Again, don't worry about whether you're asking Need-payoff Questions well. Concentrate on quantity — on asking lots of them."
**Who this is for:** Sellers who have developed Implication chains but still pivot to telling the customer about benefits rather than asking them to articulate benefits.
**Goal:** Volume of Need-payoff Questions per call — after Implication chains have developed the need. Quality and phrasing improve with volume.
**Duration:** 2-4 weeks for sellers who have completed Steps 1-3 successfully.
**Advance condition:** You naturally ask customers to articulate value rather than presenting value to them.
---
## Two Additional Implementation Practices
### Analyze Products in Problem-Solving Terms
Stop thinking about products as a list of features and advantages. For each product, create a list of the problems it is designed to solve. Use this list to plan questions.
The shift: from "what does our product do?" to "what customer problems does our product solve?" — and then into SPIN planning.
### Plan, Do, and Review
After each practice call, ask:
- Did I achieve my objectives?
- If I were making the call again, what would I do differently?
- What have I learned that will influence future calls on this account?
- What have I learned that I can use elsewhere?
> "Two differences stand out [in top salespeople]. The first is that the top people I've traveled with put great emphasis on reviewing each call — dissecting what they've learned and thinking about possible improvement. The second difference is that most of the really successful salespeople I've studied recognize that their success depends on getting details right."
For a structured Plan-Do-Review system tied to specific deals, use `sales-call-plan-do-review-coach`.
Plan Situation, Problem, Implication, and Need-payoff (SPIN) questions for a specific B2B sales call. Use this skill whenever a sales rep wants to prepare di...
---
name: spin-discovery-question-planner
description: "Plan Situation, Problem, Implication, and Need-payoff (SPIN) questions for a specific B2B sales call. Use this skill whenever a sales rep wants to prepare discovery questions for an upcoming meeting, when someone asks 'what should I ask in this call?', when planning questions for a deal in any industry, when drafting Implication questions for a complex sale, when prepping for a discovery call with a prospect, when a seller wants to go into a meeting with a structured question bank instead of winging it, or when a rep has a call next week and needs help thinking through what to ask. This skill applies Rackham's empirically-validated SPIN methodology — specifically including the 3-step Implication question planning sub-workflow (problem → related difficulties → questions) that makes the hardest question type executable. The output is NOT a generic discovery checklist: it is a planned conversation with branches, Implication chains, and Need-payoff conversion moves tied to specific likely customer problems and product capabilities the seller can actually deliver."
version: 1.0.0
homepage: https://github.com/bookforge-ai/bookforge-skills/tree/main/books/spin-selling/skills/spin-discovery-question-planner
metadata: {"openclaw":{"emoji":"❓","homepage":"https://github.com/bookforge-ai/bookforge-skills"}}
status: draft
source-books:
- id: spin-selling
title: "SPIN Selling"
authors: ["Neil Rackham"]
chapters: [4]
domain: b2b-sales
tags: [sales, b2b-sales, enterprise-sales, discovery, spin-methodology, questioning-techniques, implication-questions, need-payoff, pre-call-planning]
depends-on:
- need-type-classifier
execution:
tier: 1
mode: plan-only
inputs:
- type: document
description: "deal-brief.md — company, contact, deal stage, deal size, what is known so far"
- type: document
description: "product-capabilities.md — what the seller's product can and cannot do"
- type: document
description: "account-research.md — company background, priorities, recent news (optional but valuable)"
- type: document
description: "needs-log.md — Implied/Explicit needs from prior calls (if this is a follow-up call)"
tools-required: [Read, Write]
tools-optional: [Grep]
mcps-required: []
environment: "Document set: deal-brief.md, product-capabilities.md, account-research.md (optional), needs-log.md (optional). Agent produces question-bank-{deal}-{date}.md. Human asks the questions on the actual call."
discovery:
goal: "Produce a pre-call question bank organized by SPIN type, with Implication chains planned in advance and Need-payoff questions ready to deploy when needs are confirmed"
tasks:
- "Plan Situation Questions that establish context without boring the buyer"
- "Plan Problem Questions to surface 3+ specific likely problems this prospect may have"
- "Apply the 3-step Implication planning sub-workflow for each problem: consequences → questions"
- "Plan Need-payoff Questions keyed to capabilities the seller can actually deliver"
- "Produce a sequence guide: when to move from S → P → I → N, and when to loop back"
audience:
roles: [account-executive, enterprise-sales-rep, sdr, solutions-consultant, founder-led-seller]
experience: intermediate
when_to_use:
triggers:
- "Rep has a discovery or follow-up call coming up and needs to plan questions"
- "Seller wants to move beyond 'winging it' and into structured discovery"
- "Manager is coaching a rep on how to develop Implication questions"
- "Preparing for a call with a decision-maker who responds to implications, not features"
prerequisites:
- "Some knowledge of the prospect's business situation (even basic) — gathered from deal-brief.md, public research, or prior calls"
- "Knowledge of what capabilities the seller's product offers"
not_for:
- "Classifying customer responses during or after the call (use need-type-classifier)"
- "Drafting Benefit statements after Explicit Needs are confirmed (use benefit-statement-drafter)"
- "Classifying whether a call outcome was an Advance or Continuation (use call-outcome-classifier)"
environment:
codebase_required: false
codebase_helpful: false
works_offline: true
quality:
scores:
with_skill: 0
baseline: 0
delta: 0
tested_at: ""
eval_count: 0
assertion_count: 0
iterations_needed: 0
what_skill_catches:
- "Output organizes questions by SPIN type, not as a flat generic list"
- "Implication questions are derived from specific problems via the 3-step sub-workflow"
- "Need-payoff questions are keyed to capabilities the seller can actually deliver"
- "Sequence guide tells the rep when to transition between question types"
- "Anti-pattern warnings prevent over-asking Situation Questions and premature Need-payoff"
what_baseline_misses:
- "Produces a flat list of generic open questions with no internal structure"
- "Implication questions are filler ('why does that matter?') not consequence chains"
- "Need-payoff questions appear before the need has been developed"
- "No guidance on sequence, branching, or when to transition between types"
---
# SPIN Discovery Question Planner
## When to Use
You have a B2B sales call coming up — discovery, follow-up, or executive conversation — and you want to go in with more than good intentions. This skill produces a pre-call question bank organized by SPIN type (Situation, Problem, Implication, Need-payoff) with Implication chains planned in advance and Need-payoff questions ready to deploy once a need is confirmed.
Use this skill when:
- Preparing for a discovery call with a new prospect or existing account
- Coaching a rep to plan questions for a specific deal
- Moving from a generic question list to a structured conversation with branches
- Dealing with a decision-maker (Implication Questions are especially powerful with people who think in consequences and effects)
**Critical prerequisite:** Before deploying Need-payoff Questions from this plan on an actual call, use `need-type-classifier` to verify that customer responses represent Implied Needs that have been sufficiently developed — or Explicit Needs that are ready for conversion. The question bank is a plan; what the customer says on the call determines where you actually go.
This skill is OUT OF SCOPE for: classifying customer responses (use `need-type-classifier`), drafting Benefit statements (use `benefit-statement-drafter`), or assessing whether a call outcome was an Advance (use `call-outcome-classifier`).
## Context & Input Gathering
### Required Context (must have — ask if missing)
- **Deal context:** Who is the prospect? What's the deal stage? What do we know about their situation?
-> Check environment for: `deal-brief.md`
-> If missing, ask: "Tell me about the deal — company, contact role, what stage you're at, and what you already know about their situation."
- **Product capabilities:** What can your product solve? What can it NOT solve?
-> Check environment for: `product-capabilities.md`
-> If missing, ask: "What are the main problems your product solves? Are there any capabilities it definitely does not cover?"
### Observable Context (gather from environment)
- **Account research:** `account-research.md` — company priorities, recent news, competitors
-> If available: use to hypothesize likely problems. If unavailable: use deal-brief plus industry knowledge.
- **Prior needs log:** `needs-log.md` — Implied/Explicit needs from previous calls
-> If available: read it. If some Implied Needs are already identified, the Implication chain work is partially done — plan questions to develop them further or convert them.
-> If unavailable: treat this as a first call; hypothesize likely problems from scratch.
- **Call type:** New account (first contact) vs follow-up call
-> If first contact: more Situation Questions are appropriate — you have less background
-> If follow-up: reduce Situation Questions aggressively; move faster to Problem → Implication
### Default Assumptions
- Default to a **large-sale context** (high value, multi-stakeholder, relationship-dependent). In large sales, Implied Needs alone do not predict success; they must be developed into Explicit Needs before presenting solutions.
- Default to **not presenting solutions** in the question bank. This is a plan-only output; Benefit statements come after Explicit Needs are confirmed on the call.
### Sufficiency Threshold
SUFFICIENT: Deal context + product capabilities → produce full question bank
PROCEED WITH DEFAULTS: Only deal context known, no product capabilities → produce Problem + Implication plans, note that Need-payoff questions require capability mapping
MUST ASK: No deal context at all (no account, no call objective, no product)
## Process
### Step 1: Establish Deal Context and Confirm the Large-Sale Gate
**ACTION:** Read available deal documents or gather from the user. Confirm: is this a large sale (high value, multiple stakeholders, long cycle) or a small/transactional sale? Note the call type (new account vs follow-up) and what has already been uncovered.
**WHY:** The entire SPIN question sequence is designed for large sales. If this is a small sale (low value, single decision-maker, one-call close), Implication Questions are useful but not as critical — Problem Questions alone can move the sale. In large sales, failing to develop Implied Needs into Explicit Needs before presenting solutions is the primary cause of price objections and stalled deals. Knowing the sale type calibrates how many Situation Questions to plan and how aggressively to build Implication chains.
**Output:** A one-paragraph deal context summary: company, contact, stage, what is already known about problems or needs, and sale type (large/small).
### Step 2: Identify 3+ Likely Customer Problems Mapped to Your Capabilities
**ACTION:** Based on the account research, deal brief, and product capabilities, write down at least three specific problems this customer is likely to have that your product or service can solve. These are hypotheses — you will surface and confirm them on the call. Do not include problems you cannot solve.
**WHY:** This is the foundational planning step from the book's methodology: "Before the call, write down at least three potential problems which the buyer may have and which your products or services can solve." This step forces you to pre-qualify which problems are worth pursuing (you can solve them) and prevents two failure modes: (1) spending the call on Situation Questions that do not lead anywhere, and (2) accidentally developing a problem via Need-payoff Questions that you cannot address — which strengthens a need you cannot meet.
**Format:**
```
Problem 1: [Specific problem — e.g., "Manual reporting process takes 2+ days per month"]
-> Capability match: [What we can solve — e.g., "Automated report generation"]
Problem 2: [...]
-> Capability match: [...]
Problem 3: [...]
-> Capability match: [...]
```
Add additional problems if context supports them.
### Step 3: Plan Situation Questions (Limited Set)
**ACTION:** For each problem identified in Step 2, plan 1-2 Situation Questions that establish the factual background needed to ask about that specific problem. Keep the total Situation Question list short (5-8 maximum for a new account call; 2-3 for a follow-up call).
**WHY:** Situation Questions collect background facts. They serve the seller, not the buyer — buyers find them tedious when overused. Research on thousands of calls found that inexperienced sellers ask far more Situation Questions than experienced ones, and that excessive Situation Questions are negatively correlated with call success. The goal is to do homework before the call (reducing unnecessary fact-finding), then ask only the Situation Questions directly needed to set up a Problem Question. Think of Situation Questions as runway, not the flight.
**Anti-pattern: Situation Question overload.** Detection signal: your Situation Question list has more than 8 items for a first call, or any for a follow-up where context is established. Fix: cut any Situation Question that doesn't directly enable a Problem Question you've already planned.
**Format:**
```
Situation Questions for Problem 1:
- "How does your team currently handle [process area]?"
- "What system do you use for [relevant function]?"
```
### Step 4: Plan Problem Questions for Each Identified Problem
**ACTION:** For each problem in Step 2, write 2-3 Problem Questions that probe directly for that problem, difficulty, or dissatisfaction. These questions should invite the customer to express an Implied Need.
**WHY:** Problem Questions are more strongly linked to sales success than Situation Questions, especially in smaller sales. In large sales, they provide the raw material — the Implied Needs — that Implication Questions will develop. Without Problem Questions, you have no foundation to build an Implication chain. The goal is to hear the customer say something like "Yes, that is a problem for us" — that statement is the Implied Need you will develop in the next step.
**Format:**
```
Problem Questions for Problem 1:
- "Are you finding [process area] takes longer than it should?"
- "What difficulties do you run into when [specific situation]?"
- "Is [current approach] creating any reliability or bottleneck issues?"
```
### Step 5: Apply the 3-Step Implication Question Planning Sub-Workflow
**ACTION:** For each problem identified in Step 2, apply this three-step method to generate a chain of Implication Questions:
**Step 5a — Write down the problem:**
State the specific Implied Need the customer might express (from your Problem Question above).
Example: *"Our current system is hard for operators to use."*
**Step 5b — Write down the related difficulties this problem leads to:**
Ask yourself: what are the downstream consequences of this problem? Think broadly — cost, time, people, quality, risk, downstream processes, stakeholder impact. Be especially alert for implications that reveal the problem to be more severe than it initially appears.
Example consequences:
- Only 3 trained operators → creates bottleneck when one is absent
- Bottleneck → overtime costs
- Overtime → operator dissatisfaction → turnover
- Turnover → retraining cost (estimate: $5,000/operator)
- Sending work outside → quality risk, delivery delays
**Step 5c — Write the questions each difficulty suggests:**
For each consequence, write the Implication Question that surfaces it. These questions are problem-centered — they make the problem feel more serious. This is intentional: the customer must perceive the problem as large enough to justify the cost of solving it.
Example questions:
- "If you only have three trained operators, what happens when one is out sick or leaves?"
- "How does that bottleneck affect your production schedules?"
- "What does retraining a replacement operator cost you in wages and training fees?"
- "When you send work outside, how does that affect your quality control?"
**WHY this step is the hardest and most valuable:** Research found that only 1 in 20 questions asked in an average sales call is an Implication Question — not because sellers don't know they're useful, but because good Implication Questions require advance planning. They cannot be improvised reliably. The reason $120,000 solutions seem outrageous against a small Implied Need is that the buyer has not yet connected the Implied Need to its full cost. The Implication chain makes that connection explicit — not by manipulation, but by helping the buyer see the total size of the problem. This is also why Implication Questions are especially powerful with decision-makers: executives think in consequences and effects, not surface symptoms.
**Apply Quincy's Rule to verify your Implication Questions:** Implication Questions should feel "sad" — they are problem-centered and make the problem more serious. If a question feels "happy" or solution-focused, it has drifted into Need-payoff territory. Reclassify it in the next step.
**Format per problem:**
```
Problem 1 Implication Chain:
Implied Need likely heard: "[Customer's words if the Problem Q works]"
Related difficulties:
- [Consequence A]
- [Consequence B]
- [Consequence C]
Implication Questions:
- "[Question surfacing Consequence A]"
- "[Question surfacing Consequence B]"
- "[Question surfacing Consequence C — if appropriate given conversation flow]"
```
### Step 6: Plan Need-Payoff Questions Keyed to Deliverable Capabilities
**ACTION:** For each problem, plan 2-3 Need-payoff Questions to ask after the Implication chain has developed the need. Each Need-payoff Question must map to a capability your product can actually deliver.
**WHY:** Need-payoff Questions shift the conversation from problem-focused (which is "sad") to solution-focused (which is "happy"). They achieve two things simultaneously: (1) they focus the customer's attention on what a solution would mean for them — positive and constructive; (2) they get the customer articulating the benefits themselves. A customer who explains to you why your solution would help them is rehearsing the pitch they will later give to their internal stakeholders. This internal selling effect is a major reason Need-payoff Questions are so powerful in large, multi-stakeholder deals.
**Critical restrictions:**
- **Do not ask Need-payoff Questions before the need is developed.** If the Problem Question has just been asked and the customer has only given a mild Implied Need, the Implication chain must come first. Need-payoff before development produces customer defensiveness.
- **Do not ask Need-payoff Questions for capabilities you cannot deliver.** If a customer raises a need you cannot meet and you ask "Why would that be important to you?" — you are strengthening a need you have no answer for. Ask Need-payoff Questions only where you have a capability match (Step 2).
**Format per problem:**
```
Need-payoff Questions for Problem 1:
(Ask only after Implication chain confirms the need is felt as serious)
- "If you could eliminate [the core difficulty], what would that mean for your team?"
- "Would it help you if [capability we offer]?"
- "Is there any other way solving [problem] would benefit you?"
```
### Step 7: Write the Sequence Guide and Branching Rules
**ACTION:** Produce a one-page sequence guide for the call. This tells the seller: when to move from S → P, from P → I, from I → N, and when to loop back.
**WHY:** The SPIN sequence is a guideline, not a rigid formula. Calls branch. The customer might open the call by volunteering an Explicit Need — in which case move directly to Need-payoff. They might raise a problem the seller cannot solve — in which case do NOT ask Need-payoff Questions. They might shut down after too many Implication Questions — in which case shift to Need-payoff earlier to restore positive energy. The sequence guide arms the seller with conscious decision rules rather than reactive improvisation.
**Sequence guide format:**
```
CALL SEQUENCE GUIDE — [Deal Name] — [Date]
Opening: [1-2 Situation Questions to establish context — see Step 3]
↓
Problem surfacing: Ask Problem Questions for Problem 1.
If customer confirms → move to Implication chain.
If customer denies or deflects → move to Problem Questions for Problem 2 or 3.
↓
Implication development: Work through the Implication chain for the confirmed problem.
If customer language shifts from "it's a minor issue" to "actually, this is significant" →
the need is developed enough. Move to Need-payoff.
If customer seems uncomfortable or negative → move to Need-payoff earlier to shift energy.
↓
Need-payoff conversion: Ask Need-payoff Questions for the developed need.
Get the customer talking about what a solution would mean for them.
Only present a capability AFTER a Need-payoff Question has elicited a customer statement
describing the value they want. That statement is the Explicit Need.
↓
If a second problem path is needed: repeat Problem → Implication → Need-payoff cycle.
LOOP BACK SIGNALS:
- Customer raises a new problem mid-Implication chain → finish current chain or note
the new problem and return to it. Do not abandon a chain once started.
- Customer raises a need you cannot meet → do NOT ask Need-payoff Questions.
Acknowledge and redirect to a need you can meet.
- Call is running short → compress by combining Implication Questions and moving to
Need-payoff faster. Never skip the Problem Questions — they are the foundation.
```
### Step 8: Write the Question Bank File
**ACTION:** Compile all outputs from Steps 2-7 into a single file: `question-bank-{deal}-{date}.md`. The file should be readable in 5 minutes before the call starts.
**WHY:** A written artifact carries the work forward. Without it, the planning exists only in this conversation. The question bank is also the input to `spin-skill-practice-coach` if the rep wants to learn from the call afterward, and to `sales-call-plan-do-review-coach` for a full Plan-Do-Review cycle.
## Key Principles
- **Implication Questions require advance planning.** They are the most powerful question type in large sales — and the hardest to generate under fire. Research found only 1 in 20 questions asked in an average call is an Implication Question. The 3-step sub-workflow (problem → consequences → questions) is the only reliable way to have good Implication Questions ready before the call. Sellers who skip this step improvise generic probes that do not build a consequence chain.
- **Need-payoff Questions only work when the need is developed.** A Need-payoff Question asked against a mild, undeveloped Implied Need produces price shock ("$120,000 just to make a machine easier to use?"). The same question asked after a thorough Implication chain — where the buyer has walked through $25,000 in training costs, expensive overtime, and quality failures from work sent outside — produces the opposite reaction. The problem is now large enough to make the solution feel justified.
- **Situation Questions serve you, not the buyer.** Do your homework before the call. Every Situation Question you eliminate through pre-call research is a question that doesn't bore your prospect. Experienced sellers ask far fewer Situation Questions than inexperienced ones — they move faster to problems because they have prepared better.
- **Never ask Need-payoff Questions for capabilities you cannot deliver.** When a customer raises a need you cannot meet, do NOT ask "Why is that important to you?" — you will strengthen a need that will become an objection you cannot answer. Redirect to a need within your capability set.
- **The SPIN sequence is a map, not a script.** Customers branch. If the customer opens with an Explicit Need, go directly to Need-payoff. If a new problem surfaces mid-chain, note it and return. If the customer seems discouraged after several Implication Questions, shift to Need-payoff earlier to restore positive energy. Quincy's Rule is the reset button: Implication = sad (problem-centered); Need-payoff = happy (solution-centered). If the conversation tone needs to change, shift question types accordingly.
- **For decision-makers, lean into Implication Questions.** Decision-makers respond most favorably to sellers who explore consequences and effects. The language of implications is the language of senior executives. More Implication Questions in calls with executives; more Problem Questions in calls with users and operational stakeholders.
## Examples
**Scenario: First discovery call with a mid-market manufacturing prospect**
Trigger: Rep asks — "I have a discovery call next Tuesday with an operations director at a 200-person manufacturer. We sell production scheduling software. Help me plan my questions."
Process:
1. Deal context: large sale, first call, no prior needs data. Product capabilities: scheduling automation, bottleneck detection, operator load balancing.
2. Likely problems: (a) manual scheduling leads to production bottlenecks, (b) operator overtime from poor load distribution, (c) last-minute changes cascade into downstream delays.
3. Situation Questions (limited): "What does your current scheduling process look like?" / "How far in advance are you typically scheduling production runs?"
4. Problem Questions: "Are you finding that last-minute order changes create downstream disruptions?" / "When one line slows down, how does that ripple into the rest of the schedule?"
5. Implication chain for Problem (a): Implied Need likely heard: "Yes, we have bottlenecks when orders change." Consequences: delayed shipments → customer dissatisfaction → expediting costs → overtime. Implication Questions: "What's the downstream effect when a bottleneck forces a late shipment?" / "How much overtime do you typically absorb when you're trying to catch up?"
6. Need-payoff: "If you could catch a bottleneck forming 24 hours in advance, what would that mean for your on-time delivery rate?"
Output: `question-bank-acme-mfg-2026-04-15.md` — a three-problem, three-chain question bank with sequence guide. Rep reads it the morning of the call.
---
**Scenario: Follow-up call after a discovery call where Implied Needs were surfaced**
Trigger: Rep shares needs-log.md showing two Implied Needs: "approval workflow is slow" and "reporting takes two days." "I'm seeing them next week — what do I ask?"
Process:
1. Deal context: follow-up call, large sale, two Implied Needs already logged. Reduce Situation Questions to near zero — context is established.
2. Prior implied needs → needs to be developed into Explicit Needs via Implication chains before any solution presentation.
3. Implication chain for "approval workflow is slow": Consequences: deals slip because contracts wait in queue → salespeople lose momentum → deals lost to faster-moving competitors → revenue at risk. Implication Questions: "When a contract sits in queue, how long does the delay typically run?" / "Have you lost any deals because a competitor moved faster while your approval was pending?"
4. Need-payoff after development: "If you could cut approval time from days to hours, what would that mean for your close rate?"
Output: `question-bank-deal-name-2026-04-22.md` with two Implication chains, minimal Situation Questions, and Need-payoff questions ready after each chain.
---
**Scenario: Implication chain worked example (verbatim dialogue reference)**
Trigger: Rep asks — "How do Implication Questions actually work in practice? Show me an example."
Process: Reference the Contortomat/Easiflo dialogue in `references/contortomat-implication-dialogue.md`. The dialogue shows a seller starting from a mild Implied Need ("the machines are rather hard to use") and building it — through 7 Implication Questions — into a recognized $25,000+ annual problem, at which point a $120,000 solution no longer seems unreasonable. The worked example is the clearest illustration of how the value equation shifts through Implication questioning.
## References
- Verbatim Contortomat/Easiflo Implication Question dialogue (the worked example that shows how the value equation shifts): [references/contortomat-implication-dialogue.md](references/contortomat-implication-dialogue.md)
- Verbatim telephone system Need-payoff dialogue (the worked example showing buyer-stated benefits): [references/telephone-needpayoff-dialogue.md](references/telephone-needpayoff-dialogue.md)
- Stock question patterns by SPIN type (template library for rapid planning): [references/spin-question-templates.md](references/spin-question-templates.md)
## License
This skill is licensed under [CC-BY-SA-4.0](https://creativecommons.org/licenses/by-sa/4.0/).
Source: [BookForge](https://github.com/bookforge-ai/bookforge-skills) — SPIN Selling by Neil Rackham.
## Related BookForge Skills
This skill depends on:
- `clawhub install bookforge-need-type-classifier` — classify customer responses as Implied or Explicit Needs during or after the call (reads `needs-log.md` that this skill uses as input)
Skills that build on this one:
- `clawhub install bookforge-benefit-statement-drafter` — draft Benefit statements once Explicit Needs are confirmed (Level 2, depends on this skill)
- `clawhub install bookforge-commitment-and-advance-planner` — plan the specific Advance you'll seek on the call (Level 1, use alongside this skill)
- `clawhub install bookforge-sales-call-plan-do-review-coach` — wrap the full Plan-Do-Review cycle around a single call (Level 2, orchestrates this skill)
- `clawhub install bookforge-spin-skill-practice-coach` — build your SPIN questioning skill systematically over weeks (Level 2, depends on this skill)
Or install the full SPIN Selling skill set from GitHub: [bookforge-skills](https://github.com/bookforge-ai/bookforge-skills)
FILE:references/contortomat-implication-dialogue.md
# Contortomat / Easiflo — Verbatim Implication Question Dialogue
**Source:** SPIN Selling, Neil Rackham, Chapter 4 (The SPIN Strategy)
This dialogue is the book's central worked example of how Implication Questions transform a small Implied Need into a problem large enough to justify a large-sale solution. The value equation shifts from "$120,000 seems outrageous" to "when you put it that way, those machines are creating a very serious problem indeed."
---
## Part 1: Without Implication Questions (the failure case)
> **SELLER:** (Situation Question) Do you use Contortomat machines in this division?
>
> **BUYER:** Yes, we've got three of them.
>
> **SELLER:** (Problem Question) And are they difficult for your operators to use?
>
> **BUYER:** (Implied Need) They are rather hard, but we've learned how to get them working.
>
> **SELLER:** (jumping to solution) We could solve that operating difficulty for you with our new Easiflo system.
>
> **BUYER:** What does your system cost?
>
> **SELLER:** The basic system is about $120,000 and...
>
> **BUYER:** (amazed) $120,000!!! Just to make a machine easier to use! You must be kidding.
**What went wrong:** The buyer perceives only a small Implied Need ("they are rather hard"). The problem does not feel large enough to justify a $120,000 solution. The value equation is out of balance — cost outweighs perceived problem severity.
---
## Part 2: With Implication Questions (the success case)
> **SELLER:** (Problem Question) And are they difficult for your operators to use?
>
> **BUYER:** (Implied Need) They are rather hard, but we've learned how to get them working.
>
> **SELLER:** (Implication Question) You say they're hard to use. What effect does this have on your output?
>
> **BUYER:** (perceiving the problem as small) Very little, because we've specially trained three people who know how to use them.
>
> **SELLER:** (Implication Question) If you've only got three people who can use them, doesn't that create work bottlenecks?
>
> **BUYER:** (still seeing the problem as unimportant) No, it's only when a Contortomat operator leaves that we have trouble while we're waiting for a replacement to be trained.
>
> **SELLER:** (Implication Question) It sounds like the difficulty of using these machines may be leading to a turnover problem with the operators you've trained. Is that right?
>
> **BUYER:** (recognizing a bigger problem) Yes, people certainly don't like using the Contortomat machines, and operators generally don't stay with us for long.
>
> **SELLER:** (Implication Question) What does this turnover mean in terms of training cost?
>
> **BUYER:** (seeing more) It takes a couple of months before an operator gets proficient, so that's maybe $4000 in wages and benefits for each operator. On top of that we pay Contortomat $500 to put new operators through off-site training in their Southampton plant. So add perhaps $1000 for travel costs. You know, that's about $5000 for each operator we train — and I guess we must have trained at least five this year already.
>
> **SELLER:** So that's more than $25,000 in training costs in less than 6 months. (Implication Question) If you've trained five people in 6 months, it sounds like you've never had three fully competent operators at any time: how much production loss has this led to?
>
> **BUYER:** Not much. Whenever there's been a bottleneck, we've persuaded the other operators to work overtime, or we've sent work outside.
>
> **SELLER:** (Implication Question) Doesn't the overtime add even more to your costs?
>
> **BUYER:** (realizing the problem is quite serious) Yes, we've been paying overtime at two and a half times the normal job rate. Even with the additional pay, the operators aren't very willing to work the extra hours — which I'm sure is one of the reasons we're getting such high turnover.
>
> **SELLER:** (Implication Question) I can see how sending the work outside must also increase your costs, but is that the only implication of sending work out? Is the quality of work affected, for example?
>
> **BUYER:** That's what I'm most unhappy about. I can control the quality of everything we produce internally, but when anything goes outside I'm at the mercy of other people.
>
> **SELLER:** (Implication Question) And presumably, being forced to send work outside also puts you at the mercy of other people's delivery schedules?
>
> **BUYER:** Don't talk about it! I've just spent 3 hours on the phone chasing a late delivery.
>
> **SELLER:** (summarizing) So from what you've said, because your Contortomat machines are so difficult to use, you've spent $25,000 in training costs this year and you're getting expensive operator turnover. You've bottlenecks in production, and these result in expensive overtime and force you to send jobs outside. But sending jobs outside isn't satisfactory, because you're losing quality and getting late deliveries.
>
> **BUYER:** When you put it that way, those Contortomat machines are creating a very serious problem indeed.
---
## The Value Equation — Before and After
| | Before Implication Chain | After Implication Chain |
|---|---|---|
| Perceived problem | "They are rather hard to use" | $25,000+ training costs, overtime, quality risk, delivery delays |
| Perceived solution cost | $120,000 (outrageous) | $120,000 (now feels justifiable) |
| Buyer state | Dismissive | "When you put it that way, this is very serious" |
---
## Planning Notes (how this maps to the 3-step sub-workflow)
**Problem identified:** Contortomat machines are difficult to operate.
**Related difficulties written down (Step 5b):**
1. Only 3 trained operators → bottleneck risk
2. Hard-to-use machines → operator turnover
3. Turnover → retraining costs (~$5,000/operator × 5 = $25,000/year)
4. Bottlenecks → overtime (2.5× rate)
5. Overtime → increased turnover (circular)
6. Work sent outside → quality risk, delivery delays
**Questions each difficulty suggested (Step 5c):**
- "If you've only got three people who can use them, doesn't that create work bottlenecks?"
- "It sounds like the difficulty of using these machines may be leading to a turnover problem. Is that right?"
- "What does this turnover mean in terms of training cost?"
- "If you've trained five people in 6 months, it sounds like you've never had three fully competent operators at any time: how much production loss has this led to?"
- "Doesn't the overtime add even more to your costs?"
- "Is that the only implication of sending work out? Is the quality of work affected?"
- "Being forced to send work outside also puts you at the mercy of other people's delivery schedules?"
FILE:references/spin-question-templates.md
# SPIN Question Templates — Stock Patterns by Type
**Source:** SPIN Selling, Neil Rackham. Chapter 4 (The SPIN Strategy).
Use these patterns as starting points when planning questions for a specific deal. Adapt to the actual context — these are structural templates, not word-for-word scripts. Specificity beats generality: "Are you finding that approval cycles are slowing your contract close times?" is far more powerful than "Are there any process challenges you're facing?"
---
## Situation Questions
**Purpose:** Establish factual background about the customer's current state. Use sparingly — they serve the seller, not the buyer. Do your homework first; ask only what you couldn't find out in advance.
| Template | Example |
|---|---|
| "What does your current [process/system] look like?" | "What does your current approval workflow look like?" |
| "How are you currently handling [area]?" | "How are you currently handling production scheduling?" |
| "How many [people/systems/locations] are involved in [function]?" | "How many operators use the system currently?" |
| "What [tool/vendor/approach] are you using for [function]?" | "What system are you using for demand forecasting?" |
| "How long have you been using [current approach]?" | "How long have you had this setup?" |
| "Who is involved in [process/decision]?" | "Who else is involved in the procurement decision?" |
**Situation Question limit:**
- New account, first call: 5-8 maximum
- Follow-up call: 2-3 maximum (context already established)
- Executive call: 1-2 maximum (they expect you to have done your homework)
---
## Problem Questions
**Purpose:** Probe for problems, difficulties, and dissatisfactions. Invite the customer to express Implied Needs. More effective than Situation Questions. In large sales, they provide the raw material for Implication chains.
| Template | Example |
|---|---|
| "Are you finding [situation] creates [difficulty]?" | "Are you finding that manual reporting creates delays for your team?" |
| "What difficulties do you run into when [situation]?" | "What difficulties do you run into when scheduling around last-minute orders?" |
| "Is it difficult to [task] with your current [system/approach]?" | "Is it difficult to track accountability with your current phone system?" |
| "How satisfied are you with [current approach]?" | "How satisfied are you with the speed of your current approval process?" |
| "Does [current situation] give you any [reliability/quality/cost] problems?" | "Does your current setup give you any visibility problems across regions?" |
| "Are there any bottlenecks or slowdowns in [process]?" | "Are there any bottlenecks in how work gets assigned to operators?" |
| "What would you change about your current [approach], if you could?" | "What would you change about your scheduling process if you could?" |
---
## Implication Questions
**Purpose:** Develop the seriousness of an Implied Need. Make the problem feel larger — not by exaggerating, but by helping the customer connect the problem to its downstream consequences. Problem-centered ("sad" per Quincy's Rule).
**Best used with:** Decision-makers (who think in consequences); high-value, multi-stakeholder deals; any situation where the cost of the solution needs to feel justified.
**Planning requirement:** Do NOT improvise these. Use the 3-step sub-workflow (Step 5 of the skill body) to generate them from specific consequences before the call.
| Template | Example |
|---|---|
| "What effect does [problem] have on [downstream area]?" | "What effect does the bottleneck have on your production schedule?" |
| "If [problem] occurs, what happens to [related process]?" | "If an operator leaves, how long before a replacement is fully trained?" |
| "[Problem] seems to lead to [consequence A]. Does it also affect [consequence B]?" | "Turnover leads to retraining costs. Does it also cause bottlenecks during the gap?" |
| "What does [problem] mean in terms of [cost/time/quality/risk]?" | "What does the overtime situation mean in terms of your annual labor costs?" |
| "When [problem occurs], how does that affect [stakeholder/team]?" | "When a shipment is late, how does that affect your relationship with the customer?" |
| "Is [problem] also affecting [adjacent area you've noticed]?" | "Is the turnover also making it harder to maintain quality on specialized jobs?" |
| "Does [problem] put you at risk of [specific negative outcome]?" | "Does sending work outside put you at the mercy of other people's delivery schedules?" |
---
## Need-Payoff Questions
**Purpose:** Shift the conversation from problem-centered to solution-centered. Get the buyer articulating benefits. "Happy" per Quincy's Rule. Only deploy these after the need has been developed.
**Critical constraint:** Only ask Need-payoff Questions for capabilities you can deliver. Asking "why would that be important?" for a need you cannot meet strengthens a need that becomes an objection.
| Template | Example |
|---|---|
| "Why is [solving this problem] important to you?" | "Why is controlling unauthorized long-distance calls important to you?" |
| "How would [capability] help you?" | "How would automated scheduling help your team?" |
| "Would it be useful if you could [specific capability]?" | "Would it help if you could restrict long-distance calling to authorized users?" |
| "Is there any other way [solving this] could help you?" | "Is there any other way having faster reports would help your team?" |
| "If you could [solve the problem], what would that mean for [outcome]?" | "If you could cut approval time from days to hours, what would that mean for your close rate?" |
| "How significant would it be if [positive change occurred]?" | "How significant would it be if you could catch a bottleneck 24 hours in advance?" |
| "Would [solving this] also help with [related area]?" | "Would better cost tracking also help you with your department accountability reporting?" |
| "What would be the value to you of [specific improvement]?" | "What would be the value to you of having operators up to speed in weeks rather than months?" |
---
## Sequence and Balance Notes
**Top performer benchmarks (from Rackham's research):**
- Top performers ask approximately **10× more Need-payoff Questions** than average performers
- Only **1 in 20 questions** in an average call is an Implication Question — despite being the most powerful type
- In failed calls: more Situation Questions, far fewer Implication and Need-payoff Questions
**Practical balance for a 45-minute discovery call:**
- Situation Questions: 4-6 (or fewer if you've done homework)
- Problem Questions: 6-10 (primary exploration phase)
- Implication Questions: 4-8 per confirmed problem (the chain)
- Need-payoff Questions: 3-5 per confirmed Implication chain (conversion phase)
**Transition signals — when to move to the next type:**
- S → P: Once you have the background context you need (don't wait until you have everything)
- P → I: Once the customer confirms a problem ("yes, that is a difficulty for us")
- I → N: Once the customer's language shifts from "it's minor" to "actually, this is significant" OR once you have 3+ consequences developed
- N → present: Once the buyer has expressed a specific want or desire (Explicit Need) in their own words
FILE:references/telephone-needpayoff-dialogue.md
# Telephone System — Verbatim Need-Payoff Question Dialogue
**Source:** SPIN Selling, Neil Rackham, Chapter 4 (The SPIN Strategy)
This dialogue shows a seller using Need-payoff Questions to get the buyer talking about benefits — shifting the conversation from problem-focused to solution-focused, and rehearsing the buyer to sell internally.
---
## The Dialogue
> **SELLER:** (Need-payoff Question) ...so would you be interested in a way to control long-distance calls?
>
> **BUYER:** Well... yes, of course... but that's only one of the problems I have at the moment.
>
> **SELLER:** (Need-payoff Question) I'd like to consider those other problems in a minute. But first, you say you would like to control long-distance calling. Why is that important to you?
>
> **BUYER:** Well, right now I'm receiving a lot of pressure from the controller to contain my network costs. If I could reduce long-distance charges, it would sure help.
>
> **SELLER:** (Need-payoff Question) Would it help if you could restrict long-distance calling to authorized persons?
>
> **BUYER:** Well, yes... it would certainly prevent some of the excessive long-distance usage we're getting. Most of it's coming from unauthorized long-distance use.
>
> **SELLER:** Can we go back to issues you raised about preparing phone-system management reports? (Need-payoff Question) May I assume you'd like improvement there also?
>
> **BUYER:** Yes, it would be a big help.
>
> **SELLER:** (Need-payoff Question) Is that because it would provide you with a better method for telephone cost accounting?
>
> **BUYER:** Yes. You see, if we can identify departments that make calls, we can hold them accountable for their telephone charges.
>
> **SELLER:** (Need-payoff Question) I see... is there any other way it might help?
>
> **BUYER:** Umm... No. I think accountability is the main thing.
>
> **SELLER:** (Need-payoff Question) Well that's certainly important... but don't you think it might also be important to know how long it takes to answer incoming calls and the total number of calls that go through each extension?
>
> **BUYER:** That could be really useful.
>
> **SELLER:** (Need-payoff Question) Useful for cost reasons, or is there something else?
>
> **BUYER:** No, I wasn't thinking of costs. Where it would really help us is in improving customer service, and in this business that's important! Can you help us there?
>
> **SELLER:** Yes, we can. Let me explain how our equipment will help to...
---
## What These Need-Payoff Questions Achieved
1. **Shifted attention from problems to solutions.** Each question prompted the buyer to think about what a solution would mean, not what was wrong with the current state.
2. **Got the buyer articulating the benefits.** The buyer said "where it would really help us is in improving customer service" — the seller did not say this; the buyer did. Rackham's research showed that calls where buyers state benefits (rather than sellers) produce better outcomes.
3. **Rehearsed the buyer for internal selling.** The buyer now understands the value in their own terms — cost containment, accountability, customer service. When they go back to the controller or executive team, they can articulate the benefits in business language, not product language.
4. **Reduced objections.** Because the seller did not claim "our system will improve your customer service," the buyer discovered it themselves through the question sequence. Self-discovered benefits face far less internal resistance than seller-stated ones.
---
## Quincy's Rule Applied to This Dialogue
Quincy's Rule (from the book): *"Implication Questions are always sad. Need-payoff Questions are always happy."*
Every question in this dialogue is "happy" — it points toward solutions, value, and benefits. Compare the tone to an Implication Question like "What happens when unauthorized long-distance calls aren't controlled?" — that question is "sad" (it makes the problem feel worse). The Need-payoff Questions in this dialogue do the opposite: they make the solution feel better.
**Sequence signal:** If the buyer sounds discouraged or weighed down by Implication Questions, shift to Need-payoff Questions to restore positive energy. Need-payoff Questions are also the signal that you are close to an Explicit Need — when the buyer says "yes, I'd want that" or "that would be important," they are expressing an Explicit Need you can link a Benefit to.
---
## Stock Need-Payoff Question Patterns
These questions work across industries and deal types:
- "Why is that important to you?"
- "How would that help?"
- "Would it be useful if you could [capability]?"
- "Is there any other way this could help you?"
- "If you could [solve the problem], what would that mean for [business outcome]?"
- "Would [solving this] also help with [related area]?"
- "How significant would it be if [positive change]?"
Wrap a structured Plan-Do-Review learning loop around any B2B sales call. Use this skill when someone says 'prep me for tomorrow's call with [company]', 'rev...
---
name: sales-call-plan-do-review-coach
description: "Wrap a structured Plan-Do-Review learning loop around any B2B sales call. Use this skill when someone says 'prep me for tomorrow's call with [company]', 'review my call from yesterday', 'help me debrief this meeting', 'what should I learn from this call?', 'build a call brief for next week', 'post-call debrief', 'I just had a discovery call and want to process it', or 'am I actually improving between calls?'. This skill runs in two modes: PRE-CALL — consolidates outputs from spin-discovery-question-planner (SPIN question bank) and commitment-and-advance-planner (Advance objective) into a single call brief you can read 5 minutes before the meeting; POST-CALL — applies Rackham's seven specific review questions to your actual call notes and produces a written debrief that ties directly to the next call's plan. The closed loop is the distinguishing feature: each call's review becomes the next call's plan. Based on Rackham's empirical finding that top performers review every call in detail while average performers say 'it went quite well' — a global conclusion that prevents any learning. Works on call notes, transcripts, or recalled summaries."
version: 1.0.0
homepage: https://github.com/bookforge-ai/bookforge-skills/tree/main/books/spin-selling/skills/sales-call-plan-do-review-coach
metadata: {"openclaw":{"emoji":"🔄","homepage":"https://github.com/bookforge-ai/bookforge-skills"}}
status: draft
source-books:
- id: spin-selling
title: "SPIN Selling"
authors: ["Neil Rackham"]
chapters: [8]
tags: [sales, b2b-sales, enterprise-sales, call-planning, call-review, post-call-analysis, learning-loop, spin-methodology, pre-call-prep, discovery, plan-do-review]
depends-on:
- spin-discovery-question-planner
- call-outcome-classifier
execution:
tier: 1
mode: plan-only
inputs:
- type: document
description: "deal-brief.md — company, contact, deal stage, deal size, what is known so far"
- type: document
description: "call-notes-{date}.md or call-transcript-{date}.md — for post-call mode (what actually happened)"
- type: document
description: "question-bank-{deal}-{date}.md — output of spin-discovery-question-planner (if already run)"
- type: document
description: "commitment-plan-{deal}-{date}.md — output of commitment-and-advance-planner (if already run)"
- type: document
description: "call-review-{prior-date}.md — prior review from a previous call on this deal (optional, enables closed-loop iteration)"
tools-required: [Read, Write]
tools-optional: [Grep]
mcps-required: []
environment: "document_set — reads deal-brief.md, call notes/transcripts, and prior dependency outputs; produces call-plan-{date}.md (pre-call) and call-review-{date}.md (post-call)."
discovery:
goal: "Produce a pre-call brief that consolidates question bank and Advance objective into a single readable artifact, and a post-call review that applies seven specific review questions with evidence from actual call notes, tied to the next call's plan"
tasks:
- "Pre-call: consolidate spin-discovery-question-planner output and commitment-and-advance-planner output into a 5-minute call brief"
- "Post-call: apply all 7 of Rackham's specific review questions to the call notes"
- "Post-call: cite evidence from actual call notes when answering each review question"
- "Tie post-call review findings to the next call's planning inputs (closed loop)"
- "Surface the top-performer distinction: specific detail analysis vs global 'it went well'"
audience:
roles: [account-executive, enterprise-sales-rep, solutions-consultant, founder-led-seller]
experience: intermediate
when_to_use:
triggers:
- "Rep has a call tomorrow and needs a consolidated brief"
- "Rep just finished a call and wants a structured debrief"
- "Manager wants to coach a rep using post-call review discipline"
- "Rep is working a multi-call deal and wants to learn between calls"
not_for:
- "Generating SPIN questions from scratch — use spin-discovery-question-planner"
- "Planning the Advance objective from scratch — use commitment-and-advance-planner"
- "Classifying the call outcome — use call-outcome-classifier"
- "SPIN methodology learning curriculum — use spin-skill-practice-coach"
- "Closing attitude self-assessment — use closing-attitude-self-assessment"
quality:
scores:
with_skill: 0
baseline: 0
delta: 0
tested_at: ""
eval_count: 0
assertion_count: 0
iterations_needed: 0
what_skill_catches:
- "Output applies all 7 of Rackham's specific post-call review questions"
- "Output cites evidence from the user's actual call notes when answering review questions"
- "Output produces a pre-call brief consolidating question bank and Advance objective"
- "Output ties post-call review to a specific next-call planning input (closed loop)"
- "Output does NOT produce vague feedback like 'the call went well'"
what_baseline_misses:
- "Produces vague post-call feedback ('looks like the customer was interested, follow up next week')"
- "Does not apply all 7 specific review questions — generates generic debrief points"
- "Does not cite specific moments from the call notes as evidence"
- "Does not connect post-call findings to a next-call plan"
---
# Sales Call Plan-Do-Review Coach
## When to Use
You are working a multi-call B2B deal and you want to learn from each call — not just run them.
**Pre-call mode:** You have a call coming up. You may already have a question bank from `spin-discovery-question-planner` and an Advance objective from `commitment-and-advance-planner`. This skill consolidates both into a single call brief you can read 5 minutes before the meeting.
**Post-call mode:** You just finished a call. You have call notes or a transcript. This skill applies Rackham's seven specific review questions to your actual notes, producing a written debrief with evidence-grounded answers — and ties those findings to what you should do differently on the next call.
**Closed loop:** The post-call review feeds the pre-call plan for the next call. Over a multi-call cycle, each call iteration builds on lessons from the prior one.
**The top-performer distinction:** Rackham's research found two consistent differences in top salespeople: (1) they put great emphasis on reviewing each call — dissecting what they learned and thinking about possible improvement; (2) they understand that success rests on getting behavioral details right, not broad strategy. Average sellers say "it went quite well" and learn nothing. This skill enforces detail-level review.
This skill is out of scope for: generating SPIN questions (use `spin-discovery-question-planner`), planning the Advance objective (use `commitment-and-advance-planner`), classifying call outcomes (use `call-outcome-classifier`), SPIN learning curriculum (use `spin-skill-practice-coach`).
## Context & Input Gathering
### Mode Detection
**Step 0:** Determine which mode to run.
- If the user says "prep me", "plan", "call brief", "before the call" → **PRE-CALL mode** (Steps 1-2)
- If the user says "review", "debrief", "what did I learn", "just had a call" → **POST-CALL mode** (Steps 3-5)
- If unclear → ask: "Are you preparing for an upcoming call or reviewing one that just happened?"
### Required Context (must have — ask if missing)
For **PRE-CALL mode:**
- **Deal context:** Who is the prospect? What stage is the deal?
-> Check for: `deal-brief.md`
-> If missing, ask: "Tell me the company, contact, deal stage, and what you know about their situation."
- **Question bank:** SPIN questions for this call.
-> Check for: `question-bank-{deal}-{date}.md` (output of `spin-discovery-question-planner`)
-> If missing: "Invoke `spin-discovery-question-planner` first, then return here with the output. Alternatively, paste your planned questions directly."
- **Advance objective:** The specific customer action you're targeting.
-> Check for: `commitment-plan-{deal}-{date}.md` (output of `commitment-and-advance-planner`)
-> If missing: "Invoke `commitment-and-advance-planner` first, then return here with the output. Or tell me: what specific action do you want the customer to commit to on this call?"
For **POST-CALL mode:**
- **Call record:** Notes or transcript from the call just completed.
-> Check for: `call-notes-{date}.md` or `call-transcript-{date}.md`
-> If missing, ask: "Paste your notes or describe what happened on the call."
- **Prior call brief:** What you planned to do (the pre-call artifact, if available).
-> Check for: `call-plan-{date}.md`
-> If missing: ask what the objectives were. Proceed without it if unavailable.
### Observable Context (gather from environment)
- **Prior review:** `call-review-{prior-date}.md` — findings from the previous call on this deal.
-> If available: read it. Surface any unresolved findings from the prior review in this one.
-> If unavailable: treat this as the first review in the cycle.
### Sufficiency Threshold
PRE-CALL SUFFICIENT: Deal context + question bank + Advance objective → produce full call brief
PRE-CALL PROCEED WITH DEFAULTS: Deal context only → produce a brief with dep skill prompts clearly marked
POST-CALL SUFFICIENT: Call notes → produce full review (prior brief improves quality but is not required)
MUST ASK: No call notes AND no recalled summary for post-call mode
## Process
### PRE-CALL: Step 1 — Assemble the Call Brief Header
**ACTION:** Produce a one-page header for the call. Include: account name, contact name and role, call date/time, call type (first discovery / follow-up / executive / demo), deal stage, and the primary Advance objective from `commitment-and-advance-planner` (or from the user if the skill has not been run).
**WHY:** The brief's header frames everything that follows. Reading the Advance objective first keeps the seller oriented toward the call's success criterion — the specific customer action to be obtained — rather than a vague sense of "having a good meeting." Without this anchor, sellers drift toward information exchange (a Continuation pattern) instead of driving toward a committed next step.
**Output format:**
```
CALL BRIEF — [Account] — [Date]
Contact: [Name], [Role]
Call type: [Type]
Deal stage: [Stage]
PRIMARY ADVANCE OBJECTIVE:
[Specific customer action — verbatim from commitment-and-advance-planner output or user input]
FALLBACK ADVANCE:
[Secondary commitment target if primary is declined]
```
### PRE-CALL: Step 2 — Consolidate the Question Bank
**ACTION:** From the `spin-discovery-question-planner` output (or user-supplied questions), extract and reformat into a condensed, readable question brief. Structure: Situation Questions (limited), Problem Questions per hypothesis, Implication chains (key questions only — not the full planning artifact), Need-payoff Questions, and the sequence guide. Keep to one page.
**WHY:** The full question bank from `spin-discovery-question-planner` may be 2-3 pages — too long to skim in 5 minutes before a call. This step distills it to the essentials: the questions the seller actually intends to ask, in sequence order, with the branching rules that matter most. Sellers who have to search through a long document during a call lose conversational flow. A condensed brief produces more natural question delivery.
**Output:** Append to the call brief as Section 2: "Question Sequence." Mark each question by type (S/P/I/N). Include: "START HERE →" for the first 1-2 questions, and the sequence-guide decision rules in plain language (1-2 lines max).
**Output file:** `call-plan-{date}.md` — write the complete pre-call brief to this file.
---
### POST-CALL: Step 3 — Read the Call and Extract Evidence
**ACTION:** Read the call notes or transcript. Create an evidence inventory: list every significant customer statement, question response, and behavioral signal observed during the call. Note where questions were asked and what responses they produced. Note any surprises relative to the pre-call plan.
**WHY:** The post-call review requires evidence-grounded answers. Without this extraction step, review answers become based on the seller's overall impression ("I think it went well") rather than specific moments. Evidence prevents rationalization and exposes what was actually said vs. what the seller thought they said. This step is the foundation for all seven review questions that follow.
**Output:** A structured evidence log (internal, not written to a file yet) organized by call stage: opening → discovery questions → customer responses → capability discussion → closing.
### POST-CALL: Step 4 — Apply the Seven Review Questions
**ACTION:** Apply each of Rackham's seven specific post-call review questions to the evidence extracted in Step 3. Answer each with specific, evidence-grounded observations from the call notes. Do not produce vague summaries.
See `references/post-call-review-questions.md` for the verbatim questions and detailed prompts.
**The seven review questions:**
**Q1: Did I achieve my call objectives?**
Check the Advance objective from the pre-call plan (or stated objective). Did the customer commit to the specific action targeted? Apply `call-outcome-classifier` if not already done, or apply the four-outcome framework inline (Order / Advance / Continuation / No-sale). State which outcome was achieved and name the customer action if it was an Advance.
**WHY:** Without a clear objective, every call feels like a success. Checking the specific Advance forces precision — the outcome either happened or it didn't, and a Continuation is not a success regardless of how positive the call felt.
**Q2: If I were making this call again, what would I do differently?**
Name 1-3 specific behavioral changes. Not general improvements ("ask better questions") — specific moments ("In minute 12, when the customer mentioned the procurement delay, I moved to a feature demo instead of asking an Implication Question about the downstream effects of the delay — that was the wrong turn").
**WHY:** Specific behavioral alternatives are actionable in the next call. Generic self-critique ("I could have done better") produces no change in behavior. The behavioral specificity is what transforms review from reflection into a learning signal.
**Q3: What have I learned that will influence future calls on this account?**
Extract 1-2 account-specific insights: new information about the customer's situation, stakeholder dynamics, decision process, or buying criteria that changes how you should approach the next call. Update the deal-brief.md or needs-log.md with these findings.
**WHY:** Each call should advance your model of the account. Sellers who treat calls as isolated events lose cumulative intelligence. This question forces the seller to update their account model rather than starting the next call from the same baseline.
**Q4: What have I learned that I can use elsewhere?**
Extract 1 transferable insight — a question that worked better than expected, an Implication chain structure that moved the conversation, a customer language pattern that surfaced the need more clearly. This insight applies across the portfolio.
**WHY:** Individual learning compounds across the account portfolio. A Implication Question structure that worked in this conversation may be directly applicable to three other accounts with similar problems. Without this question, individual call learning stays siloed.
**Q5: Did some parts of the call go better than others? Why?**
Identify 1-2 high-performing moments and 1-2 low-performing moments from the evidence inventory. For each, state the specific behavior and the customer response it produced. Distinguish luck from skill.
**WHY:** Global assessments ("the call went well" or "the call went poorly") obscure what actually drove the outcome. Understanding which behaviors worked — and which didn't — at the moment level is how behavioral repertoire is built. This is the granular diagnostic that separates learning from experience-accumulation.
**Q6: Which specific questions had the most influence on the customer?**
Identify 2-3 questions that visibly shifted the customer's engagement, language, or position. Note what type they were (S/P/I/N) and what the customer's response revealed about their needs.
**WHY:** Rackham's research found only 1 in 20 questions in an average call is an Implication Question — yet these are the questions most correlated with major-sale success. Tracking which questions actually influenced the customer builds empirical awareness of the seller's question portfolio and corrects systematic imbalances.
**Q7: Which needs changed or emerged during the discussion? Why?**
List any needs that shifted from Implied to Explicit during the call, any new needs that surfaced unexpectedly, and any needs the customer raised that you had not anticipated. Note what prompted the shift.
**WHY:** Need development is the core mechanism of SPIN. If needs changed, something in the conversation caused it — an Implication chain that landed, a question that reframed the problem's cost. If no needs developed, that is the most important finding: the Investigating stage failed to advance the deal. This question diagnoses the core of the call's value.
### POST-CALL: Step 5 — Write the Review Artifact and Next-Call Inputs
**ACTION:** Write `call-review-{date}.md` with the seven review question answers. Then produce a brief "Next Call Inputs" section at the bottom: 2-4 specific observations from this review that should change the pre-call plan for the next call.
**WHY:** The review artifact closes the loop. Without it, the insights exist only in the current conversation and are lost. The "Next Call Inputs" section makes the handoff from review to planning explicit — the seller reads this section when invoking this skill for the next call's pre-call mode. This is the mechanism that converts per-call learning into cumulative skill development.
**Output template:**
```
# Call Review — [Account] — [Date]
Reviewed by: sales-call-plan-do-review-coach
## Call Outcome
[Classification: Order / Advance / Continuation / No-sale]
[Customer action committed, or statement that none was committed]
## Seven Review Questions
### Q1: Did I achieve my call objectives?
[Evidence-grounded answer]
### Q2: If I were making this call again, what would I do differently?
[1-3 specific behavioral alternatives with moment-level context]
### Q3: What have I learned that will influence future calls on this account?
[1-2 account-specific insights — update deal-brief.md or needs-log.md accordingly]
### Q4: What have I learned that I can use elsewhere?
[1 transferable insight]
### Q5: Did some parts of the call go better than others?
[High-performing moments and low-performing moments with evidence]
### Q6: Which specific questions had the most influence on the customer?
[2-3 questions with type label and customer response that followed]
### Q7: Which needs changed or emerged during the discussion?
[Need shifts: Implied → Explicit, new unexpected needs, unopened need paths]
---
## Next Call Inputs (closed loop)
**Changes to the question plan:**
[Specific adjustments to the question bank for the next call]
**Advance objective adjustment:**
[Whether the same Advance applies next call or needs to be revised]
**Account model updates:**
[New information to add to deal-brief.md or needs-log.md before next call]
```
## Key Principles
- **Detail over global judgment.** Rackham's exact words: "Never be content with global conclusions like 'it went quite well.' Ask yourself about the details." Top performers dissect what worked and what didn't at the moment level. Every vague summary is a missed learning opportunity.
- **The review is more valuable than the call itself.** Limited learning comes from planning a call or running it. The most important lessons come from the way you review it. Planning and doing are prerequisites; reviewing is where skill development actually happens.
- **Evidence grounds every answer.** Each of the seven review questions must be answered with a specific moment, quote, or observation from the call notes — not from impression. If the call notes do not support a specific answer, that absence is itself a finding (the seller did not observe carefully enough).
- **Dependency orchestration.** This skill orchestrates two dependency skills. For pre-call mode: invoke `spin-discovery-question-planner` to produce the question bank, and `commitment-and-advance-planner` to produce the Advance objective. For post-call mode: invoke `call-outcome-classifier` (or apply its four-outcome framework inline) to classify the call outcome before answering Q1. This skill consolidates and coaches — it does not replicate the methodology of its dependencies.
- **The closed loop is the output.** A pre-call plan without a post-call review is wasted. A post-call review without a next-call plan is incomplete. The value of this skill is the accumulation: each cycle produces a better-informed plan, which produces a more observable call, which produces a richer review. Without the loop, each call is isolated.
## Examples
**Scenario: Pre-call brief for a second discovery call**
Trigger: AE has a follow-up call in the morning. They've already run `spin-discovery-question-planner` and `commitment-and-advance-planner`. They ask: "Build a call brief for my call tomorrow with Acme."
Process: (1) Read `question-bank-acme-2026-04-14.md` and `commitment-plan-acme-2026-04-14.md` from the working directory. (2) Extract the Advance objective (e.g., "Customer agrees to introduce VP of Operations on the next call") and fallback ("Customer agrees to a product trial"). (3) Distill the question sequence to the 3-4 most important questions per problem, with branching rules in plain language. (4) Write `call-plan-2026-04-15.md` as a single-page brief.
Output: `call-plan-2026-04-15.md` — one page. AE reads it in 5 minutes before the meeting. Advance objective is the first thing visible.
---
**Scenario: Post-call review after a demo that "went well"**
Trigger: AE says "The demo went really well. The VP seemed very interested. Help me review it." Call notes show the VP said "fantastic, we'll be in touch" and no specific next step was set.
Process: (1) Read call notes. Evidence inventory: VP said "this is exactly what we need" (sentiment, not commitment) and "we'll be in touch" (classic Continuation phrase). No customer action committed. (2) Q1: Did I achieve objectives? No — the outcome is a Continuation (apply `call-outcome-classifier` framework: no specific customer action = not an Advance). Flag the Continuation-as-success misread. (3) Q2: What would I do differently? At the call's end, instead of accepting "we'll be in touch," I should have asked "What would make sense as a next step — would it make sense to loop in your procurement lead this week?" (4) Q7: Which needs changed? The VP's strong interest suggests the need shifted toward explicit — but without a Need-payoff question being asked, it was never confirmed as explicit. Plan: ask Need-payoff questions earlier next call. (5) Write `call-review-2026-04-14.md`. Next Call Inputs: adjust Advance objective (VP intro to procurement); add specific Need-payoff questions for the emerging need around reporting automation.
Output: `call-review-2026-04-14.md` — 7-question review with moment-level evidence, a Continuation classification, and 3 specific next-call inputs.
---
**Scenario: Closed-loop iteration — third call in a deal**
Trigger: AE is prepping for call #3. Prior review (`call-review-2026-04-07.md`) identified that Implication Questions about the downstream effects of delayed procurement were skipped on call #2. AE asks: "Build a call brief for my call next week."
Process: (1) Read prior review. Note the unresolved finding: Implication Questions on procurement delay were planned but not asked — insert them explicitly into the brief's question sequence. (2) Read updated `question-bank-acme-2026-04-14.md` (which incorporates the prior review's learnings). (3) Note the Advance objective from the prior review's "Next Call Inputs" section: "Get VP to commit to a product trial date." (4) Write `call-plan-2026-04-21.md` — leading with the Implication chain that was missed last time.
Output: `call-plan-2026-04-21.md` — brief with the specific corrective from the prior review embedded. The loop is closed.
## References
- Seven post-call review questions (verbatim + detailed prompts): [references/post-call-review-questions.md](references/post-call-review-questions.md)
## License
This skill is licensed under [CC-BY-SA-4.0](https://creativecommons.org/licenses/by-sa/4.0/).
Source: [BookForge](https://github.com/bookforge-ai/bookforge-skills) — SPIN Selling by Neil Rackham.
## Related BookForge Skills
This skill depends on:
```
clawhub install bookforge-spin-discovery-question-planner
clawhub install bookforge-call-outcome-classifier
```
Also useful alongside this skill:
```
clawhub install bookforge-commitment-and-advance-planner
```
This skill orchestrates the full Plan-Do-Review loop. Browse the complete SPIN Selling skill set: [bookforge-skills](https://github.com/bookforge-ai/bookforge-skills)
FILE:references/post-call-review-questions.md
# Post-Call Review Questions
Source: SPIN Selling, Chapter 8 ("Turning Theory into Practice"), section "Plan, Do, and Review" — Neil Rackham.
These are the verbatim review questions Rackham specifies, followed by detailed prompts for evidence-grounded answering.
---
## The Seven Questions (Verbatim)
Rackham writes: "After each call, ask yourself such questions as these:"
1. **Did I achieve my objectives?**
2. **If I were making the call again, what would I do differently?**
3. **What have I learned that will influence future calls on this account?**
4. **What have I learned that I can use elsewhere?**
And from the "never be content with global conclusions" passage:
5. **Did some parts of the call go better than others? Why?**
6. **Which specific questions you asked had the most influence on the customer?**
7. **Which needs did the customer feel strongly? Which needs changed during the discussion? Why?**
(The passage also asks "Which of the behaviors you used had the most impact?" — this is folded into Q5 and Q6 as the behavioral lens on both questions.)
---
## Detailed Prompts Per Question
### Q1: Did I achieve my call objectives?
**What to check:** Compare the call outcome to the Advance objective stated in the pre-call brief. Apply the four-outcome framework:
- **Order:** Firm purchase commitment. Customer showed unmistakable intention to buy.
- **Advance:** A specific customer action committed — attends demo, arranges stakeholder meeting, agrees to pilot, submits internal eval request. The customer must be the actor.
- **Continuation:** No specific customer action. The sale continues but nothing concrete was agreed. Classic phrases: "We'll be in touch," "fantastic presentation, let's meet again," "visit us again next time you're in the area."
- **No-sale:** Explicit rejection.
**Guided prompt:** Was there a specific customer action committed in the call notes? If yes, name it — that is the Advance. If no, classify as Continuation regardless of how positive the call felt.
**Continuation flag:** If your call notes say "it went well" or "they were very interested" with no specific customer action named, flag this immediately. Positive sentiment is not an Advance.
---
### Q2: If I were making this call again, what would I do differently?
**What to check:** Identify the 1-3 specific behavioral decisions that produced suboptimal outcomes. Be moment-level specific:
- At what point in the call did the conversation go off-track?
- What did you say or do that you would change?
- What would the alternative behavior have been?
**Guided prompt:** Name the moment (e.g., "minute 15, when the customer mentioned delayed procurement"), name the behavior you used ("I pivoted to a feature demo"), name the alternative ("I should have asked an Implication Question: 'When procurement is delayed, what's the downstream effect on your project timeline?'").
**Anti-pattern:** "I should have asked better questions" is not specific enough to produce behavioral change. Name the exact question, the exact moment, and what it would have surfaced.
---
### Q3: What have I learned that will influence future calls on this account?
**What to check:** What new information did this call reveal about the account that changes your strategy or approach?
- New stakeholders or decision-makers mentioned
- Shifts in the customer's stated priorities or timeline
- Budget signals (positive or negative)
- Internal political dynamics surfaced
- New problem areas or needs that weren't in the deal brief
**Guided prompt:** "What do I now know about this account that I didn't know before? What should I update in deal-brief.md or needs-log.md?"
**Output action:** Write the findings as specific updates to the deal brief or needs log. Do not keep them in the review artifact only — they need to persist to the next call's planning inputs.
---
### Q4: What have I learned that I can use elsewhere?
**What to check:** Identify one portable insight from this call:
- A question structure that produced unusually strong engagement
- An Implication chain sequence that made the problem feel more serious than anticipated
- A customer language pattern that revealed how this industry frames a problem
- A technique for getting past a gatekeeper or moving to a higher-level stakeholder
**Guided prompt:** "If I replaced the account-specific details in this insight, could it apply to another account or industry? What is the transferable version of this learning?"
**Output action:** Optionally note this in a personal "question library" or "call patterns" log across deals.
---
### Q5: Did some parts of the call go better than others? Why?
**What to check:** Identify the high-performing moments and the low-performing moments from the call.
- High-performing: A moment where the customer became more engaged, volunteered information unprompted, shifted language from vague to specific, or agreed to a direction.
- Low-performing: A moment where the customer disengaged, became defensive, gave a non-committal response, or the conversation lost direction.
**Guided prompt:** "For each high-performing moment: what specific behavior preceded it? For each low-performing moment: what specific behavior triggered the disengagement?"
**Anti-pattern:** Do not conflate the call's overall tone with per-moment performance. A call can feel positive and still have significant low-performing moments — those moments are the learning opportunities.
---
### Q6: Which specific questions had the most influence on the customer?
**What to check:** From the call notes, identify 2-3 questions that visibly moved the customer:
- A question followed by an unusually long or specific customer response
- A question that caused the customer to pause and think
- A question that surfaced a need or problem the customer hadn't previously articulated
- A question that changed the direction of the conversation
**Guided prompt:** "Label each influential question by SPIN type (S/P/I/N). Was there an imbalance? If all influential questions were Situation Questions, the call was in fact a fact-gathering session, not a discovery call."
**Pattern check:** Research finding: only 1 in 20 questions in an average sales call is an Implication Question. If none of the influential questions were Implication Questions, that is a systematic gap in the seller's call pattern — note it explicitly.
---
### Q7: Which needs did the customer feel strongly? Which needs changed during the discussion? Why?
**What to check:**
- **Needs felt strongly:** Where did the customer use emotional or urgent language? Where did they volunteer problems without being asked?
- **Needs that changed:** Did any needs shift from Implied (customer expressed a problem or difficulty) to Explicit (customer expressed a desire or intent to act)? What prompted the shift?
- **Needs that emerged unexpectedly:** Did the customer raise a problem or need you had not anticipated? Is it within your capability to address?
- **Needs that stalled:** Were there problems you identified but were unable to develop? Why?
**Guided prompt:** "For each need shift (Implied → Explicit): what question preceded it? That question is the high-leverage behavior in this call. For each stalled need: what would the Implication chain look like if you had continued developing it?"
**Critical finding signal:** If no needs changed during the call — if all needs remained at the Implied level — then the Investigating stage failed to move the deal. This is the most important post-call finding available: the call did not develop needs, and without developed needs, solution presentation and commitment-seeking will fail on the next call.
---
## Context: Rackham's Top-Performer Distinction
From Chapter 8, verbatim:
> "Over the years I've had the opportunity to travel with dozens of the world's top salespeople — and as a researcher, I've looked for any differences that distinguish them from those who haven't made it to the top. Two differences stand out. The first is that the top people I've traveled with put great emphasis on reviewing each call — dissecting what they've learned and thinking about possible improvement."
> "It's worth asking yourself whether you are giving enough time to reviewing the details of what happened in the call. **Never be content with global conclusions like 'it went quite well.'** Ask yourself about the details."
This is the empirical basis for the skill's design: structured post-call review is not a best-practice recommendation — it is a behavioral differentiator observed directly in top-performing sellers.
Diagnose WHY a deal is accumulating objections by tracing each one back to its root-cause seller behavior. Use this skill when a customer keeps pushing back,...
---
name: objection-source-diagnoser
description: "Diagnose WHY a deal is accumulating objections by tracing each one back to its root-cause seller behavior. Use this skill when a customer keeps pushing back, when you're getting too many price objections, when the prospect raised concerns you don't know how to address, when a rep asks 'why does my pitch generate so much resistance?', or when someone asks 'what's wrong with my presentation?'. Invoke when someone says 'diagnose these objections', 'we keep getting price objections', 'why does the customer keep saying it's not worth it', 'how do I stop getting so many objections', 'the prospect raised X — how should I respond?', 'my deal has too many concerns', 'objections are killing my pipeline', or 'what's causing all this pushback?'. The skill reads call notes or transcripts, extracts every objection, and maps each to its FAB-source root cause using Rackham's empirically-derived behavior→response chain: Features cause price concerns; Advantages cause value and capability objections; premature solution presentation causes early-call objections. The output is a prevention plan for the NEXT call — which seller behaviors to remove, and which SPIN questions to use to develop needs more thoroughly. This skill explicitly refuses to produce 'when they say X, respond with Y' objection-handling scripts. That approach treats symptoms. This skill treats causes. Backed by Rackham's analysis of 35,000+ sales calls and Linda Marsh's correlation study showing Advantages as the primary driver of objections. Applies to B2B AEs, enterprise sales reps, and sales managers debriefing a struggling rep."
version: 1.0.0
homepage: https://github.com/bookforge-ai/bookforge-skills/tree/main/books/spin-selling/skills/objection-source-diagnoser
metadata: {"openclaw":{"emoji":"🔍","homepage":"https://github.com/bookforge-ai/bookforge-skills"}}
status: draft
source-books:
- id: spin-selling
title: "SPIN Selling"
authors: ["Neil Rackham"]
chapters: [6]
tags: [sales, b2b-sales, enterprise-sales, objection-prevention, spin-methodology, fab-methodology, call-diagnosis, deal-review]
depends-on:
- fab-statement-classifier
execution:
tier: 1
mode: hybrid
inputs:
- type: document
description: "Call notes or transcript from one or more calls on this deal — the raw material for objection extraction"
- type: document
description: "Objections log (optional) — accumulated list of objections across the deal lifecycle if available"
- type: document
description: "FAB audit (optional) — if fab-statement-classifier has already been run on the seller's pitch content, its output is used to cross-reference objection sources"
tools-required: [Read, Write]
tools-optional: [Grep]
mcps-required: []
environment: "Document set: call-notes-{date}.md, call-transcript-{date}.md, objections-log.md (if accumulated). Agent diagnoses and outputs objection-prevention-plan-{deal}.md; human applies the behavioral changes on the next call."
discovery:
goal: "Trace each objection back to a specific seller behavior (Feature overuse, Advantage without developed need, premature solution) and produce a ranked prevention plan for the next call"
tasks:
- "Extract every objection raised by the customer from the provided call notes or transcript"
- "Map each objection to its most probable FAB-source root cause using the behavior→response causal map"
- "Identify which seller behaviors generated objections (Features used heavily, Advantages before Explicit Needs, solutions offered before value built)"
- "Produce a prevention plan: behaviors to remove, SPIN questions to use instead"
- "Explicitly decline to produce objection-handling response scripts"
audience:
roles: [account-executive, enterprise-sales-rep, sales-manager, solutions-consultant, founder-led-seller]
experience: intermediate
when_to_use:
triggers:
- "After a call where the customer raised multiple objections — diagnose before the next call"
- "When price objections are piling up across multiple calls in a deal"
- "Sales manager debriefing a rep who is generating excessive objections"
- "When a rep asks 'how do I handle this objection?' — redirect to root cause first"
- "Accumulated objections-log.md suggests a pattern worth diagnosing"
prerequisites:
- "fab-statement-classifier — provides the FAB definitions and the behavior→response chain needed to classify each objection's source; if not run separately, the definitions are embedded in this skill"
not_for:
- "Writing objection-handling response scripts ('when they say X, say Y') — this skill refuses to do that"
- "Auditing pitch deck FAB distribution (use fab-statement-classifier)"
- "Generating SPIN questions for the next call (use spin-discovery-question-planner — this skill recommends invoking it)"
- "Pricing strategy or discount decisions"
environment:
codebase_required: false
codebase_helpful: false
works_offline: true
quality:
scores:
with_skill: 0
baseline: 0
delta: 0
tested_at: ""
eval_count: 0
assertion_count: 0
iterations_needed: 0
what_skill_catches:
- "Recognizes price objections as caused by Feature overuse, not price sensitivity — rejects the symptom/treatment default"
- "Maps 'it's not worth it' objections to Advantage-before-Explicit-Need pattern, not to poor articulation"
- "Identifies early-call objections as premature solution presentation, not inadequate opening"
what_baseline_misses:
- "Baseline produces 'when they say X, respond with Y' scripts — treats symptoms, not causes"
- "Baseline does not distinguish between objection types by FAB source"
- "Baseline cannot recommend specific upstream behavioral changes to prevent recurrence"
---
# Objection Source Diagnoser
## When to Use
You have call notes or a transcript from a deal where the customer raised objections — price concerns, "it's not worth it" responses, capability doubts, or outright resistance — and you want to understand why those objections arose and how to prevent them on the next call.
**The central reframe:** Most sales training treats objections as a communication problem. The customer raised an objection; you need a better response. SPIN research — backed by analysis of 35,000+ calls and Linda Marsh's correlation study — shows that this is backwards. Objections are not a communication problem. They are a sequencing problem. The customer raised an objection because the seller offered a solution before building sufficient value. The fix is not a better response script; the fix is better behavior earlier in the call.
Use this skill:
- After any call where the customer raised more than one objection, before planning the next call
- When price objections have appeared more than twice across a deal — this is a signal of Feature overuse, not a price problem
- When a sales manager is reviewing a call where a rep received heavy pushback
- When your instinct is "I need better objection-handling techniques" — run this diagnosis first; the answer is almost always prevention, not handling
**IMPORTANT — What this skill will NOT do:** This skill will not produce "when they say X, respond with Y" objection-handling scripts. That approach treats symptoms. This skill treats causes. If you want handling scripts, you need a different tool. This skill will produce a prevention plan.
Do NOT use this skill to audit pitch-deck FAB distribution (use `fab-statement-classifier`), generate SPIN questions for the next call (use `spin-discovery-question-planner`), or make pricing decisions.
## Context & Input Gathering
### Required Context (must have — ask if missing)
- **Call notes or transcript from the call(s) where objections arose**
-> Check prompt for: pasted text, file path, or transcribed dialogue
-> Check environment for: `call-notes-{date}.md`, `call-transcript-{date}.md`
-> If missing, ask: "Can you paste the call notes or point me to the file where the objections occurred?"
- **What product or capability was being discussed**
-> Check prompt for: product name, deal context, what was being presented
-> Check environment for: `deal-brief.md`, `product-capabilities.md`
-> If missing: infer from call notes OR ask: "What were you selling or presenting when the objections arose?"
### Observable Context (gather from environment)
- **Accumulated objections log**
-> Look for: `objections-log.md` — if present, scan for patterns across multiple calls before diagnosing any single call
-> If available: note recurring objection types as they suggest a systemic seller behavior pattern, not a one-call anomaly
- **FAB audit from fab-statement-classifier**
-> Look for: `fab-audit-{deal}.md` — if the seller's pitch content has already been audited for FAB distribution, use it to cross-reference objection sources
-> If unavailable: apply FAB definitions directly from the call notes themselves (Step 2 embeds the definitions)
- **Needs log from prior calls**
-> Look for: `needs-log.md` — if available, it tells you which Explicit Needs have been developed; Advantages presented without a corresponding Explicit Need are the primary objection source
-> If unavailable: note the absence and treat all capability statements as Advantage candidates
### Sufficiency Threshold
SUFFICIENT: Call notes or transcript with at least one objection present + product/deal context
PROCEED WITH DEFAULTS: Notes without a separate FAB audit (apply FAB definitions inline in Step 2)
MUST ASK: No call notes and no pasted objection text
## Process
### Step 1: Extract Every Objection
**ACTION:** Read the provided call notes or transcript and extract every customer objection — any statement where the customer expresses resistance, doubt, price concern, or skepticism about the seller's capability or value claim. List them individually.
**WHY:** Diagnosis requires a complete inventory. Sellers often remember the most painful objection and forget others. The diagnostic value comes from seeing the full pattern — three price objections plus two "not worth it" responses is a different diagnosis than one early resistance to a solution. Missing objections will produce an incomplete prevention plan.
**What counts as an objection:**
- Price concern: "It's too expensive," "I can't justify that cost," "That's a lot of money for what it does"
- Value doubt: "I don't think it's worth the trouble," "We're happy with our current system," "That seems like overkill"
- Capability doubt: "My people would never use that," "That wouldn't work in our environment"
- Resistance to change: "We tried something like this before and it didn't work"
- Early-call resistance: Any pushback or deflection that occurs in the first half of a call, often in response to a solution being mentioned too soon
**What does NOT count:** Questions ("How does that work?"), clarification requests ("Can you show me an example?"), or neutral fact-checks ("Is that compatible with our system?"). These are engagement, not objections.
**Step 1 output:** A numbered list of objections, each quoted or closely paraphrased from the call notes, with the approximate position in the call (early/mid/late) noted.
---
### Step 2: Map Each Objection to Its FAB-Source Root Cause
**ACTION:** Apply the behavior→response causal map to each objection. Determine which seller behavior most likely caused it, based on Rackham's three-part chain.
**WHY:** This is the diagnostic core of the skill. The causal map is not intuitive — most sellers assume objections reflect the customer's personality, budget situation, or competitor preference. The research evidence is different: in the overwhelming majority of cases, objections are seller-caused. Linda Marsh's correlation study found statistically significant links between FAB behavior patterns and specific objection types. Without this map, a seller diagnoses the wrong cause and applies the wrong remedy.
**The FAB→Response Causal Map:**
| Seller Behavior | Customer Response | Mechanism |
|---|---|---|
| **Heavy Feature use** | **Price concerns** ("It's expensive," "I don't know if it's worth the cost") | Features increase price sensitivity. For expensive products, this works against the seller — the customer is primed to scrutinize cost without having developed a sense of value. |
| **Advantages before Explicit Needs** | **Value objections** ("It's not worth the trouble," "We don't really need that," "We're fine with what we have") | An Advantage shows capability. But if the customer hasn't expressed a want for that capability, they evaluate it as a cost-to-benefit tradeoff — and in large sales the solution cost often outweighs an undeveloped problem. The customer raises an objection because the seller has not yet built sufficient value through need development. |
| **Premature solution presentation** | **Early-call objections** — any objection that arises in the first half of the call, often about price, fit, or necessity | Solutions offered before questions are asked generate resistance because the customer has not yet become invested in the problem. They haven't been led to see the problem's magnitude, so the solution feels unnecessary. |
| **Benefits meeting Explicit Needs** | **Support and approval** | When a seller presents a capability that meets a want the customer has already expressed, the natural response is endorsement, not objection. The absence of objections at this point is confirmation the sequencing was correct. |
**Classification steps for each objection:**
1. Look at what the seller said immediately BEFORE the objection arose. Find that seller statement in the call notes.
2. Classify that seller statement using FAB definitions:
- Is it a **Feature** (describing the product's characteristics without linking to value)?
- Is it an **Advantage** (showing how the product helps, but without a prior Explicit Need from the customer)?
- Is it a **premature solution** (naming capabilities or solutions before the customer has developed the problem's importance)?
3. Match the objection type to the causal map above.
4. Note whether an Explicit Need had been developed before the Advantage or solution was offered. If a needs-log exists, cross-reference it. If not, scan the transcript for any prior customer statement expressing a specific want ("we need X," "I'd like Y," "we're looking for Z"). Absence of such a statement before the capability was presented = Advantage, not Benefit.
**Step 2 output:** A per-objection table:
| # | Objection (quoted) | Objection Type | Seller behavior that preceded it | FAB-source classification | Explicit Need present before offer? |
|---|---|---|---|---|---|
| 1 | "..." | Price concern / Value doubt / Early-call resistance | "..." | Feature / Advantage / Premature solution | Yes (quote it) / No |
---
### Step 3: Identify the Behavioral Pattern
**ACTION:** Scan the Step 2 table for patterns across all objections. Name the dominant seller behavior driving objections for this call or deal.
**WHY:** Individual objection sources tell you what happened moment-to-moment. The pattern tells you what behavior to change systematically. A rep with 5 Advantage-sourced objections has a different problem than one with 5 Feature-sourced price concerns. The pattern determines the prevention prescription.
**Common patterns and their implications:**
- **Majority of objections are price concerns** → Dominant behavior: Feature overuse. The seller is describing the product heavily without developing needs first. The customer is primed to scrutinize cost. Prevention: cut Features; shift to Problem and Implication Questions.
- **Majority are value doubts ("not worth it," "don't need it")** → Dominant behavior: Advantages before Explicit Needs. The seller is presenting capabilities before the customer feels the problem's importance. Prevention: stop presenting capabilities until Implication Questions have made the problem consequential and Need-payoff Questions have elicited the customer's own expression of wanting a solution.
- **Objections cluster in the first half of the call** → Dominant behavior: premature solution presentation. The seller is mentioning the product or capabilities before establishing the customer's problems and their consequences. Prevention: no solution mentions until at least the midpoint of the call, after thorough need development.
- **Objections appear despite good questioning early on** → May indicate a true objection (product gap or competitor advantage) rather than a seller-caused objection. Note this — true objections require honest gap acknowledgment, not prevention.
**Step 3 output:** A 2-3 sentence behavioral diagnosis naming the dominant pattern and its implication for prevention.
---
### Step 4: Produce the Prevention Plan
**ACTION:** Write a concrete, ranked prevention plan for the next call. Address what to stop doing and what to do instead. Recommend invoking `spin-discovery-question-planner` if need development is identified as the gap.
**WHY:** Diagnosis without prescription is incomplete. The seller needs to know specifically which behaviors to change and what to replace them with. The plan must be concrete enough to use as a pre-call brief — not abstract coaching ("build more value") but specific behavioral guidance ("do not present any product capability until you have asked at least two Implication Questions per problem area").
**Prevention plan format:**
**Behaviors to remove from the next call:**
- [Specific behavior, e.g.: "Do not list product Features in the opening 20 minutes of the call"]
- [Specific behavior, e.g.: "Do not present any capability before the customer has expressed a specific want in their own words"]
- [Specific behavior, e.g.: "Do not mention pricing until needs are fully developed and customer has expressed at least one Explicit Need"]
**Behaviors to add:**
- [e.g.: "Ask at least 2 Problem Questions before any capability mention"]
- [e.g.: "For each Implied Need expressed, ask at least 1 Implication Question to develop the consequence before moving forward"]
- [e.g.: "Use Need-payoff Questions ('If we could eliminate that, what would that mean for your team?') to get the customer to articulate their own want before presenting the solution"]
**SPIN question development gaps** (for each objection that traces to an undeveloped need):
- [Problem: what problem question was missing or too shallow]
- [Implication: what consequence was not explored]
- [Need-payoff: what customer-expressed want needs to be elicited before presenting this capability]
**Recommended next step:** IF the pattern shows systemic need-development gaps → invoke `spin-discovery-question-planner` with this deal context to build a targeted question bank before the next call.
**Step 4 output:** Written prevention plan with ranked behaviors and SPIN question gaps.
---
### Step 5: Write the Objection Prevention Plan
**ACTION:** Compile Steps 1-4 into a single structured document. Write it to `objection-prevention-plan-{deal}.md`.
**WHY:** The written plan persists across the conversation and can be used directly as pre-call preparation. It also creates a reference point for tracking whether the prevention behaviors are adopted in the next call.
**Report structure:**
```
# Objection Prevention Plan — {Deal Name} — {Date}
## Objections Extracted
{Numbered list from Step 1}
## Objection-to-Source Mapping
{Table from Step 2}
## Behavioral Diagnosis
{Pattern summary from Step 3}
## Prevention Plan
### Behaviors to Remove
{List from Step 4}
### Behaviors to Add
{List from Step 4}
### SPIN Question Gaps
{Per-gap items from Step 4}
## Recommended Next Step
{Invoke spin-discovery-question-planner or other specific action}
```
## Key Principles
- **Objections are seller-caused, not customer-caused.** Rackham's analysis of 35,000+ calls found that in a typical sales team, one rep receives 10x more objections per selling hour than another rep selling the same product to the same customers. The product is not the variable. The seller's behavior is. Objections cluster around sellers who use Advantages heavily — and they drop by 55% when those sellers are trained to develop Explicit Needs first.
- **Symptoms versus causes.** Price objections are not a price problem — they are a Feature-use problem. "Not worth it" objections are not a value communication problem — they are a need-development problem. Objection-handling training treats the symptom. This skill treats the cause. Teaching a seller to handle price objections when their Feature overuse is generating them is as effective as eating ice cream to prevent typhoid.
- **The Advantage trap.** The most common objection source is not poor product knowledge or bad closing technique. It is Advantages — showing how the product can help before the customer has expressed a want for that help. The customer evaluates the Advantage against an undeveloped problem and concludes it is not worth the cost. This is the Dallas word-processor pattern: every Advantage met with an objection, because the seller jumped to solution before building value.
- **Objection prevention is upstream, not downstream.** The question "how do I handle this objection?" is downstream. The right question is "what did I do 5 minutes ago that generated this objection?" This skill answers the right question.
- **Some objections are true objections.** Not every objection is seller-caused. If the customer has a genuine need your product cannot meet, or if a competitor has a clear advantage, those objections are real. Prevention techniques cannot eliminate true objections — only seller-caused ones. Approximately 55% of objections in a typical sales team can be prevented through better sequencing. The other 45% require honest acknowledgment and, where possible, product-level responses.
- **Never produce handling scripts.** If a seller asks "how do I respond to this objection?" — the first answer is always the prevention diagnosis. If after understanding the cause they still need help with a specific true objection, refer them to the appropriate resource. But producing a response script for a seller-caused objection is counterproductive: it makes the seller more confident about a behavior that is hurting them.
## Examples
**Scenario: AE with a deal accumulating price objections**
Trigger: AE says "We keep getting price objections on this deal. The customer is very price-sensitive. How do I handle it?"
Process:
- (Step 1) Read `call-notes-2025-03-12.md`. Extract 4 objections: "That's very expensive for what it does," "I'm not sure we can justify this cost," "Is there a cheaper version?", "The budget is tight — this is hard to sell internally."
- (Step 2) Examine seller statements immediately preceding each. Find: seller opened with a 5-minute product overview listing 8 product Features (processing speed, storage capacity, integration count, API options, support tier, uptime SLA, security certifications, deployment options). No Problem Questions asked in the first 15 minutes. Classify: Feature-heavy opening → price concerns.
- (Step 3) Pattern: All 4 objections are price concerns. Dominant behavior: Feature overuse before any need development. The customer's price sensitivity was amplified by the Feature dump, which primed them to ask "is this worth the cost?" before having any sense of what the cost of NOT solving their problem would be.
- (Step 4) Prevention plan: Remove the product overview from the first 15 minutes. Replace with 3-4 Problem Questions about the customer's current workflow and where they're losing time or quality. Add Implication Questions for each confirmed problem. Before any product mention, elicit at least one Explicit Need.
- (Step 5) Write `objection-prevention-plan-acme-corp.md`.
Output: Prevention plan showing that price sensitivity is seller-amplified (Feature dump), not inherent customer resistance. Recommendation: invoke `spin-discovery-question-planner` to build a question bank before the next call.
---
**Scenario: Sales manager reviewing a rep's call with repeated "not worth it" objections**
Trigger: Manager says: "Listen to this transcript — the prospect keeps saying it's not worth switching. What's going on?"
Process:
- (Step 1) Extract 3 objections from transcript: "We're really quite happy with our current system," "I don't see enough value to justify a switch," "It'd be a lot of hassle for marginal improvement."
- (Step 2) Find seller statements before each. All three are Advantages: "Our system would eliminate that manual reconciliation for you," "You'd get real-time visibility across all your accounts," "We integrate directly so there's no import step." Scan for prior customer-expressed Explicit Needs: buyer mentioned "our reconciliation takes longer than it should" (Implied Need — problem statement, not a want). No Explicit Need expressed before any Advantage was offered.
- (Step 3) Pattern: All 3 objections are value doubts. Dominant behavior: Advantages offered before Explicit Needs developed. The rep heard an Implied Need and jumped to the solution. The customer hasn't internalized the cost of the problem, so the solution seems unnecessary.
- (Step 4) Prevention: Rep should have used Implication Questions to develop the reconciliation problem ("What does that extra time cost you across 50 accounts per month?" "What happens when a reconciliation error reaches a client?") and then Need-payoff Questions to get the customer to express the want ("If reconciliation were automatic, what would you do with that time?"). Only after explicit want is expressed should the capability be presented.
Output: `objection-prevention-plan-prospect-review.md`. Coaching for manager: the rep has the Advantage habit — trained to give "Benefits" but actually giving Advantages. Recommend fab-statement-classifier audit of rep's standard pitch and then spin-discovery-question-planner session for this account.
---
**Scenario: Early-call objection pattern**
Trigger: AE says "The prospect shut down the conversation almost immediately — I barely got through my intro before they were resistant."
Process:
- (Step 1) Extract: "Look, I'm not sure I need to hear a whole pitch right now," "We're not really in the market for new tools this quarter," "Our budget is locked."
- (Step 2) Seller statement before first objection: "We help companies like yours cut their invoice processing time by up to 40% with our workflow automation platform." This is an opening benefit statement — a premature Advantage in the first 2 minutes of the call.
- (Step 3) Pattern: Early-call objections caused by premature solution presentation (opening benefit/Advantage statement).
- (Step 4) Prevention: Do not mention the product, its capabilities, or any value claims in the first third of the call. Open by establishing the right to ask questions. Use Problem Questions first. The customer needs to feel invested in the problem before hearing about the solution.
Output: Prevention plan showing this is a classic premature-solution pattern. Recommend `discovery-call-opening-planner` for the next call to redesign the opening.
## References
- [objection-source-causal-map.md](references/objection-source-causal-map.md) — Full behavior→response chain with research evidence, the Dallas word-processor dialogue annotated by objection source, and the Japanese-recruit case study
## License
This skill is licensed under [CC-BY-SA-4.0](https://creativecommons.org/licenses/by-sa/4.0/).
Source: [BookForge](https://github.com/bookforge-ai/bookforge-skills) — SPIN Selling by Neil Rackham.
## Related BookForge Skills
This skill depends on:
- `clawhub install bookforge-fab-statement-classifier` — Classify seller statements as Features, Advantages, or Benefits; provides the FAB definitions and behavior→response chain that underpin objection diagnosis
Skills that work alongside this one:
- `clawhub install bookforge-spin-discovery-question-planner` — Build a SPIN question bank for the next call, addressing the specific need-development gaps identified by this diagnosis
- `clawhub install bookforge-benefit-statement-drafter` — Draft Benefit statements once Explicit Needs have been developed; the constructive step after removing Advantage-heavy selling
- `clawhub install bookforge-discovery-call-opening-planner` — Redesign the call opening when early-call objections indicate premature solution presentation
Or install the full SPIN Selling skill set from GitHub: [bookforge-skills](https://github.com/bookforge-ai/bookforge-skills)
FILE:references/objection-source-causal-map.md
# Objection Source Causal Map
This reference supplements the `objection-source-diagnoser` skill. It provides the full behavior→response chain with research evidence, the annotated Dallas word-processor dialogue, and the Japanese-recruit case study.
---
## The FAB→Customer Response Chain
Derived from Linda Marsh's correlation studies (cited in SPIN Selling, Chapter 6), which analyzed statistically significant links between seller FAB behaviors and customer response patterns across thousands of sales calls.
| Seller Behavior | Most Probable Customer Response | Why |
|---|---|---|
| **Feature** (describing product characteristics without linking to value) | Price concern ("It's expensive," "Is it worth the cost?") | Features increase price sensitivity. For low-cost products this is advantageous — the customer expects a high price, then the low price is pleasant. For expensive products, Feature-heavy selling amplifies cost scrutiny before value has been established. |
| **Advantage** (showing how product can help, without a prior Explicit Need) | Objection — value doubt ("We don't need that," "Not worth the trouble," "We're fine with what we have") | The customer evaluates the Advantage against an undeveloped problem. In large sales, the solution cost typically exceeds the perceived urgency of an undeveloped problem. The customer raises a "not worth it" objection because the seller has not invested sufficient time in building the problem's importance through Implication Questions. |
| **Benefit** (showing how product meets a customer-expressed Explicit Need) | Support / approval ("Yes, that's exactly what we need," "That would solve our problem") | Benefits, by definition, meet something the customer already wants. Approval follows naturally. |
| **Premature solution presentation** (mentioning product capabilities in the first third of the call, before need development) | Early-call objection — budget lock, "not in the market," deflection | The customer has not been led to feel the problem's importance, so any solution appears unnecessary. Early resistance is generated by missequencing, not by the product. |
---
## Research Backing
### Linda Marsh Correlation Studies
Rackham's colleague Linda Marsh carried out correlation studies mapping statistically significant links between each FAB behavior type and customer responses. Key findings:
- Features → price concerns (significant positive correlation)
- Advantages → objections (most probable customer response to Advantages is an objection)
- Benefits → support/approval (significant positive correlation)
### The 55% Objection Reduction Experiment
In a division of a high-tech corporation, Huthwaite identified 8 salespeople who each received unusually high numbers of objections per selling hour. Behavior analysis showed all 8 were high in Advantage use. An objection-prevention training program was designed that:
- Did not mention the word "objection" or any objection-handling technique
- Taught SPIN questioning to develop Explicit Needs
- Taught offering Benefits only after Explicit Needs were expressed
Post-training measurement: average objections per selling hour fell by 55%.
Conclusion: More than half of objections in a typical sales team are seller-caused and preventable through sequencing change. The remaining objections represent true product/competitor gaps.
### Japanese-Recruit Price Objection Case
A major U.S.-based multinational corporation faced competition from lower-cost Japanese machines and attempted to recruit top salespeople from the Japanese competitor. These recruits had strong track records selling Feature-rich, lower-priced machines.
Outcome: The recruits received 30% MORE price objections than the existing sales force selling the identical product to the same customers.
Root cause: The recruits' selling style was trained on Feature-rich machines where Features worked in their favor (lower price + Feature list = positive purchase). Transferred to a premium-priced product, the same Feature-heavy style amplified price sensitivity without having built value through need development. The Features primed customers to scrutinize cost just when they needed to feel the value.
Remedy: Retrain in SPIN questioning and Benefits-first selling. Result: sales increased, price objections fell.
Key lesson: Price objections are not caused by price. They are caused by Feature overuse before value development.
---
## The Dallas Word-Processor Dialogue (Annotated)
Recorded September 1981. Seller attempting to sell word processors to a Dallas office manager. Edited for length; behavior labels added.
```
SELLER: [Problem Question] Does all this retyping waste time?
BUYER: [Implied Need] Yeah, some. But there's not so much of it here, not like in Fort Worth.
SELLER: [Advantage — jumps to solution before building need importance]
"Here's where our word processors would be a real big help because they'd eliminate
that retyping for you."
BUYER: [Objection — value doubt]
"Look, we retype stuff, sure. But you won't get me paying for fancy $15,000 machines
just to cut down on some retyping."
SELLER: [Advantage — tries to counter with another capability claim]
"I understand you, but the labor costs of retyping can climb out of sight. A big plus
of word processors is that they save you money by making your people more efficient."
BUYER: [Objection — value doubt + capability resistance]
"We're very efficient right now — and if I wanted to do better on efficiency I can
think of 16 ways without new word processors."
[Pattern continues: each Advantage → each objection]
```
**What went wrong:** The seller heard an Implied Need ("some retyping") and immediately presented an Advantage ("would be a real big help"). The customer had not developed the problem — had not articulated how much it cost, what consequences it had, or what the business impact was. The Advantage was evaluated against an undeveloped problem and found not worth $15,000.
**The prevention path (shown in Chapter 6):** On the final exchange in the dialogue, Rackham shows an alternative sequence. The seller uses Implication Questions to develop the error-rate problem into: proofreading time (2 hours/day), bottlenecking the team, preventing training, risk of sending errors to clients, retyping costs. Only after the customer says "When you put it that way, those mistakes are really hurting us" does the seller present the capability — now as a Benefit meeting an expressed Explicit Need. No objection arises.
---
## Two Signs You Are Getting Preventable Objections
From Rackham's summary guidance (Chapter 6):
1. **Objections early in the call.** Customers rarely object to questions. If objections are arising in the first half of a call, the seller is presenting solutions too soon — before questions have developed the need's importance. Cure: no solution mentions until thorough need development.
2. **Objections about value.** "It's too expensive," "not worth the trouble," "happy with our existing system" — these are value objections indicating needs have not been developed strongly enough. Cure: more Implication and Need-payoff Questions, not better objection-handling scripts. Specifically, cut Feature use and replace with Problem, Implication, and Need-payoff Questions.
---
## Objection Types Reference
| Objection Type | Language Pattern | Most Probable FAB Source |
|---|---|---|
| Price concern | "Too expensive," "Can't justify the cost," "Budget is tight," "Is there a cheaper version?" | Feature overuse |
| Value doubt | "Not worth the trouble," "Don't see enough value," "Happy with what we have," "Marginal improvement" | Advantage before Explicit Need |
| Capability doubt | "My people won't use it," "Wouldn't work in our environment," "Too complex" | Advantage on an undeveloped need (fit hasn't been established) |
| Early-call resistance | "Not in the market right now," "Budget is locked," "Send me something to review" | Premature solution presentation (opening benefit/Advantage statement) |
| True objection | "Our current vendor has a contract," "You don't have [feature] that we specifically need" | Genuine product/competitor gap — cannot be prevented, requires acknowledgment |
Classify customer statements from sales calls as Implied Needs (problems, difficulties, dissatisfactions) or Explicit Needs (wants, desires, intentions to ac...
---
name: need-type-classifier
description: "Classify customer statements from sales calls as Implied Needs (problems, difficulties, dissatisfactions) or Explicit Needs (wants, desires, intentions to act). Use this skill whenever a sales rep shares something a prospect said and asks what to do next, when reviewing call notes or transcripts to identify which customer statements represent buying signals, when diagnosing whether discovery went deep enough before a demo or proposal, when building a needs log from call notes, or when a colleague says 'the prospect sounded interested — they mentioned several problems.' This skill applies Rackham's empirically-validated SPIN methodology to prevent the most common large-sale mistake: treating Implied Needs (problems) as buying signals. In large B2B sales, Explicit Needs — not Implied Needs — predict deal success. Invoke whenever any customer statement needs to be categorized, developed, or acted on in a B2B or enterprise sales context."
version: 1.0.0
homepage: https://github.com/bookforge-ai/bookforge-skills/tree/main/books/spin-selling/skills/need-type-classifier
metadata: {"openclaw":{"emoji":"🎯","homepage":"https://github.com/bookforge-ai/bookforge-skills"}}
status: draft
source-books:
- id: spin-selling
title: "SPIN Selling"
authors: ["Neil Rackham"]
chapters: [3]
domain: b2b-sales
tags: [sales, b2b-sales, enterprise-sales, discovery, customer-needs, spin-methodology, need-development, buying-signals]
depends-on: []
execution:
tier: 1
mode: hybrid
inputs:
- type: document
description: "Customer statement(s) — pasted text, call-notes file, transcript excerpt, or a single verbal quote from a prospect"
tools-required: [Read, Write]
tools-optional: [Grep]
mcps-required: []
environment: "Document set: deal-brief.md, call-notes-{date}.md, call-transcript-{date}.md, or plain text input. Agent classifies and writes output; human acts on the recommendations."
discovery:
goal: "Determine whether each customer statement represents an Implied Need (problem/dissatisfaction) or an Explicit Need (want/desire/intention), and know exactly what to do next"
tasks:
- "Classify individual customer statements from a call as Implied Need, Explicit Need, or Neither"
- "Review call notes or transcript to build a categorized needs log"
- "Diagnose whether discovery surfaced enough Explicit Needs before moving to presentation"
- "Determine the next recommended move for each statement (develop, convert, or capitalize)"
- "Flag misreading of Implied Needs as buying signals in large-sale contexts"
audience:
roles: [account-executive, enterprise-sales-rep, sdr, solutions-consultant, founder-led-seller]
experience: intermediate
when_to_use:
triggers:
- "Prospect said something in a call and the rep wants to know what it means and what to do"
- "Post-call review of notes or transcript to log what needs were expressed"
- "Pre-demo check: 'Do we have enough Explicit Needs to present our solution?'"
- "Coaching: manager wants to show a rep why a call stalled despite the prospect 'agreeing to problems'"
prerequisites: []
not_for:
- "Generating the actual SPIN questions (use spin-discovery-question-planner)"
- "Drafting Benefit statements from confirmed Explicit Needs (use benefit-statement-drafter)"
- "Negotiation tactics or pricing objections (out of SPIN scope)"
environment:
codebase_required: false
codebase_helpful: false
works_offline: true
quality:
scores:
with_skill: 0
baseline: 0
delta: 0
tested_at: ""
eval_count: 0
assertion_count: 0
iterations_needed: 0
what_skill_catches:
- "Correct distinction between problem statements (Implied) and desire/action statements (Explicit)"
- "Explicit warning when reps treat Implied Needs as buying signals in large sales"
- "Correct next-move recommendation: develop vs convert vs capitalize"
what_baseline_misses:
- "Conflates 'the customer agreed they have a problem' with 'the customer wants to buy'"
- "Recommends presenting solutions immediately after hearing Implied Needs"
- "Fails to surface the large-sale vs small-sale distinction in interpretation"
---
# Need Type Classifier
## When to Use
A customer said something on a call — or you have a batch of statements from call notes — and you need to know: is this a buying signal, a starting point, or something else?
This skill applies specifically to B2B discovery calls and enterprise sales contexts where the distinction between a customer's expressed problems (Implied Needs) and expressed desires (Explicit Needs) determines what happens next. If you are in a large-sale context (high value, multiple stakeholders, long cycle), this distinction is load-bearing: treating an Implied Need as a buying signal is the primary cause of premature solution presentations and stalled deals.
Use this skill:
- During or after a discovery call, to classify what the prospect actually expressed
- When building or updating a needs log from call notes
- Before moving to demo or proposal stage, to verify you have Explicit Needs to link capabilities to
- When coaching a rep who thinks a call went well because "the customer agreed to problems"
Do NOT use this skill to generate follow-up questions (use `spin-discovery-question-planner`) or to draft capability presentations (use `benefit-statement-drafter`).
## Context & Input Gathering
### Input Sufficiency Check
Before classifying, determine: "Do I have the customer statement(s) and enough context to apply the large-sale gate?"
### Required Context (must have — ask if missing)
- **Customer statement(s):** The actual words the customer used, not a paraphrase
-> Check prompt for: quoted text, call-notes file, transcript excerpt
-> Check environment for: `call-notes-{date}.md`, `call-transcript-{date}.md`, `needs-log.md`
-> If still missing, ask: "Paste the customer statement(s) you want me to classify, or point me to the call-notes file."
- **Sale context (large vs small):** Determines whether Implied Needs count as buying signals
-> Check prompt for: deal size, enterprise/SMB label, multi-stakeholder mention
-> Check environment for: `deal-brief.md` (look for deal size, stakeholder count, sales cycle length)
-> If still missing, ask: "Is this a large sale (high value, multiple decision-makers, weeks or months to close) or a small/transactional sale?"
### Observable Context (gather from environment)
- **Deal brief:** Look for `deal-brief.md` — provides company context, deal stage, stakeholder count
-> If unavailable: proceed without company context; note the assumption
- **Prior needs log:** Look for `needs-log.md` — prior Implied/Explicit needs from previous calls
-> If available: cross-reference to detect progression (Implied → Explicit across calls = positive signal)
-> If unavailable: treat each statement independently
### Default Assumptions
- **Default to large-sale context** if deal size or cycle length is unspecified. This is the conservative assumption — it prevents premature solution presentation, which is the more costly error.
- **Classify based on exact words, not inferred intent.** A customer who says "our system has limitations" is expressing an Implied Need even if they seem eager. Only "we need a better system" counts as Explicit.
### Sufficiency Threshold
SUFFICIENT: Customer statement(s) + sale context (large vs small)
PROCEED WITH DEFAULTS: Statement(s) present but sale context unknown (apply large-sale gate with a note)
MUST ASK: No customer statements provided
## Process
### Step 1: Confirm the Classification Framework
**ACTION:** Before classifying, establish the two categories and their strict definitions. Apply these as written — the distinction is load-bearing.
**WHY:** The most common error is conflating these two types. A general agent (without this methodology) will treat "our current vendor is unreliable" as a buying signal. It is not — not in a large sale. The definitions must be applied with precision.
**Definitions:**
**Implied Need** — A customer statement expressing a problem, difficulty, or dissatisfaction with the current situation. The customer is describing something that is wrong, painful, or suboptimal. They are NOT expressing desire for change or intention to act.
- Structural markers: "can't cope with," "struggling with," "not happy about," "limitations," "problem with," "we're finding it difficult," "the current X is inadequate"
- Examples: "Our present system can't cope with the throughput." / "I'm unhappy about wastage rates." / "We're not satisfied with the speed of our existing process."
**Explicit Need** — A customer statement expressing a specific want, desire, or intention to act. The customer is describing something they WANT or are PLANNING TO DO, not just a problem they have.
- Structural markers: "we need," "we're looking for," "I'd like," "we want," "our goal is," "we're planning to," "we'd require"
- Examples: "We need a faster system." / "What we're looking for is a more reliable machine." / "I'd like to have a backup capability." / "We're going to overhaul our data network next year."
**Neither** — Factual statements, background information, or neutral observations that don't express a problem or a want.
- Examples: "We have 200 users." / "We've been using this vendor for three years." / "Our IT team handles procurement."
**Step 1 output:** Confirm the framework is understood before proceeding to classification.
### Step 2: Classify Each Statement
**ACTION:** For each customer statement provided, apply the definitions from Step 1. Produce a classification with supporting evidence and rationale.
**WHY:** Showing the evidence (the exact words that triggered the classification) makes the output auditable and teachable. Reps need to understand WHY a statement is Implied vs Explicit so they can hear this distinction in real time on future calls.
**For each statement, output:**
1. The statement (quoted verbatim)
2. Classification: Implied Need / Explicit Need / Neither
3. Evidence: the specific words that indicate the type
4. Rationale: one sentence explaining why
**Example classification:**
> "Our approval workflow takes forever — we lose deals because contracts sit in queue."
> - Classification: **Implied Need**
> - Evidence: "takes forever," "lose deals," "sit in queue" — describes a problem and its consequences, not a desire for a solution
> - Rationale: The customer expresses dissatisfaction and a negative outcome, but does not state a want or intention to act on it.
> "We're looking for a contract automation tool that integrates with Salesforce."
> - Classification: **Explicit Need**
> - Evidence: "We're looking for" — direct expression of a desired capability
> - Rationale: Specific want expressed; matches the structural pattern of an Explicit Need.
### Step 3: Apply the Large-Sale Gate
**ACTION:** After classifying each statement, apply the large-sale gate: if the context is a large B2B sale, check whether any Implied Needs have been mistakenly treated as buying signals or used to justify moving to presentation/demo.
**WHY:** In small sales (low value, single decision-maker, short cycle), Implied Needs ARE reliable buying signals. In large sales (high value, multiple stakeholders, long cycle), they are not. The empirical basis for this is stark: analysis of 1,406 larger sales found no relationship between Implied Needs and call success, but Explicit Needs were twice as high in successful calls. This distinction is counterintuitive — most reps and most sales training conflate the two. An agent without this methodology will incorrectly validate "the customer agreed to problems = positive signal."
**Gate logic:**
IF large-sale context:
- Flag any Implied Need where the rep seems to be treating it as a buying signal
- Include this explicit warning: "Implied Needs in large sales are a starting point, not a buying signal. A prospect agreeing to problems does not indicate readiness to buy. The number of Implied Needs you surface has no statistical relationship to success in large deals. What matters is whether you develop them into Explicit Needs."
- Identify which statements are Explicit Needs — these ARE the buying signals in large sales
IF small/transactional sale:
- Implied Needs do predict success; surfacing more problems helps
- Note this context difference explicitly
IF sale context is unknown (defaulted to large):
- Apply the large-sale warning, note the assumption
### Step 4: Generate Next-Move Recommendations
**ACTION:** For each classified statement, recommend the specific next move in the SPIN methodology.
**WHY:** Classification without a next action is an observation, not a tool. The rep needs to know what to DO — and the correct action depends entirely on which type of need they're working with. Prescribing the wrong next move (e.g., presenting a solution to an Implied Need in a large sale) is one of the most common and costly errors in enterprise selling.
**Next-move logic:**
| Classification | Large Sale | Small Sale |
|---|---|---|
| Implied Need | Develop with Implication Questions (explore consequences of the problem) | Present a solution or ask Need-payoff Questions |
| Explicit Need | Capitalize with a Benefit statement (link capability to this specific want) | Capitalize with a Benefit statement |
| Neither | No action required; gather more context | No action required |
**Develop with Implication Questions:** When a customer expresses an Implied Need, the next move is to ask questions that help the customer feel the full weight of the problem — its consequences, its ripple effects, its cost to the business. This is not about creating artificial urgency; it is about helping the customer understand the full scale of the problem so that the cost of solving it feels justified. Do not yet present solutions.
**Convert with Need-payoff Questions:** When an Implied Need has been sufficiently developed (the customer has expressed its consequences and is clearly feeling the weight of the problem), ask questions that prompt the customer to articulate what a solution would mean for them. "If you could eliminate that bottleneck, what would that mean for your team?" This converts Implied into Explicit.
**Capitalize with a Benefit:** When the customer expresses an Explicit Need and your product or service can meet it, link your capability directly to their expressed want. This is a Benefit in the SPIN sense — it is tied to a specific, customer-expressed desire, not a feature you want to highlight.
### Step 5: Produce the Classification Report
**ACTION:** Compile all classifications, large-sale gate findings, and next-move recommendations into a single structured report. Write it to `needs-log.md` (update if it exists; create if it does not).
**WHY:** A written report creates a persistent record that carries forward to question planning, benefit drafting, and call review. Without a written artifact, the classification exists only in the conversation and cannot be referenced by other skills (`spin-discovery-question-planner` reads `needs-log.md`).
**Report format:**
```markdown
# Needs Classification — {Account Name} — {Date}
## Sale Context
{Large / Small / Unknown — defaulted to large}
## Classification Results
| # | Statement (verbatim) | Type | Evidence | Next Move |
|---|---|---|---|---|
| 1 | "{statement}" | Implied Need | "{trigger words}" | Develop with Implication Qs |
| 2 | "{statement}" | Explicit Need | "{trigger words}" | Capitalize with a Benefit |
| 3 | "{statement}" | Neither | — | No action |
## Summary
- Implied Needs identified: {N}
- Explicit Needs identified: {N}
- Neither: {N}
## Large-Sale Gate Assessment
{If large sale: note whether the call has sufficient Explicit Needs to justify moving to solution presentation, or whether more development is needed.}
## Recommended Next Move
{One-paragraph narrative: what should the rep do next given this classification?}
```
## Key Principles
- **Explicit Needs, not Implied Needs, are the buying signals in large sales.** This is the single most important and counterintuitive insight in the SPIN methodology — backed by analysis of 1,406 large-sale calls. A customer agreeing to problems says nothing about whether they will buy. A customer expressing a want, desire, or intention to act is a meaningful signal. When a rep says "the call went great — they agreed they had X, Y, and Z problems," that tells you only that the call surfaced raw material, not that it advanced the deal.
- **Classify on words, not inferred intent.** "Our system is slow" is Implied even if the customer seems highly motivated. "We need a faster system" is Explicit even if the customer sounds calm. The classification depends on the grammatical and semantic form of the statement, not on the rep's gut feeling about the customer's readiness.
- **Implied Needs are raw material, not failure.** Surfacing Implied Needs is necessary — you can't develop what hasn't been uncovered. The error isn't finding Implied Needs; it's stopping there. In large sales, Implied Needs are the starting point for a development strategy, not a signal to present solutions.
- **The value equation explains the large/small split.** In small sales, the cost of the solution is low, so even a mild problem can justify a purchase. In large sales, the cost (financial, risk, disruption) is high, so a mild problem never justifies action alone. The customer must perceive the problem as large enough to outweigh the cost — and that requires development, not just identification.
- **Stay in scope.** This skill classifies and recommends. It does not generate the actual Implication Questions (that is `spin-discovery-question-planner`) or draft the Benefit statements (that is `benefit-statement-drafter`). When the classification output points to a next move, the rep or the relevant skill executes it.
## Examples
**Scenario: Post-call classification of transcript excerpt (large enterprise deal)**
Trigger: Rep shares: "Here are four things our prospect said on the call today. Did the call go well?"
Statements:
1. "Our current reporting is really painful — it takes the team 2 days to pull together the board deck."
2. "We've had compliance issues with our current data process."
3. "We're actively evaluating vendors for a new reporting platform."
4. "We have about 50 analysts who use the system."
Process:
- Statement 1: Implied Need — "really painful," "takes 2 days" = dissatisfaction with current situation. Next move: Develop with Implication Questions (what does the 2-day delay cost? who is affected?).
- Statement 2: Implied Need — "compliance issues" = problem statement. Next move: Develop with Implication Questions (what are the consequences of non-compliance?).
- Statement 3: Explicit Need — "actively evaluating vendors" = stated intention to act. Next move: Capitalize with a Benefit — this is a buying signal.
- Statement 4: Neither — factual background. No action.
- Large-sale gate: 2 Implied Needs, 1 Explicit Need. The call surfaced good raw material but is NOT ready for solution presentation yet — Statement 3 is the only Explicit Need. The rep should develop Statements 1 and 2 further before presenting capabilities.
Output: Needs classification report written to `needs-log.md`. Rep advised to plan Implication Questions for the next call and NOT lead with a product demo.
---
**Scenario: Single statement quick-check (large sale)**
Trigger: "My prospect said 'we're not happy with our current platform.' Is that a buying signal?"
Process: Classify the single statement. "Not happy with" = dissatisfaction = Implied Need. Apply large-sale gate: no, this is NOT a buying signal in a large sale. The customer has expressed a problem, not a desire for change. Next move: develop with Implication Questions.
Output: Classification report (short form). Clear warning that treating this as a buying signal would be premature — this is the error that cost the inexperienced telecom rep the deal in the book (CS-07).
---
**Scenario: Pre-demo sufficiency check**
Trigger: Rep asks: "We have a demo tomorrow. Here's our needs log from the last two calls — are we ready?"
Process: Read `needs-log.md`. Count Implied vs Explicit Needs. Apply large-sale gate: if the needs log contains only Implied Needs and no Explicit Needs, the customer has not yet expressed a want or desire that can be linked to a Benefit. A demo at this stage risks presenting Features and Advantages to a customer who has not confirmed any Explicit Need — which predicts objections and value challenges, not advancement.
Output: Pre-demo sufficiency report. If Explicit Needs are present: ready to proceed, list which capabilities can be linked to which Explicit Needs. If no Explicit Needs: recommend one more discovery call to develop the strongest Implied Need into an Explicit Need before presenting.
## References
For a quick-reference card of Implied vs Explicit Need markers with 20+ examples by selling context, see [references/need-type-reference-card.md](references/need-type-reference-card.md).
## License
This skill is licensed under [CC-BY-SA-4.0](https://creativecommons.org/licenses/by-sa/4.0/).
Source: [BookForge](https://github.com/bookforge-ai/bookforge-skills) — SPIN Selling by Neil Rackham.
## Related BookForge Skills
This skill is standalone (no dependencies). Skills that build on it:
- `clawhub install bookforge-spin-discovery-question-planner` — Plan the Situation, Problem, Implication, and Need-payoff questions for a specific deal call (reads `needs-log.md` from this skill)
- `clawhub install bookforge-fab-statement-classifier` — Classify whether seller statements are Features, Advantages, or true Benefits (requires distinguishing Explicit Needs)
- `clawhub install bookforge-benefit-statement-drafter` — Draft Benefit statements that link product capabilities to customer-expressed Explicit Needs
Or install the full SPIN Selling skill set from GitHub: [bookforge-skills](https://github.com/bookforge-ai/bookforge-skills)
FILE:references/need-type-reference-card.md
# Need Type Reference Card
Quick lookup for classifying customer statements during or after a discovery call.
---
## The Two Types
| Type | What the Customer Expresses | Test |
|------|----------------------------|------|
| **Implied Need** | A problem, difficulty, or dissatisfaction with the current situation | Could the sentence be completed with "…and that's a problem"? |
| **Explicit Need** | A specific want, desire, or intention to act | Does the sentence express what the customer WANTS or PLANS TO DO? |
| **Neither** | Factual background, neutral information | Is this just a fact with no emotional valence? |
---
## Signal Words by Type
### Implied Need Signal Words
- Dissatisfaction: "unhappy with," "not satisfied," "disappointed in," "frustrated by"
- Problems: "struggling with," "having issues with," "problems with," "challenges around"
- Difficulty: "can't cope with," "hard to," "difficult to," "time-consuming"
- Inadequacy: "limitations," "too slow," "too expensive," "doesn't work well," "unreliable"
- Consequences: "losing deals because of," "falling behind," "causing errors"
### Explicit Need Signal Words
- Wants: "we need," "we want," "we'd like," "I want," "what we're looking for is"
- Plans: "we're planning to," "we're going to," "we're evaluating," "we're in the process of"
- Requirements: "we require," "it has to," "we must have," "a key requirement is"
- Goals: "our goal is to," "we're aiming to," "we're trying to achieve"
---
## 20 Classified Examples
| # | Statement | Type | Key Signal |
|---|-----------|------|-----------|
| 1 | "Our present system can't cope with the throughput." | Implied Need | "can't cope with" |
| 2 | "I'm unhappy about wastage rates." | Implied Need | "unhappy about" |
| 3 | "We're not satisfied with the speed of our existing process." | Implied Need | "not satisfied with" |
| 4 | "Our approval workflow takes forever." | Implied Need | "takes forever" |
| 5 | "We've had compliance issues with our current data process." | Implied Need | "compliance issues" |
| 6 | "The reporting is really painful for our team." | Implied Need | "really painful" |
| 7 | "Our sales cycle is too long." | Implied Need | "too long" |
| 8 | "We lose visibility once a deal goes to legal." | Implied Need | "lose visibility" |
| 9 | "We need a faster system." | Explicit Need | "we need" |
| 10 | "What we're looking for is a more reliable machine." | Explicit Need | "looking for" |
| 11 | "I'd like to have a backup capability." | Explicit Need | "I'd like to have" |
| 12 | "We're going to overhaul our data network next year." | Explicit Need | "going to" |
| 13 | "We're actively evaluating vendors for a new platform." | Explicit Need | "actively evaluating" |
| 14 | "A key requirement for us is single sign-on." | Explicit Need | "key requirement" |
| 15 | "We want to reduce our time-to-hire by 30%." | Explicit Need | "we want to" |
| 16 | "Our goal is to consolidate onto one platform this year." | Explicit Need | "our goal is to" |
| 17 | "We have 200 users on the current system." | Neither | Factual background |
| 18 | "We've been with our current vendor for three years." | Neither | Neutral history |
| 19 | "Our IT team handles procurement decisions." | Neither | Background context |
| 20 | "We're in the financial services industry." | Neither | Factual classification |
---
## The Large-Sale Gate
**In small/transactional sales:** Implied Needs ARE reliable buying signals. More problems uncovered = higher probability of purchase.
**In large B2B sales:** Implied Needs are NOT buying signals. Analysis of 1,406 large-sale calls showed:
- No relationship between number of Implied Needs and call success
- Explicit Needs were 2x higher in successful calls vs unsuccessful calls
**Practical implication:** A prospect who agrees to three problems in a large-sale call has NOT indicated readiness to buy. A prospect who says "we're looking for a solution that does X" has given you a buying signal.
---
## Next Move by Type
| Type | Large Sale | Small Sale |
|------|-----------|-----------|
| Implied Need | Develop with Implication Questions (ask about consequences) → then convert with Need-payoff Questions | Present solution or ask Need-payoff Questions directly |
| Explicit Need | Capitalize: state a Benefit that links your capability to this specific want | Capitalize: state a Benefit |
| Neither | Gather context; use as Situation background | Use as background |
---
## Common Misclassification Patterns
**"The customer agreed to the problem" ≠ Explicit Need**
Saying "yes, that's a problem for us" confirms an Implied Need. It does NOT become Explicit until the customer says what they want.
**Strong emotion ≠ Explicit Need**
"That is a HUGE problem for our business!" is still Implied. Intensity of frustration does not change the type.
**Rhetorical questions ≠ Explicit Need**
"Don't you think we should have a better system?" is ambiguous — probe to confirm whether this is a genuine want.
**Conditional statements may be Explicit**
"If the cost were reasonable, we'd want to move on this." This contains an Explicit Need ("we'd want to move on this") with a condition. Classify as Explicit, note the condition.
Classify seller statements as Features, Advantages, or true Benefits using Rackham's strict FAB definitions. Use this skill to audit a pitch deck, sales emai...
---
name: fab-statement-classifier
description: "Classify seller statements as Features, Advantages, or true Benefits using Rackham's strict FAB definitions. Use this skill to audit a pitch deck, sales email, demo script, or call transcript for FAB distribution. Invoke when someone asks 'are these benefits or advantages?', 'is this a feature or a benefit?', 'review my sales deck for FAB', 'audit my pitch for Rackham FAB', 'classify these statements', 'are my slides benefit-focused?', 'why am I getting objections to my deck?', 'does this qualify as a benefit?', or 'I call these benefits but I'm not sure'. The skill enforces Rackham's empirically-derived definition: a statement is a Benefit ONLY if it meets a customer-expressed Explicit Need — not if it sounds helpful, not if it shows the product can help, and not if it meets a problem (Implied Need). In typical B2B sales content, 40-50% of statements labeled 'Benefits' by the seller are actually Advantages. This skill catches that. Also includes Rackham's 10-item FAB classification quiz for self-testing. Applies to B2B account executives auditing their own decks, sales managers reviewing team content, and marketers producing collateral for large-sale contexts."
version: 1.0.0
homepage: https://github.com/bookforge-ai/bookforge-skills/tree/main/books/spin-selling/skills/fab-statement-classifier
metadata: {"openclaw":{"emoji":"🔬","homepage":"https://github.com/bookforge-ai/bookforge-skills"}}
status: draft
source-books:
- id: spin-selling
title: "SPIN Selling"
authors: ["Neil Rackham"]
chapters: [5, 6]
tags: [sales, b2b-sales, enterprise-sales, fab-methodology, pitch-deck-review, content-audit, spin-methodology, objection-prevention]
depends-on:
- need-type-classifier
execution:
tier: 1
mode: hybrid
inputs:
- type: document
description: "Seller content to audit — pitch deck export (.md, .pdf, .txt), sales email, demo script, or call transcript with seller statements extracted"
- type: document
description: "Deal context (optional) — needs-log.md or deal-brief.md identifying Explicit Needs the customer has expressed"
tools-required: [Read, Write]
tools-optional: [Grep]
mcps-required: []
environment: "Document set: pitch-deck-{deal}.md, sales-email.md, demo-script.md, or call-transcript-{date}.md. Agent classifies and produces audit report; human acts on the coaching."
discovery:
goal: "Determine whether each seller statement is a Feature, Advantage, or true Benefit — and surface the dominant error: statements labeled Benefits that are actually Advantages"
tasks:
- "Classify each statement from provided seller content as Feature, Advantage, Benefit, or unclassifiable"
- "Flag the Advantage-as-Benefit anti-pattern when present"
- "Produce a count summary by FAB type"
- "Provide coaching guidance on converting Advantages into Benefits by first developing Explicit Needs"
- "Run interactive FAB practice using Rackham's 10-item quiz"
audience:
roles: [account-executive, sales-manager, solutions-consultant, marketer, founder-led-seller]
experience: intermediate
when_to_use:
triggers:
- "Before a major demo or proposal — check whether the deck has any true Benefits or only Advantages"
- "After a call with objections — diagnose whether the content generated them by offering Advantages"
- "Marketing reviewing collateral for large-sale context — most 'Benefits' in decks are Advantages"
- "Sales coaching — showing a rep why their 'benefits-focused' deck actually contains zero Benefits"
- "Self-training using the 10-item FAB quiz"
prerequisites:
- "need-type-classifier — needed to verify whether an Explicit Need is present before classifying as Benefit"
not_for:
- "Drafting new Benefit statements (use benefit-statement-drafter)"
- "Generating SPIN questions to develop Explicit Needs (use spin-discovery-question-planner)"
- "Diagnosing objection causes in full (use objection-source-diagnoser)"
- "Pricing strategy"
environment:
codebase_required: false
codebase_helpful: false
works_offline: true
quality:
scores:
with_skill: 0
baseline: 0
delta: 0
tested_at: ""
eval_count: 0
assertion_count: 0
iterations_needed: 0
what_skill_catches:
- "Marks aspirational claims ('so you can focus on what matters') as Advantages, not Benefits"
- "Correctly requires a prior customer-expressed Explicit Need before allowing Benefit classification"
- "Flags the industry-wide pattern of labeling Advantages as Benefits"
what_baseline_misses:
- "Calls any statement showing value a 'Benefit' without checking for an Explicit Need"
- "Treats implied needs ('they mentioned this problem') as sufficient basis for a Benefit"
- "Does not distinguish between Type A and Type B definitions or flag the naming confusion"
---
# FAB Statement Classifier
## When to Use
You have seller content — a pitch deck, sales email, demo script, or call transcript — and you need to know: are these statements Features, Advantages, or true Benefits?
This matters because the most common error in B2B sales content is calling Advantages "Benefits." Rackham's 5,000-call study found that Advantages (what most training calls "Benefits") had no statistically significant relationship to success in large sales — but true Benefits were strongly linked to Orders and Advances. The distinction is not semantic. It predicts whether your content will close deals or generate objections.
Use this skill:
- Before a major demo or proposal, to verify the content contains true Benefits (not just Advantages)
- After a call where you received value objections or "it's not worth it" responses — these are the customer response most correlated with Advantage-heavy selling
- When marketing reviews collateral for use in large-sale contexts
- When coaching a rep who claims their deck is "benefit-focused"
**Interactive practice mode:** If you want to self-test your FAB classification ability, ask the skill to run Rackham's 10-item quiz. The full quiz with answer key is in [references/fab-classification-quiz.md](references/fab-classification-quiz.md).
Do NOT use this skill to draft new Benefit statements (use `benefit-statement-drafter`) or to generate questions to develop needs (use `spin-discovery-question-planner`).
## Context & Input Gathering
### Required Context (must have — ask if missing)
- **Seller content to audit:** The actual statements you want classified
-> Check prompt for: pasted text, file path, or slide content
-> Check environment for: `pitch-deck-{deal}.md`, `sales-email.md`, `demo-script.md`, `call-transcript-{date}.md`
-> If missing, ask: "Paste or point me to the seller statements you want me to classify."
- **Sale context (large vs small):** Determines how strictly to apply the Benefit gate
-> Check prompt for: enterprise/SMB label, deal size, multi-stakeholder mention
-> Check environment for: `deal-brief.md`
-> If missing: default to large-sale context (conservative — prevents overstating Benefits)
### Observable Context (gather from environment)
- **Needs log or deal brief:** Explicit Needs the customer has expressed
-> Look for: `needs-log.md` from `need-type-classifier`, `deal-brief.md`
-> If available: use it to verify Benefit candidates — a statement meets an Explicit Need only if that need is documented as expressed by the customer
-> If unavailable: note the absence; any statement claiming to meet a need will be classified as Advantage unless the customer's Explicit Need is visible in the content itself (e.g., in a call transcript where both sides speak)
- **Mode check:** Is this an audit of existing content, or an interactive quiz?
-> Audit mode: read the provided content, classify each statement, produce `fab-audit-{artifact}.md`
-> Quiz mode: deliver the 10-item Rackham quiz, collect answers, give scored feedback
### Sufficiency Threshold
SUFFICIENT: Seller content + sale context (or default to large)
PROCEED WITH DEFAULTS: Content present, no needs log (classify all statements without confirmed Explicit Needs as Advantages with a note)
MUST ASK: No seller content provided AND no quiz mode requested
## Process
### Step 1: Establish the Classification Framework
**ACTION:** Before classifying any statement, confirm the three definitions below. Apply them exactly as written.
**WHY:** The entire value of this audit depends on using Rackham's strict definitions, not the common-usage definitions. Most sellers have been trained that "a Benefit is anything that shows how a feature can help the customer" — this is what Rackham calls an Advantage. If this step is skipped, every subsequent classification defaults to the casual definition and the audit produces no new information.
**The three definitions:**
**Feature** — A fact, data point, or characteristic about the product or service. Does not explain use or link to customer outcomes. Neutral in impact.
- Test: "Is this just describing what the product is or what it includes?" → Feature
**Advantage** — A statement showing how a product feature can be used or can help the customer. More than a Feature — links capability to potential value. BUT: does not meet a customer-expressed Explicit Need.
- This is what most sales training calls a "Benefit." It is not a Benefit in Rackham's taxonomy.
- Test: "Does this show how the product helps, but without a prior customer-expressed want?" → Advantage
**Benefit (Rackham's Type B)** — A statement showing how the product meets an Explicit Need that the customer has expressed. Both conditions required:
1. The customer stated a specific want, desire, or intention before this statement
2. This statement shows the product meets that specific expressed need
- Test: "Did the customer explicitly say they need/want this? AND does this statement show we can deliver it?" → Benefit
**Unclassifiable** — A statement that cannot be evaluated without more context (e.g., a question, a transition phrase, a non-product statement).
**Step 1 output:** Confirm the three definitions are loaded before proceeding.
### Step 2: Identify Explicit Needs in the Context
**ACTION:** Before classifying individual statements, scan the available context (needs-log, deal brief, or transcript) for any customer-expressed Explicit Needs. List them. These are the only valid anchors for Benefit classifications.
**WHY:** The classification of any statement as a Benefit requires a prior customer-expressed Explicit Need. Without identifying those first, there is no reference point — every statement defaults to Feature or Advantage. This step also prevents the common error of using Implied Needs (problems, difficulties) as Benefit anchors. If you need to verify whether a specific customer statement is an Explicit Need, invoke `need-type-classifier` or apply its definitions directly: an Explicit Need is a statement of want, desire, or intention ("we need X," "we're looking for Y," "I'd like Z") — not a statement of problem or dissatisfaction.
**IF** a `needs-log.md` exists from a prior `need-type-classifier` run → read it and extract the Explicit Needs column
**ELSE IF** the content being audited is a call transcript (both sides visible) → scan buyer statements for Explicit Need language
**ELSE** → note that no confirmed Explicit Needs are available; proceed with the understanding that Benefit classifications will be rare or absent
**Step 2 output:** A short list of confirmed Explicit Needs available in context (may be empty).
### Step 3: Classify Each Statement
**ACTION:** For each seller statement in the provided content, apply the definitions from Step 1, anchoring to the Explicit Needs from Step 2. Produce a per-statement classification table.
**WHY:** Showing the classification evidence per statement makes the output auditable. The seller needs to see exactly which words triggered each classification — especially for statements they labeled Benefits that are actually Advantages. Without this granularity, coaching is abstract; with it, the seller can rewrite specific statements.
**For each statement, output:**
1. Statement (quoted or paraphrased if long)
2. Classification: Feature / Advantage / Benefit / Unclassifiable
3. Explicit Need present: Yes (quote it) / No
4. Rationale: one sentence explaining the classification
**The dominant pattern to flag:** Statements containing phrases like "so you can," "which means you'll," "helping your team," "giving you the ability to," "enabling you to" are structural markers of Advantages. They show use or help — but unless paired with a prior customer-expressed want, they are Advantages, not Benefits. Flag these explicitly when the seller has labeled them Benefits.
**Step 3 output:** Classification table, one row per statement.
### Step 4: Produce the Count Summary and Anti-Pattern Flag
**ACTION:** Summarize the classification results. Count Features, Advantages, Benefits, and Unclassifiable. Flag the Advantage-as-Benefit anti-pattern if present.
**WHY:** The count summary makes the FAB distribution visible at a glance. Most sellers are surprised by how few true Benefits their content contains. A deck with 15 statements that the seller calls "benefit-focused" typically contains 0-2 true Benefits, 8-10 Advantages, and 4-5 Features. Showing this quantitatively is more persuasive than qualitative criticism.
**Count summary format:**
- Total statements audited: N
- Features: N (N%)
- Advantages: N (N%)
- **True Benefits (Rackham): N (N%)**
- Unclassifiable: N (N%)
**Advantage-as-Benefit flag:** If any statement was labeled a "Benefit" by the seller (in the deck or script) but classified as an Advantage by this audit, call this out explicitly:
> "WARNING: N statements labeled 'Benefits' in this content are Advantages by Rackham's definition. They show how the product can help but do not meet a customer-expressed Explicit Need. In large sales (5,000-call study), Advantages have no statistically significant relationship to call success. True Benefits require a prior Explicit Need from the customer."
**Step 4 output:** Count summary + anti-pattern flag (if applicable).
### Step 5: Coaching — Converting Advantages to Benefits
**ACTION:** For each statement classified as Advantage, provide a one-line coaching note: what Explicit Need the customer would need to express before this Advantage could become a Benefit, and how to develop that need.
**WHY:** The audit is only useful if it tells the seller what to do next. "This is an Advantage, not a Benefit" is a diagnosis. The prescription — "here's the Explicit Need you'd need to develop before presenting this" — is what makes the content actionable. Without this step, the seller knows their deck is weak but not how to fix it.
**Coaching note format (per Advantage):**
> Statement: "[statement]"
> To become a Benefit, your customer would need to have said something like: "[example Explicit Need in customer's words]"
> How to develop it: Ask [Problem Question to surface Implied Need] → [Implication Question to build consequence] → [Need-payoff Question to get the customer to articulate the want]. Then present this capability as directly meeting their stated need.
**Step 5 output:** Per-Advantage coaching notes.
### Step 6: Write the Audit Report
**ACTION:** Compile Steps 3-5 into a single structured report. Write it to `fab-audit-{artifact}.md` (e.g., `fab-audit-q2-deck.md`, `fab-audit-discovery-email.md`).
**WHY:** A written report persists across the conversation and can be shared with the team. It also feeds downstream skills: `benefit-statement-drafter` reads a needs log and the audit to draft Benefits for the confirmed Explicit Needs; `objection-source-diagnoser` reads the audit to trace objections back to FAB source.
**Report structure:**
```
# FAB Audit — {Content Name} — {Date}
## Sale Context
{Large / Small / Unknown}
## Explicit Needs Available
{List, or "None confirmed — all Benefits require Explicit Need development first"}
## Statement-by-Statement Classification
| # | Statement | Classification | Explicit Need Present | Rationale |
|---|---|---|---|---|
## FAB Distribution Summary
- Features: N (N%)
- Advantages: N (N%)
- True Benefits: N (N%)
## Advantage-as-Benefit Flag
{If applicable: which statements were miscategorized, and why it matters}
## Coaching: Converting Advantages to Benefits
{Per-Advantage coaching notes}
## Recommended Next Step
{One paragraph: what the seller should do before the next call/send}
```
### Optional: Interactive Practice Mode
**ACTION:** If the user asks to test their own FAB classification ability, deliver Rackham's 10-item quiz from [references/fab-classification-quiz.md](references/fab-classification-quiz.md). Present all 10 statements, collect the user's answers, then score and explain each one.
**WHY:** The quiz uses a real selling dialogue with deliberate classification traps (especially #7 and #10, which sound like Benefits but are Advantages). Users who pass it — especially #7 — have internalized the Type A/Type B distinction. The quiz also grounds future audits in shared vocabulary.
## Key Principles
- **A Benefit requires customer speech first.** No matter how aspirational, customer-focused, or value-oriented a statement sounds, it is an Advantage unless the customer has expressed the underlying want in their own words before the statement was made. Pitch decks, by their nature, cannot contain true Benefits — they are written before the customer has spoken. They contain Features and Advantages at best.
- **The naming confusion is the problem.** Decades of sales training have used the word "Benefit" to mean what Rackham calls an Advantage. Sellers who were well-trained in "Feature-Benefit selling" are actually Feature-Advantage sellers. Rackham's 5,000-call study showed that what most people call Benefits have no statistical relationship to large-sale success. True Benefits — those meeting customer-expressed Explicit Needs — are strongly correlated with Orders and Advances.
- **Advantages create objections in large sales.** Linda Marsh's correlation study showed that Advantages are the most probable source of customer objections. When a seller presents a capability the customer hasn't asked for, the customer evaluates whether it's worth the cost — and in large sales, "not worth it" is a likely response. This is why objection-heavy calls are typically Advantage-heavy calls.
- **More Features → more price sensitivity.** From the 18,000-call Features study: heavy use of Features increases customer sensitivity to price. In expensive products, this works against the seller. When classified Features are high, advise shifting from description to need development.
- **Development precedes Benefits.** Rackham's advice: "Do a good job of developing Explicit Needs and the Benefits almost look after themselves." You cannot manufacture Benefits from content alone — you develop them from customer conversations. The audit's purpose is to show what's missing and guide what needs to be developed before the content can be benefit-loaded.
- **Stay in scope.** This skill classifies existing content. It does not draft new statements (use `benefit-statement-drafter`), generate questions to develop needs (use `spin-discovery-question-planner`), or diagnose objections in depth (use `objection-source-diagnoser`). When the audit points to a development gap, name the right skill.
## Examples
**Scenario: Pitch deck audit before a major enterprise demo**
Trigger: AE says: "I'm presenting a 15-slide deck tomorrow. Can you check if it's benefit-focused?"
Process:
- (Step 1) Load FAB definitions.
- (Step 2) Read needs-log.md — 2 Explicit Needs documented: "We need to cut monthly close from 5 days to 2" and "We need a single dashboard for all 3 regions."
- (Step 3) Classify all 15 slides' key statements. Find: 6 Features (product specs, pricing, company stats), 8 Advantages ("helps your team move faster," "gives you complete visibility," "reduces guesswork"), 1 Benefit (the single-dashboard claim meets the stated Explicit Need), 0 unclassifiable.
- (Step 4) Count: 40% Features, 53% Advantages, 7% Benefits. Flag: 8 statements the seller labeled "Benefits" in the deck are Advantages.
- (Step 5) For each of the 8 Advantages, provide a coaching note on what Explicit Need to develop.
- (Step 6) Write `fab-audit-q2-demo-deck.md`.
Output: Audit report showing 1 confirmed Benefit, 8 Advantages mislabeled as Benefits, 6 Features. Recommendation: develop 1-2 more Explicit Needs on the pre-call before tomorrow — at minimum, convert the "monthly close" Advantage into a Benefit by confirming the need directly.
---
**Scenario: Sales email review**
Trigger: SDR asks: "Is this prospecting email too feature-heavy? I'm not getting replies."
Process:
- (Step 1) Load definitions.
- (Step 2) No needs log — cold outreach, no prior customer speech. Note: no Explicit Needs available → no Benefits possible.
- (Step 3) Read the email. Find: 3 Features (product specs), 4 Advantages ("so you can close deals faster," "giving your team complete pipeline visibility," "cutting your reporting time by 50%"), 0 Benefits (no customer has spoken).
- (Step 4) Count: 43% Features, 57% Advantages, 0% Benefits. Flag: a cold email structurally cannot contain Benefits — the customer hasn't spoken yet. All value claims are Advantages.
- (Step 5) Coach: the email is Advantage-heavy, which in cold outreach is normal but not maximally effective. Recommend opening with a Problem Question or a specific Implied Need trigger ("If your team is finding that...") to earn a reply, then developing needs in the first call before giving Benefits.
Output: `fab-audit-prospect-email.md`. Coaching: shift the email to problem-focused language (what problem do they probably have?) rather than solution-focused language (what can our product do?). Follow up with `spin-discovery-question-planner` for the first call.
---
**Scenario: Interactive quiz practice**
Trigger: "I want to test my FAB classification. Give me the quiz."
Process: Present the 10-item dialogue from [references/fab-classification-quiz.md](references/fab-classification-quiz.md). Collect answers. Score. For any misclassified items, explain the rule that applies — especially items 7 and 10, which are the traps (they sound like Benefits, but no Explicit Need was expressed).
Output: Score (X/10). Detailed feedback on any misses. Particular emphasis if the user called #7 or #10 a Benefit — these are the most common errors and map directly to the Advantage-as-Benefit anti-pattern in real sales content.
## References
- [fab-classification-quiz.md](references/fab-classification-quiz.md) — Rackham's 10-item FAB classification exercise with full answer key and scoring guide
- [fab-definitions-and-edge-cases.md](references/fab-definitions-and-edge-cases.md) — Complete FAB definitions, research findings, edge cases, and the FAB-to-customer-response chain (Features → price concerns; Advantages → objections; Benefits → approval)
## License
This skill is licensed under [CC-BY-SA-4.0](https://creativecommons.org/licenses/by-sa/4.0/).
Source: [BookForge](https://github.com/bookforge-ai/bookforge-skills) — SPIN Selling by Neil Rackham.
## Related BookForge Skills
This skill depends on:
- `clawhub install bookforge-need-type-classifier` — Classify customer statements as Implied or Explicit Needs (needed to verify whether an Explicit Need exists before classifying a statement as a Benefit)
Skills that build on this one:
- `clawhub install bookforge-benefit-statement-drafter` — Draft Benefit statements that link product capabilities to customer-expressed Explicit Needs (the constructive complement to this audit skill)
- `clawhub install bookforge-objection-source-diagnoser` — Trace objections back to their FAB-behavior root cause (Feature → price concerns; Advantage → value objections)
Or install the full SPIN Selling skill set from GitHub: [bookforge-skills](https://github.com/bookforge-ai/bookforge-skills)
FILE:references/fab-classification-quiz.md
# FAB Classification Quiz — Rackham's 10-Item Exercise
This quiz is drawn from Chapter 5 ("Giving Benefits in Major Sales") of SPIN Selling by Neil Rackham. It is a verbatim reconstruction of the exercise Rackham uses to test readers' understanding of Features, Advantages, and Benefits before they examine the research data.
**Instructions:** Read the dialogue below. Ten numbered statements are made by the seller (marked with italic in the original text). For each one, decide whether it is a Feature, Advantage, or Benefit — then check your answers against the answer key at the bottom of this file.
---
## The Dialogue
The product being sold is a computer system. Statements by the buyer are included to provide context for classification.
**BUYER:** We've been having some power-related quality problems with our data. Is that something your system handles?
**SELLER (1):** Our system includes balanced voltage stabilization.
**SELLER (2):** This means that if you ever have power fluctuations, the stabilization protects your data integrity automatically.
**SELLER (3):** We also have a backup memory capability, so if the power does fail, your data won't be lost.
**BUYER:** What's the cost on a system like this?
**SELLER (4):** The base configuration costs $42,000.
**BUYER:** That's in range. I need to be able to read source data straight into memory — we're doing a lot of imports from external files.
**SELLER (5):** Our direct memory access module handles exactly that — it lets you read source data straight into memory, which is what you said you need.
**BUYER:** We also can't have errors in those imported data sets. Our tolerance is less than 1 error in 100,000 characters.
**SELLER (6):** Our system has been certified to an error rate of fewer than 1 in 500,000 characters — well within the error tolerance you've described.
**SELLER (7):** Beyond accuracy, the low error rate also means your operators spend less time on data validation, so overall throughput improves.
**BUYER:** I see. What about data access speed?
**SELLER (8):** The system uses a 32-bit processor with a 512K buffer.
**SELLER (9):** The standard configuration includes 256MB RAM and dual I/O channels.
**SELLER (10):** We also have time-based coding, which means that every data record is automatically time-stamped, so you can track exactly when any entry was made.
---
## Answer Key
*(Rackham's answers, paraphrased from the original text)*
**1. Feature.** Balanced voltage stabilization is a fact about the system. The statement does not explain how stabilization can be used or can help the customer. No context about customer need is present.
**2. Advantage.** This statement shows how the Feature in statement 1 can be used or can help the customer — protecting data during power fluctuations. However, it is NOT a Benefit because the customer has not expressed an Explicit Need for stabilization. The customer mentioned quality problems, but that is an Implied Need, not an Explicit one.
**3. Advantage.** The statement shows how backup memory can be used or help the customer. But because there is no evidence the customer has expressed an Explicit Need for backup memory, it cannot be classified as a Benefit.
**4. Feature.** Statements of cost are facts or data about the product. They are Features regardless of whether they are positive or negative facts.
**5. Benefit.** In the preceding statement the customer expressed an Explicit Need: *"I need to be able to read source data straight into memory."* The seller's statement shows how the product meets that Explicit Need directly. This is a true Benefit (Rackham's Type B).
**6. Benefit.** The buyer stated an Explicit Need: an error rate less than 1 in 100,000. The seller shows that the product easily meets that need (1 in 500,000). Explicit Need present → Benefit.
**7. Advantage.** The seller shows another way in which having a low error rate can help the customer (less validation time, better throughput). However, as the next buyer statement shows, the customer has not expressed an Explicit Need for throughput improvement. Benefit-sounding language does not make something a Benefit.
**8. Feature.** A piece of data about the product's processor and buffer. No explanation of how it helps the customer.
**9. Feature.** Further product specifications. Facts about the configuration.
**10. Advantage.** The seller shows how the Feature of time-based coding can be used to help the customer (tracking when entries were made). But the customer has not expressed an Explicit Need for record tracking — so it remains an Advantage.
---
## Score Summary
| # | Classification | Key Rule Illustrated |
|---|---|---|
| 1 | Feature | Neutral fact, no explanation of use |
| 2 | Advantage | Shows use but no Explicit Need expressed |
| 3 | Advantage | Shows use but no Explicit Need expressed |
| 4 | Feature | Cost statements are Features |
| 5 | **Benefit** | Explicit Need stated before → this meets it |
| 6 | **Benefit** | Explicit Need stated before → this meets it |
| 7 | Advantage | Sounds useful but no Explicit Need for it |
| 8 | Feature | Raw specification |
| 9 | Feature | Raw specification |
| 10 | Advantage | Shows use but no Explicit Need for tracking |
**Distribution in this exercise:** 4 Features, 4 Advantages, 2 Benefits (20%)
This distribution is intentional. Rackham's research on typical sales calls found similar ratios — most sellers give far more Features and Advantages than true Benefits. The quiz makes the "Advantage-as-Benefit" confusion vivid by including statements (#7, #10) that sound like Benefits but lack the required Explicit Need.
---
## The Core Rule to Memorize
A statement is a **Benefit** if and only if:
1. The **customer has expressed** a specific want, desire, or intention (an Explicit Need) — in *their* words, before the statement was made
2. The seller's statement directly **shows that the product meets** that specific expressed need
If either condition is missing, the statement is an Advantage at best, a Feature at worst.
"If you can get your customers to say, 'I want it,' it's not difficult to make a Benefit by replying, 'We can give it to you.'" — Neil Rackham
---
*Source: SPIN Selling by Neil Rackham (1988), Chapter 5. This reference is part of the BookForge skill set for SPIN Selling — licensed CC-BY-SA-4.0.*
FILE:references/fab-definitions-and-edge-cases.md
# FAB Definitions and Edge Cases
This reference supplements the `fab-statement-classifier` skill. It provides the full definitions for Features, Advantages, and Benefits as derived from Rackham's SPIN Selling research, with edge cases that commonly trip up classifiers.
---
## Authoritative Definitions
### Feature
**What it is:** A neutral fact, data point, or characteristic about a product or service. Features do not explain use; they do not connect to customer outcomes.
**Research finding:** From analysis of 18,000 sales calls, Features are neutral overall — they neither help nor harm significantly. In large sales, Features used *early* in the call have a slight negative effect. Late in long selling cycles, technically sophisticated buyers may develop a "Features appetite" and respond positively to product detail — but this is an exception.
**Classification test:** Does the statement describe a product characteristic without explaining how it helps? → Feature.
**Examples:**
- "This system has 512K buffer storage."
- "Our consultants have a background in educational psychology."
- "The base configuration is $42,000."
- "It runs on a 32-bit processor."
- "There is a four-stage exposure control."
**Price sensitivity note:** Heavy use of Features increases the customer's sensitivity to price. For low-cost products this works in the seller's favor. For expensive products, it works against them — causing more price objections.
---
### Advantage (Rackham's "Type A Benefit")
**What it is:** A statement showing how a product feature *can be used* or *can help* the customer. More than a Feature — it links the capability to potential customer value. However, it does NOT meet a customer-expressed Explicit Need.
**The naming problem:** Most sales training (including the majority of programs over the past 60 years) teaches sellers to give "Benefits." What those programs actually describe and teach are Advantages in Rackham's taxonomy. The term "Benefit" has been so badly degraded in common usage that it is near-meaningless. Rackham's distinction is what separates his research from the consensus.
**Research finding:** From the 5,000-call Benefits study, Advantages had **no statistically significant relationship** to call success in large sales. In small sales, they have a moderate positive relationship. Early in a multi-call selling cycle, Advantages have some positive effect (they "run out of steam" as the cycle progresses). They create objections at higher rates than Benefits — Linda Marsh's correlation study showed that Advantages are the primary source of customer objections.
**Classification test:** Does the statement show how the product can help or be used, but without a prior customer-expressed Explicit Need? → Advantage.
**Examples:**
- "This means you won't lose data during power failures." (no Explicit Need for backup expressed)
- "Our system would eliminate that retyping for you." (only an Implied Need for less retyping expressed)
- "The low error rate means your operators spend less time on validation." (no Explicit Need for validation reduction stated)
- "Time-based coding means you can track when any entry was made." (no Explicit Need for tracking stated)
- "This would speed up your workflow significantly." (general capability claim, no specific Explicit Need stated)
**The Advantage-as-Benefit anti-pattern:** This is the single most prevalent error in B2B sales content. Sellers (and marketers) write statements that sound helpful, aspirational, and customer-facing — and label them "Benefits" — but no customer has ever expressed the underlying want. Examples include:
- "So you can focus on what matters most" (common deck filler — sounds beneficial, meets no stated need)
- "Giving your team the visibility they need" (who asked for visibility? no one, in this context)
- "Eliminating the guesswork from your process" (guesswork was described as a problem = Implied Need at best)
---
### Benefit (Rackham's "Type B Benefit")
**What it is:** A statement showing how the product meets an Explicit Need that the customer has expressed. Two conditions, both required:
1. The customer expressed a specific want, desire, or intention **before** the statement was made
2. The seller's statement **directly links the product capability to that specific expressed need**
**Research finding:** From the 5,000-call study, Benefits (Type B) were significantly higher in calls leading to Orders and Advances. Benefits are the only product statement type strongly linked to success in ALL sizes of sales. The Motorola Canada controlled study showed salespeople trained to use Benefits instead of Advantages increased dollar sales volume by 27%.
**Structural pattern:** A Benefit almost always has two components:
- A reference to what the customer said ("Since you need X..." / "You mentioned you want Y...")
- A demonstration that the product delivers it ("...our product does exactly that")
**Classification test:** Did the customer express this specific want in their own words before this statement? AND does this statement show the product meets that exact expressed want? → Benefit.
**Examples:**
- Customer: "I need to read source data straight into memory." → Seller: "Our direct memory access module does exactly that." → **Benefit**
- Customer: "We need error rates under 1 in 100,000." → Seller: "We're certified at 1 in 500,000, which meets your requirement." → **Benefit**
- Customer: "We'd like a single dashboard for all three regions." → Seller: "Our unified reporting module shows all regions in one view." → **Benefit**
**The development dependency:** You cannot give a true Benefit without first developing an Explicit Need. This is why Benefits cannot be "written in advance" without customer context. A pitch deck can contain Features and Advantages. It cannot contain Benefits — because Benefits require the customer to have spoken first. This is why pre-call deck audits typically find 0% true Benefits and why Rackham's training advice is: "Do a good job of developing Explicit Needs and the Benefits almost look after themselves."
---
## Edge Cases and Common Misclassifications
### Edge Case 1: "They mentioned a problem, then I showed the solution"
Situation: Customer says "Our reporting takes 2 days." Seller says: "Our system cuts reporting time to 2 hours."
**Classification: Advantage, NOT Benefit.**
Reason: "Takes 2 days" is an Implied Need — a problem statement, a dissatisfaction. The customer described a pain, not a want. To be a Benefit, the customer must have said "We need faster reporting" or "We want to get reporting down to under a day" (Explicit Need). Showing a solution to an Implied Need = Advantage.
To convert: Use Implication Questions to develop the consequences of the 2-day delay, then Need-payoff Questions to get the customer to articulate "We need reporting in under a few hours." Once they say it → you can give a Benefit.
---
### Edge Case 2: "I'm just assuming they want this"
Situation: Seller says "Since you're probably looking for something that scales with your business, our platform supports 10x growth without re-architecting."
**Classification: Advantage.**
Reason: "Probably looking for" is the seller's assumption. No Explicit Need for scalability was expressed. Even if this assumption turns out to be correct, the statement does not meet a *stated* need — it meets an *inferred* one.
---
### Edge Case 3: "The customer nodded and seemed interested when I mentioned it"
Buyer engagement, enthusiasm, or non-verbal cues do not convert an Advantage into a Benefit. Classification is based on words expressed, not inferred intent. A customer who seems interested in a capability they haven't asked for is responding to an Advantage. If they then say "Yes, that's exactly what we've been looking for," that statement IS an Explicit Need — and the next product statement you make that addresses it can be a Benefit.
---
### Edge Case 4: "We always include this in our deck as a Benefit"
Common in marketing-produced collateral. Statements like "Reduce costs by 30%" or "Cut time-to-insight in half" are Advantages — they show the product can help, but they do not meet an expressed Explicit Need. No prospect's Explicit Need is present in a deck that hasn't been customized to a specific buyer's stated requirements.
Exception: If you are auditing a deck against a specific deal's `needs-log.md` and a matching Explicit Need is documented, you can reclassify the statement as a Benefit in that context. Without that deal-specific Explicit Need, classify as Advantage.
---
### Edge Case 5: "The customer said they want to improve efficiency"
"Improve efficiency" is usually still vague enough to be borderline. If the customer said "We want to improve efficiency" with no specifics, it is a very general Explicit Need. A matching Benefit would need to show how the product improves efficiency in the specific way the customer described. If the product statement is specific and the customer's Explicit Need is general, look for closer matches.
**Rule of thumb:** The more specific the Explicit Need, the more clearly a matching product statement qualifies as a Benefit. "We need to cut our monthly close process from 5 days to 2" → a statement showing the product achieves this = unambiguous Benefit.
---
### Edge Case 6: "A cost fact followed by context"
"Our system costs $42,000 — which is 30% less than the industry average" — the first part is a Feature (cost fact), the second part is an Advantage (shows comparative value). If the customer had said "We need something under $50,000" before this, then "our system at $42,000 meets your budget" would be a Benefit.
---
## FAB Distribution Norms
From Rackham's research on typical sales calls:
| Behavior | Typical distribution | Impact in large sales |
|---|---|---|
| Features | ~40-50% of product statements | Neutral to mildly negative; creates price sensitivity |
| Advantages | ~40-50% of product statements | Not linked to success; creates objections |
| Benefits | ~5-15% of product statements | Strongly linked to success; generates approval |
A typical pitch deck audited against this framework will show 0-10% true Benefits, 40-60% Advantages, and 30-50% Features. This is normal — not a failure of the sales team, but a structural limitation of content created without specific deal context. The audit's purpose is to surface this gap and guide the seller on which Advantages to convert into Benefits before the next call.
---
## The FAB → Customer Response Chain (Chapter 6)
Rackham's colleague Linda Marsh identified the most probable customer responses to each type:
| Seller uses | Most probable customer response |
|---|---|
| Features | Price concerns / price sensitivity |
| Advantages | Objections (value challenges, "not worth it," "don't need that") |
| Benefits | Support, approval, positive buying statements |
This chain explains why objection-heavy calls are usually high-Advantage calls: the seller is offering solutions before the customer has expressed a want, so the customer raises the value question themselves. The prescription is not better objection-handling techniques — it is better need development so that product statements become Benefits rather than Advantages.
---
*Source: SPIN Selling by Neil Rackham (1988), Chapters 5–6. Part of the BookForge SPIN Selling skill set — licensed CC-BY-SA-4.0.*
Plan a discovery call opening that earns the right to ask questions — without leading with product details or personal rapport fishing. Use this skill whenev...
---
name: discovery-call-opening-planner
description: "Plan a discovery call opening that earns the right to ask questions — without leading with product details or personal rapport fishing. Use this skill whenever a B2B rep is preparing for a sales call and needs to know what to say in the first 60-90 seconds, when someone asks 'how should I open this call?', 'what should I say first in my meeting with [company]?', 'how do I start a discovery call with a senior executive?', 'draft an opening for my call tomorrow', 'what's the best way to open a follow-up call?', or 'how do I avoid the awkward opener?'. This skill applies Rackham's empirically-validated framework for call openings in major sales: establish who you are, state why you're there (without product details), and earn the buyer's consent to ask questions. It explicitly prevents the two conventionally taught but research-debunked patterns: personal rapport fishing ('talk about the yacht photo on their wall') and opening benefit statements ('I'm here to show you how X saves you 30%'). Output is a call-prep script and checkpoint review the rep can read 5 minutes before the meeting. Invoke whenever any sales call opening needs to be planned, scripted, reviewed, or adapted to a specific call context."
version: 1.0.0
homepage: https://github.com/bookforge-ai/bookforge-skills/tree/main/books/spin-selling/skills/discovery-call-opening-planner
metadata: {"openclaw":{"emoji":"🎤","homepage":"https://github.com/bookforge-ai/bookforge-skills"}}
status: draft
source-books:
- id: spin-selling
title: "SPIN Selling"
authors: ["Neil Rackham"]
chapters: [7]
domain: b2b-sales
tags: [sales, b2b-sales, enterprise-sales, discovery, call-opening, spin-methodology, call-preparation, meeting-prep]
depends-on: []
execution:
tier: 1
mode: plan-only
inputs:
- type: document
description: "Deal brief or call context: account name, contact name/role, call objective, whether this is a new account or follow-up. A deal-brief.md file is ideal; a brief verbal description is sufficient."
tools-required: [Read, Write]
tools-optional: [Grep]
mcps-required: []
environment: "Document set: deal-brief.md, account-research.md, previous call-notes (if follow-up). Agent produces call-opening-{date}.md. Human delivers the opening."
discovery:
goal: "Produce a 60-90 second opening script that establishes who you are, why you're there (without product details), and earns the buyer's consent to ask questions — avoiding the two debunked conventional patterns"
tasks:
- "Draft a 60-90 second opening script for a specific call (new account, follow-up, senior buyer, multi-stakeholder)"
- "Apply the 3-point objective framework: who I am / why I'm here / right to ask questions"
- "Review an existing opening plan against the 3 structural checkpoints"
- "Adapt a generic opening to a specific buyer role (C-suite, procurement, technical buyer)"
- "Identify whether a planned opening falls into the two debunked patterns and replace it"
audience:
roles: [account-executive, enterprise-sales-rep, sdr, solutions-consultant, founder-led-seller]
experience: intermediate
when_to_use:
triggers:
- "Rep is preparing for a first meeting with a new account"
- "Rep is preparing for a follow-up call and wants to open purposefully rather than just recapping last time"
- "Rep is meeting a senior executive and unsure how to open without wasting their time"
- "Rep has been taught to open with rapport-building or benefit statements and wants an evidence-based alternative"
- "Manager is coaching a rep on call openings"
prerequisites: []
not_for:
- "The SPIN questions themselves (use spin-discovery-question-planner)"
- "Deciding what advance to target at end of the call (use commitment-and-advance-planner)"
- "Cold outreach or prospecting (this skill assumes the meeting is already booked)"
- "Demo or proposal scripting"
- "Negotiation tactics (see Never Split the Difference skills)"
environment:
codebase_required: false
codebase_helpful: false
works_offline: true
quality:
scores:
with_skill: 0
baseline: 0
delta: 0
tested_at: ""
eval_count: 0
assertion_count: 0
iterations_needed: 0
what_skill_catches:
- "Output that opens with product/solution details rather than earning permission to ask questions"
- "Personal rapport fishing (family photo, sports trophy, weekend small talk)"
- "Opening benefit statements that trap the seller into product details before needs are established"
- "Openings that spend more than 20% of estimated call time on pleasantries"
what_baseline_misses:
- "Recommends personal rapport (family, sports, shared interests) as an opening tactic"
- "Recommends 'hook them with a benefit' as an opening technique"
- "Does not establish the seller's role as questioner / investigator before discovery begins"
- "Does not account for buyer seniority, new vs follow-up context, or multi-stakeholder dynamics"
---
# Discovery Call Opening Planner
## When to Use
You are preparing for a B2B sales call — first meeting or follow-up — and need a concrete plan for the first 60-90 seconds. You want to open in a way that earns the buyer's engagement and consent to be asked questions, rather than launching into product details or small talk.
This skill is for major sales contexts: high-value deals, multiple stakeholders, long cycles. If you are running a transactional or retail sale, the opening matters less and conventional techniques may suffice.
Use this skill:
- Before any discovery call to prepare a purposeful, crisp opening
- When adapting your opening for a senior executive, procurement buyer, or technical evaluator
- When coaching a rep whose calls stall because the buyer takes over the questioning role immediately
- When you have been trained to open with benefit statements or rapport-building and want a research-backed alternative
Do NOT use this skill to plan the SPIN questions themselves (use `spin-discovery-question-planner`), to decide what advance to target at end of the call (use `commitment-and-advance-planner`), or to script a cold outreach.
## Context & Input Gathering
### Required Context (must have — ask if missing)
- **Account and contact:** Who is this call with? Company name, contact name, their role/seniority.
-> Check for: `deal-brief.md` (company, contact, deal size, stage)
-> If missing, ask: "Who is the call with — company name, contact name, and their role?"
- **Call type:** Is this a first meeting with a new account, or a follow-up with an existing contact?
-> Check for: `deal-brief.md` deal stage, or previous `call-notes-{date}.md`
-> If missing, ask: "Is this your first meeting with this person, or have you spoken before?"
- **Call objective:** What do you need to accomplish? What would constitute a successful advance from this call?
-> Check for: `deal-brief.md` objective field, or `next-call-plan.md`
-> If missing, ask: "What's the goal of this call? What specific outcome would make it a success?"
### Observable Context (gather from environment)
- **Buyer seniority:** C-suite, VP, director, manager, technical buyer, procurement
-> Read from `deal-brief.md` or `stakeholder-map.md`
-> Senior buyers are more time-sensitive; the opening must get to business even faster
- **Previous call notes:** What was discussed last time? Any commitments made?
-> Look for `call-notes-{date}.md` — reference the last agreed topic or question to re-establish context in a follow-up
- **Multi-stakeholder situation:** Are there multiple people on this call?
-> Look for `stakeholder-map.md` — if so, the opening must address the group, not just one person
### Default Assumptions
- Default to treating this as a large sale requiring careful opening. Overshooting rigor costs little; under-preparing costs deals.
- Default to first-meeting format if call type is ambiguous — it is the more constrained case.
### Sufficiency Threshold
SUFFICIENT: Account/contact name + call type (new vs follow-up) + call objective
PROCEED WITH DEFAULTS: Objective unknown — assume "earn the right to ask discovery questions"
MUST ASK: No account or contact information at all
## Process
### Step 1: Gather and Confirm Call Context
**ACTION:** Read the available context files (`deal-brief.md`, `account-research.md`, previous call notes if follow-up). Confirm the four context variables: (1) buyer name and role, (2) call type, (3) call objective, (4) buyer seniority level. If any are missing, ask before proceeding.
**WHY:** The opening script is a short, high-leverage piece of communication. Every word is visible to the buyer. An opening drafted without knowing whether you're speaking to a VP of Engineering vs a procurement manager, or whether this is a first call or a fourth, will feel generic and erode trust. Senior buyers in particular read imprecision as wasted time.
### Step 2: Check for the Two Debunked Patterns
**ACTION:** Before drafting, check whether any existing opening notes, phrases, or habits from the user contain either of the two patterns that research shows do NOT improve major-sale call success:
**Pattern 1 — Personal rapport fishing:** Opening by referencing something from the buyer's personal life (family photos, sports trophies, hobbies, weekend activities) to build relationship before getting to business.
**Pattern 2 — Opening benefit statement:** Opening with a statement about what your product can do for the buyer ("I'm here to show you how X reduces your costs by Y%") before any needs have been established.
**WHY:** Both patterns are explicitly taught by conventional sales training and both are debunked by Rackham's research. Personal rapport fishing fails for two reasons: in large urban/enterprise accounts (vs small rural accounts), there is no relationship between personal references and sales success; and senior professional buyers often resent it. The BP buyer's yacht-photo example illustrates the trap — a professional buyer kept a yacht photo specifically to waste the time of reps who fished for personal openers, responding "I hate sailing. What did you want to see me about?" Opening benefit statements fail because they trigger the buyer to ask product questions before you have established any needs — the seller gets drawn into product details, pricing, and objections before they have earned the right to ask a single discovery question.
**IF** the user's existing plan contains either pattern -> flag it explicitly and replace it in Step 4.
**ELSE** -> proceed to Step 3.
### Step 3: Apply the 3-Point Opening Objective
**ACTION:** Structure the opening around the three objectives Rackham's research identifies for effective call openings in large sales:
1. **Who you are** — Establish your name and role clearly. In follow-up calls, briefly re-establish who you are and what your company does if the buyer may not remember from a previous call (especially true with senior executives who have many vendor conversations).
2. **Why you're there** — State the purpose of this call in terms of what you want to learn or understand, NOT in terms of what your product does. The purpose framing should be investigative, not promotional. "I'd like to understand how you're currently handling X and whether there are areas where we might be relevant" — not "I'm here to show you how we can solve Y."
3. **Right to ask questions** — Explicitly or implicitly earn the buyer's consent to be in the asking role. This is the pivot that gets you into the Investigating stage. The buyer should leave the opening understanding that you are going to ask them questions — not that you are about to deliver a presentation.
**WHY:** These three objectives form the minimum functional unit of a call opening in major sales. The goal of the Preliminaries stage is simply to get the customer's consent to move to the Investigating stage — nothing more. The common mistake is treating the opening as an opportunity to make an impression or establish credibility through product knowledge. The opening is not the place for that. The durable impression is made during the Investigating stage, when the quality of your questions demonstrates competence.
### Step 4: Draft the Opening Script
**ACTION:** Write a 60-90 second opening script (approximately 120-180 words when spoken at a normal pace) tailored to the specific call context. Format as a draft the user can read, internalize, and adapt — not a rigid script to be read verbatim.
**Adapt based on call type:**
**New account / first meeting:**
```
"[Name], thanks for making the time. I'm [Your Name] from [Company] — [one sentence: what your company does at the category level, not product level]. The reason I wanted to speak is [framing as a question or area to explore, not a pitch]. Before I tell you more about what we do, I'd find it really useful to understand [your situation / how you currently approach X / what's on your plate in this area]. Would it be OK if I asked you a few questions first?"
```
**Follow-up call (existing contact):**
```
"[Name], thanks for picking this back up. Last time we talked, you mentioned [brief reference to the most relevant thing from previous notes — 1 sentence]. I've been thinking about what you said about [X], and I'd like to understand [area to explore further]. Before I go into anything on our side, could I ask a few more questions to make sure I'm thinking about this correctly?"
```
**Senior executive (C-suite, VP):**
- Compress the opening further. Get to the investigative framing in one sentence.
- Avoid any preamble about your company history, market position, or product roadmap.
- State that you will be brief and ask focused questions: "I only need 20 minutes and most of it I'd like to spend asking you questions rather than talking about us — is that alright?"
**Multi-stakeholder (multiple people on the call):**
- Open by briefly acknowledging the group composition: "I know we have [role A], [role B], and [role C] on this call — that's helpful because I want to understand [X] from multiple angles."
- Establish your questioning role for the group, not just one person.
- Avoid opening by addressing only one person in the room.
**WHY:** The script must be tailored because the opening's function — establishing permission to ask questions — is delivered differently depending on the buyer's seniority and the call's relationship history. A first-meeting C-suite opening that spends 30 seconds on company background will lose the executive before you get to the question. A follow-up call that does not reference the last conversation treats the buyer as a stranger and wastes the accumulated context.
### Step 5: Apply the 3 Pre-Call Checkpoints
**ACTION:** Review the drafted opening against the three checkpoints drawn from Rackham's effective-opening criteria. Each checkpoint is a yes/no test.
**Checkpoint 1: Get to business within 20% of call time**
- Estimate the total call length. Calculate 20% (e.g., 40-minute call → 8 minutes max for opening).
- Does the opening script complete in well under that threshold?
- Typical target: opening completes in 60-90 seconds for a 40-minute call (< 4% of call time).
- IF the opening script runs longer than 3 minutes when read aloud -> trim. The complaint Rackham documented consistently from senior executives and professional buyers is that salespeople waste time with idle chatter. There is no recorded complaint that a seller got down to business too quickly.
**Checkpoint 2: No solutions or product details in the first half of the call**
- Does the opening mention any product name, feature, benefit, capability, or case study?
- IF yes -> remove it. The opening must NOT introduce solutions before needs have been established.
- Does the opening leave a clear path for you to ask questions before you discuss your product?
- IF the buyer could reasonably leave the opening asking "so what does your product do?" and you have not answered that yet -> pass. IF you have answered it -> revise.
**Checkpoint 3: Role as questioner is established**
- Does the opening make it clear — explicitly or by strong implication — that you are going to ask the buyer questions, and that the buyer's information matters before you say anything about your product?
- IF not clear -> add a single explicit line: "Before I tell you anything about what we do, I'd like to ask you a few questions about how you're currently handling X."
**WHY:** These three checkpoints reflect Rackham's three guidance points for effective Preliminaries. Checkpoint 1 prevents the time-drain pattern that frustrates senior buyers. Checkpoint 2 is the primary structural guard against premature solution presentation — one of the highest-correlation behaviors with call failure in large sales. Checkpoint 3 is the mechanism by which you maintain the questioning role; without explicitly establishing it, buyers often step into that role themselves, asking about your product before you have gathered any needs.
### Step 6: Produce the Call Opening Plan
**ACTION:** Write the final call-prep document to `call-opening-{YYYY-MM-DD}.md`. The document should be scannable in 2-3 minutes before the call.
**Document format:**
```markdown
# Call Opening Plan — {Account Name} — {Date}
## Call Context
- Contact: {Name}, {Role}
- Call type: {New account / Follow-up}
- Objective: {What this call needs to accomplish}
- Estimated length: {X} minutes
## Anti-Pattern Check
- [ ] No personal rapport fishing in this opening
- [ ] No opening benefit statements in this opening
## Opening Script (~60-90 seconds)
{Tailored script from Step 4}
## 3 Checkpoints
- [ ] Get to business within 20% of call time (~{X} minutes for this call)
- [ ] No product/solution details in first half of call
- [ ] Buyer understands I will be asking questions before I pitch
## If It Goes Off-Script
{One sentence: if the buyer jumps immediately to "what does your product do?" — how to redirect back to the questioning role}
```
**WHY:** A written plan creates an artifact the user can review 5 minutes before the call. The checklist format makes the anti-pattern guards fast to verify. The "if it goes off-script" note prepares for the most common opening disruption: the buyer who asks about your product before you have asked a single question.
## Key Principles
- **The goal of the opening is permission, not impression.** The Preliminaries stage has one functional purpose in a major sale: get the buyer's consent to answer your questions. Trying to also establish credibility, build rapport, or plant a compelling vision in the opening is overloading it. The impression that matters is made during the Investigating stage, when the quality of your questions signals your understanding of the buyer's world.
- **First impressions in large sales are less important than most sales training claims.** Rackham's research found no reliable relationship between opening smoothness and call success in major sales. Successful calls start awkwardly; well-polished openings lead nowhere. The experienced observation from hundreds of observed calls: "I no longer believe that first impressions can make or break your sales success in larger sales." The call's trajectory is set by the quality of investigation, not by the opening.
- **Personal rapport fishing has a measurable ceiling.** The Imperial Group rural/urban study found that personal references correlate with sales success in small rural accounts (where reps had long tenure and buyers had time) but show no relationship in large urban accounts. The dynamic has also shifted: where buyers once said "I buy from Fred because I like him," they now say "I like Fred, but I buy from his competition because they're cheaper." Personal loyalty is no longer an adequate commercial basis. The BP buyer's yacht-photo technique — designed specifically to deflect rapport-fishing openers — illustrates the professional buyer's response to this tactic.
- **Opening benefit statements hand control to the buyer.** When you open with "we help companies like yours reduce costs by X%," you invite the buyer to ask "what does that cost?" and "how does it work?" — before you have established any needs. You are now answering questions about your product rather than asking questions about their situation. The seller in Rackham's example (the Executype typewriter scenario) starts by establishing a product benefit, gets pulled into pricing within 30 seconds, and has lost the questioning role before the call has begun.
- **Variety matters more than technique in multi-call sales.** Rackham's research found that less effective sellers opened every call the same way. More effective sellers varied their openings. If you have used the same opening benefit statement with the same buyer twice in a row, it has already shifted from a positive to an irritant — as illustrated by the office products rep whose second visit with the identical opener drew visible annoyance from the same executive who had praised it a week earlier.
- **Get to business quickly; you will not offend anyone.** Rackham noted that senior executives and professional buyers consistently complain about sellers who dwell on non-business topics before getting to the point. He recorded zero complaints from buyers about sellers who moved too quickly to business. The asymmetry is decisive: you can only lose time by dwelling; you lose nothing by moving briskly.
## Examples
**Scenario 1: First meeting with a new account, mid-level manager**
Trigger: Rep is preparing a first discovery call with the Director of Operations at a mid-sized manufacturing company. The rep has a deal brief but no prior relationship.
What a baseline agent typically produces:
> "Hi Sarah, great to meet you! I saw from your LinkedIn that you were at [previous company] — I know some people there. How's the team been treating you since you joined? [... 3 minutes of small talk ...] Anyway, I'm here today because our platform has helped companies like yours reduce operational downtime by 30-40%, which I know is always top of mind for ops teams. Does that sound like something that would be relevant to you?"
Why this fails Checkpoint 2 and 3: Opens with personal rapport fishing (LinkedIn connection bait), then launches into an opening benefit statement ("reduce downtime by 30-40%"). The buyer is now in the asking role: "How do you do that? What does it cost? Who else have you worked with?" The seller has lost the questioning role and introduced product details before understanding a single need.
What this skill produces:
> "Sarah, thank you for the time. I'm [Name] from [Company] — we work with manufacturing teams on operational reliability. The reason I wanted to speak is that I've been hearing from a number of ops directors that the way they manage [area] has been changing significantly, and I'm genuinely curious whether you're seeing the same thing. Before I tell you anything about what we do, I'd like to ask you a few questions about how you're currently handling [relevant area] — would that be OK?"
Checkpoint review:
- Completes in ~30 seconds (well under 20% of a 45-minute call)
- No product features, benefits, or case studies mentioned
- "Before I tell you anything about what we do, I'd like to ask you a few questions" — questioning role explicitly established
---
**Scenario 2: Follow-up call with a VP who has attended one previous call**
Trigger: Rep is returning to a VP of Finance after an initial meeting where the VP raised concerns about their month-end close process. The rep wants to continue discovery.
What a baseline agent typically produces:
> "Thanks for having me back. Last time we had a great conversation about your finance operations. I wanted to follow up and also share some slides on how our platform handles month-end consolidation. I think you'll find it really relevant to what you described."
Why this fails Checkpoint 2: Immediately introduces product ("slides on how our platform handles month-end consolidation") before re-establishing the questioning role. The call is about to become a presentation, not a discovery session — even though the rep has not yet fully understood the scope of the problem or whether there is an Explicit Need.
What this skill produces:
> "Alex, good to pick this back up. Last time you mentioned the month-end close was taking longer than it should and there were reconciliation issues across the regional entities. I've been thinking about that — I'd like to understand that a bit more before I tell you anything about what we do. Specifically, I want to understand what the downstream effects look like when the close runs late. Could I ask you a few questions on that before we go into anything on our side?"
Checkpoint review:
- References prior context (establishes continuity, not a restart)
- Opens with a desire to understand, not to present
- "Before I tell you anything about what we do" — questioning role explicitly re-established
- "I want to understand the downstream effects" — frames toward Implication territory without revealing it
---
**Scenario 3: C-suite meeting, 20-minute slot, senior stakeholder new to the deal**
Trigger: Rep is joining a call where the CFO is attending for the first time. Two previous calls were with the VP of IT. The CFO has 20 minutes and is known to be direct.
What a baseline agent typically produces:
> "Thank you for joining us. I know your time is valuable, so I'll start with why we think we're uniquely positioned to help a company at your scale. We've worked with [reference company] and [reference company 2], and in both cases we were able to deliver [benefit statement]. I'd love to show you a few slides and then we can take questions."
Why this fails all three checkpoints: Starts with a product/capability claim, references case studies before understanding any CFO-specific needs, and immediately moves toward a presentation — the seller has become the answerer, not the questioner.
What this skill produces:
> "[Name], thanks for joining — I know your time is short. We've been having productive conversations with [IT VP name]. I wanted to meet you briefly because any decision of this scale obviously has a financial and risk dimension that I want to understand from your perspective. I have three questions for you; they should take about 15 minutes. Does that work?"
Checkpoint review:
- Opens in under 15 seconds
- Acknowledges relationship history (prior calls) without over-explaining
- No product claims, benefits, or case studies
- "I have three questions for you" — questioning role established immediately and explicitly
- Respects time constraint by naming it directly
## References
For an opening-template library with variants by call type (new account, follow-up, C-suite, multi-stakeholder, procurement buyer), see [references/opening-templates.md](references/opening-templates.md).
For an anti-pattern catalog with detection criteria and replacement phrases, see [references/opening-anti-patterns.md](references/opening-anti-patterns.md).
## License
This skill is licensed under [CC-BY-SA-4.0](https://creativecommons.org/licenses/by-sa/4.0/).
Source: [BookForge](https://github.com/bookforge-ai/bookforge-skills) — SPIN Selling by Neil Rackham.
## Related BookForge Skills
This skill is standalone. Skills that follow in the call sequence:
- `clawhub install bookforge-spin-discovery-question-planner` — Plan the SPIN questions for the Investigating stage (reads the call context this opening has established)
- `clawhub install bookforge-commitment-and-advance-planner` — Plan the specific advance to target at the end of the call (depends on call-outcome-classifier)
- `clawhub install bookforge-need-type-classifier` — Classify what the buyer says during discovery as Implied or Explicit Needs
Or install the full SPIN Selling skill set from GitHub: [bookforge-skills](https://github.com/bookforge-ai/bookforge-skills)
Plan the specific commitment to target on a B2B sales call, and script how to obtain it without using closing pressure. Use this skill when someone asks "how...
---
name: commitment-and-advance-planner
description: |
Plan the specific commitment to target on a B2B sales call, and script how to obtain it without using closing pressure. Use this skill when someone asks "how do I close this deal", "what should I ask for at the end of the call?", "should I push for the close on Tuesday?", "how do I get this prospect to commit?", "what's a realistic next step to aim for?", "they keep saying they're interested but won't commit", "my manager wants me to close harder but I don't think that's working", "I need to plan my commitment strategy before tomorrow's call", "how do I avoid getting a non-answer at the end of the meeting?", "what's the difference between a real commitment and a polite brush-off?", "I keep getting Continuations instead of Advances", "how do I know what level of commitment to ask for?", or "what do I do when the prospect says 'let's stay in touch'?" Also invoke when someone is prepping for any major B2B sales call and wants a written plan for how to end it — even if they don't use the word "close" or "commitment." This skill produces a pre-call commitment plan with a primary Advance target, a fallback Advance, a Four Successful Actions script, and a Continuation guard. It explicitly replaces closing-technique training with Advance-targeting, grounded in empirical research showing that pressure closing reduces success rates in large sales. After the call, run spin-selling:call-outcome-classifier to verify whether the planned Advance was actually obtained.
version: 1.0.0
homepage: https://github.com/bookforge-ai/bookforge-skills/tree/main/books/spin-selling/skills/commitment-and-advance-planner
metadata: {"openclaw":{"emoji":"📚","homepage":"https://github.com/bookforge-ai/bookforge-skills"}}
status: draft
depends-on:
- call-outcome-classifier
source-books:
- id: spin-selling
title: "SPIN Selling"
authors: ["Neil Rackham"]
chapters: [2]
tags: [sales, b2b-sales, enterprise-sales, commitment, advance, closing, call-planning, pre-call-prep, complex-sales, spin-selling, deal-progression, pipeline-management, anti-closing]
execution:
tier: 1
mode: plan-only
inputs:
- type: document
description: "deal-brief.md — company, contact, deal size, current stage, what is known so far"
- type: document
description: "stakeholder-map.md (optional) — decision-makers, influencers, and their access levels"
- type: document
description: "call-notes-{date}.md (optional) — notes from the most recent prior call; shows what advances have already been obtained"
tools-required: [Read, Write]
tools-optional: [Grep]
mcps-required: []
environment: "document_set — reads deal context from the working directory; writes commitment-plan-{deal}-{date}.md"
discovery:
goal: "Produce a pre-call commitment plan naming a specific primary Advance, a fallback Advance, and a scripted sequence to obtain commitment without pressure techniques — so the seller walks into the call knowing exactly what customer action they need and how to ask for it"
tasks:
- "Read deal-brief.md and prior call notes to understand deal stage, what has already been agreed, and what access is still needed"
- "Determine what specific customer action would meaningfully move this deal forward (primary Advance)"
- "Determine a smaller but still valid Advance to fall back to if the primary is not obtainable"
- "Script the Four Successful Actions sequence for this specific call and deal"
- "Embed a Continuation guard: phrases to watch for that signal the buyer is offering a Continuation disguised as a commitment"
- "Write commitment-plan-{deal}-{date}.md as the pre-call planning artifact"
audience:
roles: ["account-executive", "enterprise-sales-rep", "solutions-consultant", "founder-led-seller"]
experience: "intermediate — has run B2B sales calls; may have been trained on classic closing techniques; this skill replaces that training with Advance-targeting"
triggers:
- "User is preparing for a key B2B sales call and wants to plan the commitment strategy"
- "User has been getting Continuations and wants a plan to obtain real Advances instead"
- "User is being pressured by their manager to close harder and wants an evidence-based alternative"
- "User wants to know what specific action to ask for on the next call"
- "User wants to understand the difference between an Advance and a Continuation before planning"
not_for:
- "Generating SPIN discovery questions for the Investigating stage — use spin-selling:spin-discovery-question-planner"
- "Drafting Benefit statements for the summary step — use spin-selling:benefit-statement-drafter"
- "Closing-attitude self-introspection — use spin-selling:closing-attitude-self-assessment"
- "Classifying whether last call's outcome was an Advance or Continuation — use spin-selling:call-outcome-classifier"
- "Multi-call deal forecasting or probability modeling"
quality: placeholder
---
# Commitment and Advance Planner
## When to Use
You are preparing for a significant B2B sales call and need to know:
1. **What specific customer action should I target at the end of this call?**
2. **How do I ask for it without triggering defensive pressure reactions?**
This skill is for pre-call planning. The human runs the actual call; the agent helps plan the commitment strategy before it.
**Who this skill is for:** B2B account executives, solutions consultants, or founder-led sellers — particularly those who have been trained on traditional closing techniques (assumptive close, alternative close, ABC/Always-Be-Closing) or who keep getting polite non-commitments ("we'll be in touch") at the end of calls.
**Prerequisite knowledge:** This skill assumes you understand the Advance vs Continuation distinction. If not, run `spin-selling:call-outcome-classifier` on your last call first — that skill will make the distinction concrete before you plan the next one.
**Outputs:** `commitment-plan-{deal}-{date}.md` — a written pre-call plan naming your primary Advance, fallback Advance, and a scripted Four Successful Actions sequence for this specific call.
---
## Context & Input Gathering
### Required
- **Deal stage:** Where is this deal in the cycle? What advances have already been obtained? What stakeholders have been accessed?
- **What specific action would move this deal forward?** If you don't know yet, this skill will help you work through it.
### Useful (read from working directory if available)
- **`deal-brief.md`** — account name, contact, deal size, current stage, timeline, known information
- **`stakeholder-map.md`** — who the decision-makers are, who hasn't been accessed yet, who the champion is
- **`call-notes-{date}.md`** — most recent prior call notes; what was the last Advance obtained?
### Defaults (used if not provided)
- If no call notes are available, ask the user to describe the deal stage in two sentences.
- If no stakeholder map is available, ask: "Who is in the room on Tuesday, and who isn't that should be?"
### Sufficiency threshold
A deal-brief or brief verbal deal summary is sufficient to begin. The more context available, the more specific the Advance targets will be.
---
## Process
### Step 1: Establish Deal Stage and What Has Already Been Committed
**Action:** Read `deal-brief.md` and the most recent `call-notes-{date}.md`. Identify:
- The specific Advance(s) already obtained (e.g., "they agreed to a technical review with their IT lead on the previous call")
- What stakeholders have been accessed and which have not
- What the stated or implied decision criteria are
- Any explicit needs the customer has expressed (these are used in Step 4's Benefit summary)
If no documents are available, ask the user to describe these points directly.
**WHY:** An Advance must move the sale forward from its current position — you cannot define "forward" without knowing where the deal currently stands. An Advance that was realistic two calls ago (e.g., "get a meeting with the department head") may already be achieved. Planning an Advance below the current stage results in a wasted call; planning one above achievable limits pushes the buyer past their readiness and produces pressure resistance.
**Output:** A brief written summary (3-5 lines) of current deal state and last Advance obtained.
---
### Step 2: Define the Primary Advance
**Action:** Define a primary Advance that meets both criteria for a valid Advance:
1. **The commitment advances the sale** — as a result of this customer action, the deal will move closer to a decision.
2. **The commitment is the highest realistic action the customer can give** — do not push beyond achievable limits, but do not settle for less than what is achievable.
A valid Advance is a specific, named customer action. Apply the **"name the action" test**: can you complete this sentence with a concrete noun phrase?
> *"By the end of this call, the customer will have agreed to ___."*
**Advance examples that pass the name-the-action test:**
- "…attend a 90-minute product demonstration at our offices with CFO present"
- "…arrange a technical evaluation session with their IT security lead by next Friday"
- "…submit an internal request to procurement to begin contract review"
- "…introduce us to the VP of Operations who is the final decision-maker"
- "…run a 30-day pilot of the integration module with two real accounts"
**Advance examples that fail the test (too vague):**
- "…consider moving forward" — no specific action
- "…stay interested" — no action at all
- "…follow up" — this is a seller action, not a customer action
**Large-sale gating check:** Is this a deal where the customer is a sophisticated buyer with an ongoing post-sale relationship? If yes, use the Advance framing. Pressure techniques are especially counterproductive in these contexts.
**WHY:** Rackham's research on major-account sales forces showed that fewer than 10% of calls result in an Order or No-sale. The dominant outcome is either an Advance or a Continuation — and the difference between pipeline health and pipeline stagnation is consistently whether each call ends in a named Advance or a polite non-commitment. Sellers who plan for vague objectives ("build a relationship," "keep the conversation going") are planning for Continuations. Writing the specific action into your pre-call plan forces the discipline before the call, not after.
**Output:** One sentence: "Primary Advance: [specific customer action]."
---
### Step 3: Define a Fallback Advance
**Action:** Define a fallback Advance — a smaller but still valid customer action — to use if the primary Advance cannot be obtained on this call. The fallback must also pass the name-the-action test.
Ask: if the primary Advance proves unachievable, what smaller but genuine customer commitment would still represent real forward progress?
**Example:** Primary = "CFO attends product demo on Tuesday." Fallback = "Customer agrees to send us the evaluation criteria document so we can tailor the demo agenda."
**WHY:** Without a fallback, sellers under pressure to "get a commitment" will often accept a Continuation disguised as an Advance rather than leave empty-handed. A buyer saying "let's reconnect next month" feels like progress; a pre-written fallback Advance gives you a specific alternative to counter-offer with: "I understand the timeline is tight — would it help if we started with a 30-minute technical overview for your IT lead this week instead?" The fallback prevents collapse into Continuation.
**Output:** One sentence: "Fallback Advance: [specific customer action]."
---
### Step 4: Script the Four Successful Actions Sequence
**Action:** For this specific call and deal, write out how you will apply Rackham's Four Successful Actions — the behaviors consistently observed in successful major-sale commitment situations. Write this as a scripted sequence you can refer to before and during the call.
**The Four Successful Actions:**
**Action 1 — Invest in the Investigating stage**
The single most reliable predictor of obtaining commitment is doing an outstanding job in the discovery (Investigating) stage. Customers who clearly perceive a need for what you offer will often close the sale themselves. If the discovery has been thorough, the commitment is easier to obtain.
For this call: note what Investigating is still needed. Do not rush through discovery to get to commitment. If the customer does not yet clearly perceive the need, commitment-seeking is premature.
*Script:* [Note what questions or discovery you still need to complete in this call before moving toward commitment.]
**Action 2 — Check that key concerns are covered**
Before moving to commitment, ask the buyer directly whether there are areas you haven't covered or questions they still have. Do not use closing techniques to surface concerns — that creates antagonism.
*Script:* "Before we go further, I want to make sure I've covered everything that's important to you. Are there areas you'd like to explore more, or questions I haven't addressed?"
Effective alternative phrasings:
- "Is there anything else I should be telling you about before we talk about next steps?"
- "What are the biggest open questions from your side right now?"
**Action 3 — Summarize Benefits**
In a major sale, the call has likely covered significant ground. Before proposing commitment, briefly pull together the Benefits that are most relevant to this buyer — specifically, capabilities that match Explicit Needs the customer has expressed (not Features or Advantages you want to present).
*Script:* "Let me summarize where we are. You mentioned that [Explicit Need 1] is a priority — and we've seen how [Capability A] addresses that directly. You also raised [Explicit Need 2], and [Capability B] handles that by [specific mechanism]. Based on what you've shared, there seem to be meaningful gains for you from [the change / the solution]."
Note: Do not present Features or Advantages as Benefits unless the customer has explicitly stated the underlying need. If you are unsure whether you have genuine Explicit Needs to summarize, note this as a gap and adjust the Investigating plan for Action 1.
**Action 4 — Propose a commitment**
Successful sellers in Rackham's research did not "ask" for commitment — they proposed a specific next step. This is an important distinction: "asking" invites the buyer to evaluate whether they want to commit; "proposing" describes a logical next step that flows from what has been established.
*Script:* "Based on everything we've discussed, the most logical next step seems to be [Primary Advance]. Does [specific date/format] work on your end?"
If the buyer hesitates or the primary Advance is not achievable:
- Do not escalate to pressure techniques.
- Offer the fallback Advance directly: "Alternatively, we could start with [Fallback Advance] — that would give us [specific value]."
- If neither is possible: ask what the buyer's constraints are, rather than applying pressure. Understanding the constraint is more valuable than forcing a premature commitment.
**WHY:** The Four Successful Actions replace closing techniques with behaviors that are empirically associated with commitment success in large sales. The sequence works because: Action 1 ensures the buyer perceives a genuine need (making commitment natural, not forced); Action 2 clears residual doubts before they become antagonistic objections; Action 3 re-grounds the decision in the buyer's own stated needs; Action 4 proposes rather than pressures. The sequence removes the conditions that make closing techniques feel necessary — if the buyer clearly perceives a need and has no unresolved concerns, they will often agree to a next step without pressure.
**Output:** The Four Successful Actions script, tailored to this call (fill in the bracketed sections with deal-specific content).
---
### Step 5: Embed the Continuation Guard
**Action:** Before writing the final plan document, add a Continuation guard — a list of buyer phrases and patterns that signal a Continuation is being offered, and the redirect response.
**Classic Continuation phrases** (Rackham's research verbatim and near-variants):
- "Thank you for coming. Why don't you visit us again the next time you're in the area."
- "Fantastic presentation, we're very impressed. Let's meet again some time."
- "We liked what we saw and we'll be in touch if we need to take things further."
- "We'll definitely keep you in mind."
- "Interesting — we'll have to discuss it internally and come back to you."
- "Let's reconnect in Q3." (with no confirmed date, agenda, or attendees)
- "Send me the proposal and we'll take a look."
**What these phrases have in common:** The buyer is expressing a positive feeling, not committing to an action. The next step is conditional, vague, or seller-initiated only. No specific customer action is named.
**How to redirect when you hear a Continuation phrase:**
1. Do not accept the Continuation as success. Recognize it for what it is.
2. Do not apply closing pressure — that escalates resistance.
3. Offer a specific, smaller Advance: "I'd love to set that up — what if we blocked a specific 30 minutes next week? I can work around your schedule."
4. If the buyer resists even that: ask a diagnostic question — "Is there something you need to see or understand before that next step makes sense?" This surfaces concerns that can be addressed rather than forcing a non-committal closure.
After the call, run `spin-selling:call-outcome-classifier` to verify whether the outcome was a genuine Advance or a Continuation. This closes the learning loop and prevents the most common failure mode: leaving a Continuation call feeling like it went well.
**WHY:** Buyers — especially sophisticated professional buyers in major accounts — are skilled at ending calls warmly without committing to anything. This is not deception; it is how professional procurement operates. Sellers who classify these warm endings as advances have systematically inflated pipelines and delayed recognition of stalled deals. The Continuation guard is a pre-call inoculation: if you have named the Continuation phrases before the call, you are less likely to mistake them for Advances in the moment.
**Output:** Continuation guard section embedded in the final plan document.
---
### Step 6: Write the Commitment Plan Document
**Action:** Write `commitment-plan-{deal}-{date}.md` with the structure below.
**WHY:** A written plan creates pre-call clarity and serves as a post-call reference for outcome classification. The act of writing out the specific Advance in advance forces precision that does not survive the heat of the call without it. A plan document also enables the seller to review it in the five minutes before the meeting.
---
## Output Template
```markdown
# Commitment Plan: [Account Name]
**Call date:** [Date]
**Stage:** [Current deal stage]
**Prepared by:** commitment-and-advance-planner
---
## Current Deal State
[3-5 sentences: what has been established, what Advances have been obtained, who has been accessed, what Explicit Needs the customer has expressed]
---
## Advance Targets
**Primary Advance:**
[Specific customer action — passes the name-the-action test]
**Fallback Advance:**
[Smaller but still valid customer action — also passes the name-the-action test]
**Name-the-action check:**
- Primary: "By end of call, customer will have agreed to ___." ✓
- Fallback: "By end of call, customer will have agreed to ___." ✓
---
## Four Successful Actions Script
### Action 1 — Investigating (Discovery to complete first)
[What discovery is still needed before moving toward commitment on this call]
### Action 2 — Check key concerns
Script: "Before we go further, is there anything I haven't covered that's important to you, or any questions I should address?"
[Add deal-specific variants if needed]
### Action 3 — Summarize Benefits
Script: "Let me summarize what we've established. You mentioned [Explicit Need 1] — [Capability A] addresses that by [mechanism]. You also raised [Explicit Need 2] — [Capability B] handles that with [mechanism]. There seem to be real gains from [the solution/change] based on what you've shared."
[Fill in from needs-log.md or prior call notes]
### Action 4 — Propose commitment
Primary: "[Date/format of Primary Advance] — does that work for you?"
Fallback: "Alternatively, we could start with [Fallback Advance]. That would give us [specific value] without requiring [the constraint]."
---
## Continuation Guard
If you hear any of these phrases, recognize them as Continuations and redirect:
- "We'll be in touch" → Redirect: "Would it make sense to put a specific date on the calendar now so we don't lose momentum?"
- "Let's reconnect in Q3" → Redirect: "Happy to — could we block a 30-minute call in the first week of July?"
- "Send us the proposal and we'll look at it" → Redirect: "Absolutely — to make the proposal as specific as possible, it would help to first spend 20 minutes with [stakeholder/function]. Could you arrange that?"
- "Fantastic presentation — let's meet again sometime" → Redirect: "Glad it was useful. What would be the most valuable next step — a technical deep-dive for your IT team, or a commercial conversation with [decision-maker]?"
---
## Post-Call Verification
After the call, run **spin-selling:call-outcome-classifier** on your call notes to verify:
- Was the outcome classified as an Advance or a Continuation?
- If an Advance: does the classification name the specific customer action obtained?
- If a Continuation: what was the phrase that masked it, and what redirect could have been used?
CRM update guidance: do not advance the pipeline stage until call-outcome-classifier confirms a genuine Advance was obtained.
```
---
## Key Principles
**An Advance is a customer action, not a seller action.** "I'll send them the proposal" is a seller action — it is not an Advance, no matter how much effort it represents. An Advance is when the customer agrees to do something. This distinction is the single most important reframe in the methodology.
**WHY:** Sellers who track their own actions as pipeline progress create a false picture of deal health. The deal advances when the customer moves — not when the seller does. Reorienting planning toward "what customer action do I need?" rather than "what do I need to do?" produces fundamentally different call objectives.
**Plan the Advance before the call, not after.** The Advance target must be written down before the call. In the call itself, buyer enthusiasm and the social pressure to end warmly can override the discipline to hold out for a real commitment. A pre-written plan is much harder to rationalize away than an in-the-moment judgment.
**WHY:** Rackham found that sellers without pre-call Advance objectives reliably ended up with Continuations — the call's warm social ending felt like success. The pre-call plan is the structural defense against the Continuation-as-success misread.
**Pressure closing reduces success rates in large sales — empirical evidence.** A seller who pushes back with "but my manager says I need to close harder" can be shown:
- *Photo-Store study:* Closing training increased success on low-value goods (72% → 76%), but reduced success on high-value goods (42% → 33%). The same technique that works in small sales actively reduces success in large ones.
- *54-buyer questionnaire:* Of 54 professional buyers asked whether closing techniques affected their likelihood of buying: 2 said more likely, 18 said indifferent, 34 said less likely. The buyers who are most important to major-account sellers are the ones most put off by closing techniques.
- *190-call study:* In a large office-equipment firm, the 30 calls where sellers closed most often produced 11 sales; the 30 calls where sellers closed least often produced 21 sales.
For deeper treatment of each empirical study, see `references/closing-anti-patterns.md`.
**The Four Successful Actions are not a script — they are an emphasis sequence.** They do not guarantee commitment will be obtained. They ensure the conditions under which commitment is most likely: genuine need development, concern clearance, benefit summary, and a logical proposed next step. If a buyer still will not commit after these four actions, the most productive response is a diagnostic question, not more closing pressure.
**Sophisticated buyers have defenses against closing techniques.** Professional procurement staff often keep cards cataloging common closing techniques and are trained to recognize and penalize their use. The most important deals in a B2B portfolio involve exactly these kinds of buyers. Closing technique training is not neutral in these accounts — it is actively counterproductive.
---
## Examples
### Example 1: AE Preparing for a Third Call — Moving from Champion to Economic Buyer
**Scenario:** Enterprise AE has had two discovery calls with a logistics VP (champion). She has obtained Advances on both prior calls (agreement to technical evaluation, then agreement to a product pilot scoping session). Now she needs to get in front of the CFO who controls budget.
**Trigger:** "I have a third call with the logistics VP next week. The pilot was positive but I need to get to the CFO. How do I plan this?"
**Process:**
- Step 1: Current stage — pilot scoping complete, champion is supportive, CFO not yet accessed.
- Step 2: Primary Advance — "VP agrees to arrange a 30-minute introductory meeting between seller and CFO, scheduled within 2 weeks."
- Step 3: Fallback Advance — "VP agrees to share pilot results summary with CFO and copy seller on the email."
- Step 4: Four Successful Actions script — Action 1: complete remaining pilot ROI discussion; Action 2: "Before we talk about next steps, are there any concerns from the CFO's side that I should know about?"; Action 3: "We've seen [Explicit Need 1] and [Explicit Need 2] addressed — the pilot confirmed that both are achievable in your environment."; Action 4: "Given that, the most useful next step seems to be looping in [CFO name] — could you arrange a 30-minute session in the next couple of weeks?"
- Step 5: Continuation guard — "If she says 'I'll mention it to the CFO,' that is a Continuation. Redirect: 'Happy to have you mention it — and to make it as easy as possible for her to say yes, could we set a specific time now while we're both here?'"
**Output:** `commitment-plan-acme-logistics-2024-06-18.md` with specific Advance targets and tailored Four Successful Actions script. AE walks into the call knowing exactly what she needs.
---
### Example 2: SDR Over-relying on Assumptive Closes
**Scenario:** Inside sales rep has been trained to use assumptive and alternative closes. His manager says he needs to "close harder." He keeps getting warm-sounding responses that don't turn into booked meetings.
**Trigger:** "I need to close harder on calls but I feel like pushing is making it worse. What should I do instead?"
**Process:**
- Context: This is a major-account scenario (enterprise SaaS) — closing techniques are empirically counterproductive here.
- The skill surfaces the empirical evidence (Photo-Store, 54-buyer questionnaire, 190-call study) and explains why the manager's instinct is correct for small sales but wrong for major accounts.
- Primary Advance for his next call: "Prospect agrees to a 30-minute product discovery call with their operations lead, booked on a specific date."
- Fallback: "Prospect agrees to receive and review a 2-page case study from a similar company."
- Four Successful Actions script adapted for the SDR's calling context.
- Continuation guard: "If they say 'send me some info and I'll take a look,' that is a Continuation. Respond: 'Happy to send it — to make it as relevant as possible, could we spend 10 minutes first so I can tailor it to your situation?'"
**Output:** `commitment-plan-techcorp-2024-06-20.md` — a pre-call plan the SDR can use before his next outreach. Plus: a one-paragraph explanation he can share with his manager showing why low-pressure Advance-targeting outperforms pressure closing in enterprise deals.
---
### Example 3: Founder Prepping for a High-Stakes Pilot Conversation
**Scenario:** Founder-led B2B SaaS startup. Founder has had two good calls with a potential pilot customer. The prospect has been enthusiastic but has not committed to anything concrete. The founder is unsure what to ask for next.
**Trigger:** "They seem really interested. I have a call Thursday. How do I turn this into something real?"
**Process:**
- Current stage: Enthusiasm confirmed, no Advance yet obtained.
- Primary Advance: "Prospect agrees to a 60-day paid pilot with 3 specific user accounts, starting the first week of next month."
- Fallback Advance: "Prospect agrees to a specific date (within 2 weeks) for a technical scoping session with their IT lead to assess integration requirements."
- Step 4 — Action 2 (check concerns): "Before we talk about next steps, what would need to be true for a pilot to make sense from your perspective?"
- Continuation guard: "If they say 'we're definitely interested — let's reconnect after we've had some time to digest this,' that is a classic Continuation. Don't end the call there. Respond: 'I hear you — what would it take to get a specific next step agreed today? I want to make sure we're not creating a delay that doesn't serve either of us.'"
**Output:** `commitment-plan-{company}-2024-06-22.md`. Founder walks into the call knowing the enthusiasm is not an Advance, what a real Advance looks like, and how to propose it without pressure.
---
## References
| File | Contents |
|------|----------|
| `references/four-successful-actions-detail.md` | Expanded treatment of each of the Four Successful Actions; additional dialogue examples; how to adapt the sequence for different call lengths and deal stages |
| `references/closing-anti-patterns.md` | Full empirical evidence for why pressure closing fails in major sales; Photo-Store study data; 54-buyer questionnaire; 190-call study; named closing techniques and their specific failure modes |
---
## License
This skill is licensed under [CC-BY-SA-4.0](https://creativecommons.org/licenses/by-sa/4.0/).
Source: [BookForge](https://github.com/bookforge-ai/bookforge-skills) — SPIN Selling by Neil Rackham.
## Related BookForge Skills
This skill depends on:
```
clawhub install bookforge-call-outcome-classifier
```
For pre-call discovery question planning (the Investigating stage this skill references):
```
clawhub install bookforge-spin-discovery-question-planner
```
For drafting Benefit statements (used in Action 3 of the Four Successful Actions):
```
clawhub install bookforge-benefit-statement-drafter
```
For post-call learning and plan-do-review cycle:
```
clawhub install bookforge-sales-call-plan-do-review-coach
```
Browse the full SPIN Selling skill set: [bookforge-skills](https://github.com/bookforge-ai/bookforge-skills)
FILE:references/closing-anti-patterns.md
# Closing Anti-Patterns — Empirical Evidence and Named Techniques
Source: SPIN Selling, Chapter 2, "Obtaining Commitment: Closing the Sale"
This reference provides the empirical grounding for why pressure closing techniques are counterproductive in major B2B sales. It is intended for use when a seller or sales manager challenges the Advance-targeting methodology with "but shouldn't I be closing harder?"
---
## The Three Key Studies
### Study 1: The 190-Call Initial Study
**Context:** Large office-equipment corporation. Researchers observed 190 sales calls in the field.
**Method:** The 30 calls where sellers closed most often were compared against the 30 calls where sellers closed least often.
**Results:**
- High-close calls (most closing behaviors): 11 of 30 resulted in a sale (37%)
- Low-close calls (fewest closing behaviors): 21 of 30 resulted in a sale (70%)
**Interpretation:** Higher closing frequency was associated with lower success rates. This was a small study and subject to confounding (the most resistant customers may have drawn more closing attempts). But the finding was consistent with subsequent larger studies.
**What this means for planning:** Sellers who are told to close more often are being given advice that is negatively correlated with success in major sales.
---
### Study 2: The Photo-Store Study
**Context:** A leading chain of photographic retail stores trained its salespeople in closing techniques. Researchers observed the salespeople both before and after training on two types of goods: low-value items (film, accessories) and high-value items (cameras, hi-fi equipment, video).
**Low-value goods results:**
- Before training: 72% of transactions resulted in a purchase, average 1.3 closes per transaction
- After training: success rate increased to approximately 76% (not statistically significant, but directionally positive), transaction time shortened
**High-value goods results:**
- Before training: 42% of transactions resulted in a purchase
- After training: success rate fell to 33% — a substantial decline
**Interpretation:** Closing training that improves performance on low-value, transactional sales actively reduces performance on higher-value sales by the same seller population. The technique is not neutral — it is appropriate in one context and harmful in another.
**Mechanism:** In high-value decisions, buyers require time to evaluate. Closing techniques that force a faster decision work when the natural decision cycle is short (buy film or don't). When the natural decision cycle is longer (buy a $1,200 camera), forcing a faster decision produces resistance and lost sales.
**Implication for major B2B accounts:** Enterprise software, professional services, and other high-value B2B decisions have decision cycles that are orders of magnitude longer than a camera purchase. The same mechanism applies with greater force. Closing training from transactional environments is especially harmful when imported into major-account sales.
---
### Study 3: The BP Buyer — Index Card Story
**Context:** Rackham attended a training program for professional buyers from large organizations. During the program, a VP of purchasing for BP (British Petroleum) described how he handled sellers who used closing techniques.
**The story:** During a sales call, a seller used an Assumptive Close ("you've agreed our cups are cheaper than your present supplier, so shall we make our first delivery of, say, 20,000 cups next month?"). The buyer opened a drawer, selected an index card labeled ASSUMPTIVE CLOSE, and placed it face up on the desk. He told the seller: "That's your first chance. I give people two. If you use just one more closing technique on me, then it's no sale."
The buyer had a full box of 3×5 index cards, each labeled with a specific closing technique.
**The 54-Buyer Questionnaire**
Rackham circulated a questionnaire to 54 professional buyers from three large organizations. The question asked: "If you detect that a seller is using closing techniques while selling to you, what effect, if any, does this have on your likelihood of buying?"
Results:
- More likely to buy: 2
- Indifferent: 18
- Less likely to buy: 34
**Interpretation:** Of 54 professional buyers, 34 said closing techniques made them less likely to buy, against only 2 who said they were more likely. The professional buyers who are most relevant to major-account B2B sellers — experienced procurement personnel and senior executives — are systematically the most put off by closing techniques.
**Why this matters:** Sales training materials sometimes claim that sophisticated buyers respond positively to closing techniques because it signals a professional seller. Rackham explicitly calls this "dangerous nonsense" and notes there is no research evidence supporting the claim. The available evidence runs in the opposite direction.
---
## Named Closing Techniques and Their Failure Modes
These are the specific techniques documented in SPIN Selling's research. The book does not teach them as legitimate — they are documented to allow sellers to recognize when they are defaulting to them.
**Assumptive close**
Assumes the sale is made before the buyer has agreed: "Where would you like it delivered?" Failure mode: creates immediate resistance in buyers who have not made a decision. The BP buyer's index card was labeled "ASSUMPTIVE CLOSE."
**Alternative close**
Presents a forced choice between two options, neither of which is "no": "Would you prefer delivery on Tuesday or Thursday?" Failure mode: buyers recognize the technique and feel manipulated; it reduces credibility with sophisticated buyers.
**Standing-room-only close**
Creates false urgency: "If you can't decide now, I'll have to offer it to another customer." Failure mode: in major B2B sales with known vendor alternatives and professional procurement, false scarcity is easily identified and penalized.
**Last-chance close**
Artificial deadline: "The price goes up next week, so unless you buy now…" Failure mode: same as standing-room-only. Professional buyers frequently call this bluff.
**Order-blank close**
Filling in an order form without buyer agreement, to create momentum. Failure mode: creates hostility. The visual of an order form being filled out without consent is perceived as presumptuous.
**ABC (Always Be Closing)**
The sales management doctrine of closing at every opportunity throughout the call. Failure mode: the 190-call study directly tested this and found it negatively correlated with success in major sales.
---
## Why Salespeople and Managers Continue Using These Techniques Despite the Evidence
Rackham explored the persistence of ineffective behavior in Chapter 2 and identified two mechanisms:
**Short-term feedback loops:** Closing techniques sometimes produce a commitment — particularly with lower-sophistication buyers, in smaller sales, or in situations where the buyer was going to buy anyway. When they work occasionally, they reinforce belief in the technique regardless of the overall success rate.
**Environmental reinforcement:** Sales managers who were trained on closing techniques reward closing behavior in their teams. Sales training programs designed around closing proliferate the training. The internal reward system (manager approval, recognition for "closing hard") operates independently of actual close rates.
**The analogy (Rackham):** A manager at a seminar suggested the same logic applies to superstitions: if a baseball player touches his cap before swinging and occasionally hits a home run, he may come to believe the cap-touching is causal. The correlation is false but the reinforcement is real.
---
## The Counter-Position (and Why It Does Not Hold)
Some sales trainers argue that sophisticated buyers respond positively to closing because it signals a professional seller. Rackham notes: "There's not one scrap of evidence to back that sort of assertion." The 54-buyer questionnaire, the Photo-Store study, and the 190-call study all point in the same direction. No available research supports the claim that sophisticated buyers prefer sellers who use closing techniques.
The correct model is the opposite: sophisticated buyers in major accounts — who have been through many sales processes and often have training in recognizing closing techniques — react most negatively to pressure closing, and represent the most important deals in the portfolio.
---
## The Alternative: Advance-Targeting
The empirical evidence does not show that commitment should not be sought in major sales. It shows that pressure techniques are counterproductive in seeking it. The alternative — documented in the Four Successful Actions — is:
1. Develop need perception through thorough Investigating.
2. Proactively clear concerns before they surface as resistance.
3. Summarize Explicit Benefits aligned to the buyer's stated needs.
4. Propose a specific, realistic next step (the Advance).
Buyers who clearly perceive a need for what you offer and have no unresolved concerns will often agree to a logical next step without any pressure. The Four Successful Actions create those conditions.
FILE:references/four-successful-actions-detail.md
# Four Successful Actions — Extended Detail
Source: SPIN Selling, Chapter 2, "Obtaining Commitment: Four Successful Actions"
## Overview
Rackham's research on major-sale commitment situations identified four behaviors that consistently appeared in successful outcomes and were absent or reduced in unsuccessful ones. These are not a rigid script — they are an emphasis framework that changes where attention and time go in the final phase of the call.
---
## Action 1: Invest in the Investigating Stage
### What the research found
Successful salespeople in major sales give their primary attention to the Investigating (discovery) stage, taking substantially more time on it than less successful sellers. The critical insight is that customers who clearly perceive a need for what you offer will often close the sale themselves. When the need perception is strong enough, the buyer does the commitment work.
Less successful sellers rush through Investigating to get to solution presentation and commitment. This creates a situation where they must use pressure techniques to force a decision — because the buyer does not yet have a strong enough reason to act.
### Practical implication for planning
Before planning your commitment sequence, ask: does this customer clearly perceive the need that your solution addresses? If the answer is uncertain or no, the priority for this call should be Investigating — not commitment-seeking. A premature commitment attempt on undeveloped needs will require pressure to work, and pressure will produce resistance.
The pre-call planning question: "What Investigating do I still need to complete before this buyer will naturally want to take the next step?"
### When Action 1 is the primary work of the call
On early calls, or when prior discovery has been incomplete, Action 1 may be the entire substance of the call. The commitment obtained on such a call may be modest (e.g., a follow-up to continue discovery) — and that is the correct outcome, not a failure.
---
## Action 2: Check That Key Concerns Are Covered
### What the research found
Successful sellers proactively asked whether there were areas the buyer still wanted explored or concerns that had not been addressed — before attempting to obtain commitment. Less successful sellers used closing techniques to surface concerns, which created an antagonistic dynamic.
Rackham's contrast illustration:
**Closing-technique version (creates antagonism):**
> Seller: (Assumptive Close) "…so I'll arrange for our technical people to set up a demonstration next week."
> Buyer: "Hey, wait a minute, I'm not sure whether I'm ready for a demonstration."
> Seller: (Alternative Close) "Then would it be better if, instead of setting it up for next week, I set it up for the week after?"
> Buyer: "Now, not so fast. You still haven't explained how this leasing arrangement would work. What are you trying to hide?"
**Proactive concern-checking version (creates dialogue):**
> Seller: "Well, I think that covers everything, Ms. Brown. But before we go further, could I check whether there are any areas that you feel I should tell you more about?"
> Buyer: "Yes, you haven't mentioned the terms of the leasing arrangement."
> Seller: "Then let me cover that now. The way it works is…"
In the second example, the same concern surfaces — but as a query rather than an antagonistic protest. The seller's proactivity removes the pressure dynamic.
### Practical dialogue variants
- "Before we talk about next steps, I want to make sure I've covered everything important to you. Are there questions I haven't addressed?"
- "Is there anything else you need to understand before a next step would make sense?"
- "What are the biggest open questions from your perspective right now?"
### What to do with concerns that surface
Address them directly. Do not minimize them or redirect with more closing pressure. A concern addressed with straight information is almost always less damaging than a concern that surfaces as antagonism after a failed close.
---
## Action 3: Summarize Benefits
### What the research found
In major sales where calls have covered substantial ground over extended time, customers often do not hold a clear running summary of what has been established. Successful sellers explicitly pulled key discussion threads together before moving to commitment.
The instruction is specific: summarize Benefits — not Features, not Advantages. In Rackham's taxonomy, a Benefit is a capability that meets a specific Explicit Need the customer has already expressed. Features (product characteristics) and Advantages (reasons a feature is useful in general) have much lower impact at the commitment stage.
### How to identify what to summarize
Scan prior call notes and the current call for Explicit Need statements — buyer statements expressing a specific want, desire, or intention (not just problems or difficulties, which are Implied Needs). Each Explicit Need that you can meet with a real capability is a genuine Benefit and belongs in the summary.
If no Explicit Needs have been developed in this call cycle, the summary step will be thin — which is a diagnostic signal that more Investigating is needed (back to Action 1).
### Sample summary structure
"Let me bring together what we've covered. You mentioned that [Explicit Need A] was a priority — and we've established that [Capability X] addresses that specifically by [mechanism]. You also raised [Explicit Need B], and [Capability Y] handles that through [mechanism]. Based on everything you've shared, there seem to be clear gains from [the solution / the change] — particularly around [the most important Explicit Need]."
### Length calibration
In a short call (30-45 min), the summary may be 3-4 sentences. In a multi-hour consultative session, it may be a paragraph or two. Scale to the complexity of the call, not to a fixed length.
---
## Action 4: Propose a Commitment
### The "propose" vs "ask" distinction
Most closing training tells sellers to "ask for the order." Rackham's research found that successful sellers do something subtly different: they propose a specific next step. This changes the framing from "would you like to commit?" (which invites evaluation) to "here is the logical next step" (which frames commitment as the natural conclusion of what has been established).
This is not manipulation — it is more accurate framing. If the previous three actions have been executed well, a specific next step genuinely is the natural conclusion. The proposal acknowledges that.
### Example of propose-not-ask
> Seller: (summary complete)
> Buyer: "Yes, when you add it all up, there's a lot of value to us from making the change."
> Seller: "Then I might suggest that the most logical next step would be for you and your accountant to come and see one of these systems in operation."
The seller does not say "would you like to see a demo?" — an open invitation to say no. The seller proposes the specific next step that follows from what has been established.
### The two commitment criteria
When choosing what commitment to propose, apply both:
1. **The commitment advances the sale** — something will move forward as a result.
2. **The commitment is the highest realistic commitment the customer can give** — do not push beyond achievable limits; do not settle for less than what is achievable.
"Highest realistic" requires judgment based on deal stage, stakeholder access, and buyer readiness. If uncertain, err toward proposing a slightly smaller Advance rather than pushing past the buyer's readiness threshold — the Fallback Advance planned in Step 3 is precisely for this.
### When the commitment is not obtained
If the buyer does not agree to the proposed commitment:
1. Do not escalate to pressure techniques.
2. Offer the fallback Advance.
3. If the fallback is also declined, ask a diagnostic question: "What would need to be true for [the proposed next step] to make sense?" This surfaces the actual obstacle rather than attempting to pressure past it.
---
## Adapting the Sequence to Different Call Lengths
| Call type | Action 1 weight | Action 2 | Action 3 | Action 4 |
|-----------|----------------|----------|----------|----------|
| 30-min discovery call | Primary work of call — Investigating is everything | Light check: "Any questions before we wrap?" | Very brief — 1-2 Explicit Needs if established | Propose a modest Advance: next call with specific agenda |
| 90-min consultative session | First 60 min — extensive | Full proactive check | Full summary — may be 5-6 Explicit Needs | Propose a specific, substantive Advance |
| 3-hour enterprise workshop | Action 1 runs the entire workshop | Full check at end — 5 min | Formal summary document, handed over | Propose a project-level next step with stakeholder commitment |
The sequence is not eliminated for shorter calls — it is compressed. Even a 30-minute call benefits from asking "any open questions?" before wrapping and summarizing one benefit before proposing a next call.
Administer and score the Closing-Attitude Scale — a 15-item validated psychometric from SPIN Selling research. Use when a salesperson wants to evaluate wheth...
---
name: closing-attitude-self-assessment
description: "Administer and score the Closing-Attitude Scale — a 15-item validated psychometric from SPIN Selling research. Use when a salesperson wants to evaluate whether their attitude toward closing techniques fits the kind of sale they are running, assess whether they are closing too aggressively or too passively, diagnose if their pro-closing beliefs could be hurting their large-sale results, answer 'should I close harder?', 'am I closing too aggressively?', 'is my closing approach wrong for this type of deal?', or 'evaluate my closing attitude'. Also invoke for anyone who believes 'always be closing' is good sales practice, wants to test themselves against Rackham's research findings, or needs to understand why closing techniques hurt large-sale performance. The skill administers all 15 items interactively, calculates a total (15-75), interprets the score against the user's actual selling context (deal value, buyer sophistication, post-sale relationship), and delivers a written assessment with remediation guidance when score and context are mismatched."
version: 1.0.0
homepage: https://github.com/bookforge-ai/bookforge-skills/tree/main/books/spin-selling/skills/closing-attitude-self-assessment
metadata: {"openclaw":{"emoji":"🎯","homepage":"https://github.com/bookforge-ai/bookforge-skills"}}
status: draft
source-books: ["spin-selling"]
tags: ["sales", "b2b-sales", "closing", "enterprise-sales", "self-assessment", "sales-methodology"]
depends-on: []
execution:
tier: 1
mode: hybrid
inputs:
- type: interactive
description: "User answers to 15 Likert items (1-5 each) administered one at a time or in batch"
- type: interactive
description: "Selling context: deal value level, buyer sophistication, and post-sale relationship length"
outputs:
- closing-attitude-assessment.md
tools-required: [Write]
tools-optional: [Read]
mcps-required: []
discovery:
trigger_phrases:
- "evaluate my closing attitude"
- "should I close harder"
- "am I closing too aggressively"
- "is my closing approach wrong"
- "how do I know if my closing style is hurting me"
- "always be closing"
- "closing techniques in large sales"
- "why am I losing deals after good demos"
- "closing attitude test"
- "spin selling closing scale"
---
# Closing-Attitude Self-Assessment
## When to Use
You are working with a salesperson — B2B account executive, enterprise rep, solutions consultant, or founder-led seller — who wants to evaluate whether their beliefs and instincts about closing techniques align with the type of selling they actually do.
**This skill is the right tool when:**
- The user asks "am I closing too hard?" or "should I be closing more?"
- The user has been trained in closing techniques and wants to know if those beliefs are helping or hurting them
- The user is losing large deals after what felt like strong calls, and instinct is to "close harder"
- A sales manager suspects a rep's pro-closing attitude is alienating sophisticated buyers
- The user wants to benchmark their beliefs against Rackham's empirical research
**This skill is NOT the right tool when:**
- The user wants to plan how to obtain commitment on a specific upcoming call — use `commitment-and-advance-planner`
- The user wants to classify the outcome of a past call — use `call-outcome-classifier`
- The user wants to learn closing techniques (this skill may conclude the opposite is needed)
**Scale origin:** The Closing-Attitude Scale was developed by Neil Rackham and colleagues at Huthwaite when a chemical company's marketing director believed poor results were an "attitude problem." The researchers created the 15-item instrument and measured 38 salespeople. The results were the opposite of what the director expected: those with the most pro-closing attitude were *below* sales target, not above it. The scale and its interpretation framework appear verbatim in SPIN Selling, Appendix B.
## Context and Input Gathering
### Required (ask if not provided)
- **Nothing is required upfront.** The skill administers the assessment first, then gathers selling context for interpretation.
### Gathered During Administration
After scoring, ask about three contextual factors:
1. **Deal value:** Are the deals you typically run low-value (under ~$5K), mid-range, or high-value/enterprise?
2. **Buyer sophistication:** Are your buyers typically consumers or junior buyers — or trained procurement professionals, senior executives, and professional purchasing agents?
3. **Post-sale relationship:** After the sale closes, do you have no ongoing relationship, or does your company provide ongoing service, support, or account management to that buyer?
These three factors are Rackham's exact criteria for determining whether a pro-closing attitude is justified or harmful.
## Process
### Step 1: Administer the Scale
**ACTION:** Present all 15 items from the Closing-Attitude Scale. Either administer one item at a time (recommended for reflective engagement) or present all 15 at once and ask for scores.
**WHY:** The scale is designed to surface instinctive beliefs, not considered positions. Presenting items one at a time reduces the tendency for the user to "correct" later answers based on pattern recognition from earlier ones. However, batch presentation is acceptable when the user explicitly prefers efficiency.
**Administration format (one at a time):**
> "I'll ask you 15 statements. For each one, rate your agreement on a 1-5 scale where:
> 1 = Strongly Disagree, 2 = Disagree, 3 = Neutral, 4 = Agree, 5 = Strongly Agree
>
> There are no trick questions, and no answer is wrong — the scale is calibrated empirically. Answer what you actually believe, not what you think is 'correct.'"
Present each item and collect the numeric score. See [`references/closing-attitude-scale.md`](references/closing-attitude-scale.md) for all 15 items verbatim. Do not paraphrase the items.
**Record scores in a running list:** Item 1: ___, Item 2: ___, ... Item 15: ___
### Step 2: Calculate the Total Score
**ACTION:** Sum all 15 scores. Present the result clearly.
**WHY:** The sum is the only metric — there are no subscales. The total positions the user on the pro-closing spectrum relative to Rackham's threshold.
**Formula:**
```
Total = Item1 + Item2 + Item3 + ... + Item15
Range: 15 (all 1s) to 75 (all 5s)
Neutral point: 45
Favorable-attitude threshold: > 50
```
**Present as:**
> "Your total score is **[X]** out of 75.
> - Below 45: Skeptical of closing techniques
> - 45-50: Neutral to mildly favorable
> - Above 50: Favorable attitude toward closing techniques"
Do NOT interpret the score yet. Proceed to context gathering first.
**WHY the order matters:** A score of 58 means something very different for a door-to-door canvasser vs an enterprise software AE. Interpreting before establishing context produces advice that could be directionally wrong.
### Step 3: Gather Selling Context
**ACTION:** Ask three context questions about the user's typical sales:
> "Before I interpret your score, I need to understand the type of selling you do. Three quick questions:
>
> 1. **Deal value:** What is the typical deal size in your work — roughly low-value (under $5K, quick transactions), mid-range ($5K-$50K), or high-value/enterprise ($50K+, multi-month sales cycles)?
>
> 2. **Buyer sophistication:** Are the people you sell to typically consumers or informal buyers — or professional procurement agents, trained purchasing managers, and senior executives who buy for organizations regularly?
>
> 3. **Post-sale relationship:** After a deal closes, does the relationship end, or does your company have an ongoing relationship with that buyer (account management, support contracts, renewals, expansions)?"
Record responses for use in Step 4.
**WHY:** Rackham's key finding was that the *effectiveness* of closing techniques depends entirely on sale type. The same pro-closing behavior that helps in low-value transactional sales actively hurts in large, sophisticated, or relationship-based sales. Context is not a secondary consideration — it is the interpretation framework.
### Step 4: Apply Context-Aware Interpretation
**ACTION:** Apply Rackham's interpretation matrix against the user's score and context. Output a clear verdict — ALIGNED, MISMATCH-MILD, or MISMATCH-CRITICAL.
**WHY:** A high score (>50) is only a problem if the selling context is large/sophisticated/relational. The skill must be willing to tell a high-scoring user that their attitude is a **liability** in their context — this is Rackham's empirical finding, not opinion.
**Interpretation Matrix:**
| Score | Deal Value | Buyer Sophistication | Post-Sale Relationship | Verdict |
|-------|-----------|----------------------|------------------------|---------|
| ≤ 50 | Any | Any | Any | ALIGNED — skepticism about closing is well-founded across all sale types |
| > 50 | Low-value | Unsophisticated | None/minimal | ALIGNED — pro-closing attitude may be justified for transactional selling |
| > 50 | Low-value | Unsophisticated | Ongoing | MISMATCH-MILD — closing pressure strains relationships even in smaller sales |
| > 50 | Mid/High | Any | Any | MISMATCH-CRITICAL — pro-closing attitude is a liability in your context |
| > 50 | Any | Sophisticated | Any | MISMATCH-CRITICAL — professional buyers actively resist detected closing techniques |
**Deliver verdict with evidence:**
For ALIGNED (low score, any context):
> "Your score of [X] indicates a skeptical or neutral attitude toward closing techniques. This aligns with Rackham's research. In the Huthwaite 190-call study, calls with the fewest closing attempts outsold calls with the most closing attempts (21 vs 11 sales out of 30 calls each). Your instincts are evidence-based."
For ALIGNED (high score, transactional context):
> "Your score of [X] shows a favorable attitude toward closing. Given your context — [describe their context] — this is defensible. Rackham's Photo-Store Study found that closing training improved low-value sales success from 72% to 76%. The pro-closing approach is suited to transactional, high-volume selling where decision speed matters."
For MISMATCH-MILD:
> "Your score of [X] shows a favorable attitude toward closing. However, your context — [describe their context] — introduces friction. [Explain relevant mismatch factor.] The BP buyer questionnaire found that 34 of 54 professional buyers said they were *less likely* to buy when they detected closing techniques. Consider the risk of eroding the post-sale relationship."
For MISMATCH-CRITICAL:
> "Your score of [X] shows a strongly favorable attitude toward closing. **In your selling context, this is a liability, not an asset.**
>
> Rackham's evidence:
> - **Photo-Store Study:** Closing training *reduced* high-value sales success from 42% to 33%. The same training that helped with cheap goods hurt with expensive goods.
> - **190-Call Study:** Among 190 observed calls, the 30 calls with the most closing attempts produced only 11 sales. The 30 calls with the fewest closing attempts produced 21 sales.
> - **BP Buyer Questionnaire:** 34 of 54 professional buyers said detecting closing techniques made them *less* likely to buy. Only 2 said it made them more likely.
> - **Chemical Company Attitude Study (the origin of this scale):** The salespeople with the most favorable closing attitude were *below sales target*. The pro-closing group underperformed the skeptical group.
>
> Your beliefs about closing are optimized for a type of sale you are NOT running."
### Step 5: Deliver Remediation Guidance (if MISMATCH)
**ACTION:** For any MISMATCH verdict, explain the replacement behavior framework and point to the relevant skill.
**WHY:** Telling a user their beliefs are counterproductive without giving them a replacement creates anxiety without direction. Rackham's research identified exactly what should replace closing pressure — a framework called the Four Successful Actions for Obtaining Commitment.
**Remediation message:**
> "The goal is not to close less — the goal is to replace closing pressure with a framework that actually works in large sales. The research-backed replacement is called Advance-targeting: defining specific customer actions (advances) you want before each call, and using the Four Successful Actions to obtain them.
>
> The Four Successful Actions (Rackham, Chapter 2/Chapter 6):
> 1. **Invest in investigating** — spend enough time in discovery that the buyer has expressed real explicit needs, not just surface-level curiosity
> 2. **Check for unresolved concerns** — explicitly ask if there are remaining objections or issues before moving toward commitment
> 3. **Summarize benefits linked to the buyer's explicit needs** — not features or advantages, but benefits tied to what they said they needed
> 4. **Propose a specific commitment** — name a concrete next action, not a vague 'where do we go from here?'
>
> For a full tool to plan a specific advance for your next call, use `commitment-and-advance-planner`."
### Step 6: Write the Assessment Artifact
**ACTION:** Write a `closing-attitude-assessment.md` file summarizing the full assessment.
**WHY:** The assessment is a starting point for behavioral change, not a one-time read. A written artifact the user can return to — and share with their manager — increases the probability that the insights translate into practice.
**File structure:**
```markdown
# Closing-Attitude Assessment
**Date:** [date]
**Score:** [X] / 75
**Verdict:** [ALIGNED / MISMATCH-MILD / MISMATCH-CRITICAL]
## My Scores
[Item-by-item list with values]
## My Selling Context
- Deal value: [their answer]
- Buyer sophistication: [their answer]
- Post-sale relationship: [their answer]
## Interpretation
[Full interpretation text from Step 4]
## Recommended Next Steps
[Remediation from Step 5, if applicable; or affirmation if aligned]
```
## Key Principles
**The scale is empirically grounded, not prescriptive.**
Rackham did not design this scale to push a philosophical position about selling. It was created in response to a manager's hypothesis that pro-closing attitudes would predict better results. The data falsified that hypothesis. The scale's interpretation reflects what the data showed.
**High score in the right context is fine.**
Do not treat every score above 50 as a failure. The skill's job is context-sensitive interpretation, not universal anti-closing advocacy. A door-to-door canvasser, a retail floor salesperson, or a telesales rep handling low-value inbound may legitimately be well-served by pro-closing instincts.
**The mismatch verdict must be stated clearly.**
Baseline language models, trained on general sales literature, will validate pro-closing beliefs because most sales books still teach closing techniques. This skill enforces Rackham's empirical finding: **in large, sophisticated, or relationship-based sales, closing pressure correlates with worse outcomes.** The skill must be willing to state this plainly to a user who scores >50 and works in that context. Softening the verdict to avoid discomfort defeats the purpose.
**Remediation, not criticism.**
When delivering a mismatch verdict, always pair the critique with the replacement behavior (Four Successful Actions / Advance-targeting). The goal is to change behavior, not shame beliefs.
**Score items at face value — do not reverse-score.**
Items 2, 6, 8, 10, and 15 express skepticism about closing techniques. A high raw score on these means the user *disagrees* with the skepticism (i.e., is pro-closing). Score all items 1-5 as stated and sum them directly. The scale is calibrated so the sum produces the correct result without any adjustment.
## Examples
### Example 1: Enterprise AE with high score
**Scenario:** Senior enterprise AE, $200K average deal size, 6-month sales cycles, ongoing account management.
**Trigger:** User asks "I always close hard — is that hurting me?"
**Process:** Administer 15 items → score = 62 → gather context (high-value deals, trained procurement buyers, long-term account relationships) → apply matrix → MISMATCH-CRITICAL → deliver Photo-Store Study + BP buyer evidence → explain Four Successful Actions → write assessment artifact.
**Output:** `closing-attitude-assessment.md` with MISMATCH-CRITICAL verdict, citing that 42→33% high-value closing data matches their context exactly, plus pointer to `commitment-and-advance-planner`.
### Example 2: Inside sales rep with high score — aligned context
**Scenario:** Inside sales rep, $500 SaaS monthly subscriptions, 1-week close cycles, no post-sale relationship (product-led growth, no CSM).
**Trigger:** User wants to test their beliefs before a sales training.
**Process:** Administer 15 items → score = 55 → gather context (low-value, semi-sophisticated buyers, minimal ongoing relationship) → apply matrix → ALIGNED (pro-closing justified) → affirm with Photo-Store low-value data (72→76%) → acknowledge tradeoff (shorter relationship window).
**Output:** `closing-attitude-assessment.md` with ALIGNED verdict, noting that transactional context makes pro-closing attitude defensible.
### Example 3: New rep wanting to test themselves
**Scenario:** SDR transitioning to AE role, not sure if their closing instincts fit enterprise selling.
**Trigger:** Manager asks them to assess their beliefs before joining the SPIN training cohort.
**Process:** Administer 15 items → score = 47 → context (moving to $80K avg deal size, will be dealing with VP-level buyers) → ALIGNED (neutral score is already appropriate for the context they're entering) → affirm the score, note that as they enter enterprise AE work, scores above 50 would become a risk.
**Output:** `closing-attitude-assessment.md` with ALIGNED verdict and context-forward note about why maintaining this skeptical orientation matters for the new role.
## References
- Full 15-item instrument, scoring formula, and supporting study data: [`references/closing-attitude-scale.md`](references/closing-attitude-scale.md)
- For planning specific commitments and advances using the Four Successful Actions: `commitment-and-advance-planner`
- For classifying past call outcomes (Order / Advance / Continuation / No-sale): `call-outcome-classifier`
## License
This skill is licensed under [CC-BY-SA-4.0](https://creativecommons.org/licenses/by-sa/4.0/).
Source: [BookForge](https://github.com/bookforge-ai/bookforge-skills) — SPIN Selling by Neil Rackham.
## Related BookForge Skills
This skill is standalone. Companion skills from SPIN Selling:
- `clawhub install bookforge-commitment-and-advance-planner` — plan the specific commitment to seek on your next call using the Four Successful Actions
- `clawhub install bookforge-call-outcome-classifier` — classify whether a past call ended in an Advance, Continuation, Order, or No-sale
Browse more BookForge skills: [bookforge-skills](https://github.com/bookforge-ai/bookforge-skills)
FILE:references/closing-attitude-scale.md
# Closing-Attitude Scale — Verbatim Instrument
Source: SPIN Selling by Neil Rackham (1988), Appendix B.
---
## Administration Instructions (verbatim from Appendix B)
> 1. Read the following 15 statements about closing.
> 2. After each statement, put a check in the box that most nearly represents your own opinion.
> 3. Follow the instructions at the end of the scale to calculate and interpret your score.
---
## The 15 Items
Rate each statement on a scale of **1 to 5**:
- 1 = Strongly Disagree
- 2 = Disagree
- 3 = Neutral
- 4 = Agree
- 5 = Strongly Agree
| # | Statement | Score (1-5) |
|---|-----------|-------------|
| 1 | Closing is the most valuable of all techniques for increasing sales. | ___ |
| 2 | Trying to close a sale too often will reduce your chances of success. | ___ |
| 3 | Unless you know a lot of closing techniques, you will be unable to sell effectively. | ___ |
| 4 | Even at the start of a sale, it never hurts to use a trial close. | ___ |
| 5 | Weak closing is the most common cause of lost sales. | ___ |
| 6 | Customers are less likely to buy if they recognize that you are using closing techniques. | ___ |
| 7 | You cannot close too often when selling. | ___ |
| 8 | Closing techniques don't work with professional buyers. | ___ |
| 9 | The ABC of selling is Always Be Closing. | ___ |
| 10 | It's your other behavior earlier in the sale, not your closing technique, that determines whether a customer will buy. | ___ |
| 11 | You should try to close every time that you see a buying signal. | ___ |
| 12 | From the moment you enter the customer's office, you should act as though the sale has already been made. | ___ |
| 13 | If a customer resists your trial close, then it's a sign that you should have closed more forcefully. | ___ |
| 14 | No matter how good your other skills, you will never succeed unless you have good closing techniques. | ___ |
| 15 | Using closing techniques early in the sale is a sure way to antagonize customers. | ___ |
---
## Scoring Formula (verbatim from Appendix B)
> To calculate your score, take the number (between 1 and 5) of the box that you checked for each statement and add up your total for the 15 statements.
>
> Theoretically, a score of 45 is absolutely neutral. A higher score shows a positive attitude toward closing, and a lower score shows a negative attitude. In practice, most salespeople score a little above 45, and in our studies we allowed for this by taking a score above 50 as demonstrating a favorable attitude toward closing.
**Total score range:** 15 (minimum) to 75 (maximum)
**Neutral point:** 45
**Favorable-attitude threshold:** > 50
---
## Interpretation Context (verbatim from Appendix B)
> In the study described in Chapter 2, the salespeople with the best results were those with a low (unfavorable) score: one below 50.
>
> As Chapter 2 explains, however, the effectiveness of closing techniques depends on the type of selling you do.
>
> If your business involves **low-value goods and services, unsophisticated customers, and no after-sale relationship with the customer**, then a very favorable attitude toward closing (a score above 50) might well be justified in terms of your selling situation.
>
> But if you score above 50 on this test and your business involves **larger sales, sophisticated customers, and a continuing post-sale relationship**, then please read Chapter 2 very carefully. In the larger sale, closing techniques are more of a liability than an asset.
---
## Supporting Evidence from Chapter 2
### Photo-Store Study
- **Setup:** A photographic store chain trained salespeople in closing techniques; study compared performance before and after training on low-value vs high-value goods.
- **Low-value result:** Success rate rose from **72% to 76%** (transaction time shortened, more closes used). Positive, directionally favorable.
- **High-value result:** Success rate fell from **42% to 33%**. The same closing training that helped with cheap goods *hurt* results with expensive goods.
- **Conclusion:** Closing techniques speed transactions in low-value sales. They reduce success in high-value sales.
### 190-Call Study (initial Huthwaite research)
- **Setup:** Observed 190 calls; compared the 30 calls with the most closes vs the 30 with the fewest closes.
- **High-close calls:** 11 of 30 resulted in a sale.
- **Low-close calls:** 21 of 30 resulted in a sale.
- **Conclusion:** Contrary to the "five closes per call" folk wisdom, more closes correlated with *fewer* sales.
### BP Buyer Study (professional buyer questionnaire)
- **Setup:** 54 professional buyers from three large organizations asked: "If you detect that a seller is using closing techniques, what effect does this have on your likelihood of buying?"
- **Results:**
- More likely to buy: **2**
- Indifferent: **18**
- Less likely to buy: **34**
- **Conclusion:** 63% of sophisticated buyers actively resist when they detect closing techniques.
### Chemical Company Attitude Study (the origin of the scale)
- **Setup:** A marketing director believed poor sales results were due to "attitude problems" — not enough closing drive. Rackham devised the 15-item scale and measured 38 salespeople.
- **Finding:** 21 of 38 scored above 50 (favorable attitude toward closing). Comparing sales results: the high-attitude group was *below target*, not above it.
- **Conclusion:** Pro-closing attitude did not predict sales success; it predicted underperformance.
---
## Notes for Score Administration
Items 2, 6, 8, 10, and 15 are **negatively worded** (agreement = skepticism about closing). The scale is designed so that all items are scored at face value (1-5 as stated); the aggregation naturally produces a lower total for pro-SPIN, anti-closing attitudes. Do not reverse-score any items — use the raw sum.
Classify whether a sales call outcome was an Order, an Advance, a Continuation, or a No-sale — and flag when the seller has misread a Continuation as success...
---
name: call-outcome-classifier
description: |
Classify whether a sales call outcome was an Order, an Advance, a Continuation, or a No-sale — and flag when the seller has misread a Continuation as success. Use when someone asks "did this call go well?", "was this a successful call?", "classify this call outcome", "is this a Continuation or an Advance?", "the prospect said they were impressed but didn't commit to anything specific", "they said 'fantastic presentation, let's meet again' — is that progress?", "I'm not sure if we moved the deal forward", "how do I score this call?", or "the customer seemed positive but I don't know if we advanced." Also invoke when someone shares call notes or a transcript and wants to know whether the deal progressed, whether to update their CRM with a pipeline advance, or whether a next call is needed because this one stalled. Works on raw call notes, transcripts (Gong, Chorus, Zoom exports), or recalled summaries. Reads the call; identifies the specific customer action committed (or the absence of one); classifies the outcome against SPIN Selling's four-outcome framework; flags the classic Continuation-as-success misread; and outputs a written deal-tracking assessment. Pair with commitment-and-advance-planner (SPIN Selling) to plan a better Advance objective for the next call when this one ended in Continuation.
version: 1.0.0
homepage: https://github.com/bookforge-ai/bookforge-skills/tree/main/books/spin-selling/skills/call-outcome-classifier
metadata: {"openclaw":{"emoji":"📚","homepage":"https://github.com/bookforge-ai/bookforge-skills"}}
status: draft
depends-on: []
source-books:
- id: spin-selling
title: "SPIN Selling"
authors: ["Neil Rackham"]
chapters: [2]
tags: [sales, b2b-sales, enterprise-sales, call-review, call-outcome, advance, continuation, deal-tracking, commitment, complex-sales, spin-selling, post-call-analysis, pipeline-management]
execution:
tier: 1
mode: hybrid
inputs:
- type: document
description: "call-notes-{date}.md or call-transcript-{date}.md — raw notes or transcript from the call being classified"
- type: document
description: "deal-brief.md (optional) — provides deal context (stage, prior advances, account name) for a richer assessment"
tools-required: [Read, Write]
tools-optional: [Grep]
mcps-required: []
environment: "document_set — reads call notes or transcript from the working directory; writes call-outcome-{date}.md"
discovery:
goal: "Determine whether a sales call resulted in a specific customer action commitment that moves the deal forward (Advance), a firm purchase decision (Order), an explicit rejection (No-sale), or a non-committal continuation with no agreed next step (Continuation) — and surface the Continuation-as-success misread if present"
tasks:
- "Read the call notes or transcript and identify every statement the customer made that could constitute a commitment"
- "Apply the four-outcome framework to classify the call: Order / Advance / Continuation / No-sale"
- "Name the specific customer action if classified as Advance, or state explicitly that no action was committed"
- "Flag if positive seller language or customer pleasantries are masking a Continuation"
- "Detect classic Continuation phrases and distinguish them from genuine Advance commitments"
- "Write call-outcome-{date}.md with the classification, rationale, and deal-tracking note"
audience:
roles: ["account-executive", "enterprise-sales-rep", "sales-development-representative", "solutions-consultant", "founder-led-seller"]
experience: "intermediate — has run sales calls but may have been classifying Continuations as success throughout their career"
triggers:
- "User shares call notes or transcript and asks whether the call went well or moved the deal forward"
- "User is unsure whether to update the CRM with a pipeline advance after a call"
- "User got positive verbal feedback from the buyer but has no specific next step agreed"
- "User wants to classify a call outcome against the SPIN four-outcome framework"
- "User suspects they may have accepted a Continuation when they needed an Advance"
- "User preparing post-call review and needs a structured outcome assessment"
not_for:
- "Planning the Advance objective for the next call — use commitment-and-advance-planner for that"
- "Diagnosing why the call ended in Continuation (root cause in SPIN question quality or FAB presentation)"
- "Classifying customer need statements as Implied or Explicit — use need-type-classifier"
- "Forecasting deal probability or win likelihood"
quality: placeholder
---
# Call Outcome Classifier
## When to Use
You have just completed a B2B sales call and want to know whether it represented real progress. You have call notes or a transcript. You want a rigorous answer — not a gut-feel read of buyer positivity, but a classification grounded in whether the customer committed to a specific action.
This skill is most valuable when your honest answer to "how did the call go?" is "pretty well, they seemed interested" — that phrasing is a red flag. Rackham's research on 35,000+ sales calls found that salespeople routinely classify calls as successful when the customer expressed enthusiasm but agreed to nothing concrete. The skill forces you to find the customer action; if there isn't one, it classifies the call as a Continuation regardless of how positive the tone was.
**Input this skill needs:** Call notes or a transcript. Deal context (account name, stage, what happened on prior calls) improves the assessment but is not required.
**Do not use this skill to plan the next call.** If the outcome is a Continuation, use `commitment-and-advance-planner` to define a better Advance objective before the next call.
---
## Context & Input Gathering
### Required
- **Call document:** `call-notes-{date}.md` or `call-transcript-{date}.md` — the record of what happened on this call.
- **What was the call's intended objective?** (Even if just recalled aloud: "I was trying to get them to agree to a demo" or "I was going in for the order.")
### Useful
- **Deal context:** Account name, deal stage, what previous calls achieved — helps distinguish a first Advance from a fallback Advance.
- **What the seller said at the close:** The exact words used to ask for next steps, if present. This helps distinguish whether a Continuation happened because no commitment was sought vs. because the buyer declined one.
### Defaults (used if not provided)
- If deal context is absent, classify based on the call notes alone and note the context gap in the output.
- If call objective is absent, infer from the call content (e.g., if a proposal was presented, the objective was likely order or advance toward sign-off).
### Sufficiency check
Call notes or transcript alone is sufficient to classify. Ask the user for context only if the call notes are too sparse to identify whether a customer action was committed.
---
## Process
### Step 1: Read the Call and Extract Customer Action Statements
**Action:** Read the call notes or transcript. Extract every statement the customer made that could represent a commitment, agreement, or next step. List them verbatim or as close to verbatim as the notes allow.
Also note statements of enthusiasm, interest, or approval that do not include a specific action — these will be examined in Step 3.
**WHY:** The entire classification hinges on one question: did the customer commit to a specific action? Sellers read the whole call as a gestalt ("it went well"). This step forces a granular pass — separating customer words from seller words, and action commitments from expressions of opinion. An expression of opinion from the buyer ("we're very impressed") is not an action. A commitment is specific: a named next step, an agreed access point, a scheduled event, or a purchase decision.
---
### Step 2: Apply the Four-Outcome Framework
**Action:** Apply Rackham's four call outcomes to classify the call. Choose exactly one.
**Order** — The customer made a firm commitment to buy. "We're 99% likely to buy" is not an Order. To qualify as an Order, the customer showed an unmistakable intention to purchase — typically by signing paperwork, issuing a PO, or stating explicitly "we're buying this." In most major-account B2B sales, fewer than 10% of calls result in an Order.
**Advance** — An event took place, either during the call or agreed to happen after it, that moves the sale forward toward a decision. The defining criterion is a specific customer action. Typical Advances:
- Customer agreed to attend an off-site demonstration on a specific date
- Customer agreed to arrange a meeting with a higher-level decision-maker
- Customer agreed to run a pilot or product trial
- Customer agreed to introduce the seller to a previously inaccessible stakeholder or department
- Customer agreed to submit a formal evaluation request internally
An Advance must involve the **customer** doing something. A seller saying "I'll follow up next week" is not an Advance — that is the seller taking an action. The customer must commit.
**Continuation** — The sale will continue, but no specific customer action was agreed to move it forward. The call ended without either a purchase decision or a concrete next step from the buyer. Continuations are often disguised by positive buyer language. Classic Continuation phrases (Rackham's verbatim examples):
- "Thank you for coming. Why don't you visit us again the next time you're in the area."
- "Fantastic presentation, we're very impressed. Let's meet again some time."
- "We liked what we saw and we'll be in touch if we need to take things further."
These phrases share a pattern: the buyer is expressing a positive feeling, not committing to an action. The visit, the meeting, and the being-in-touch are all conditional, vague, or seller-initiated. No specific customer action = Continuation.
**No-sale** — The customer explicitly declined to proceed. A clear "we're not going forward," a cancellation, or an unambiguous rejection. No-sales are uncommon in major B2B sales — most calls that don't result in an Order end in either Advance or Continuation.
**Decision rule:** If you cannot name a specific customer action in the notes, classify the outcome as Continuation. The burden of proof falls on the Advance — it must be nameable and specific.
**WHY:** Rackham's research classified Continuations as unsuccessful calls. This may feel harsh when the buyer said positive things. But from the standpoint of pipeline progress, a call that ended with "we're impressed, let's stay in touch" has the same deal value as a call that never happened — no concrete step was taken. The classification is not a moral judgment about call quality; it is a factual statement about whether the deal moved.
---
### Step 3: Check for the Continuation-as-Success Misread
**Action:** If the call contains positive buyer language — enthusiasm, compliments, expressions of interest — and you have classified it as an Advance, re-examine your classification. Ask: is there a specific named customer action in the notes, or did the call simply feel positive?
Apply the Continuation test: "Did the customer agree to do something specific?" If yes — name it. If no — re-classify as Continuation.
Also flag if any of the following patterns are present in the notes:
- The seller summarized the call as "went really well" or "great meeting" with no specific next step named
- The buyer used phrases from the Continuation phrase list above (or close variants)
- The agreed "next step" is the seller's action only (e.g., "I'll send a proposal," "I'll follow up")
- The next meeting is vague ("sometime next month," "let's reconnect in Q3") without a date, attendees, or agenda confirmed
**WHY:** This is the most common failure mode in B2B sales. Rackham found that salespeople — trained on conventional "positive attitude" selling — routinely interpret buyer enthusiasm as progress. Buyers are often genuinely enthusiastic and still not ready to commit to an action. The seller who classifies "fantastic presentation, let's meet again" as an Advance has an inflated pipeline and no real progress. The misread compounds over the sales cycle: each Continuation that is miscounted as an Advance delays the recognition that the deal is stalled.
---
### Step 4: Write the Output Artifact
**Action:** Write `call-outcome-{date}.md` with the following structure.
**WHY:** A written classification creates a record for CRM entry, deal-tracking, and post-call review. It also forces the precision that avoids the Continuation-as-success misread — it is harder to write "Advance" in a document when you cannot supply the specific customer action.
---
## Output Template
```markdown
# Call Outcome Classification: [Account Name]
**Call date:** [Date]
**Call type:** [Discovery / Follow-up / Proposal / Demo / Other]
**Participants:** [Names / roles if known]
**Classified by:** call-outcome-classifier
---
## Classification
**Outcome: [ORDER / ADVANCE / CONTINUATION / NO-SALE]**
### Customer Action Identified
[For ADVANCE or ORDER: State the specific customer action verbatim or near-verbatim from the notes.
Example: "Customer agreed to arrange a meeting with VP of Finance — date TBD, seller to email calendar invite."]
[For CONTINUATION: State explicitly — "No specific customer action was committed on this call."]
[For NO-SALE: State the explicit rejection or withdrawal.]
---
## Rationale
[2-4 sentences explaining why the classification was assigned. Cite the customer's specific words where available.
For CONTINUATION: explain what positive language was present and why it does not constitute an Advance.]
---
## Continuation / Misread Flag
[If CONTINUATION: Flag any positive seller language or buyer compliments that could be misread as success.
Example: "The buyer said 'fantastic presentation' and 'we're very impressed' — these are expressions of sentiment, not commitments. No specific action was agreed."]
[If ADVANCE: State "No misread risk detected — specific customer action is named above."]
[If ORDER or NO-SALE: Not applicable.]
---
## Deal-Tracking Note
**CRM update:** [What to enter in the CRM — stage, next step, advance or not]
**Next call objective:** [If ADVANCE: confirm or extend the advance. If CONTINUATION: target a specific Advance — use commitment-and-advance-planner to define it before the next call. If ORDER: proceed to fulfillment. If NO-SALE: close opportunity.]
---
## References
- Relevant section: SPIN Selling, Chapter 2 (Obtaining Commitment), Four Call Outcomes framework
- For planning the next Advance: spin-selling:commitment-and-advance-planner
```
---
## Key Principles
**An Advance requires a customer action, not a seller action.** The seller saying "I'll send you the proposal" or "I'll follow up next week" is the seller taking an action — that is not an Advance. An Advance is when the customer agrees to do something: attend, introduce, approve, test, escalate. Seller-only next steps are Continuations.
**WHY:** The distinction matters for pipeline accuracy. A seller who believes the deal advanced because they are sending a proposal is treating their own future effort as deal progress. The deal only advances when the customer moves — not when the seller does. This reframe shifts the focus from selling activity to buyer commitment, which is the correct leading indicator in major B2B sales.
**Positive buyer language is noise, not signal.** "Fantastic presentation," "we're very impressed," and "let's meet again sometime" are social pleasantries. Buyers express these in calls they intend to act on and calls they intend to ignore. They have almost no predictive value for deal progress. The only reliable signal is a specific customer action committed.
**WHY:** Rackham's research found that salespeople who equate buyer enthusiasm with deal progress have systematically inflated pipeline views and delayed recognition of stalled deals. Enthusiasm is a necessary but insufficient condition for progress — a buyer must be willing to do something as well as feel something. Training yourself to ignore compliments until they are accompanied by specific actions is one of the highest-leverage behavioral changes available in major-sale selling.
**In major B2B sales, Continuations are the dominant outcome.** Fewer than 10% of calls in a major-account sales force result in an Order or No-sale. The working reality is that most calls end in Advance or Continuation — and the critical discipline is distinguishing between them. An Advance-heavy pipeline is healthy; a Continuation-heavy pipeline is stalled regardless of how "positive" individual calls feel.
**WHY:** The Advance/Continuation distinction is not a formality — it is a diagnostic. A pipeline where most calls are classified as Continuations is a pipeline where deals are not progressing. Recognizing this early allows corrective action: better Advance objectives, stronger commitment-seeking behavior, earlier qualification exits. The seller who classifies Continuations as Advances delays that recognition by entire deal cycles.
**The classification should be able to name the specific action or it is not an Advance.** This is the single hardest rule to apply in practice. After a warm call, the instinct is to see progress. The rule overrides that instinct: if you cannot write down "the customer agreed to [specific action]," it is a Continuation. No exceptions.
**WHY:** Vague "advances" — "they'll think about it," "they seemed interested in a pilot" — are not advances. A customer who "seems interested in a pilot" has not agreed to a pilot. The specificity requirement protects against wishful interpretation and creates a clear, verifiable record.
---
## Examples
### Example 1: Genuine Advance — Specific Action Named
**Scenario:** Enterprise AE reviewing notes from a second discovery call with a logistics software prospect.
**Trigger:** "I think we moved forward but want to make sure before I update the CRM."
**Call notes excerpt:** *"End of call: Marcus said he wants to loop in his VP of Operations before they go further. He offered to set up a three-way call for next Thursday and asked me to send him a one-pager to share internally ahead of time. I said I'd have it to him by Wednesday."*
**Process:**
- Step 1: Customer action statements: "He offered to set up a three-way call for next Thursday" — customer is taking action (setting up the call). "Asked me to send a one-pager" — seller action.
- Step 2: Advance. The customer agreed to arrange access to a higher-level decision-maker (VP of Operations) and set a specific date (next Thursday).
- Step 3: No misread risk. The customer action (three-way call with VP) is specific, dated, and customer-initiated.
**Output:** `call-outcome-2024-03-12.md` — ADVANCE. Customer action: "Marcus agreed to arrange three-way call with VP of Operations for Thursday 2024-03-14; confirmed before call." CRM update: advance to next stage, log VP of Operations introduction as next-step objective.
---
### Example 2: Continuation Disguised as Success
**Scenario:** Mid-market AE reviewing notes from a demo call that felt like a breakthrough.
**Trigger:** "The demo went great. They were really engaged and the VP said it was exactly what they needed. Should I move this to 'proposal stage' in the CRM?"
**Call notes excerpt:** *"Fantastic demo — they loved the reporting module. VP said 'this is exactly the kind of solution we've been looking for.' Everyone was nodding. At the end, Sarah (champion) said 'we're definitely interested, let's circle back after we've had some time to digest this.' I said I'd follow up next week."*
**Process:**
- Step 1: Customer action statements: "Let's circle back after we've had some time to digest this" — no specific action, conditional and buyer-initiated in form only. "I'd follow up next week" — seller action only.
- Step 2: Continuation. No customer action committed. "Circling back" is buyer-conditional with no date, no agenda, no confirmed attendees.
- Step 3: Continuation-as-success misread present. The VP's phrase "this is exactly the kind of solution we've been looking for" is an expression of sentiment, not a commitment. "Let's circle back" matches the classic Continuation pattern. Flag raised.
**Output:** `call-outcome-2024-03-15.md` — CONTINUATION. No specific customer action committed. Flag: "The VP's enthusiasm and Sarah's 'we're definitely interested' are positive sentiment signals, not Advance commitments. 'Let's circle back after we've had some time to digest' is a Continuation — no date, no agreed agenda, no access to additional stakeholders. Do not advance CRM stage. Use commitment-and-advance-planner to define a specific Advance objective before the next contact."
---
### Example 3: Order — Clear Purchase Commitment
**Scenario:** Field sales rep reviewing notes from a closing call.
**Trigger:** "They signed the MSA, right? Just documenting the outcome."
**Call notes excerpt:** *"Jeff signed the MSA on the spot. 3-year contract, $180K ARR. CC'd their procurement lead on the confirmation email."*
**Process:**
- Step 1: Customer action: Signed MSA. Procurement loop-in confirming.
- Step 2: Order. Unmistakable intention to purchase, paperwork signed.
- Step 3: No misread risk.
**Output:** `call-outcome-2024-03-18.md` — ORDER. Customer action: MSA signed, $180K ARR, 3-year term. CRM: move to Closed Won, initiate onboarding.
---
## References
| File | Contents |
|------|----------|
| `references/continuation-phrase-guide.md` | Verbatim Continuation phrases from SPIN Selling research; variants and near-miss examples; how to distinguish Continuation language from genuine Advance language; detection checklist |
## License
This skill is licensed under [CC-BY-SA-4.0](https://creativecommons.org/licenses/by-sa/4.0/).
Source: [BookForge](https://github.com/bookforge-ai/bookforge-skills) — SPIN Selling by Neil Rackham.
## Related BookForge Skills
```
clawhub install bookforge-commitment-and-advance-planner
```
For need classification on the same call, also:
```
clawhub install bookforge-need-type-classifier
```
Browse the full SPIN Selling skill set: [bookforge-skills](https://github.com/bookforge-ai/bookforge-skills)
FILE:references/continuation-phrase-guide.md
# Continuation Phrase Guide
Source: SPIN Selling by Neil Rackham (1988), Chapter 2 — Obtaining Commitment.
This reference is for use by the `call-outcome-classifier` skill during Step 3 (Continuation-as-success check).
---
## Verbatim Continuation Phrases (from SPIN research)
Rackham's team observed these specific phrases as classic Continuation endings. Each contains a positive sentiment but no specific customer action.
| Phrase | Why it's a Continuation, not an Advance |
|--------|------------------------------------------|
| "Thank you for coming. Why don't you visit us again the next time you're in the area." | No date, no agenda, no agreed attendees. Visit is seller-initiated and conditional on the seller being "in the area." Customer has committed to nothing. |
| "Fantastic presentation, we're very impressed. Let's meet again some time." | "Some time" has no date, no purpose, no agenda. Enthusiasm is genuine; commitment is absent. |
| "We liked what we saw and we'll be in touch if we need to take things further." | "If we need to" and "we'll be in touch" place all initiative with the buyer, with no committed action or timeline. The condition ("if we need to") makes this contingent, not agreed. |
---
## Common Variants in the Wild
These are not Rackham's verbatim phrases but follow the same pattern:
| Variant Phrase | Pattern it follows |
|---------------|-------------------|
| "Send me some more info and I'll have a look." | Seller action only. No customer commitment. |
| "Let's reconnect in Q3 when the budget cycle opens." | Conditional and vague. No date, no confirmation of a meeting, no agenda. |
| "I'll share this with the team and see what they think." | Customer is vaguely agreeing to an internal action but has not committed to a specific next step that moves the deal forward. |
| "This is really interesting. I'd like to dig deeper at some point." | "At some point" = no commitment. Interest is present; action is absent. |
| "I think we can make this work." | Statement of belief, not commitment to action. No next step. |
| "I don't see why we wouldn't move forward." | Double-negative hedge. No affirmative action. No specific step. |
| "Keep me posted on developments." | No action from the customer. Seller is being asked to maintain contact with no agreed objective. |
---
## Advance vs. Continuation: Side-by-Side
| Outcome | Example Phrase | What Makes It Advance vs. Continuation |
|---------|---------------|----------------------------------------|
| **Advance** | "Let's set up a call with our VP of Operations. I can make Thursday work — can you send a calendar invite?" | Customer agrees to a specific action (arranging access), names a person (VP of Operations), and sets a date. |
| **Continuation** | "Let's meet again sometime and loop in the VP." | No date, no confirmed attendees, no agreed agenda. "Sometime" is not a commitment. |
| **Advance** | "I'd like to run a pilot. Can we agree to 30 days starting next month with two of our warehouses?" | Customer proposes specific terms (30 days, two warehouses, start date). Action is theirs. |
| **Continuation** | "A pilot might be interesting. Let's explore that." | No terms, no dates, no customer action. "Might be interesting" is sentiment. |
| **Advance** | "I'll get procurement to send over a standard vendor questionnaire this week." | Customer commits to a specific next step (sending a document) with a timeframe (this week). |
| **Continuation** | "Our procurement team will need to be involved at some point." | No action, no timeline. Observation about process, not a commitment. |
---
## The Specificity Test
An Advance can pass the specificity test. A Continuation cannot.
**Specificity test questions:**
1. What specific action did the customer agree to take?
2. Who will take it? (Must be the customer, not the seller.)
3. When? (A date, a timeframe, or a confirmed trigger event — not "sometime.")
If you cannot answer all three questions from the call notes, classify the outcome as Continuation.
---
## Why Buyers Say These Things
Rackham's observation: "Having worked closely with buyers over the years, I can no longer accept positive strokes and compliments as reliable signs of call success. Too often I've seen customers make these positive noises at the end of a [call without intending to move forward]."
Buyers express enthusiasm because:
1. They are genuinely interested but not yet at a decision point — the enthusiasm is real; the commitment timing is not.
2. They want to avoid the discomfort of an explicit "no" — the warm phrases are a socially acceptable exit.
3. They liked the person selling to them even if they don't intend to buy — professional warmth ≠ purchase intent.
4. They are deferring to a decision process (committee, budget cycle) that they didn't explain — the enthusiasm is theirs; the decision authority belongs to someone else.
None of these cases constitutes an Advance. The classifier's job is not to evaluate the buyer's sincerity — it is to assess whether a specific action was committed.
Draft Benefit statements that link product capabilities to specific customer-expressed Explicit Needs. Use this skill when preparing follow-up emails, propos...
---
name: benefit-statement-drafter
description: "Draft Benefit statements that link product capabilities to specific customer-expressed Explicit Needs. Use this skill when preparing follow-up emails, proposals, or demos after a discovery call, when someone asks 'how should I frame our solution for this customer?', 'draft Rackham-style benefits for my proposal', 'write the value section of my deck', 'the customer said X — what should I say back?', 'help me write benefits for this deal', 'turn our capabilities into benefits for this account', or 'I have a needs log — help me write the customer-facing statements'. This skill REFUSES to draft a Benefit when no matching Explicit Need exists in the customer record — it redirects to spin-discovery-question-planner instead. That refusal is not a flaw; it is the skill's core protection against the most common error in B2B sales: presenting capabilities before needs are confirmed. The output is a short, grounded set of draft statements (one per matched pair) that the user can adapt into a proposal section, a follow-up email, or a demo opening. Also applies the new-product-launch sub-flow: when the user is launching a new product and has no needs log yet, the skill shifts focus to problem identification and need development before drafting any Benefits — the approach that produced 54% higher sales in a controlled medical diagnostics experiment. Applies to B2B account executives, solutions consultants, and founder-led sellers preparing for follow-up conversations after discovery calls."
version: 1.0.0
homepage: https://github.com/bookforge-ai/bookforge-skills/tree/main/books/spin-selling/skills/benefit-statement-drafter
metadata: {"openclaw":{"emoji":"✍️","homepage":"https://github.com/bookforge-ai/bookforge-skills"}}
status: draft
source-books:
- id: spin-selling
title: "SPIN Selling"
authors: ["Neil Rackham"]
chapters: [5]
tags: [sales, b2b-sales, enterprise-sales, fab-methodology, benefit-statements, proposal-writing, spin-methodology, capability-presentation, new-product-launch]
depends-on:
- fab-statement-classifier
- spin-discovery-question-planner
execution:
tier: 1
mode: hybrid
inputs:
- type: document
description: "needs-log.md — Explicit Needs the customer has expressed, ideally produced by need-type-classifier after discovery calls"
- type: document
description: "product-capabilities.md — the seller's product capabilities (what problems it solves, what it can and cannot deliver)"
tools-required: [Read, Write]
tools-optional: [Grep]
mcps-required: []
environment: "Document set: needs-log.md, product-capabilities.md. Agent produces benefit-statements-{deal}.md. Human reviews, adapts, and incorporates into proposals, emails, or demos."
discovery:
goal: "Produce one draft Benefit statement per matched (Explicit Need, capability) pair — and refuse to produce statements where no matching Explicit Need exists"
tasks:
- "Read needs-log.md and extract only the Explicit Needs (not Implied Needs)"
- "Match each Explicit Need to a capability in product-capabilities.md"
- "Draft one Benefit statement per matched pair"
- "Flag Explicit Needs with no capability match (coverage gaps)"
- "Refuse to draft Benefits for unmatched capabilities and redirect to spin-discovery-question-planner"
- "Apply new-product-launch sub-flow when no needs log exists"
audience:
roles: [account-executive, solutions-consultant, enterprise-sales-rep, founder-led-seller]
experience: intermediate
when_to_use:
triggers:
- "After a discovery call — customer has expressed Explicit Needs and the seller wants to frame the solution"
- "Preparing a follow-up email, proposal section, or demo opening"
- "The user has a needs-log.md from need-type-classifier with confirmed Explicit Needs"
- "Preparing to present at the Demonstrating Capability stage of the sales call"
prerequisites:
- "At least one Explicit Need in needs-log.md — the skill will refuse to draft if no Explicit Needs are present"
- "product-capabilities.md describing what the product can solve"
not_for:
- "Auditing existing sales content for FAB distribution (use fab-statement-classifier)"
- "Generating discovery questions (use spin-discovery-question-planner)"
- "Pricing or packaging decisions"
- "Writing complete proposals or emails (this skill produces statement-level drafts to incorporate into longer content)"
environment:
codebase_required: false
codebase_helpful: false
works_offline: true
quality:
scores:
with_skill: 0
baseline: 0
delta: 0
tested_at: ""
eval_count: 0
assertion_count: 0
iterations_needed: 0
what_skill_catches:
- "Refuses to draft a Benefit for a capability with no matching Explicit Need in the customer record"
- "Distinguishes Explicit Needs from Implied Needs in the needs-log and acts only on the former"
- "Flags capability gaps (Explicit Needs the product cannot meet)"
- "Applies problem-first framing for new product launches rather than feature dumping"
what_baseline_misses:
- "Produces aspirational capability claims without checking whether the customer has expressed a need"
- "Treats Implied Needs (problems, difficulties) as sufficient anchors for Benefit statements"
- "Does not distinguish capabilities from needs — produces a feature dump labeled 'Benefits'"
- "Has no concept of capability gap analysis"
---
# Benefit Statement Drafter
## When to Use
You have completed a discovery call or series of calls. The customer has expressed specific Explicit Needs (wants, desires, or intentions — not just problems). You have product capabilities that can meet some of those needs. Now you need to draft the statements that link your capabilities to what the customer actually said they want.
This skill drafts one Benefit statement per matched (Explicit Need, capability) pair. Each statement follows a simple structure: the customer stated they need X; our product delivers X in this way.
**Use this skill when:**
- Preparing a follow-up email after a discovery call where Explicit Needs were surfaced
- Writing the value section of a proposal or deck for a specific account
- Drafting a demo opening that references what the customer told you they needed
- Moving from "here's what our product does" to "here's why it matters for this customer"
**Critical gate:** This skill requires a `needs-log.md` with at least one confirmed Explicit Need. If you ask this skill to draft Benefits before Explicit Needs exist, it will decline and redirect you to `spin-discovery-question-planner` to develop needs first. This is intentional — Rackham's 5,000-call study found that statements meeting Explicit Needs (true Benefits) are strongly linked to call success, while statements showing how a product can help (Advantages) have no statistically significant relationship to success in large sales.
**Do NOT use this skill to:** audit existing sales content (use `fab-statement-classifier`), generate discovery questions (use `spin-discovery-question-planner`), or write full proposals or emails (this skill produces statement-level drafts you incorporate into longer content).
## Context & Input Gathering
### Required Context (must have — ask if missing)
- **Needs log with Explicit Needs:** The customer-expressed wants or intentions from prior discovery
-> Check environment for: `needs-log.md` (ideally produced by `need-type-classifier`)
-> If missing, ask: "Do you have a needs log or notes from the discovery call? What specific needs did the customer say they wanted to solve?"
-> If the user provides only Implied Needs (problems, difficulties): **apply the Refusal Protocol** in Step 2
- **Product capabilities:** What the seller's product can actually deliver
-> Check environment for: `product-capabilities.md`
-> If missing, ask: "What capabilities does your product offer? What problems does it solve, and what can it NOT do?"
### Observable Context (gather from environment)
- **Deal context:** Account name, deal stage, contact role
-> Look for: `deal-brief.md`
-> If absent: proceed with needs log alone; name the output generically
- **New product flag:** Is the user launching a product so new that no needs log exists yet?
-> Signal: user says "we're launching X" or "there's no needs log yet — we just got this product"
-> If flagged: apply the **New-Product-Launch Sub-flow** in Step 3 before any drafting
### Sufficiency Threshold
SUFFICIENT: needs-log.md with at least one Explicit Need + product-capabilities.md
PARTIAL: needs-log.md with only Implied Needs → apply Refusal Protocol, redirect to discovery
NEW PRODUCT: no needs log, new product → apply New-Product-Launch Sub-flow
MUST ASK: no needs information AND no new-product context
## Process
### Step 1: Extract Explicit Needs from the Needs Log
**ACTION:** Read `needs-log.md` (or the user-provided notes). Separate Explicit Needs from Implied Needs. List only the Explicit Needs — these are the valid anchors for Benefit drafting.
**WHY:** The single most important distinction in this skill is between Implied Needs and Explicit Needs. Implied Needs are statements of problem, difficulty, or dissatisfaction: "Our reporting takes too long," "We struggle with operator turnover." Explicit Needs are statements of want, desire, or intention: "We need to cut reporting time to one day," "We want a system our operators can learn in a week." Only Explicit Needs anchor a true Benefit. Using an Implied Need as the anchor produces an Advantage — a statement that shows how the product can help but does not meet a confirmed customer want. Advantages have no statistically significant relationship to success in large sales (Rackham, 5,000-call study).
**Classification test:**
- Explicit Need: customer used words like "we need," "we want," "we're looking for," "I'd like," "our goal is," "it's important that we have" → valid anchor
- Implied Need: customer said "we struggle with," "it's a problem when," "we're not happy with," "it takes too long" → NOT a valid anchor for Benefits; needs development first
**Step 1 output:** Two lists:
1. Confirmed Explicit Needs (with customer's words quoted or paraphrased)
2. Implied Needs present (noted for Step 2 — the refusal check)
### Step 2: Apply the Refusal Protocol
**ACTION:** For each capability in `product-capabilities.md`, check whether a matching Explicit Need exists in the Step 1 output. If a capability has no matching Explicit Need, do NOT draft a Benefit for it.
**WHY:** The default behavior of any language model given a product spec is to produce aspirational statements: "Our solution drives 30% productivity gains for teams like yours." These are Advantages at best — they claim value the customer has not asked for. In large sales (Huthwaite's analysis of 18,000+ calls), presenting capabilities to unconfirmed needs causes customers to evaluate whether the stated value is worth the cost. Their answer, in large-sale contexts, is often "not worth it" — generating value objections. Refusing to draft unanchored Benefits is the primary protection against this pattern.
**REFUSAL PROTOCOL:**
If the needs log contains ONLY Implied Needs (no Explicit Needs):
> STOP. No Explicit Needs are documented in the needs log. This skill drafts Benefits only when the customer has expressed a specific want or desire. What you have are Implied Needs — problems or difficulties the customer mentioned. These are valuable, but they are not yet ready to anchor a Benefit statement.
>
> **What to do instead:** Run `spin-discovery-question-planner` to plan Implication and Need-payoff Questions that develop these Implied Needs into Explicit Needs on the next call. Once the customer has expressed a confirmed want, return here to draft the Benefits.
If the needs log contains some Explicit Needs and some unmatched capabilities:
> Note: [Capability X] has no matching Explicit Need in the current record. No Benefit will be drafted for it. If you want to develop a need for this capability, use `spin-discovery-question-planner` to plan the appropriate Need-payoff Question sequence.
**Step 2 output:** A mapping table:
| Capability | Explicit Need Present? | Action |
|---|---|---|
| [Capability A] | Yes — "[customer's words]" | Draft Benefit |
| [Capability B] | No | Refused — redirect to discovery |
| [Capability C] | Yes — "[customer's words]" | Draft Benefit |
### Step 3: New-Product-Launch Sub-flow (if applicable)
**ACTIVATE WHEN:** The user is launching a new product and has no `needs-log.md` — or explicitly says "we just got this product, I haven't run discovery yet."
**WHY:** When a product is new, salespeople tend to shift from need-development to feature presentation. Rackham's Huthwaite research tracked this behavior: during new-product launches, salespeople give more than 3 times the level of Features and Advantages compared to when selling established products. This "bells-and-whistles" approach consistently underperforms. In a controlled medical diagnostics experiment, a group launched with the conventional feature-dump approach was outsold by 54% by a group that received no product demonstration at all — only a list of the problems the machine solved and the SPIN questions to develop those problems with customers. The 54%-higher group's attention was on customer needs, not product features.
**New-Product-Launch Sub-flow:**
**Step 3a — Identify problems the product solves:**
Ask the user (or read the product documentation): "What specific problems is this product designed to solve? What are the pain points it was built for?"
Output: A list of 3-6 problem types the product addresses.
**Step 3b — Map problems to likely customer types:**
For each problem, identify which customer roles or segments are most likely to experience it. This determines who to target in discovery.
**Step 3c — Plan the discovery-first approach:**
Produce a brief plan: "Before drafting any Benefits, run discovery on these accounts using questions that surface these problem types. When customers confirm they have these problems AND express a want for the solution, return to this skill to draft the Benefits."
Point the user to `spin-discovery-question-planner` with the problem list as input.
**Step 3d — Draft provisional benefit templates (clearly labeled PROVISIONAL):**
Draft one placeholder Benefit statement per problem type — clearly marked as PROVISIONAL and not to be used until the customer has expressed the matching Explicit Need in their own words. These are targeting templates, not presentation-ready statements.
**Step 3 output:** Problem list + discovery plan + PROVISIONAL benefit templates with explicit instruction not to use them until Explicit Needs are confirmed on a call.
### Step 4: Draft the Benefit Statements
**ACTION:** For each matched (Explicit Need, capability) pair from Step 2, draft one Benefit statement. Each statement has three components: what the customer said they need, what the product delivers, and how that delivery meets the stated need.
**WHY:** A Benefit statement structured this way gives the seller something they can deliver verbatim or adapt. It also serves as an internal proof: if the seller cannot fill in "what the customer said they need," the statement is an Advantage, not a Benefit. The three-component structure enforces this check and makes the drafts auditable.
**Benefit statement structure:**
```
EXPLICIT NEED: "[Customer's words — their want or desire]"
CAPABILITY: [What the product delivers]
BENEFIT STATEMENT: "You mentioned that [restate the customer's need in their words].
[Product/feature] does [X], which means [that specific need is met in this way]."
```
**Drafting guidelines:**
- Use the customer's language, not your marketing language
- Keep each statement to 1-3 sentences — it is a draft building block, not a paragraph
- Do not chain multiple Explicit Needs into one statement; one pair per statement
- Avoid aspirational embellishment ("and this will transform your business") — stay grounded in the specific need expressed
- If the customer quantified their need ("we need to cut close time from 5 days to 2"), include the customer's number, not a generic claim
**Step 4 output:** Numbered list of draft Benefit statements, each labeled with the Explicit Need it meets and the capability it draws on.
### Step 5: Coverage Gap Analysis
**ACTION:** List any Explicit Needs in the needs log that no capability in `product-capabilities.md` can meet. Flag these clearly.
**WHY:** Capability gaps discovered now, before the follow-up meeting, are far less damaging than gaps discovered mid-presentation. An AE who goes into a proposal knowing there is one unmet need can plan for it: acknowledge the gap honestly, redirect to what is covered, or explore whether a partner integration addresses it. An AE who discovers the gap live gives the customer the impression of poor preparation — which erodes trust at the moment it matters most.
**Coverage gap format:**
> CAPABILITY GAP: The customer expressed a need for [X]. Our current product capabilities do not cover this. Do not draft a Benefit for this need. Recommended action: [acknowledge honestly in the meeting / explore partnership / check product roadmap / confirm this is out of scope].
**Step 5 output:** Coverage gap list (may be empty).
### Step 6: Write the Output File
**ACTION:** Compile the Benefit statements, coverage gaps, and any refusals into a single file: `benefit-statements-{deal}.md`. Include a short summary table at the top.
**WHY:** A written artifact carries forward. The AE reads it before the follow-up meeting, pastes appropriate statements into the proposal, and uses the summary table to confirm coverage. The file also feeds `commitment-and-advance-planner` for the Four Successful Actions step ("summarize Benefits before proposing commitment").
**Output file structure:**
```
# Benefit Statements — {Deal/Account Name} — {Date}
## Coverage Summary
| Explicit Need | Capability | Benefit Drafted? |
|---|---|---|
| [Need 1] | [Capability A] | Yes |
| [Need 2] | [none] | GAP — no capability match |
| [Implied Need 3] | n/a | REFUSED — Implied Need only |
## Draft Benefit Statements
### Benefit 1 — [Short label, e.g., "Monthly close time"]
EXPLICIT NEED: "[Customer's words]"
BENEFIT STATEMENT: "[Draft text]"
### Benefit 2 — [Short label]
...
## Coverage Gaps
{If any: description and recommended action per gap}
## Refused Statements
{If any: list capabilities with no Explicit Need match, with redirect to spin-discovery-question-planner}
## Recommended Next Step
{One paragraph: how to use these statements — e.g., incorporate into proposal section, use in demo opening, include in follow-up email summary}
```
## Key Principles
- **No Explicit Need, no Benefit.** Rackham's research is unambiguous: statements that meet Explicit Needs are strongly correlated with orders and advances in large sales; statements showing how a product can help (Advantages) have no statistically significant relationship to large-sale success. This skill enforces that distinction by refusing to draft Benefits where no Explicit Need is recorded.
- **The customer's words are the anchor.** The correct structure of a Benefit statement begins with what the customer said, not with what the seller wants to say. If you cannot quote or closely paraphrase the customer's expressed want, you do not have the material to write a Benefit. You have the material to write an Advantage — which is a step backward.
- **Implied Needs are not sufficient.** A customer who said "our reporting takes too long" has expressed an Implied Need — a dissatisfaction. That is valuable for discovery (it is the foundation for Implication questioning). But it does not authorize a Benefit statement. The customer has not yet said they want or need a faster reporting solution. Until they do, any capability statement you make about your reporting product is an Advantage, not a Benefit.
- **Develop first, then benefit.** Rackham's principle: "Do a good job of developing Explicit Needs and the Benefits almost look after themselves." If you are returning here and finding few or no Explicit Needs in the log, the right response is not to lower the standard — it is to plan better discovery for the next call.
- **New products demand problem-first thinking.** The impulse to communicate a new product by listing its features and advantages ("bells and whistles") is understandable — it is exactly how product marketing communicates internally. Resist it. Identify the problems the product solves, develop those problems into Explicit Needs through SPIN questioning, and then draft Benefits. This sequence produced 54% higher sales in a controlled experiment compared to the conventional feature-led approach.
- **Benefits are building blocks.** The output of this skill is statement-level drafts, not finished proposals or emails. The AE's job is to select the relevant Benefits, sequence them appropriately for the communication channel, and write the connective tissue. This skill does not write the entire follow-up email; it writes the core statements that give the email its substance.
- **Stay within scope.** Do not audit existing content (use `fab-statement-classifier`). Do not generate discovery questions (use `spin-discovery-question-planner`). When a gap appears or a refusal fires, name the right skill to handle it.
## Examples
**Scenario: Follow-up email after a discovery call with confirmed Explicit Needs**
Trigger: AE says — "I had a great call with a VP of Finance. My needs-log shows she said 'we need to cut our monthly close from 7 days to 2 days' and 'we need a single dashboard all three regional controllers can see.' Help me draft the Benefits for my follow-up email."
Process:
- (Step 1) Read needs-log.md: Two Explicit Needs confirmed: "cut close from 7 to 2 days" and "single dashboard for three regions."
- (Step 2) Match to product-capabilities.md: Automated close workflow matches Need 1. Unified multi-entity dashboard matches Need 2. No gaps.
- (Step 4) Draft Benefit 1: "You mentioned you need to cut monthly close from 7 days to 2. Our automated close workflow runs the period-end reconciliation and approval chain in parallel rather than sequentially, which is how clients in your situation have reduced their close to 1-3 days." Draft Benefit 2: "You said you need a single dashboard your three regional controllers can all see. Our multi-entity view consolidates all three regions into one real-time view with role-based access — each controller sees the same live data, and you see the consolidated view above them."
- (Step 6) Write benefit-statements-vp-finance-acme.md.
Output: Two grounded Benefit statements ready to be pasted into the follow-up email, anchored to the customer's own words. No aspirational claims.
---
**Scenario: Refusal — only Implied Needs in the record**
Trigger: AE says — "I have a call transcript. The customer mentioned they're frustrated with their current system and said approvals take forever. Help me write Benefits."
Process:
- (Step 1) Read the transcript: "frustrated with current system" = Implied Need. "approvals take forever" = Implied Need. No Explicit Needs (no "we need," "we want," "we're looking for").
- (Step 2) **Refusal Protocol fires:** No Explicit Needs present.
Output:
> STOP. The customer has expressed problems and frustrations (Implied Needs) but has not yet stated what they want or need. This skill cannot draft Benefits without confirmed Explicit Needs — drafting now would produce Advantages, which have no statistically significant relationship to large-sale success.
>
> Run `spin-discovery-question-planner` before your next call. Input: the Implied Needs above. The planner will build Implication Questions to develop these into felt problems, and Need-payoff Questions to get the customer to articulate the want. Once they do, return here with the updated needs log.
---
**Scenario: New product launch**
Trigger: Solutions consultant says — "We just launched a new security monitoring tool. We don't have any customer conversations yet. How do I use Benefits with this product?"
Process:
- (Step 3) New-Product-Launch Sub-flow activates.
- (Step 3a) Ask: "What problems is this tool designed to solve?" User provides: insider threat detection, compliance audit trail, real-time alert on anomalous access.
- (Step 3b) Map to likely customer types: security directors, compliance officers, CISOs in regulated industries.
- (Step 3c) Draft discovery plan: "Before presenting any capabilities, plan Problem → Implication → Need-payoff question chains for each of these three problems. Use `spin-discovery-question-planner` with this problem list. Only after customers have expressed wants related to these problems should you draft Benefits."
- (Step 3d) Draft three PROVISIONAL benefit templates, labeled clearly: "PROVISIONAL — do not use until customer has confirmed this need."
Output: `benefit-statements-new-security-tool-provisional.md` — problem list, discovery plan, three provisional templates with explicit instruction to develop needs first.
## References
- [new-product-launch-sub-flow.md](references/new-product-launch-sub-flow.md) — The problem-first approach, the +54% Kodak/medical diagnostics experiment details, and step-by-step launch planning workflow
## License
This skill is licensed under [CC-BY-SA-4.0](https://creativecommons.org/licenses/by-sa/4.0/).
Source: [BookForge](https://github.com/bookforge-ai/bookforge-skills) — SPIN Selling by Neil Rackham.
## Related BookForge Skills
This skill depends on:
- `clawhub install bookforge-fab-statement-classifier` — Classify existing seller statements as Features, Advantages, or true Benefits (defines the Benefit standard this skill enforces)
- `clawhub install bookforge-spin-discovery-question-planner` — Plan SPIN questions to develop Implied Needs into Explicit Needs before drafting Benefits
Skills that build on this one:
- `clawhub install bookforge-commitment-and-advance-planner` — The Four Successful Actions for obtaining commitment include "summarize Benefits" — use this skill's output as the Benefits input to that step
- `clawhub install bookforge-objection-source-diagnoser` — If Benefits generate objections, diagnose whether the root cause is Advantage overuse or premature capability demonstration
Or install the full SPIN Selling skill set from GitHub: [bookforge-skills](https://github.com/bookforge-ai/bookforge-skills)
FILE:references/new-product-launch-sub-flow.md
# New Product Launch Sub-flow Reference
Source: SPIN Selling by Neil Rackham, Chapter 5 ("Giving Benefits in Major Sales"), section "Selling New Products"
---
## The Problem: Why New Product Launches Underperform
When a product is new, the default communication path creates a predictable failure:
1. Product marketing explains the product to the sales force using Features and Advantages — "the bells and whistles."
2. The sales force becomes enthusiastic and goes out to sell.
3. In front of customers, they communicate the product exactly as it was communicated to them: Features and Advantages.
4. The average level of Features and Advantages given when selling new products is more than 3 times the level given when selling established products.
5. Sales are slow early in the launch. Management asks, "What's wrong?"
The root cause: seller attention is on the product, not on the customer. Features and Advantages cannot produce Benefits because the customer has not yet expressed a want. Without Explicit Needs, there are no Benefits — only a product presentation that requires the customer to figure out for themselves whether it is worth the cost.
## The Empirical Anchor: The +54% Medical Diagnostics Experiment
Huthwaite was invited to run a controlled experiment on a new-product launch in a medical diagnostics company. The product was sophisticated, expensive diagnostic equipment — clearly a large-sale context.
**Conventional group:** launched in the standard way — a high-key presentation of Features and Advantages by the product marketing team.
**Experimental group (small):** launched differently:
- The group was NOT shown the product
- They were told: "What is important is that this machine is designed to solve problems for the doctors who use it"
- They were given a list of the problems the machine solved and the needs it met
- They made a list of accounts where these problems could exist
- They planned the Problem, Implication, and Need-payoff Questions they would ask when visiting those accounts
**Result:** The experimental group averaged **54% higher sales** than the conventional group during the product's first year.
The experimental group's attention was on customer needs, not product features. They went into accounts asking questions to develop Explicit Needs before presenting any capability. Because they presented capabilities as Benefits (meeting confirmed Explicit Needs), their presentations were far more effective.
## Why Conventional Launches Fail Then Recover
A common pattern: a new product launches to poor results, and then — often 6-12 months in — sales suddenly improve. The explanation is behavioral, not market-based.
When the product is new, the sales force is excited. Excitement = product-centered selling = Features and Advantages = poor results.
As the initial enthusiasm fades and the "it's just another product" mentality sets in, sellers stop talking about the product and start paying attention to customer needs again. They return to discovery habits. That shift — from product-centered to need-centered — is what drives the improvement, not any change in the market.
The lesson: do not wait for enthusiasm to fade. Build the problem-first discipline into the launch from day one.
## Step-by-Step: Problem-First Launch Workflow
### Step 1: List the Problems the Product Solves
Before any product demonstration or feature explanation, ask: "What specific problems is this product designed to solve?"
Output: A list of 3-6 named problem types. Each should be a problem a specific type of customer would recognize in their own operation.
Example format:
```
Problem 1: [Name] — [Description of the problem in customer terms, not product terms]
Problem 2: ...
Problem 3: ...
```
### Step 2: Identify Target Accounts by Problem Fit
For each problem, identify which customer roles, industries, or segments are most likely to experience it. This determines who to prioritize for discovery calls and which accounts to target with which problem questions.
### Step 3: Plan SPIN Questions for Each Problem
For each problem:
- Write Problem Questions to surface the problem (if the customer has it)
- Apply the Implication chain (problem → consequences → questions) to build urgency
- Write Need-payoff Questions to get the customer to articulate the want — the Explicit Need
Use `spin-discovery-question-planner` with the problem list as input.
### Step 4: Run Discovery Calls — Develop Explicit Needs
Go into accounts with the problem list and SPIN question bank. Do NOT show the product, describe Features, or present Advantages. Ask questions. When a customer confirms they have one of these problems AND articulates a want for the solution ("we need," "we're looking for," "I'd like"), record that as an Explicit Need in `needs-log.md`.
### Step 5: Draft Benefits — Now
Return to `benefit-statement-drafter` with the populated `needs-log.md`. Now you have the anchors to draft true Benefits — one per confirmed (Explicit Need, capability) pair.
---
## Common Failure Modes to Avoid
**Bells-and-whistles presentation:** Showing all the product's Features and Advantages in the first meeting. The customer's first exposure to the product should be through questions about their problems, not a product demonstration.
**Premature Benefits:** Drafting "Benefits" from the product spec without running discovery. These are Advantages at best — they meet presumed needs, not expressed ones. Provisional templates (Step 3d of the sub-flow) are placeholders only.
**Enthusiasm-driven feature dumping:** The excitement of a new product naturally pulls sellers toward it. Recognize this impulse and redirect it toward problem-focused questions.
**Skipping Implication Questions:** For a new product, the customer may not yet see why the problem it solves is large enough to justify the cost. Implication Questions do the work of showing the true size of the problem. Without them, the customer hears the price and says "not worth it."
---
## Key Quote
> "Instead of showing them the product and describing its Features and Advantages, we didn't even let them see what they would be selling. 'It's not important,' we explained. 'What is important is that this machine is designed to solve problems for the doctors who use it.'"
— Neil Rackham, SPIN Selling, Chapter 5
The result: 54% higher sales in year one, compared to the conventional feature-led group.
Select and execute the correct refactoring path for type codes — enumerations, integer constants, or string tags that flag object variants (e.g., ENGINEER/SA...
---
name: type-code-refactoring-selector
description: |
Select and execute the correct refactoring path for type codes — enumerations, integer constants, or string tags that flag object variants (e.g., ENGINEER/SALESMAN/MANAGER as ints, blood group as 0/1/2/3, ORDER_STATUS as strings). Applies Fowler's three-way decision tree to pick between Replace Type Code with Class, Replace Type Code with Subclasses, and Replace Type Code with State/Strategy, then drives the full mechanics for the chosen path through to Replace Conditional with Polymorphism. Use when: a class stores an integer or enum constant that controls conditional behavior in switch statements or if-else chains scattered across multiple methods or callers; Primitive Obsession or Switch Statements smells have been diagnosed and the root cause is a type code; a new variant keeps requiring edits in multiple places (classic signal that polymorphism is needed); a type code is passed between classes as a raw integer, weakening type safety and allowing invalid values; subclasses exist that vary only in constant return values (reverse path: Replace Subclass with Fields). Also covers the exceptions: use Replace Parameter with Explicit Methods instead of polymorphism when the switch affects only a single method and variants are stable; use Introduce Null Object when one of the cases is null.
version: 1.0.0
homepage: https://github.com/bookforge-ai/bookforge-skills/tree/main/books/refactoring/skills/type-code-refactoring-selector
metadata: {"openclaw":{"emoji":"📚","homepage":"https://github.com/bookforge-ai/bookforge-skills"}}
status: draft
depends-on:
- code-smell-diagnosis
source-books:
- id: refactoring
title: "Refactoring: Improving the Design of Existing Code"
authors: ["Martin Fowler", "Kent Beck"]
chapters: [3, 8]
tags: [refactoring, code-quality, type-codes, polymorphism]
execution:
tier: 2
mode: hybrid
inputs:
- type: codebase
description: "Source code containing the type code — the class that holds the integer/enum constant and the methods that switch on it"
- type: document
description: "Code snippet or class description if no live codebase is accessible"
tools-required: [Read, Grep, Write]
tools-optional: [Bash]
mcps-required: []
environment: "Run inside a project directory. Reading source files and grepping for switch/case patterns on the type code are the primary analysis methods."
discovery:
goal: "Correctly route a type code to one of three replacement paths; execute the full mechanics for the chosen path; eliminate the original conditional branching with polymorphism"
tasks:
- "Identify the type code: the field, its values, and the class that holds it"
- "Determine whether the type code affects behavior (switch/if-else chains that execute different code per value)"
- "If behavior-affecting: determine whether the class can be subclassed (no existing inheritance blocking it, and the type value does not change after object creation)"
- "Select and execute the correct path: Replace Type Code with Class, Replace Type Code with Subclasses, or Replace Type Code with State/Strategy"
- "Complete the follow-up: Replace Conditional with Polymorphism on all switch statements that remain"
- "Apply exceptions where polymorphism is overkill or a null case is present"
audience:
roles: ["software-developer", "senior-developer", "tech-lead"]
experience: "intermediate — assumes working knowledge of object-oriented design and subclassing"
triggers:
- "Switch statement or if-else chain that branches on a type code integer or enum constant"
- "Adding a new variant requires editing multiple switch statements in multiple places"
- "Primitive Obsession or Switch Statements smell diagnosed and traced to a type code"
- "Type code is passed between classes as a raw integer, allowing invalid values"
- "Subclasses vary only in constant return values (reverse path trigger)"
not_for:
- "Conditionals that do not involve a type code — use conditional-simplification-strategy instead"
- "Structural smells not related to type codes (Feature Envy, Shotgun Surgery, etc.) — use class-responsibility-realignment instead"
- "New code being written from scratch — this skill refactors existing type codes"
---
# Type Code Refactoring Selector
## When to Use
You have a class that stores a type code: an integer constant, an enum, or a string tag whose value identifies which variant of the object this is. The code switches on this value in multiple places — different methods, different callers — and adding a new variant means hunting down every switch and adding a case.
This skill applies when:
- A `switch (_type)` or `if (type == ENGINEER)` pattern recurs across the codebase, driven by the same type code field
- The `code-smell-diagnosis` skill has named Switch Statements or Primitive Obsession and the underlying cause is a type code
- A new business variant (new employee role, new payment method, new order state) keeps requiring edits in multiple places
- The type code is passed as a raw `int` — the compiler cannot enforce valid values, and callers can pass arbitrary numbers
**The core insight from Fowler:** Polymorphism eliminates the need to know which variant you have. Instead of asking "what type are you?" and branching, you call a method and each variant answers differently. But to reach polymorphism, you first need the right structural foundation — and which foundation you build depends on two criteria: does the type code affect behavior, and can the class be subclassed?
**Upstream dependency:** `code-smell-diagnosis` identifies the smell. This skill executes the remedy once Switch Statements or Primitive Obsession (type code variant) has been diagnosed.
**Downstream:** After this skill, use `conditional-simplification-strategy` if any complex conditionals remain that are not type-code-driven.
---
## Context and Input Gathering
### Required Input (ask if missing)
- **The class holding the type code.** File path or class name. Why: the mechanics work on the class that owns the type code field and its accessors. Everything else is found from there.
- **The type code field and its values.** Name of the field; the named constants and their meanings (e.g., `ENGINEER = 0`, `SALESMAN = 1`, `MANAGER = 2`). Why: each value becomes either a subclass or a state object; knowing the full value set before starting prevents incomplete refactoring.
### Observable Context (gather before asking)
Grep for the type code to map the full scope before choosing a path:
```
Search targets:
- The type code field name across all files → how many switch sites are there?
- switch (_type) or if (type == X) patterns → do they affect behavior or just read the value?
- The class's extends clause → does it already have a superclass?
- Setter methods on the type code → does the type change after object creation?
- Subclasses of the class → are there existing subclasses for another reason?
```
### Default Assumptions
- If the type code is only read (no switch statements execute different logic per value): treat as non-behavior-affecting → Path A
- If it is unclear whether the type changes at runtime: check for setter methods on the type field. Presence of a setter = mutable type = cannot use subclasses → Path C
- If the class already extends another class: cannot add a second superclass in single-inheritance languages → Path C
---
## The Three-Way Decision Tree
```
Does the type code drive different behavior?
(switch statements / if-else chains execute different code per value)
│
├── NO ──→ PATH A: Replace Type Code with Class
│ (type code is pure data — used for identity/classification only,
│ no conditional branching on its value)
│
└── YES
│
Can the class be subclassed?
(No existing inheritance blocking it AND type value does not change
after the object is created — no setter on the type field)
│
├── YES ──→ PATH B: Replace Type Code with Subclasses
│ (simplest path — the class itself grows subclasses,
│ one per type code value)
│
└── NO ──→ PATH C: Replace Type Code with State/Strategy
(class already subclassed for another reason, OR
type value changes at runtime — use a state object
that holds the variant behavior instead)
```
**State vs. Strategy sub-decision (within Path C):**
- Simplifying a **single algorithm** with Replace Conditional with Polymorphism → name it **Strategy** (behavior is the unit being varied)
- Object feels like it **transitions between states** at runtime (e.g., an order moves from PENDING to SHIPPED to DELIVERED) → name it **State** (object identity is the unit being varied)
- Mechanically identical — the naming reflects intent, not structure
---
## Process
### Step 1: Identify and Scope the Type Code
**ACTION:** Read the class holding the type code. Grep for the type code constants and field name across the entire codebase.
**WHY:** A type code refactoring that only touches the host class and misses switch statements in callers produces an inconsistent codebase — old integer-based calls mixed with new class-based ones. The full scope must be known before any transformation begins.
**Identify:**
1. The type code field (`private int _type`, `private String status`, etc.)
2. All named constant values and their meanings
3. Every location in the codebase that switches on or compares against this field
4. Whether those locations execute different behavior per value (not just format or display the value)
5. Whether the field has a setter (mutable = cannot subclass directly)
6. Whether the host class already extends another class
**Output of this step:** A table like:
```
Type code field: Employee._type
Values: ENGINEER(0), SALESMAN(1), MANAGER(2)
Switch sites:
- Employee.payAmount() — behavioral (different pay calculation per type)
- ReportGenerator.formatTitle() — behavioral (different title per type)
- EmployeeDAO.save() — non-behavioral (writes type code as integer to DB)
Type mutable: YES (setType() method exists) → cannot use subclasses
Host class inherits: NO
Decision: PATH C — Replace Type Code with State/Strategy
```
---
### Step 2: Route to the Correct Path
**ACTION:** Apply the decision tree using the observations from Step 1. Select Path A, B, or C.
**WHY:** Each path produces a fundamentally different structure. Applying Path B (subclasses) when the type changes at runtime will break the object model — an object cannot change its class. Applying Path A when the type code drives behavior leaves the switch statements in place and produces no improvement. The routing decision is the highest-leverage moment in this skill.
**Decision criteria (in order):**
1. Are there switch statements or if-else chains that execute different code depending on the type code's value?
- No → Path A
- Yes → continue to criterion 2
2. Does the type code field have a setter, or does the host class already extend another class?
- No setter AND no existing superclass → Path B
- Setter exists OR existing superclass → Path C
**Exception checks (apply before executing the chosen path):**
- **Polymorphism overkill:** If the switch affects only a single method and the variants are not expected to grow — use Replace Parameter with Explicit Methods instead of the full type code replacement. Signal: the type code appears in only one switch in one method, and the method's callers already know which variant they want.
- **Null case:** If one of the cases in the switch is `null` — apply Introduce Null Object for that case before or alongside the main path.
---
### Step 3A: Path A — Replace Type Code with Class
**Precondition:** Type code is pure data — no switch statements execute different behavior per value.
**WHY this path:** The compiler sees integers, not type names. Any integer can be passed, including invalid ones. Replacing the integer with a class gives the compiler the ability to enforce valid values at call sites. It also creates a home for behavior that belongs to the type (Move Method opportunities).
**Mechanics:**
1. **Create the type code class.**
- Name it after the concept the type code represents (e.g., `BloodGroup`, `EmployeeType`)
- Give it a private integer field `_code` that stores the underlying value
- Add a private constructor taking the integer code
- Add static final instances for each value: `public static final BloodGroup O = new BloodGroup(0);`
- Add a private static array `_values` to map integers to instances
- Add a public static factory method `code(int arg)` that returns the correct instance from `_values`
- Add a public `getCode()` method that returns the integer (needed during the transition)
2. **Modify the host class to use the new type.**
- Change the type of the field from `int` to the new class
- Keep old integer-based constants pointing at `NewClass.INSTANCE.getCode()` so existing callers still compile
- Update the constructor and any setter to use `NewClass.code(intArg)` to convert incoming integers
- Compile and test — this is a safe checkpoint
3. **Migrate callers one by one.**
- For each caller using the integer constants: change to use the new class's static instances (`BloodGroup.O` instead of `Person.O`)
- For each caller using the integer getter: change to use the new class-returning getter
- Rename the old integer getter before adding the new class getter to make the transition visible: `getBloodGroupCode()` (old) → `getBloodGroup()` (new, returns `BloodGroup`)
- Compile and test after each caller is updated
4. **Remove the old integer interface.**
- Once all callers use the new class, remove the integer-based constants, the integer-based getter, and the integer-based constructor
- Privatize `getCode()` on the type class — it is now an implementation detail
- Compile and test
**Alert:** Even if the type code does not cause different behavior in switch statements, check whether any behavior would be better placed on the new type code class. Apply Move Method for any method that primarily operates on the type value.
---
### Step 3B: Path B — Replace Type Code with Subclasses
**Precondition:** Type code drives behavior (switch statements exist) AND the type value is immutable after object creation AND the host class has no existing superclass blocking subclassing.
**WHY this path:** The simplest path to polymorphism. Each type code value becomes a subclass of the host class. The subclasses override the type code getter to return their specific value (a temporary measure), then the switch statements are replaced with polymorphic method dispatch. Knowledge of which variant you have moves from callers into the class itself.
**Mechanics:**
1. **Self-encapsulate the type code.**
- Add a getter method for the type code field if one does not exist: `int getType() { return _type; }`
- Replace all direct field access in the host class with calls to the getter
- Why: subclasses will override the getter; direct field access would bypass the override
2. **Replace the constructor with a factory method** (if the type code is passed to the constructor).
- Create a static factory: `static Employee create(int type) { return new Employee(type); }`
- Make the constructor private
- Update all callers to use the factory — compile and test
- Why: once there are subclasses, the factory will instantiate the correct subclass. The constructor cannot be made to return a subclass instance.
3. **Create one subclass per type code value.**
- For each value (ENGINEER, SALESMAN, MANAGER): create a subclass that overrides the type code getter to return the hard-coded value
```java
class Engineer extends Employee {
int getType() { return Employee.ENGINEER; }
}
```
- Update the factory method to return the correct subclass per value:
```java
static Employee create(int type) {
if (type == ENGINEER) return new Engineer();
else if (type == SALESMAN) return new Salesman();
// etc.
}
```
- Compile and test after adding each subclass
4. **Remove the type code field from the superclass.**
- Once all subclasses override the getter, the field in the superclass is unused
- Remove the field; declare `getType()` abstract in the superclass
- Compile and test
5. **Apply Replace Conditional with Polymorphism** (Step 4 below) on all switch statements that remain.
6. **Push down type-specific features.**
- Use Push Down Method and Push Down Field on any methods or fields that are only relevant to certain subclasses
- These are now clearly expressed by being in the subclass rather than guarded by a type code check
---
### Step 3C: Path C — Replace Type Code with State/Strategy
**Precondition:** Type code drives behavior (switch statements exist) AND either (a) the type value changes at runtime (mutable type), OR (b) the host class already has a superclass.
**WHY this path:** Path B requires the host class to be extended. When that is not possible — the host already has a superclass (single inheritance), or the type changes during the object's lifetime — the polymorphic behavior must live in a separate object. The host class delegates to a state/strategy object, which can be swapped at runtime without changing the host object's class.
**Mechanics:**
1. **Self-encapsulate the type code.**
- Add getter and setter for the type code field if not present
- Replace all direct field access in the host class with calls to the getter/setter
- Why: the getter and setter will be redirected to delegate to the state object in step 4
2. **Create the state/strategy abstract class.**
- Name it after the purpose of the type code (e.g., `EmployeeType` for an employee type code)
- Declare an abstract method to return the type code: `abstract int getTypeCode();`
- Why: the host class's existing getter will delegate to this method; all callers of the host's getter continue to work unchanged
3. **Create one concrete subclass per type code value.**
- Add all subclasses at once (easier than one at a time for this path)
- Each subclass returns its specific type code from `getTypeCode()`
```java
class Engineer extends EmployeeType {
int getTypeCode() { return Employee.ENGINEER; }
}
```
- Compile
4. **Connect the host class to the state/strategy object.**
- Add a private field of the state class type: `private EmployeeType _type;`
- Redirect the host class's type code getter to delegate: `int getType() { return _type.getTypeCode(); }`
- Redirect the host class's type code setter to instantiate the correct state subclass:
```java
void setType(int arg) {
switch (arg) {
case ENGINEER: _type = new Engineer(); break;
case SALESMAN: _type = new Salesman(); break;
// etc.
}
}
```
- Update the constructor to call the setter (not directly assign)
- Compile and test
- Note: this creates one switch statement in the setter. It is acceptable — it is isolated to object creation/transition and will be the only switch remaining once Replace Conditional with Polymorphism eliminates all the behavioral switches
5. **Move type code constants to the state class.**
- Copy the constant definitions into `EmployeeType`; add a factory method `newType(int code)` with the switch
- Update the host class's setter to call `EmployeeType.newType(arg)` instead of the switch
- Remove the constant definitions from the host class; update any remaining references to use `EmployeeType.ENGINEER` etc.
- Compile and test
6. **Apply Replace Conditional with Polymorphism** (Step 4 below) on all behavioral switch statements.
---
### Step 4: Replace Conditional with Polymorphism
**ACTION:** For each switch statement (or if-else chain) that branches on the type code, replace it with a polymorphic method call.
**WHY:** This is the payoff step. After Paths B and C, the type code has an inheritance structure. Replace Conditional with Polymorphism moves each branch of each switch into the appropriate subclass. The switch disappears; callers simply invoke the method and get the right behavior for their variant automatically. Adding a new variant now means adding one subclass — no switch hunting.
**Mechanics (apply per switch statement):**
1. **Extract Method** on the switch statement if it is embedded in a longer method. Give it a name that describes what it computes.
2. **Move Method** to the class that holds the type code (the host class in Path B, or the state/strategy abstract class in Path C).
3. For each case in the switch:
- Create an overriding method in the appropriate subclass that contains that case's body
- The superclass version becomes abstract (or raises an error if there is a default case that should never be reached)
4. Delete the switch statement from the moved method; make it abstract.
5. Compile and test after each case is moved.
**After all switches are replaced:**
- The type code field exists only in the getter (Path B: abstract; Path C: delegating to state object)
- No caller needs to ask what type an object is — they just call the method
- Adding a new variant is a single-class addition
---
### Step 5: Apply Exceptions and Reverse Path
**Replace Parameter with Explicit Methods (polymorphism overkill):**
Apply instead of the full type code replacement when ALL of these are true:
- The switch appears in only one method
- The method takes the type code as a parameter (not a stored field)
- The variants are stable — new cases are not expected
**Mechanics:** Create a separate named method for each case. Instead of one `setHeight(int metricType, double amount)` with a switch on `metricType`, create `setHeightInMeters(double amount)` and `setHeightInFeet(double amount)`. Callers use the explicit method name instead of passing a type code constant.
**Introduce Null Object:**
Apply when one of the switch cases handles `null`:
- Create a null-object subclass that returns neutral/safe values for every method (zero, empty string, no-op, etc.)
- Replace all null checks in callers with polymorphic calls — the null object responds correctly without special-casing
- The null check disappears from the switch
**Replace Subclass with Fields (reverse path):**
Apply when you have subclasses that vary only in constant return values — no real behavior difference, just returning different hard-coded constants.
Signal: subclasses that look like:
```java
class Male extends Person { boolean isMale() { return true; } char getCode() { return 'M'; } }
class Female extends Person { boolean isMale() { return false; } char getCode() { return 'F'; } }
```
**Mechanics:**
1. Apply Replace Constructor with Factory Method on the subclasses — create factory methods on the superclass (`createMale()`, `createFemale()`)
2. Update all callers to use the factory methods; remove direct references to the subclass names
3. For each constant method on the subclasses: declare a field on the superclass; add a protected superclass constructor that initializes those fields; update subclass constructors to call `super(true, 'M')` etc.
4. Implement each constant method on the superclass to return the field; remove the override from the subclass
5. Compile and test after each subclass method is removed
6. When a subclass has no remaining methods: use Inline Method to inline its constructor into the superclass factory method; delete the subclass
7. Repeat until all constant-only subclasses are gone
---
## Decision Summary (Quick Reference)
| Situation | Path |
|-----------|------|
| Type code is pure data — no switches on value | A: Replace Type Code with Class |
| Type code drives behavior + class can be subclassed + type is immutable | B: Replace Type Code with Subclasses |
| Type code drives behavior + (class already subclassed OR type changes at runtime) | C: Replace Type Code with State/Strategy |
| Focusing on a single algorithm | Name the object Strategy |
| Object transitions between states at runtime | Name the object State |
| Switch affects only one method, variants stable | Replace Parameter with Explicit Methods |
| One switch case is null | Introduce Null Object |
| Subclasses vary only in constant return values | Replace Subclass with Fields (reverse) |
---
## Examples
### Example 1: Path A — Blood Group (Pure Data)
**Situation:** `Person` class stores blood group as `int _bloodGroup` with constants `O=0, A=1, B=2, AB=3`. No method switches on blood group value — it is stored and retrieved for display. The type code is pure data.
**Routing:** No behavior-affecting switch → Path A.
**Outcome:** Create `BloodGroup` class with static instances `O, A, B, AB`. `Person` stores `private BloodGroup _bloodGroup`. All callers that used `Person.O` now use `BloodGroup.O`. The compiler now rejects `person.setBloodGroup(99)` — invalid blood groups become compile errors, not runtime surprises.
---
### Example 2: Path B — Employee Type (Immutable, No Inheritance Conflict)
**Situation:** `Employee` stores `int _type` with `ENGINEER=0, SALESMAN=1, MANAGER=2`. Method `payAmount()` switches on `_type` to calculate different pay. The type is set once at construction. `Employee` has no existing superclass.
**Routing:** Behavior-affecting switch + no setter + no existing superclass → Path B.
**Outcome:** `Engineer`, `Salesman`, `Manager` subclasses of `Employee`. `payAmount()` becomes abstract on `Employee`; each subclass implements its own pay calculation. Adding a new employee type (Contractor) means adding one class — no switch hunting.
---
### Example 3: Path C — Employee Type (Mutable — Can Change Role)
**Situation:** Same `Employee` as above, but employees can be promoted (a manager can become an engineer). `setType(int arg)` exists. Subclassing is impossible because the type changes at runtime.
**Routing:** Behavior-affecting switch + setter exists (type mutable) → Path C.
**Outcome:** `EmployeeType` abstract class with `Engineer`, `Salesman`, `Manager` subclasses. `Employee` holds `private EmployeeType _type`. `payAmount()` moves to `EmployeeType` and is overridden in each subclass. Calling `employee.setType(Employee.ENGINEER)` swaps in a new `Engineer` state object. The object model is valid — `Employee` does not change class, its delegate does.
---
### Example 4: Polymorphism Overkill — Replace Parameter with Explicit Methods
**Situation:** A single method `setValue(int metricType, double amount)` switches on `metricType` (FEET=0, METERS=1). This switch appears only in this one method.
**Routing:** Single method affected, stable variants → Replace Parameter with Explicit Methods.
**Outcome:** `setValueInFeet(double amount)` and `setValueInMeters(double amount)`. Callers are more readable; the compiler enforces the call rather than trusting the caller to pass a valid constant.
---
## Key Principles
**1. Route before executing.** The most expensive mistake is applying the wrong path. Applying Path B when the type is mutable requires undoing the subclass structure and building a state object instead. Take 10 minutes to answer the two decision tree questions before writing any code.
**2. Self-encapsulate first, always.** Both Paths B and C begin with self-encapsulation of the type code field. Skipping this step and leaving direct field access in the host class means subclasses' getter overrides are bypassed. The refactoring silently fails.
**3. One switch is acceptable after Path C.** The setter in Path C has a switch that instantiates the correct state subclass. This is the only remaining switch and it is isolated to one place. Do not try to eliminate it with more indirection — it is the factory, and one factory switch is exactly right.
**4. Migrate callers one at a time.** In Path A especially, changing all callers at once is error-prone. Change one caller, compile, test, then the next. The old and new interfaces can coexist during the transition — that is the point of keeping `getCode()` available until all callers have migrated.
**5. Replace Conditional with Polymorphism is the payoff.** Paths B and C are scaffolding. The actual gain — no more switch hunting when adding variants — comes from completing Replace Conditional with Polymorphism. Do not stop at the structural step.
---
## References
| File | Contents | When to read |
|------|----------|--------------|
| `references/refactoring-prescriptions.md` | Full prescription tree per smell with all conditional branches | Verifying routing decisions |
| `references/smell-catalog.md` | Switch Statements and Primitive Obsession detection criteria | Confirming the type code is the root cause |
**Skill relationships:**
- `code-smell-diagnosis` — upstream: identifies Switch Statements or Primitive Obsession that routes here
- `conditional-simplification-strategy` — downstream: handles complex conditionals not driven by type codes
- `data-organization-refactoring` — parallel: handles Primitive Obsession when the issue is data clusters, not type codes
## License
This skill is licensed under [CC-BY-SA-4.0](https://creativecommons.org/licenses/by-sa/4.0/).
Source: [BookForge](https://github.com/bookforge-ai/bookforge-skills) — Refactoring: Improving the Design of Existing Code by Martin Fowler and Kent Beck.
## Related BookForge Skills
Install related skills from ClawhHub:
- `clawhub install bookforge-code-smell-diagnosis`
- `clawhub install bookforge-conditional-simplification-strategy`
- `clawhub install bookforge-data-organization-refactoring`
Or install the full book set from GitHub: [bookforge-skills](https://github.com/bookforge-ai/bookforge-skills)
Select the right web presentation pattern combination for any server-side web layer under design or refactor. Covers MVC (Model View Controller) decompositio...
---
name: web-presentation-pattern-selector
description: "Select the right web presentation pattern combination for any server-side web layer under design or refactor. Covers MVC (Model View Controller) decomposition, all three view patterns (Template View, Transform View, Two Step View), all three controller patterns (Page Controller, Front Controller, Application Controller), and when to layer Application Controller above the others for wizard or state-machine flows. Use when designing a new web layer, refactoring a tangled web controller, diagnosing fat controller or Template View scriptlet anti-patterns, mapping legacy JSP/ERB/ASP patterns to modern equivalents (Spring DispatcherServlet = Front Controller, Rails Router = Front Controller, JSP/ERB/Jinja = Template View), or deciding whether a wizard flow needs an Application Controller above a Front Controller. Applies to any language/framework: Java Spring MVC, Ruby on Rails, Python Django/Flask, ASP.NET Core, Node.js Express, PHP Laravel. Produces a web presentation design record with pattern selections, anti-pattern audit, and framework-specific implementation notes. Relevant keywords: web controller pattern, MVC, Model View Controller, Page Controller, Front Controller, Application Controller, Template View, Transform View, Two Step View, web presentation pattern, web framework architecture, refactor controllers, fat controller, server-side rendering, server-side MVC."
version: 1.0.0
homepage: https://github.com/bookforge-ai/bookforge-skills/tree/main/books/patterns-of-enterprise-application-architecture/skills/web-presentation-pattern-selector
metadata: {"openclaw":{"emoji":"🌐","homepage":"https://github.com/bookforge-ai/bookforge-skills"}}
status: draft
source-books:
- id: patterns-of-enterprise-application-architecture
title: "Patterns of Enterprise Application Architecture"
authors:
- Martin Fowler
- David Rice
- Matthew Foemmel
- Edward Hieatt
- Robert Mee
- Randy Stafford
chapters:
- "Chapter 4. Web Presentation (narrative)"
- "Chapter 14. Web Presentation Patterns"
domain: software-architecture
tags:
- web-presentation
- mvc
- web-application
- controllers
- design-patterns
- software-architecture
- server-side-rendering
- enterprise-patterns
depends-on: []
execution:
tier: 2
mode: hybrid
inputs:
- type: codebase
description: "Web layer source files (controllers, views, templates, routing config). Optional but significantly improves anti-pattern detection."
- type: description
description: "Natural-language description of the web layer shape, framework in use, pain points, and workflow complexity."
tools-required:
- Read
- Glob
- Grep
- Write
tools-optional:
- Edit
mcps-required: []
environment: "Enterprise web application codebase with a web layer (controllers, views, templates). Framework detection from pom.xml, Gemfile, package.json, requirements.txt, or *.csproj. Works with description-only when no codebase is available."
discovery:
goal: "Route a web layer to the right combination of view pattern, controller pattern, and optional Application Controller for workflow features."
tasks:
- "Detect the framework and what patterns it already mandates"
- "Select view pattern: Template View, Transform View, or Two Step View"
- "Select controller pattern: Page Controller or Front Controller (or confirm framework default)"
- "Decide whether Application Controller is warranted for any wizard or state-machine flows"
- "Audit existing code for scriptlet and fat-controller anti-patterns"
- "Produce a web presentation design record with implementation notes"
audience:
roles:
- software-architect
- senior-backend-engineer
- tech-lead
- framework-designer
experience: intermediate
when_to_use:
triggers:
- "Designing the web layer of a new enterprise application"
- "Refactoring a tangled or overgrown controller layer"
- "Controllers contain domain logic (fat controller symptom)"
- "Templates contain conditionals, loops, or database calls (scriptlet symptom)"
- "A multi-step wizard or approval flow is being added to an existing app"
- "Migrating from one web framework to another and mapping old patterns to new"
- "Code review of web-layer architecture raises questions about pattern choice"
- "Team argument over Front Controller vs Page Controller style"
prerequisites: []
not_for:
- "Single-page applications with no server-rendered HTML (though the API backend still benefits from controller-pattern advice)"
- "Selecting a persistence pattern (use enterprise-architecture-pattern-stack-selector or data-source-pattern-selector)"
- "Selecting a session state strategy (use session-state-location-selector)"
environment:
codebase_required: false
codebase_helpful: true
works_offline: true
quality:
scores:
with_skill: null
baseline: null
delta: null
tested_at: null
eval_count: null
assertion_count: 13
iterations_needed: null
---
# Web Presentation Pattern Selector
## When to Use
Use this skill when designing or refactoring the web layer of a server-side enterprise application. It applies pattern-level decision heuristics from Fowler's *Patterns of Enterprise Application Architecture* — specifically the three view patterns (Template View, Transform View, Two Step View), three controller patterns (Page Controller, Front Controller, Application Controller), and the server-side MVC framework that holds them together.
**Typical triggers:** new web layer design, fat controller refactor, scriptlet cleanup, adding a wizard flow, framework migration, or a team disagreement about controller structure.
**NOT for:** selecting persistence or session-state patterns (see Related Skills). For SPA-only frontends, apply this skill to the API backend — the patterns translate cleanly (Front Controller = API router, Transform View = JSON serializer).
**Prerequisites:** none. Works with codebase or description alone.
---
## Context & Input Gathering
Before selecting patterns, gather the following. Ask if not provided.
**Required:**
- Language and framework (Spring MVC, Rails, Django, Express, ASP.NET Core, Laravel, Flask, other)
- Rendering mode: server-rendered HTML, API-only (JSON), or hybrid (server HTML + API endpoints)
- Brief description of the web layer's current shape (controllers, views, templates)
- Most acute pain point (fat controllers, scriptlet mess, duplicated cross-cutting logic, wizard complexity)
**Observable from codebase:**
- Build file (`pom.xml`, `Gemfile`, `package.json`, `*.csproj`, `requirements.txt`) for framework detection
- `src/web/` or `src/controllers/` or `app/controllers/` for controller shapes
- `src/views/` or `app/views/` or `templates/` for view/template shapes
- Routing config (`routes.rb`, `urls.py`, `*.routes.ts`, `WebMvcConfigurer`, `Startup.cs`) for dispatcher shape
**Defaults if unknown:**
- Assume Template View if server pages (JSP/ERB/Jinja/Razor) are present
- Assume Front Controller if the framework has a central dispatcher (almost all modern frameworks do)
- Assume no Application Controller unless wizard/approval flows exist
**Sufficiency check:** proceed when you know the framework, rendering mode, and at least one pain point. The codebase improves precision but is not required.
---
## Process
### Step 1 — Identify What the Framework Already Mandates
*WHY: Most modern frameworks ship with Front Controller built in. The decision between Page Controller and Front Controller is often already made. Starting here prevents recommending a pattern the framework makes impossible or redundant.*
1a. Detect the framework from build files or imports.
1b. Map the framework to its built-in controller pattern:
| Framework | Built-in controller pattern |
|---|---|
| Spring MVC | Front Controller (DispatcherServlet) |
| Rails | Front Controller (Router) + Page Controller (controller actions) |
| Django | Front Controller (URL dispatcher) + Page Controller (views) |
| ASP.NET Core MVC | Front Controller (middleware pipeline + MVC middleware) |
| Express / Koa | Front Controller (app.use middleware chain) |
| Laravel | Front Controller (Router) |
| Flask | Hybrid — route decorators approach Page Controller; Blueprints add Front Controller behavior |
1c. Note: in frameworks with a built-in Front Controller, the real decision shifts to *how to structure the command/action layer* (Page Controller style within the Front Controller) and *which view technology to use*.
---
### Step 2 — Clarify the Rendering Mode
*WHY: Rendering mode determines which view pattern is relevant. API-only backends still use view patterns — the "view" is the JSON serializer or response transformer.*
- **Server-rendered HTML:** all three view patterns apply.
- **API-only (JSON):** Transform View applies to the serializer/presenter layer. Template View and Two Step View are not relevant for HTML, but Two Step View logic applies to multi-format response shaping.
- **Hybrid:** choose view patterns per endpoint group.
For SPA frontends with a server API: proceed with Front Controller for the API router and Transform View for the JSON response shaping; note that the client-side SPA uses a browser-MVC variant (React/Vue/Angular) that is a different pattern instantiation.
---
### Step 3 — Select the View Pattern
*WHY: The view pattern determines how domain data becomes the response. Wrong choice leads to scriptlet accumulation (Template View scriptlets), unmaintainable XSLT (Transform View overuse), or unnecessary complexity (Two Step View when layout is not shared).*
**Decision tree:**
```
Is the output HTML?
├── YES
│ ├── Are designers (non-programmers) editing the templates?
│ │ └── YES → Template View (keep business logic out via helper objects)
│ ├── Is the domain data already XML or trivially convertible?
│ │ └── YES, and you need testable pure-function rendering → Transform View
│ ├── Do many screens share the same layout structure and you need
│ │ global HTML changes to touch ONE place?
│ │ └── YES → Two Step View
│ └── Default (no special constraint) → Template View
└── NO (JSON / XML API)
└── Transform View (serializer/presenter is the transform)
```
**Template View (default for HTML):**
- Embed markers in a static HTML page; markers are resolved at request time.
- Use with a helper object to keep logic out of the template.
- Modern equivalents: ERB, Jinja2, Razor, Thymeleaf, Blade, Handlebars.
- Watch for: scriptlet anti-pattern (see Step 6).
**Transform View (for clean separation or multi-format):**
- Write a transform that walks domain data and produces output (HTML, JSON, XML).
- Organized around *input element types*, not output page structure.
- Classic XSLT; modern equivalents: React/Vue render functions, JSON serializer classes, GraphQL resolvers.
- Pro: deterministic, testable. Con: alien syntax (XSLT); requires domain data in a traversable structure.
**Two Step View (for site-wide consistency):**
- Stage 1: per-screen component produces a logical screen (fields, tables, headers — no HTML).
- Stage 2: single application-wide component converts logical screen to HTML.
- Use when: many screens share the same layout AND you need one-place global HTML control.
- Modern equivalents: layout components (Next.js layouts, Rails application layout), design-system primitive libraries.
- Do NOT use when screens are highly design-intensive and differ significantly.
---
### Step 4 — Select the Controller Pattern
*WHY: The controller pattern determines where HTTP request handling, cross-cutting concerns (auth, logging, i18n), and flow decisions live. Wrong choice leads to duplicated cross-cutting logic (Page Controller without helpers) or untraceable routing (Front Controller with too much dispatch logic).*
**If the framework mandates Front Controller (most frameworks):** confirm and document it. Focus instead on how action-level handlers are structured (Page Controller style actions within the Front Controller).
**If the framework is flexible (CGI, raw servlets, simple HTTP handlers):**
```
Does the site have many similar cross-cutting concerns
(auth, logging, i18n, CSRF) across all or most requests?
├── YES → Front Controller
│ └── Use Intercepting Filter (decorator chain) for cross-cutting concerns
└── NO, or team wants simpler per-page ownership → Page Controller
└── Factor cross-cutting into a common base class or helper
```
**Page Controller:**
- One handler per logical action/page.
- Responsibilities: decode URL + form data → invoke model (no HTTP leakage into model) → forward to view.
- Scale: works well when team members own separate page areas; minimal coupling.
- Add a base class or before-filter mechanism for shared behavior.
- Modern pattern: Rails controller actions, Django class-based views (each action IS a Page Controller).
**Front Controller:**
- Single Web Handler receives all requests, dispatches to Command objects.
- Command objects have no Web knowledge (testable independently).
- Static dispatch: explicit URL → command mapping (compile-time checked, flexible URL shapes).
- Dynamic dispatch: URL contains command class name or properties-file lookup (extensible without changing the handler).
- Use Intercepting Filter / middleware pipeline for auth, logging, CSRF.
---
### Step 5 — Decide Whether Application Controller Is Warranted
*WHY: Application Controller removes duplicated flow logic from individual page controllers. Without it, wizard-style features get partially re-implemented in each step's controller, and changing step order touches multiple files.*
**Trigger check — answer YES to any of these to proceed with Application Controller:**
- Does any feature follow a fixed or conditional ordered sequence of screens (wizard, checkout, onboarding)?
- Do multiple controllers need to share the decision of which screen to show next?
- Is navigation state (current step, earlier answers) being passed between controllers manually?
- Would reordering or adding a step in a flow require changing multiple controller files?
**If YES:** introduce an Application Controller that holds:
- A collection of domain commands (which service/domain method to invoke for each step)
- A collection of view references (which view to show after each step)
- The flow/state-machine logic (what comes next given current state + input)
Input controllers (Page or Front) ask the Application Controller for commands and views rather than deciding themselves.
Prefer: Application Controller with no direct dependencies on HTTP/UI machinery — keeps it testable.
Modern equivalents: step-based wizard routers (React Router with step state), BPM/workflow engines (Temporal, Camunda), Next.js parallel routes with step orchestration.
**If NO:** Application Controller is not warranted. Keep flow decisions simple and local.
---
### Step 6 — Audit for Anti-Patterns
*WHY: Identifying existing anti-patterns grounds the recommendation in concrete code symptoms and provides a refactoring roadmap.*
If a codebase is available, grep for:
**Template View scriptlets:**
```
# JSP: <% ... %> blocks with logic
Grep: "<%[^=!@]" in *.jsp
# ERB: <% ... %> with conditionals, queries
Grep: "<%[^=]" with if/ActiveRecord in *.erb
# Jinja: {% if %} containing service calls or SQL
Grep: "{% if\|{% for" in templates/*.html — check body for data calls
```
Symptom: template contains `if`, `for`, database access, or business rules.
Fix: extract to helper object / view model; controller populates a DTO; template only renders.
**Fat Controller:**
```
Grep: SQL strings, repository calls, calculation logic inside controller action methods
Grep: action methods > ~20 lines of non-HTTP logic
```
Symptom: controller action contains domain logic (calculations, validation beyond input format, business rules).
Fix: move to service layer or domain object; controller decodes input → calls service → forwards to view.
**Domain Logic in Front Controller Dispatch:**
Symptom: Web handler / router contains `if` blocks for business rules (not routing decisions).
Fix: move to Command objects or domain layer; keep the handler purely routing.
---
### Step 7 — Produce the Web Presentation Design Record
*WHY: A concise written record makes the pattern decision reviewable, shareable with the team, and referenceable during implementation.*
Write a `web-presentation-design.md` in the project root or architecture docs folder with:
```markdown
# Web Presentation Design Record
## Framework
[Framework name + version]
## Rendering Mode
[Server-rendered HTML | API-only JSON | Hybrid]
## View Pattern
**Selected:** [Template View | Transform View | Two Step View]
**Rationale:** [1-2 sentences]
**Implementation:** [specific technology — ERB, Jinja2, XSLT, React SSR, etc.]
**Guard against:** [scriptlet accumulation | XSLT complexity | layout rigidity]
## Controller Pattern
**Selected:** [Front Controller | Page Controller | Framework Default (Front Controller)]
**Rationale:** [1-2 sentences]
**Implementation:** [Spring DispatcherServlet + command objects | Rails Router + controller actions | etc.]
**Cross-cutting concerns:** [middleware chain | base controller | before_action filters]
## Application Controller
**Decision:** [Warranted | Not warranted]
**Rationale:** [1-2 sentences]
**Features requiring it:** [list of wizard/workflow features, or "none"]
**Implementation:** [step router | BPM engine | custom state machine | n/a]
## Anti-Pattern Audit
| Anti-pattern | Found? | Location | Fix |
|---|---|---|---|
| Template View scriptlets | [Yes/No] | [files] | [action] |
| Fat controller | [Yes/No] | [controllers] | [action] |
| Domain logic in dispatcher | [Yes/No] | [location] | [action] |
## Open Questions
[Any unresolved decisions for team discussion]
```
---
## Inputs
- **Framework name and version** (required)
- **Rendering mode** (server HTML / JSON API / hybrid)
- **Web layer source files** (`controllers/`, `views/`, `templates/`) — optional
- **Description of pain points** — optional but recommended
- **Workflow features list** — needed for Application Controller decision
---
## Outputs
- `web-presentation-design.md` — pattern selection record with rationale + anti-pattern audit
- Inline code snippets for controller/view refactoring (if codebase available)
- Framework-specific implementation notes
---
## Key Principles
**1. Server-side MVC is not browser MVC.**
Fowler's MVC places the model on the server; the view produces HTML; the controller handles HTTP. Browser-side MVC (React, Angular, Vue) is a different instantiation of the same idea on the client. Conflating them causes design confusion — especially when teams debate whether to "add MVC" to a Rails or Spring app that already has it.
**2. Most frameworks ship Front Controller — confirm, don't debate.**
Spring DispatcherServlet, Rails Router, Django URL dispatcher, Express middleware chain, ASP.NET Core pipeline — all are Front Controllers. The debate between Page Controller and Front Controller is already settled by the framework choice. Document this and move to the more meaningful decisions (view technology, Application Controller).
**3. Template View is the default; keep logic out of templates.**
Template View is appropriate for almost all HTML output. The risk is scriptlet accumulation — business logic creeping into templates. Prevent this with helper objects / view models: the controller populates a clean data structure; the template only renders. Scriptlets are a symptom of missing helper discipline.
**4. Transform View enables testable, format-agnostic output.**
A Transform View is organized around input elements, not output structure. This makes it deterministic and testable without a browser. XSLT is the classic implementation; modern equivalents are React render functions (client-side) and JSON serializer classes (API layer). Prefer Transform View when the same domain data must be rendered in multiple formats.
**5. Two Step View pays off only when layout is truly shared.**
The second-stage bottleneck is also the second-stage advantage: one place controls all HTML. This is valuable for site redesigns and theme changes, but harmful when screens need unique layouts. Design-system component libraries are the modern equivalent.
**6. Application Controller belongs to workflow, not every app.**
Application Controller is worth its indirection only when multiple features share navigation-state decisions. For simple request/response apps, it adds complexity without benefit. The trigger is: multiple controllers need to agree on which screen comes next based on shared state.
**7. Fat controllers signal domain logic displacement.**
When a controller action grows beyond input decoding + model invocation + view forwarding, domain logic has leaked. The controller is the wrong place for business rules: it's not reusable, not testable without HTTP, and not visible to domain developers. Move the logic to a service or domain object.
---
## Examples
### Example 1 — Rails App with Fat Controllers
**Scenario:** A Rails e-commerce app (Rails 7, ERB templates, PostgreSQL) where product and order controller actions each contain pricing calculation logic, discount rule evaluation, and direct ActiveRecord queries. Developers report tests requiring full request stacks.
**Trigger:** "Our Rails controllers are too fat — business logic is in the actions and tests are slow and fragile."
**Process:**
1. Framework: Rails 7 — Front Controller (Router) already in place. Rails controller actions = Page Controller style.
2. Rendering: Server-rendered ERB — Template View confirmed.
3. No wizard flows → Application Controller not warranted.
4. Anti-pattern audit: Grep `app/controllers/` for ActiveRecord queries and pricing logic in actions → found in `OrdersController#create` (82 lines, discount calculation) and `ProductsController#show` (direct SQL for related products).
5. Two Step View not needed — ERB templates + Rails application layout already handle shared structure.
6. Fix: extract `OrderPricingService` and `ProductQueryService`; controller actions become: decode params → call service → assign `@result` → render.
**Output design record:**
- View Pattern: Template View (ERB) — no change needed; add view models to prevent future scriptlet creep
- Controller Pattern: Front Controller (Rails Router) + Page Controller (controller actions) — confirmed, no change
- Application Controller: Not warranted
- Anti-pattern fixes: Move `OrdersController#create` pricing logic to `OrderPricingService`; move `ProductsController#show` query to `ProductRepository`
---
### Example 2 — Spring MVC App Adding a Checkout Wizard
**Scenario:** A Spring MVC app (Java 17, Thymeleaf templates) with a working product catalog. Adding a 5-step checkout wizard: (1) cart review, (2) address, (3) shipping options, (4) payment, (5) confirmation. Steps may conditionally skip (digital products skip shipping).
**Trigger:** "We need to add a multi-step checkout. How do we structure the controllers and avoid scattered navigation logic?"
**Process:**
1. Framework: Spring MVC — DispatcherServlet = Front Controller already in place.
2. Rendering: Server-rendered Thymeleaf — Template View confirmed.
3. Wizard flow with conditional step skipping → Application Controller IS warranted.
4. Application Controller holds: step sequence definition, which service method per step, which view per step, skip conditions (digital product → skip shipping step).
5. `CheckoutController` becomes thin: delegate to `CheckoutApplicationController.nextStep(session, input)` → get command + view; execute command; forward to view.
6. Anti-pattern check: ensure no business logic in the Thymeleaf templates (`th:if` on business conditions is the scriptlet equivalent — move conditions to view model).
**Output design record:**
- View Pattern: Template View (Thymeleaf) with view models (one per step)
- Controller Pattern: Front Controller (DispatcherServlet) + `CheckoutController` (Page Controller style for the wizard entry point)
- Application Controller: Warranted — `CheckoutApplicationController` owns step sequence + conditional skipping
- Anti-pattern prevention: Thymeleaf templates receive pre-computed view models; no business conditions in templates
---
### Example 3 — ASP.NET Core API Backend for a React SPA
**Scenario:** ASP.NET Core 8 Web API serving a React frontend. Team debating whether the "MVC pattern" applies — some say it's API-only so MVC doesn't matter.
**Trigger:** "We're building an API for our SPA. Does any of this web presentation pattern stuff apply to us?"
**Process:**
1. Framework: ASP.NET Core — middleware pipeline = Front Controller already in place.
2. Rendering: JSON API (no HTML).
3. MVC does apply to the API backend: Model = domain/service layer, View = JSON response shape (handled by serializer), Controller = API controller actions.
4. View pattern: Transform View — the JSON serializer/presenter walks domain objects and transforms them to JSON response DTOs. This is identical in structure to XSLT-based Transform View: input element types (domain objects) → output elements (JSON fields).
5. Controller pattern: Front Controller (ASP.NET Core pipeline) + Page Controller style actions in API controllers.
6. Application Controller: check for multi-step API flows (e.g., an onboarding flow across multiple API calls with server-side session state) — not present here → not warranted.
7. Anti-pattern note: "fat controller" still applies — business logic in API controller actions is the same problem as in HTML controllers.
**Output design record:**
- View Pattern: Transform View (JSON serializers / response DTOs) — model → DTO transformation in a dedicated presenter layer
- Controller Pattern: Front Controller (ASP.NET Core pipeline) + Page Controller style (API controller actions)
- Application Controller: Not warranted (stateless REST; no server-side wizard flows)
- SPA browser MVC: noted as separate concern — React's component model is a browser-side MVC variant; not Fowler's server-side MVC
---
## References
- `references/view-pattern-decision-matrix.md` — detailed decision matrix for Template View vs Transform View vs Two Step View with scoring criteria
- `references/controller-pattern-comparison.md` — Page Controller vs Front Controller trade-off table and base-class patterns for cross-cutting concerns
- `references/application-controller-triggers.md` — checklist of when to introduce Application Controller with flow-state modeling guidance
- `references/anti-pattern-detection-guide.md` — grep patterns, code smells, and fix recipes for scriptlet and fat-controller anti-patterns
- `references/framework-pattern-map.md` — complete mapping of modern web frameworks to PEAA controller and view patterns
---
## License
This skill is licensed under [CC-BY-SA-4.0](https://creativecommons.org/licenses/by-sa/4.0/).
Source: [BookForge](https://github.com/bookforge-ai/bookforge-skills) — Patterns of Enterprise Application Architecture by Martin Fowler et al.
---
## Related BookForge Skills
Install related skills from ClawhHub:
- `clawhub install bookforge-enterprise-architecture-pattern-stack-selector`
- `clawhub install bookforge-session-state-location-selector`
Or install the full book set from GitHub: [bookforge-skills](https://github.com/bookforge-ai/bookforge-skills)
FILE:references/framework-pattern-map.md
# Framework-to-PEAA Pattern Map
Source: Patterns of Enterprise Application Architecture, Ch 4 + Ch 14 (Fowler 2002)
Modern mappings added by BookForge.
## Controller Pattern by Framework
| Framework | Front Controller | Page Controller style |
|---|---|---|
| Spring MVC (Java) | DispatcherServlet | `@Controller` / `@RestController` methods |
| Ruby on Rails | Router (`config/routes.rb`) | Controller actions (`def show`, `def create`) |
| Django (Python) | URL dispatcher (`urls.py`) | View functions / Class-Based Views |
| ASP.NET Core | Middleware pipeline + MVC middleware | Controller action methods |
| Express (Node.js) | `app.use()` middleware chain | Route handler functions |
| Koa (Node.js) | Middleware stack | Route handler functions |
| Laravel (PHP) | Router (`routes/web.php`) | Controller methods |
| Flask (Python) | Blueprints + route decorators (hybrid) | `@app.route()` decorated view functions |
| FastAPI (Python) | Application + router | Route handler functions |
| Gin (Go) | Engine middleware chain | Handler functions |
**Key insight:** every mainstream web framework ships Front Controller by default. The Page Controller vs Front Controller debate is pre-decided by framework selection. Teams using these frameworks should document "Front Controller (framework default)" and focus decisions on: view technology, command/action structure, and Application Controller need.
## View Pattern by Technology
| Technology | PEAA View Pattern |
|---|---|
| JSP (Java) | Template View |
| Thymeleaf (Java) | Template View |
| FreeMarker (Java) | Template View |
| ERB (Rails/Ruby) | Template View |
| Jinja2 (Django/Flask/Python) | Template View |
| Django templates | Template View |
| Razor (.NET) | Template View |
| Blade (Laravel/PHP) | Template View |
| Handlebars / Mustache | Template View |
| Twig (PHP) | Template View |
| XSLT | Transform View |
| React SSR (server-side render) | Transform View (render function = element-by-element transform) |
| Vue SSR | Transform View |
| JSON serializer classes / DTO presenters | Transform View |
| GraphQL resolvers | Transform View |
| Next.js layouts + page components | Two Step View (layout = stage 2; page = stage 1) |
| Rails application layout + partials | Two Step View (application.html.erb = stage 2) |
| Design-system component library | Two Step View (component library = stage 2) |
## Application Controller Modern Equivalents
| PEAA concept | Modern equivalent |
|---|---|
| Application Controller (wizard flows) | React Router with step-state + form wizard libraries |
| Application Controller (approval flows) | BPM engines: Camunda, Activiti |
| Application Controller (long-running workflows) | Temporal, AWS Step Functions |
| Application Controller (onboarding flows) | Next.js App Router parallel routes + intercepting routes |
| Application Controller (domain commands collection) | Command pattern or service method map |
| Application Controller (view references collection) | Route name → component map |
## SPA + API Architecture Note
When a React/Vue/Angular SPA calls a JSON API backend:
- **Backend:** Front Controller (API router) + Transform View (JSON serializer) — Fowler's server-side MVC fully applies
- **Frontend:** Browser-side MVC — React component model, Vue reactivity system, Angular services are all browser-side MVC variants
- These are SEPARATE instances of the MVC idea. Do not conflate them.
- The "model" is different: server model = domain objects; client model = component state or store (Zustand, Redux, Pinia)
FILE:references/view-pattern-decision-matrix.md
# View Pattern Decision Matrix
Source: Patterns of Enterprise Application Architecture, Ch 14 (Fowler 2002)
## Three View Patterns at a Glance
| Dimension | Template View | Transform View | Two Step View |
|---|---|---|---|
| Organizing principle | Output (page structure) | Input (domain element types) | Two stages: logical then physical |
| Designer-editable | Yes (HTML skeleton visible) | No (code/rules structure) | Partially (stage 1 is code; stage 2 can be designed) |
| Testability | Medium (requires rendered HTML) | High (pure function: input XML → output) | High (stage 1 + stage 2 testable separately) |
| Multi-format output | Poor (one template per format) | Good (swap the transform per format) | Good (one second stage per output format) |
| Site-wide redesign cost | High (many templates to update) | Medium (many XSLT files) | Low (change only the second stage) |
| Scriptlet risk | High — the primary anti-pattern | Low (logic is in transform rules) | Low |
| Framework support | Universal (JSP, ERB, Jinja, Razor, Blade) | Moderate (XSLT engines; React SSR; serializers) | Limited (requires deliberate two-stage setup) |
| Complexity | Low | Medium | Medium-High |
| Modern equivalents | ERB, Jinja2, Razor, Thymeleaf, Blade, Handlebars | XSLT, React render(), Vue render(), JSON serializers | Next.js layouts, Rails application.html.erb + components, design-system libraries |
## Decision Criteria
### Choose Template View when:
- Non-programmers (designers, content editors) edit the templates
- The framework ships a server-page technology (virtually all do)
- Output is HTML and each screen has a distinct layout
- Team discipline can enforce the helper-object rule (no scriptlets)
### Choose Transform View when:
- Domain data is already in XML or easily converted
- The same data must be rendered in multiple formats (HTML + JSON + PDF + email)
- You want fully testable, side-effect-free rendering
- Team is comfortable with functional/transformation programming style
- Modern context: designing a JSON API response layer (serializer is a Transform View)
### Choose Two Step View when:
- Many screens share the same layout structure
- Global site-wide HTML changes must touch ONE place
- Supporting multiple "themes" or output styles from the same logical structure
- Modern context: building a design system where all pages compose from a single component library
## Combining Patterns
Two Step View is a modifier, not a replacement. You can have:
- Two Step Template View: stage 1 produces logical structure (code); stage 2 is a Template View
- Two Step Transform View: two XSLT stylesheets; stage 1 transforms domain XML to logical XML; stage 2 transforms logical XML to HTML
## Helper Object Pattern for Template View
To prevent scriptlet accumulation:
1. Controller creates a **View Model** (a plain data object with only the fields the template needs, pre-formatted)
2. Controller assigns view model to request scope
3. Template renders only: `viewModel.albumTitle`, no logic
The view model acts as the "helper object" Fowler describes — it absorbs all template-side logic (formatting, conditionals about what to show) and keeps the template clean.
Implement Unit of Work (UoW) — the object that tracks new, dirty, clean, and removed entities during a business operation and commits all database changes to...
---
name: unit-of-work-implementer
description: |
Implement Unit of Work (UoW) — the object that tracks new, dirty, clean, and removed entities
during a business operation and commits all database changes together in the correct order.
Use when asked: "how do I implement Unit of Work?", "how does Hibernate Session work under the
hood?", "how do I avoid N+1 writes?", "how should I structure DbContext scoping?",
"SQLAlchemy session management best practices", "EntityManager lifecycle", "ORM session
management", "how to track entity changes in a Data Mapper layer?", "first-level cache",
"identity map implementation", "object change tracking", "persistence coordination",
"commit ordering with foreign keys", "how does EF Core SaveChanges work?", "dirty tracking",
"how to batch database writes?", "UoW pattern implementation", "Hibernate Session vs
EntityManager", "SQLAlchemy Session scope", "DbContext per-request", "entity state tracking".
Applies when a Data Mapper pattern is in place (or being introduced) and the team needs
disciplined change tracking, ordered commits, and first-level caching across a business
operation. Integrates with Identity Map for cache and identity consistency. Integrates with
Optimistic Offline Lock via version-conditioned UPDATE. Prerequisite: Data Mapper must be the
chosen data-source pattern; UoW does not apply cleanly to Active Record (AR handles
per-object persistence without a coordinator). If the data-source pattern has not been
chosen, invoke `data-source-pattern-selector` first.
version: 1.0.0
homepage: https://github.com/bookforge-ai/bookforge-skills/tree/main/books/patterns-of-enterprise-application-architecture/skills/unit-of-work-implementer
metadata: {"openclaw":{"emoji":"📚","homepage":"https://github.com/bookforge-ai/bookforge-skills"}}
status: draft
source-books:
- id: patterns-of-enterprise-application-architecture
title: "Patterns of Enterprise Application Architecture"
authors: ["Martin Fowler", "David Rice", "Matthew Foemmel", "Edward Hieatt", "Robert Mee", "Randy Stafford"]
chapters: [3, 11]
tags: ["unit-of-work", "persistence", "orm", "data-access", "design-patterns", "object-relational-mapping", "identity-map", "change-tracking", "entity-state", "hibernate", "ef-core", "sqlalchemy", "fowler-peaa"]
depends-on:
- data-source-pattern-selector
execution:
tier: 2
mode: hybrid
inputs:
- type: codebase
description: "Persistence layer using Data Mapper pattern (or being refactored to it). Language, ORM framework, and transaction boundary conventions."
- type: document
description: "Description of the business operation scope, FK dependencies between entities, and any existing session or context management code."
tools-required: [Read, Write, Grep]
tools-optional: []
mcps-required: []
environment: "Any agent environment with access to the codebase. Codebase access is required to identify entity classes, mapper classes, and transaction boundaries."
discovery:
goal: "Produce a Unit of Work implementation (or ORM-native equivalent) with entity state tracking, ordered commit, Identity Map integration, and lifecycle policy."
tasks:
- "Confirm Data Mapper is the chosen data-source pattern (prerequisite check)"
- "Choose the registration strategy: caller registration, object registration, or UoW-controlled"
- "Design the UoW API: register methods, commit, rollback, and clear"
- "Implement four-state tracking: new, dirty, clean, removed"
- "Implement ordered commit procedure: INSERT (parents first), UPDATE, DELETE (children first), DB COMMIT"
- "Wire Identity Map integration: all reads through UoW; identity by primary key"
- "Establish lifecycle management: per-request or per-business-operation scope"
- "Integrate with Optimistic Offline Lock and Lazy Load collaborators"
- "Produce implementation sketch + stack-native mapping"
audience:
roles: ["senior-backend-engineer", "software-architect", "tech-lead", "framework-designer"]
experience: "intermediate-to-advanced"
when_to_use:
triggers:
- "Implementing a Data Mapper layer without an ORM and needing change tracking"
- "Diagnosing too many round trips to the database from a business operation"
- "Debugging duplicate entity bugs (two objects for the same DB row)"
- "Scoping ORM sessions or DbContext incorrectly across requests"
- "Adding optimistic concurrency control and needing a place to execute version-conditioned commits"
- "Building a Domain Model that needs test isolation from the database"
- "Retrofitting disciplined transaction management into a persistence layer"
prerequisites:
- "Data Mapper selected as the data-source pattern. If not yet chosen, run `data-source-pattern-selector` first."
not_for:
- "Active Record codebases — AR handles per-object persistence; a UoW coordinator adds complexity without benefit"
- "Simple Transaction Script + Table Data Gateway apps — explicit save calls suffice"
- "Choosing a database product or query optimization"
- "Distributed transaction coordination (two-phase commit)"
environment:
codebase_required: true
codebase_helpful: true
works_offline: true
quality:
scores:
with_skill: "{filled by tester}"
baseline: "{filled by tester}"
delta: "{filled by tester}"
tested_at: "{filled by tester}"
eval_count: "{filled by tester}"
assertion_count: 14
iterations_needed: "{filled by tester}"
---
# Unit of Work Implementer
## When to Use
A Unit of Work (UoW) is the coordinator object that tracks every entity touched during a
business operation — newly created, loaded and modified, or deleted — and then flushes all
changes to the database together in the correct order inside a single system transaction.
Use this skill when:
- You have a Data Mapper layer and need change tracking discipline across a business operation
- Your code makes too many database round trips (one UPDATE per field change, not one per commit)
- You are scoping an ORM session (Hibernate `Session`, EF `DbContext`, SQLAlchemy `Session`) and want to understand the underlying contract
- You are implementing a custom Data Mapper layer without a framework and need to track dirty objects
- You are wiring optimistic locking and need a single commit point to run version-conditioned updates
**Prerequisite:** Data Mapper must be the chosen data-source pattern. If it has not been selected, invoke `data-source-pattern-selector` first, or ask the user to confirm their persistence approach before proceeding. UoW adds a coordination layer that Active Record codebases do not need.
---
## Context & Input Gathering
### Required
- **Data-source pattern confirmation:** Verify Data Mapper is in use or being introduced. If Active Record: stop, explain UoW is not applicable, offer `data-source-pattern-selector`.
- **Language and framework:** Which language and ORM (if any)? This determines whether UoW is already built-in (Hibernate, EF Core, SQLAlchemy) or must be hand-rolled.
- **Entity classes:** What are the domain objects (e.g., `Order`, `LineItem`, `Product`)?
- **FK dependency graph:** Which entities reference which? This determines INSERT and DELETE ordering.
- **Transaction boundary:** Where does one business operation begin and end (per-HTTP-request, per-command, per-service call)?
### Helpful
- Existing mapper or repository classes — their `find()` / `insert()` / `update()` / `delete()` methods will be called by the UoW on commit.
- Any existing dirty-tracking or session management code.
- Whether Optimistic Offline Lock (version columns) or Lazy Load (proxy collections) is in use — both require UoW integration.
### Defaults if not specified
- Unknown ORM → ask before generating stack-specific code; provide pseudocode in the interim.
- Unknown FK graph → assume single-level parent/child; flag ordering analysis as required.
- Unknown transaction boundary → default to per-request scope; warn about cross-request sharing.
---
## Process
**Step 1 — Confirm Data Mapper prerequisite.**
WHY: Unit of Work is designed to work with Data Mapper's separation of domain objects from SQL. Active Record embeds persistence in the entity itself; adding a UoW coordinator duplicates responsibility and creates confusion about who owns the save call. Confirming the prerequisite prevents a misapplication that will complicate the codebase.
- Data Mapper confirmed → continue.
- Active Record found → stop. Explain that AR handles persistence per-object; suggest `data-source-pattern-selector` if the team wants to reassess.
- Unknown → invoke `data-source-pattern-selector` or ask user directly.
**Step 2 — Choose the registration strategy.**
WHY: The UoW must know which objects have changed. There are three strategies, each with a different trade-off between transparency and coupling. Choosing the wrong one for the stack and team leads to missed registrations (caller registration) or domain-layer coupling (object registration).
Evaluate each option:
| Strategy | How it works | Best for | Risk |
|---|---|---|---|
| **Caller registration** | Application code calls `uow.registerDirty(entity)` explicitly | Simple custom layers, greenfield | Easy to forget; silent data loss |
| **Object registration** | Entity setters call `UoW.getCurrent().registerDirty(this)` | Custom frameworks; Java/C# domain objects | Couples domain to UoW; requires access to current UoW |
| **UoW-controlled (copy-on-load)** | UoW registers clean objects on load; detects changes at commit via snapshot comparison | ORM-provided (Hibernate, EF, SQLAlchemy) | Higher memory overhead; infrastructure-heavy |
Decision:
- Using Hibernate / EF Core / SQLAlchemy → use the built-in Session/DbContext (UoW-controlled). The skill maps your usage to the UoW contract (see Step 9).
- Custom framework → prefer object registration; use caller registration only for simple scripts.
- Testing-heavy codebase → consider a no-op UoW for unit tests (does not write to DB on commit).
**Step 3 — Design the UoW API.**
WHY: The interface is the contract between domain code and the persistence coordinator. Keeping it minimal and explicit prevents the UoW from becoming a god object.
Minimum API:
```
registerNew(entity) — entity will be INSERTed on commit
registerDirty(entity) — entity will be UPDATEd on commit
registerClean(entity) — entity is known, no action on commit; populates Identity Map
registerRemoved(entity) — entity will be DELETEd on commit
commit() — flush all changes in order, then DB COMMIT
rollback() — discard change sets; no DB writes
clear() — reset UoW state (call after commit or on request teardown)
```
Invariant assertions (enforce at registration time):
- `registerNew`: entity must have a non-null ID; must not be in dirty or removed list.
- `registerDirty`: must not be in removed list; no-op if already in new list.
- `registerRemoved`: if in new list → just remove from new (no DB write needed); remove from dirty.
**Step 4 — Implement four-state tracking.**
WHY: The four states map directly to the four SQL operations. Tracking state precisely prevents redundant SQL (e.g., updating an entity that was just inserted) and missed SQL (e.g., forgetting to delete an entity that was removed mid-operation).
Internal storage — three collections (clean objects are tracked only in Identity Map):
```
newObjects: List<DomainObject> → INSERT on commit
dirtyObjects: List<DomainObject> → UPDATE on commit
removedObjects: List<DomainObject> → DELETE on commit
identityMap: Map<(Class, Id), DomainObject> → first-level cache
```
State transition rules:
- Load from DB → `registerClean` → add to `identityMap`.
- Mutate a clean object → `registerDirty` → move to `dirtyObjects`.
- Create new → `registerNew` → add to `newObjects` AND `identityMap`.
- Delete → `registerRemoved` → move to `removedObjects`; remove from `dirtyObjects`.
- Delete a `new` object (not yet in DB) → remove from `newObjects`; no DB action needed.
For detailed per-transition code examples see `references/entity-state-transitions.md`.
**Step 5 — Implement the ordered commit procedure.**
WHY: Database referential integrity requires that parent rows exist before child rows are inserted, and child rows are deleted before parent rows. Committing in arbitrary order produces FK violation errors. The UoW is the natural place to enforce this ordering because it holds the full change set.
Commit sequence:
1. **INSERT** `newObjects` in FK dependency order (parents before children). Use a topological sort of the FK graph for complex schemas; use explicit ordering for small schemas.
2. **UPDATE** `dirtyObjects` (order within this set is usually safe; touch each exactly once).
3. **DELETE** `removedObjects` in reverse FK dependency order (children before parents).
4. **DB COMMIT** — issue `COMMIT` on the system transaction.
5. **Clear** UoW state — discard all lists and Identity Map entries, or discard the UoW entirely.
For the topological sort algorithm and ordering metadata approach see `references/commit-ordering.md`.
**Step 6 — Wire Identity Map integration.**
WHY: Without an Identity Map, loading the same entity twice produces two separate in-memory objects for the same database row. Updating both produces conflicting writes and undefined behavior. The Identity Map, co-located in the UoW, prevents this by ensuring every load returns the same instance.
Implementation:
- Key: `(entityClass, primaryKey)` tuple.
- On `find(class, id)`: check Identity Map first. If found → return cached instance. If not → load from DB, call `registerClean`, add to map, return.
- On `registerNew`: add to map immediately (the new ID must be assigned before registration).
- On `registerRemoved`: remove from map.
- The Identity Map also serves as a performance cache (avoids redundant DB reads), but its primary purpose is identity consistency, not performance.
**Step 7 — Establish lifecycle management.**
WHY: A UoW that spans multiple requests accumulates stale data, grows without bound, and causes race conditions when shared across threads. The lifecycle must be bounded.
Standard lifecycles:
- **Per-request** (most common for web apps): create UoW at request start, commit (or rollback on error) at request end, discard. Never share a UoW across threads.
- **Per-business-operation**: create UoW at the start of a command/service call, commit at end. Useful for non-HTTP contexts (CLI, batch).
- **Explicit begin/end**: for long business transactions that span multiple system transactions, pair with Optimistic Offline Lock patterns (UoW is recreated per system transaction; lock ensures consistency across them).
Anti-pattern: **never share a UoW across requests or threads.** A shared UoW accumulates dirty objects from multiple users, produces incorrect commits, and leaks memory.
**Step 8 — Integrate with collaborators.**
WHY: UoW is rarely used in isolation. Two patterns depend on UoW for correct behavior; wiring them explicitly prevents integration bugs.
**Optimistic Offline Lock integration:**
- Each entity tracked by UoW has a `version` field (integer or timestamp).
- On `updateDirty`, the UPDATE SQL becomes: `UPDATE ... SET ..., version = version+1 WHERE id=? AND version=?`
- If `rowsAffected == 0` → collision detected → raise `ConcurrencyException`, roll back transaction.
- The UoW is the correct place to run this check (it owns all UPDATE calls). See `optimistic-offline-lock-implementer` for the full version-management workflow.
**Lazy Load integration:**
- Lazy proxy collections are populated on first access via a callback into the current UoW/session.
- The Identity Map ensures that the populated entity is the same instance that UoW is already tracking — preventing a duplicate-entity trap where a loaded proxy yields a different object than the one already in the dirty list.
**Step 9 — Map to your stack's native UoW.**
WHY: Most modern stacks include a built-in Unit of Work. Using it directly is far preferable to hand-rolling; the skill's value is understanding the contract so you configure and scope the built-in correctly.
| Stack | UoW Object | Registration strategy | Commit call |
|---|---|---|---|
| Hibernate (Java) | `Session` | UoW-controlled (snapshot) | `session.flush()` + `tx.commit()` |
| Spring Data JPA | `EntityManager` via `@Transactional` | UoW-controlled | transaction commit |
| EF Core (.NET) | `DbContext` | UoW-controlled (change tracker) | `dbContext.SaveChanges()` |
| SQLAlchemy (Python) | `Session` | UoW-controlled + explicit `session.add()` | `session.commit()` |
| TypeORM (TS/JS) | `EntityManager` / `QueryRunner` | UoW-controlled | `queryRunner.commitTransaction()` |
| Django ORM | No first-class UoW | Per-save explicit | `transaction.atomic()` wrapper |
For Django: use `transaction.atomic()` to batch saves, but note there is no central dirty tracker — `bulk_update` / `bulk_create` provides partial batching.
For stack-specific scoping patterns (request-scoped DbContext in ASP.NET, scoped Session in FastAPI, EntityManager lifecycle in Jakarta EE) see `references/stack-native-uow-guide.md`.
---
## Inputs
- Confirmed data-source pattern: Data Mapper
- Entity class list and FK dependency graph
- Language and ORM framework (or "none — hand-rolling")
- Transaction boundary convention (per-request / per-command / explicit)
- Whether Optimistic Offline Lock and/or Lazy Load are in scope
---
## Outputs
**UoW Implementation Artifact** (written to the codebase or returned inline):
```
## Unit of Work Implementation Record
### Registration Strategy
[Caller | Object | UoW-controlled] — [rationale]
### API
registerNew(entity) / registerDirty(entity) / registerClean(entity) / registerRemoved(entity)
commit() / rollback() / clear()
### State Tracking
- newObjects: [List<Entity>]
- dirtyObjects: [List<Entity>]
- removedObjects: [List<Entity>]
- identityMap: Map<(Class, Id), Entity>
### Commit Sequence
1. INSERT newObjects in order: [entity order based on FK graph]
2. UPDATE dirtyObjects
3. DELETE removedObjects in reverse order: [reverse FK order]
4. DB COMMIT
5. Clear UoW state
### Lifecycle
[Per-request | Per-command] — [where UoW is created and where it is discarded]
### Stack-Native Equivalent
[If using Hibernate/EF/SQLAlchemy: the built-in Session/DbContext IS the UoW.
Map register/commit calls to the framework's API.]
### Integration Notes
- Optimistic Offline Lock: [version column present / not applicable]
- Lazy Load: [proxy collections wired through session / not applicable]
### Anti-Patterns to Watch
- [ ] Cross-request UoW sharing
- [ ] Missing registerDirty calls (caller registration risk)
- [ ] FK ordering violations on commit
- [ ] UoW not cleared between requests → memory leak + stale data
```
---
## Key Principles
**1. UoW is the database change controller — not individual domain objects.**
Without a UoW, each domain object decides when to write to the database. This produces excessive round trips, inconsistent ordering, and no natural rollback point. The UoW centralizes that control: domain code mutates objects freely; the UoW decides *when* and *in what order* those mutations reach the database.
**2. The four states (new, dirty, clean, removed) map exactly to the four SQL operations.**
Every entity in a business operation is in exactly one of these states. Understanding the state machine prevents double-writes, missed writes, and cascade ordering errors. The UoW enforces the state machine at registration time via assertions.
**3. INSERT/DELETE order is determined by FK dependencies, not by the order changes were made.**
If `LineItem` references `Order`, then `Order` must be inserted before `LineItem`, and `LineItem` must be deleted before `Order`. The UoW must encode or compute this graph. Ignoring it works until it doesn't — a single FK violation on commit surfaces the missing ordering logic.
**4. Identity Map is not optional — it is required for correctness.**
Loading the same row twice into two objects is a correctness bug, not a performance issue. The Identity Map prevents this by making the UoW the single source of truth for in-memory entity identity. Performance caching is a beneficial side-effect, not the purpose.
**5. UoW lifecycle must be bounded to one business operation.**
A UoW that outlives its business operation accumulates stale state and grows unboundedly. Cross-request sharing is especially dangerous in web apps because it causes different users' changes to be committed together. Enforce a clear begin/end boundary and discard the UoW after commit.
**6. On modern stacks, use the built-in Session/DbContext — understand its contract, don't fight it.**
Hibernate `Session`, EF Core `DbContext`, and SQLAlchemy `Session` implement the full UoW + Identity Map contract. The skill's purpose is to understand *what* they do (so you scope, flush, and clear them correctly) — not to replace them with a hand-rolled alternative.
---
## Examples
### Scenario A: Java e-commerce — custom Data Mapper, hand-rolled UoW
**Trigger:** "We have a Java e-commerce service with Order, LineItem, and Product. We're using hand-rolled Data Mappers (no ORM). After a business operation touches 12 objects, we're making 12 separate UPDATE calls. How do we introduce a Unit of Work?"
**Process:**
1. Confirm Data Mapper in place. FK graph: LineItem references Order and Product.
2. Registration strategy: object registration — setters on `Order` and `LineItem` call `UoW.getCurrent().registerDirty(this)`.
3. UoW API: `registerNew / registerDirty / registerClean / registerRemoved / commit()`.
4. Identity Map keyed by `(Class, Long id)`; populated on `OrderMapper.find(id)`.
5. Commit sequence: INSERT Order → INSERT LineItem (Product pre-exists) → UPDATE dirty Orders → UPDATE dirty LineItems → DELETE removed LineItems → DELETE removed Orders → COMMIT.
6. Lifecycle: per-HTTP-request via servlet filter — `UnitOfWork.newCurrent()` on request start; `UnitOfWork.getCurrent().commit()` + `setCurrent(null)` on request end (in `finally` block).
**Output:** Hand-rolled `UnitOfWork` class with three lists (new/dirty/removed), `ThreadLocal` storage for current UoW, `DomainObject` base class with `markDirty()` / `markNew()` / `markRemoved()`, and per-request lifecycle managed by a servlet filter. See `references/entity-state-transitions.md` for full Java sketch.
---
### Scenario B: Python + SQLAlchemy — scoping the built-in Session
**Trigger:** "We use SQLAlchemy with a FastAPI app. We're seeing stale data and occasional DetachedInstanceError. How should we scope the Session?"
**Process:**
1. Data Mapper confirmed: SQLAlchemy ORM's mapped classes + `Session` is the UoW.
2. Registration strategy: UoW-controlled — SQLAlchemy tracks changes automatically; `session.add(entity)` registers new objects.
3. Problem diagnosis: `Session` is likely being shared across requests (application-scoped singleton) rather than per-request.
4. Fix: use a dependency-injected `Session` per FastAPI request via `Depends(get_db)`, where `get_db` yields a session and closes it after the request.
5. Commit sequence: handled by `session.commit()` — SQLAlchemy resolves INSERT ordering via mapper relationships; `session.flush()` pushes SQL without committing for mid-operation ID resolution.
6. Lazy Load: SQLAlchemy lazy proxies use the session for population; closed or detached sessions trigger `DetachedInstanceError`. Fix: load eagerly for data needed after session close, or keep session open for the request lifetime.
**Output:** `get_db` generator dependency, per-request session scope, `session.add` for new entities, `session.delete` for removed, `session.commit()` at end of each request handler (or in a middleware). Anti-pattern warning: never use a module-level `Session` instance.
---
### Scenario C: .NET + EF Core — DbContext per-request scoping
**Trigger:** "We have an ASP.NET Core app with EF Core. We're trying to understand when to call SaveChanges and how to avoid detached entity errors."
**Process:**
1. Data Mapper confirmed: EF Core `DbContext` is the UoW; entities tracked by the change tracker.
2. Registration strategy: UoW-controlled — EF Core detects changes on tracked entities automatically.
3. DbContext is registered as `Scoped` in ASP.NET Core DI → one instance per HTTP request. This is correct.
4. Commit: `await dbContext.SaveChanges()` at the end of the service method (or in a controller action). Avoid calling it multiple times per request unless intentional.
5. Optimistic Offline Lock: add a `[Timestamp]` or `[ConcurrencyToken]` property; EF Core adds `WHERE version=?` automatically and throws `DbUpdateConcurrencyException` on collision.
6. Anti-pattern: passing a `DbContext` from a scoped service into a singleton service → context outlives the request, accumulates stale data.
**Output:** Confirm `Scoped` lifetime, single `SaveChanges()` call per business operation, `[ConcurrencyToken]` on entities needing optimistic locking, and warning against singleton-scoped `DbContext`.
---
## References
- `references/entity-state-transitions.md` — Full state machine with Java pseudocode for four entity states and registration assertions
- `references/commit-ordering.md` — Topological sort algorithm for FK-ordered INSERT/DELETE + ordering for Order/LineItem/Product example
- `references/stack-native-uow-guide.md` — Per-stack session scoping patterns (FastAPI, ASP.NET Core, Spring Boot, Jakarta EE, TypeORM)
- `references/identity-map-implementation.md` — Key design choices (explicit vs generic map, one map per class vs per session, inheritance handling)
**Related patterns triggered by this skill's output:**
- If Optimistic Offline Lock needed → `optimistic-offline-lock-implementer`
- If Lazy Load proxies in scope → `lazy-load-strategy-implementer`
- If data-source pattern not yet chosen → `data-source-pattern-selector`
---
## License
This skill is licensed under [CC-BY-SA-4.0](https://creativecommons.org/licenses/by-sa/4.0/).
Source: [BookForge](https://github.com/bookforge-ai/bookforge-skills) — Patterns of Enterprise Application Architecture by Martin Fowler et al.
---
## Related BookForge Skills
Install related skills from ClawhHub:
- `clawhub install bookforge-data-source-pattern-selector`
- `clawhub install bookforge-lazy-load-strategy-implementer`
- `clawhub install bookforge-optimistic-offline-lock-implementer`
Or install the full book set from GitHub: [bookforge-skills](https://github.com/bookforge-ai/bookforge-skills)
FILE:references/commit-ordering.md
# Commit Ordering — Unit of Work
## Why Order Matters
Relational databases enforce FK constraints. If `LineItem.order_id` references `Order.id`,
inserting a `LineItem` before its `Order` exists raises a FK violation. Similarly, deleting
an `Order` before its `LineItem` children raises a constraint error.
The Unit of Work must perform all INSERTs in FK dependency order (parents first) and all
DELETEs in reverse FK dependency order (children first).
## Commit Sequence
```
1. INSERT newObjects — parents before children
2. UPDATE dirtyObjects — order within this set is usually safe
3. DELETE removedObjects — children before parents (reverse of INSERT order)
4. DB COMMIT
5. Clear UoW state
```
## Order/LineItem/Product Example
FK graph:
```
Product ←── LineItem ──→ Order
```
- `LineItem.product_id` → `Product.id`
- `LineItem.order_id` → `Order.id`
INSERT order: `Product` and `Order` first (no dependencies on each other), then `LineItem`.
DELETE order: `LineItem` first, then `Order` and `Product`.
## Explicit Ordering (Small Schemas)
For small, well-known schemas, hardcode the order:
```java
// INSERT
for (DomainObject obj : filter(newObjects, Product.class)) insertMapper(obj);
for (DomainObject obj : filter(newObjects, Order.class)) insertMapper(obj);
for (DomainObject obj : filter(newObjects, LineItem.class)) insertMapper(obj);
// DELETE (reverse)
for (DomainObject obj : filter(removedObjects, LineItem.class)) deleteMapper(obj);
for (DomainObject obj : filter(removedObjects, Order.class)) deleteMapper(obj);
for (DomainObject obj : filter(removedObjects, Product.class)) deleteMapper(obj);
```
## Topological Sort (Large or Dynamic Schemas)
For larger applications, drive ordering from metadata:
```python
# Dependency graph as adjacency list (child → [parents])
FK_DEPS = {
"LineItem": ["Order", "Product"],
"Order": [],
"Product": [],
}
def topological_sort(deps: dict) -> list:
"""Kahn's algorithm — returns insertion order (parents first)."""
in_degree = {node: 0 for node in deps}
for node, parents in deps.items():
for parent in parents:
in_degree[parent] = in_degree.get(parent, 0)
for node, parents in deps.items():
for parent in parents:
in_degree[node] += 0 # count incoming, not outgoing
# Correctly: count how many other nodes depend on each node
rev = {}
for node, parents in deps.items():
for parent in parents:
rev.setdefault(parent, []).append(node)
in_deg = {node: len(parents) for node, parents in deps.items()}
queue = [n for n, d in in_deg.items() if d == 0]
order = []
while queue:
node = queue.pop(0)
order.append(node)
for child in rev.get(node, []):
in_deg[child] -= 1
if in_deg[child] == 0:
queue.append(child)
return order # INSERT order; reverse() for DELETE order
```
## Deferring FK Constraint Checks
Most databases can defer FK constraint checking to transaction commit time:
```sql
-- PostgreSQL
SET CONSTRAINTS ALL DEFERRED;
-- Oracle
ALTER SESSION SET CONSTRAINTS = DEFERRED;
```
With deferred checking, INSERT/DELETE order within the transaction does not matter — only
the final committed state must satisfy constraints. If your database supports this, you can
simplify the UoW commit by omitting the topological sort. Check your database and DBA policy
before relying on this.
## Minimizing Deadlocks
Deadlocks often arise when two transactions update the same rows in opposite order. The UoW
provides a natural solution: always commit tables (and rows within tables) in the same fixed
sequence. Two concurrent transactions that both start with `Order` then `LineItem` will
queue behind each other rather than deadlock.
FILE:references/entity-state-transitions.md
# Entity State Transitions — Unit of Work
## The Four States
Every entity in a Unit of Work business operation is in exactly one state:
| State | Meaning | SQL on commit |
|-------|---------|---------------|
| **New** | Created in memory; not yet in DB | INSERT |
| **Dirty** | Loaded from DB, then modified | UPDATE |
| **Clean** | Loaded from DB, not modified | (none) |
| **Removed** | Marked for deletion | DELETE |
## State Machine
```
registerNew()
[Not tracked] ──────────────→ [New]
│
│ commit() INSERT
↓
[Not tracked] ←─── clear() ── [Clean] ←── registerClean() (on DB load)
│
setField() / markDirty()
│
↓
[Dirty] ──── commit() UPDATE ──→ [Clean] / discard
│
registerRemoved()
│
↓
[Removed] ──── commit() DELETE ──→ discard
[New] ──── registerRemoved() ──→ removed from newObjects (no DB write needed)
```
## Java Object Registration Example
```java
// Layer Supertype for all domain objects
public abstract class DomainObject {
private Long id;
public Long getId() { return id; }
public void setId(Long id) { this.id = id; }
protected void markNew() {
UnitOfWork.getCurrent().registerNew(this);
}
protected void markDirty() {
UnitOfWork.getCurrent().registerDirty(this);
}
protected void markRemoved() {
UnitOfWork.getCurrent().registerRemoved(this);
}
}
// Concrete entity uses object registration in setters
public class Order extends DomainObject {
public static Order create(Long id, String status) {
Order order = new Order(id, status);
order.markNew(); // registers immediately on creation
return order;
}
public void setStatus(String status) {
this.status = status;
markDirty(); // registers on every mutation
}
public void remove() {
markRemoved();
}
}
```
## UnitOfWork Registration Methods
```java
public class UnitOfWork {
private List<DomainObject> newObjects = new ArrayList<>();
private List<DomainObject> dirtyObjects = new ArrayList<>();
private List<DomainObject> removedObjects = new ArrayList<>();
private Map<String, DomainObject> identityMap = new HashMap<>();
// ThreadLocal for current UoW per thread/request
private static ThreadLocal<UnitOfWork> current = new ThreadLocal<>();
public static UnitOfWork getCurrent() { return current.get(); }
public static void newCurrent() { current.set(new UnitOfWork()); }
public static void setCurrent(UnitOfWork uow) { current.set(uow); }
public void registerNew(DomainObject obj) {
assert obj.getId() != null : "id must not be null";
assert !dirtyObjects.contains(obj) : "object must not be dirty";
assert !removedObjects.contains(obj) : "object must not be removed";
assert !newObjects.contains(obj) : "object must not already be new";
newObjects.add(obj);
identityMap.put(key(obj), obj);
}
public void registerDirty(DomainObject obj) {
assert obj.getId() != null : "id must not be null";
assert !removedObjects.contains(obj) : "removed objects cannot be dirtied";
if (!newObjects.contains(obj) && !dirtyObjects.contains(obj)) {
dirtyObjects.add(obj);
}
}
public void registerClean(DomainObject obj) {
assert obj.getId() != null : "id must not be null";
identityMap.put(key(obj), obj);
// no list entry — clean objects have no SQL action on commit
}
public void registerRemoved(DomainObject obj) {
assert obj.getId() != null : "id must not be null";
if (newObjects.remove(obj)) return; // never persisted — just forget
dirtyObjects.remove(obj);
if (!removedObjects.contains(obj)) {
removedObjects.add(obj);
}
identityMap.remove(key(obj));
}
private String key(DomainObject obj) {
return obj.getClass().getName() + ":" + obj.getId();
}
}
```
## Caller Registration Alternative
Use when domain objects cannot depend on UoW (e.g., simple value-object-style entities):
```java
// Application/service code must call register explicitly
UnitOfWork uow = UnitOfWork.getCurrent();
Order order = orderMapper.find(orderId); // mapper calls registerClean internally
order.setStatus("CONFIRMED");
uow.registerDirty(order); // caller must remember this
uow.commit();
```
Risk: forgetting `registerDirty` silently drops the update. Mitigate with code review checklists or IDE inspections.
## UoW-Controlled (Snapshot-Based) Strategy
Used by ORMs (Hibernate, EF Core, SQLAlchemy):
1. On entity load → UoW stores a deep copy (snapshot) of field values.
2. On `commit()` → UoW compares current field values to snapshot.
3. Differences → generate UPDATE for only changed columns.
4. No `markDirty()` calls in domain code; transparency is complete.
Trade-off: higher memory usage (holds snapshots) and slightly higher commit-time cost.
FILE:references/identity-map-implementation.md
# Identity Map Implementation — Unit of Work
## Purpose
The Identity Map ensures that each database row is loaded into memory exactly once per
business operation (per UoW). Any request for the same primary key returns the same
in-memory instance. This prevents:
- **Conflicting updates:** two objects for the same row, both modified, producing undefined commit behavior.
- **Phantom duplicates:** the same entity appearing twice in a list, with different in-memory states.
- **Double-loading:** unnecessary database round trips for rows already in memory.
Primary purpose = identity consistency. Performance caching = beneficial side-effect.
## Key Design Choices
### 1. Map Key
Use the entity's primary key. For surrogate keys (auto-generated integers or UUIDs):
- Java: `Map<Long, DomainObject>` per entity class, or `Map<String, DomainObject>` with composite key `"ClassName:id"`.
- Python/TS: `dict[(type, id), entity]` or `dict[str, entity]` with `f"{cls.__name__}:{id}"` key.
For composite natural keys: encode the tuple as the map key. Prefer surrogate keys to avoid this complexity (see Identity Field pattern).
### 2. Explicit vs Generic Map
**Explicit map** (one method per entity type):
```java
public Person findPerson(Long id) {
return (Person) personMap.get(id);
}
public Order findOrder(Long id) {
return (Order) orderMap.get(id);
}
```
Pros: compile-time type safety; clear API. Cons: must add a new method per entity type.
**Generic map** (single method, all types):
```java
public DomainObject find(Class type, Long id) {
Map<Long, DomainObject> map = maps.get(type);
return map != null ? map.get(id) : null;
}
```
Pros: no code change when adding new entity types. Cons: caller must cast; requires all keys to be the same type.
Fowler prefers explicit maps for readability and type safety. Generic maps are common in ORM internals.
### 3. One Map per Class vs One Map for the Whole Session
**One map per class (recommended for most codebases):**
```java
Map<Long, Order> orderMap = new HashMap<>();
Map<Long, LineItem> lineItemMap = new HashMap<>();
Map<Long, Product> productMap = new HashMap<>();
```
Works well when domain objects and tables are isomorphic (one class = one table).
**One map for the whole session (with session-unique keys):**
```java
Map<String, DomainObject> singleMap = new HashMap<>();
// Key: "Order:42", "LineItem:17", etc.
```
Simpler code; requires globally unique keys across all entity types. Use a surrogate key
strategy that guarantees this (e.g., sequence prefix per table, UUID).
**Inheritance:** For class hierarchies (Vehicle → Car, Vehicle → Truck), use a single map
for the entire inheritance tree. Polymorphic lookups then need only one map.
### 4. Where to Store the Identity Map
The Identity Map must be session-scoped — each request/business-operation gets its own
instance, isolated from all others. Options:
- **Inside the UoW:** simplest; UoW is already per-session. (Recommended.)
- **Thread-scoped Registry:** `ThreadLocal<IdentityMap>` — works for synchronous frameworks.
- **Async context variable:** Python `contextvars.ContextVar`, Java virtual thread scope.
Never store the Identity Map in a static field or application-scope singleton.
## Integration with UoW Commit Cycle
```
find(class, id)
↓
identityMap.get(class, id) exists?
YES → return cached instance (no DB call)
NO → load from DB → registerClean(entity) → identityMap.put(key, entity) → return
registerNew(entity) → identityMap.put(key, entity)
registerRemoved(entity) → identityMap.remove(key)
commit() → [INSERT / UPDATE / DELETE] → clear all maps
```
## Read-Only Entities and Shared Maps
If certain entities are truly read-only (e.g., reference data like country codes, product
categories), they can share a session-spanning or even application-scoped Identity Map.
This is safe only when:
1. No business operation ever modifies these entities.
2. Their lifetime in the map is actively managed (e.g., invalidated when reference data changes).
Fowler notes this as an exception; the default is always per-session isolation.
FILE:references/stack-native-uow-guide.md
# Stack-Native UoW Guide — Unit of Work
All major ORM frameworks include a built-in Unit of Work. This guide maps the UoW contract
to each framework's API and shows correct per-request scoping.
---
## Hibernate / Spring Data JPA (Java)
**UoW object:** `Session` (Hibernate) / `EntityManager` (JPA)
| UoW concept | Hibernate/JPA API |
|---|---|
| registerNew | `session.persist(entity)` |
| registerDirty | automatic (snapshot diff on flush) |
| registerClean | automatic on `session.find(class, id)` |
| registerRemoved | `session.remove(entity)` |
| commit | `session.flush()` + `tx.commit()` |
| rollback | `tx.rollback()` |
| clear | `session.close()` (or `session.clear()` to detach all) |
| Identity Map | First-level cache (L1) — built in, per-Session |
**Per-request scoping (Spring Boot):**
```java
// Use @Transactional on service methods — Spring manages Session lifecycle
@Service
public class OrderService {
@Transactional
public void confirmOrder(Long orderId) {
Order order = orderRepo.findById(orderId).orElseThrow();
order.setStatus("CONFIRMED");
// session.flush() happens automatically at @Transactional boundary
}
}
```
**Pitfalls:**
- `LazyInitializationException`: accessing a lazy collection after Session closes. Fix: use `@Transactional` to keep session open, or eager-load what you need.
- Open Session in View (OSIV): Hibernate's default in Spring Boot opens Session for the full request (including view rendering). This can mask N+1 issues. Consider disabling (`spring.jpa.open-in-view=false`) and being explicit about loading.
---
## Entity Framework Core (.NET)
**UoW object:** `DbContext`
| UoW concept | EF Core API |
|---|---|
| registerNew | `dbContext.Add(entity)` or `dbContext.EntitySet.Add(entity)` |
| registerDirty | automatic (change tracker detects mutations on tracked entities) |
| registerClean | automatic on `dbContext.EntitySet.Find(id)` or any LINQ query |
| registerRemoved | `dbContext.Remove(entity)` |
| commit | `await dbContext.SaveChangesAsync()` |
| rollback | dispose `DbContext` without calling `SaveChanges` |
| clear | `dbContext.ChangeTracker.Clear()` |
| Identity Map | Change tracker — per-DbContext instance |
**Per-request scoping (ASP.NET Core):**
```csharp
// Register as Scoped in DI — one instance per HTTP request
builder.Services.AddDbContext<AppDbContext>(opts =>
opts.UseSqlServer(connectionString));
// Inject into controller or service
public class OrdersController : ControllerBase {
private readonly AppDbContext _db;
public OrdersController(AppDbContext db) { _db = db; }
[HttpPut("{id}/confirm")]
public async Task<IActionResult> Confirm(int id) {
var order = await _db.Orders.FindAsync(id);
order.Status = "CONFIRMED";
await _db.SaveChangesAsync(); // single commit for the operation
return Ok();
}
}
```
**Optimistic Lock:**
```csharp
public class Order {
[Timestamp] // EF Core adds WHERE RowVersion=? on UPDATE
public byte[] RowVersion { get; set; }
}
// DbUpdateConcurrencyException thrown on collision
```
**Pitfalls:**
- Never register `DbContext` as Singleton in ASP.NET Core — it will accumulate state across all requests.
- Avoid multiple `SaveChanges()` calls per request unless each is an intentional partial commit.
---
## SQLAlchemy (Python)
**UoW object:** `Session`
| UoW concept | SQLAlchemy API |
|---|---|
| registerNew | `session.add(entity)` |
| registerDirty | automatic (attribute change tracking) |
| registerClean | automatic on `session.get(Model, pk)` or query |
| registerRemoved | `session.delete(entity)` |
| commit | `session.commit()` |
| rollback | `session.rollback()` |
| clear | `session.close()` or `session.expunge_all()` |
| flush without commit | `session.flush()` (pushes SQL; ID assigned; no DB COMMIT) |
| Identity Map | Identity Map — per-Session instance |
**Per-request scoping (FastAPI):**
```python
from sqlalchemy.orm import Session
from fastapi import Depends
def get_db():
db = SessionLocal()
try:
yield db
finally:
db.close()
@app.put("/orders/{order_id}/confirm")
def confirm_order(order_id: int, db: Session = Depends(get_db)):
order = db.get(Order, order_id)
order.status = "CONFIRMED"
db.commit()
db.refresh(order)
return order
```
**Pitfalls:**
- Never use a module-level `Session` singleton — it creates one UoW for all requests.
- `DetachedInstanceError`: accessing a lazy attribute after `session.close()`. Fix: `db.refresh(obj)` or `expire_on_commit=False` + reload.
- `session.flush()` is useful to get auto-generated IDs mid-operation without committing.
---
## TypeORM (TypeScript/JavaScript)
**UoW object:** `EntityManager` / `QueryRunner`
```typescript
// Transaction with EntityManager — explicit UoW scope
await dataSource.transaction(async (manager) => {
const order = await manager.findOneBy(Order, { id: orderId });
order.status = "CONFIRMED";
await manager.save(order); // registerDirty + immediate flush within tx
// manager.remove(entity) for DELETE
});
// Transaction commits when the async function resolves; rolls back on throw
```
**Pitfalls:**
- `save()` in TypeORM is an upsert (INSERT or UPDATE based on ID presence) — it does not batch changes until transaction commits.
- QueryRunner gives more explicit control: `await queryRunner.startTransaction()`, `await queryRunner.commitTransaction()`.
---
## Django ORM (Python)
Django ORM has no first-class Unit of Work or dirty tracker. Each `model.save()` writes immediately.
**Partial equivalent — `transaction.atomic()`:**
```python
from django.db import transaction
with transaction.atomic():
order.status = "CONFIRMED"
order.save() # writes immediately within transaction
for item in removed_items:
item.delete()
# DB COMMIT at end of with block; rollback on exception
```
**Batch writes:**
```python
Order.objects.bulk_update([order1, order2], ['status'])
LineItem.objects.bulk_create([item1, item2])
```
Django does not provide identity-map-level identity consistency. Loading the same row twice
yields two Python objects. Rely on Django's queryset caching (within a queryset, not across
separate `.get()` calls) and keep your transactions short.
Use when auditing database transaction configuration for concurrency safety — checking isolation level settings, diagnosing lost update bugs, non-repeatable...
---
name: transaction-isolation-level-auditor
description: "Use when auditing database transaction configuration for concurrency safety — checking isolation level settings, diagnosing lost update bugs, non-repeatable read vulnerabilities, phantom read risks, or ACID compliance gaps. Applies Fowler's Table 5.1 (the explicit isolation-level × anomaly matrix from Patterns of Enterprise Application Architecture Chapter 5) to map READ UNCOMMITTED / READ COMMITTED / REPEATABLE READ / SERIALIZABLE to permitted anomaly classes: dirty read, non-repeatable read (inconsistent read), phantom read, and lost update. Produces a structured isolation audit report covering: current isolation level, permitted anomalies, code locations with read-modify-write without optimistic check (lost update vulnerability), SELECT FOR UPDATE correctness, long-transaction risks, ACID compliance at system-transaction level, and ACID gaps at business-transaction level across multiple requests. Covers: transaction isolation, database concurrency, optimistic locking, pessimistic locking, version column, READ COMMITTED default risks, REPEATABLE READ upgrade decisions, SERIALIZABLE overhead, immutability as concurrency escape hatch, Spring @Transactional isolation settings, Hibernate session isolation, SQLAlchemy transaction config, EF Core transaction isolation, business transaction ACID, saga atomicity, offline lock isolation. Triggers: 'we have a lost update bug', 'two users editing the same record', 'is our isolation level correct', 'should we use SERIALIZABLE', 'transaction audit', 'ACID compliance review'."
version: 1.0.0
homepage: https://github.com/bookforge-ai/bookforge-skills/tree/main/books/patterns-of-enterprise-application-architecture/skills/transaction-isolation-level-auditor
metadata: {"openclaw":{"emoji":"🔍","homepage":"https://github.com/bookforge-ai/bookforge-skills"}}
status: draft
source-books:
- id: patterns-of-enterprise-application-architecture
title: "Patterns of Enterprise Application Architecture"
authors:
- Martin Fowler
- David Rice
- Matthew Foemmel
- Edward Hieatt
- Robert Mee
- Randy Stafford
chapters: [5]
domain: enterprise-application-architecture
tags:
- transactions
- concurrency
- acid
- database
- data-integrity
- auditing
- design-patterns
- isolation-levels
depends-on: []
execution:
tier: 2
mode: hybrid
inputs:
- type: codebase
description: "Enterprise application source code — transaction boundaries (@Transactional, begin/commit, BEGIN TRANSACTION, session.begin_nested), database config (isolation level per connection/session/global), update patterns (read-modify-write, SELECT FOR UPDATE)"
- type: user-description
description: "Description of the system, its transaction config, and the concurrency concern (lost update report, audit request, isolation level selection question)"
tools-required:
- Read
- Grep
tools-optional:
- Glob
- Write
mcps-required: []
environment: "Enterprise application with a relational database. Codebase access strongly preferred; user description of transaction config and update patterns is a workable fallback."
discovery:
goal: "Produce an isolation audit report that maps the current isolation level to permitted anomaly classes, finds code-level vulnerabilities, assesses system-transaction ACID compliance, and flags business-transaction ACID gaps."
tasks:
- "Identify the current database isolation level from config/code"
- "Apply Table 5.1 to determine which anomaly types are permitted"
- "Scan transaction code for lost update vulnerabilities (read-modify-write without optimistic check)"
- "Flag non-repeatable read and phantom read exposure per transaction"
- "Check system-transaction ACID compliance signals"
- "Identify business-transaction ACID gaps (multi-request workflows with no offline lock)"
- "Produce per-vulnerability fix recommendations"
audience:
roles:
- software-architect
- senior-backend-engineer
- tech-lead
experience: intermediate
when_to_use:
triggers:
- "Reports of lost updates — one user's save silently overwrites another's"
- "Auditing transaction configuration before a production incident"
- "Selecting an isolation level for a new service or subsystem"
- "Reviewing @Transactional or begin/commit usage in a codebase"
- "ACID compliance review for a financial, inventory, or healthcare system"
- "Performance complaints about too much locking / deadlocks suggesting over-isolation"
- "Diagnosing whether READ COMMITTED is safe for a specific workflow"
prerequisites: []
not_for:
- "Multi-request business transaction concurrency (user edits spanning multiple HTTP requests) — use offline-concurrency-strategy-selector instead for lock pattern selection"
- "Thread-level concurrency within a single process (use language-level synchronization primitives)"
- "Distributed consensus across services (use saga, two-phase commit, or Raft)"
- "NoSQL systems without SQL isolation level semantics"
environment:
codebase_required: false
codebase_helpful: true
works_offline: true
quality:
scores:
with_skill: null
baseline: null
delta: null
tested_at: null
eval_count: null
assertion_count: 13
iterations_needed: null
---
# Transaction Isolation Level Auditor
Applies Fowler's Table 5.1 isolation-level matrix to audit a system's transaction configuration, map the current isolation level to its permitted anomaly classes, locate code-level vulnerabilities, and produce a structured fix plan with ACID compliance assessment at both system-transaction and business-transaction levels.
---
## When to Use
Use this skill when you need to answer any of:
- "Are we exposed to lost updates with our current isolation level?"
- "Should we upgrade from READ COMMITTED to REPEATABLE READ or SERIALIZABLE?"
- "Is this codebase ACID-compliant at the system-transaction level? At the business-transaction level?"
- "We have a concurrency bug — which anomaly class is it and what fixes it?"
This skill targets **single-request, database-managed system transactions**. If the concern is multi-request business transactions (user edits over minutes), invoke `offline-concurrency-strategy-selector` for lock pattern selection after completing this audit.
**Prerequisites:** None. Codebase access improves coverage; a description of the transaction config is sufficient for a high-level audit.
---
## Context and Input Gathering
Gather the following before auditing. Ask the user if not inferable from the codebase.
**Required:**
- **Current isolation level:** Check in this order — (1) `config/database.yml`, `application.properties`, `appsettings.json`; (2) ORM/framework config (`@Transactional(isolation=...)`, `connection.set_isolation_level(...)`, `DbContextOptionsBuilder`); (3) database-level default (`SHOW TRANSACTION ISOLATION LEVEL`, `SELECT @@transaction_isolation`).
- **Transaction boundary pattern:** How transactions are opened and closed — declarative (`@Transactional`), programmatic (`session.begin()`, `BEGIN TRANSACTION`), or request-scoped middleware.
**Observable from codebase (Grep for):**
- `@Transactional`, `begin_transaction`, `BEGIN TRANSACTION`, `session.begin`, `session.begin_nested`, `getTransaction().begin()`, `SaveChanges()`
- `SELECT.*FOR UPDATE`, `LOCK IN SHARE MODE`, `WITH (UPDLOCK)`
- Read-modify-write patterns: load → mutate → save without a version/optimistic check
- Version/ETag columns: `version`, `optimistic_lock`, `row_version`, `etag`
**Defaults (when not found):**
- PostgreSQL, Oracle, SQL Server: default to READ COMMITTED
- MySQL InnoDB: defaults to REPEATABLE READ
- No `SELECT FOR UPDATE` or version column = no lost-update protection beyond isolation level
**Sufficiency check:** If isolation level and at least one transaction boundary are known, proceed. Flag any gaps in coverage.
---
## Process
### Step 1 — Read the Current Isolation Level
Search config files and ORM annotations for explicit isolation level settings. If not found, assume the database vendor default (READ COMMITTED for most production databases).
_WHY: The isolation level is the single configuration value that determines which anomaly classes the database prevents. Everything downstream depends on it._
**Grep commands:**
```
Grep "@Transactional" — find Spring/Java annotation-based transaction demarcation
Grep "isolation" — find explicit isolation level overrides
Grep "BEGIN\|begin_transaction\|session.begin" — find programmatic boundaries
Grep "SELECT.*FOR UPDATE\|NOWAIT\|SKIP LOCKED" — find pessimistic read locks
Grep "version\|optimistic_lock\|row_version\|etag" in schema/models — find optimistic check columns
```
### Step 2 — Apply Table 5.1 to Map Permitted Anomalies
Look up the current isolation level in the matrix below and record which anomalies are **permitted** (not prevented):
| Isolation Level | Dirty Read | Unrepeatable Read | Phantom Read |
|---|---|---|---|
| Read Uncommitted | Permitted | Permitted | Permitted |
| Read Committed | Prevented | **Permitted** | **Permitted** |
| Repeatable Read | Prevented | Prevented | **Permitted** |
| Serializable | Prevented | Prevented | Prevented |
_WHY: This is Fowler's Table 5.1 — the authoritative SQL standard mapping. It tells you exactly what the database guarantees and what it does not. Anomaly classes not in this table (specifically Lost Update) require an additional application-level check regardless of isolation level._
**Add Lost Update assessment separately:**
- Lost Update is a write-write conflict, not covered by the SQL anomaly table.
- It occurs at **any isolation level** when read-modify-write is used without an optimistic version check or SELECT FOR UPDATE.
- Flag this independently of isolation level.
### Step 3 — Scan for Code-Level Vulnerabilities
For each transaction in scope, check for the patterns below. Flag each finding with file location.
**3a. Lost Update vulnerability — read-modify-write without optimistic check:**
Look for: entity is loaded → a field is modified → entity is saved, with no version comparison between read and write.
```python
# Vulnerable pattern (Python/SQLAlchemy example)
item = session.query(Item).filter_by(id=item_id).one()
item.stock -= quantity # based on stale read
session.commit() # second concurrent writer overwrites first
# Safe pattern — version column checked
session.execute(
"UPDATE items SET stock=:s, version=version+1 WHERE id=:id AND version=:v",
{"s": item.stock - quantity, "id": item_id, "v": item.version}
)
```
**3b. Unrepeatable Read vulnerability (if isolation < Repeatable Read):**
Look for: the same row is read twice within one transaction, and correctness depends on the values being stable. Flag when isolation level is READ COMMITTED.
**3c. Phantom Read vulnerability (if isolation < Serializable):**
Look for: range queries (COUNT, SUM, WHERE date BETWEEN, WHERE status = 'pending') executed inside a transaction that also writes rows matching the same range. Flag when isolation < Serializable.
**3d. Long transaction at high isolation:**
Look for: transactions that span user think-time, file I/O, or remote calls. These hold locks (Repeatable Read/Serializable) or accumulate snapshot overhead (MVCC), degrading throughput and risking deadlocks.
_WHY: Code-level patterns determine actual exposure. The isolation level sets the floor, but read-modify-write without a guard lets Lost Update through at any level. Step 3 finds the specific call sites to fix._
### Step 4 — Check System-Transaction ACID Compliance
For each ACID property, look for its implementation signal:
| Property | Implementation Signal | Green | Red |
|---|---|---|---|
| Atomicity | Exception handler rolls back | All writes inside one try/catch with rollback | Multiple transactions for one logical operation; partial commit on error |
| Consistency | Schema constraints + post-write validation | FK constraints, NOT NULL, CHECK constraints; business invariants validated pre-commit | Deferred validation; invariants checked after commit |
| Isolation | Isolation level + optimistic/pessimistic guards | Level adequate for use case; version checks present | READ COMMITTED + read-modify-write with no version check |
| Durability | DB config | Sync commit ON; WAL enabled; replication | `synchronous_commit=off` without understanding the tradeoff |
_WHY: Each ACID property has a concrete implementation shape. Identifying the absence of that shape (no rollback on error, no version column) gives actionable fixes, not just warnings._
### Step 5 — Identify Business-Transaction ACID Gaps
A business transaction spans multiple HTTP requests and multiple system transactions. Ask:
- Does the system have any multi-step workflows (user opens record → edits across multiple screens → saves)?
- If YES: the database provides no isolation between the user's reads and final commit. Lost updates and inconsistent reads are possible regardless of system-transaction isolation level.
- IF the workflow has no Optimistic Offline Lock (version column) or Pessimistic Offline Lock (record checkout) → flag as a business-transaction isolation gap.
_WHY: Fowler explicitly distinguishes system transactions (RDBMS-managed) from business transactions (application-managed). Most ACID discussions focus on system transactions and miss the multi-request gap entirely. This is where the most severe real-world lost update bugs live._
For business-transaction gaps, cross-reference `offline-concurrency-strategy-selector` for pattern selection.
### Step 6 — Produce Fix Recommendations
For each vulnerability found in Steps 3-5, assign one of:
| Fix | When to Apply |
|---|---|
| Add version column + optimistic check | Lost Update in read-modify-write; use as default first choice |
| Add `SELECT FOR UPDATE` | Lost Update in high-contention, short-transaction context |
| Raise isolation to Repeatable Read | Unrepeatable Read in a single transaction that reads same row twice |
| Raise isolation to Serializable | Phantom Read in correctness-critical range queries |
| Refactor to immutability / snapshots | Read-heavy computation; consider read replica or snapshot isolation |
| Shorten transaction / extract reads | Long-transaction at high isolation; move non-DB work outside BEGIN/COMMIT |
| Invoke offline-concurrency-strategy-selector | Business-transaction isolation gap across multiple requests |
_WHY: Not every vulnerability needs the highest isolation fix. Recommending per-vulnerability fixes (rather than "always use Serializable") preserves throughput and minimizes lock contention._
### Step 7 — Produce the Isolation Audit Report
Assemble findings into the output format.
---
## Inputs
- Current isolation level (from config, code annotations, or database default)
- Transaction boundary locations (grep results or user description)
- Read-modify-write patterns found in code
- Presence or absence of version columns / SELECT FOR UPDATE
- Multi-request workflow description (for business-transaction check)
---
## Outputs
Produce a markdown isolation audit report with these sections:
```markdown
# Isolation Audit Report — [System Name]
Date: [date]
## Configuration
- Database: [vendor + version]
- Isolation Level: [level name]
- Transaction Demarcation: [declarative @Transactional / programmatic / request-scoped]
## Permitted Anomalies (Table 5.1 Mapping)
- Dirty Read: Prevented / Permitted
- Unrepeatable Read: Prevented / Permitted
- Phantom Read: Prevented / Permitted
- Lost Update (application-layer): Protected / VULNERABLE
## Vulnerabilities Found
### [VULN-01] Lost Update — [file:line]
Pattern: read-modify-write without version check
Risk: [describe the business consequence]
Fix: Add version column + optimistic check (see references/isolation-anomaly-matrix.md)
### [VULN-02] ...
## System-Transaction ACID Assessment
- Atomicity: [Green/Red] — [evidence]
- Consistency: [Green/Red] — [evidence]
- Isolation: [Green/Red] — [evidence]
- Durability: [Green/Red] — [evidence]
## Business-Transaction ACID Gaps
[List multi-request workflows with no offline lock; cross-reference offline-concurrency-strategy-selector]
## Recommended Actions (Prioritized)
1. [Highest risk fix]
2. ...
## Immutability Opportunities
[List read-heavy computations that could use snapshots or read replicas to sidestep concurrency entirely]
```
---
## Key Principles
**1. Table 5.1 is your baseline, not your complete answer.**
The SQL anomaly table covers Dirty Read, Unrepeatable Read, and Phantom. Lost Update — the most common application bug — is not in it. Treat the two separately: isolation level for read anomalies, application-level version check for write conflicts.
_Why this matters: Engineers who only consult the isolation level often miss Lost Update entirely because "we're on READ COMMITTED" sounds safe._
**2. READ COMMITTED + read-modify-write = Lost Update waiting to happen.**
The majority of enterprise databases default to READ COMMITTED. READ COMMITTED prevents dirty reads but allows Unrepeatable Reads, Phantoms, and Lost Updates. A read-modify-write without a version column is vulnerable at READ COMMITTED regardless of how short the transaction is.
_Why this matters: Fowler frames Lost Update as the simplest concurrency problem to understand but the easiest to miss in production because the bug appears as "someone's changes just disappeared."_
**3. Raising isolation level trades liveness for correctness — do it per-transaction.**
Fowler: "You don't have to use the same isolation level for all transactions." Serializable on every transaction in a high-throughput system causes contention and deadlocks. Apply Serializable or Repeatable Read only to the specific transactions that need it (range-scan reports, correctness-critical reads).
_Why this matters: A blanket "upgrade everything to Serializable" fix often causes worse production problems than the original bug._
**4. Immutability eliminates the problem rather than managing it.**
Fowler identifies immutability as a concurrency-control strategy: data that cannot be modified needs no concurrency control. Read replicas, event log projections, CQRS read models, and cached snapshots exploit this. Before raising isolation level, ask whether the read path can be made immutable.
_Why this matters: Immutability removes the tradeoff entirely — no liveness cost, no lock overhead, correct by construction._
**5. Business-transaction ACID requires application-level enforcement.**
The RDBMS provides ACID within a single BEGIN/COMMIT block. Across multiple HTTP requests, the database provides nothing. Multi-step user workflows need Optimistic Offline Lock (version column check at commit) or Pessimistic Offline Lock (record checkout before first read).
_Why this matters: The most severe lost update bugs in enterprise systems live in multi-step workflows, not in individual database transactions._
**6. Long transactions at high isolation are a double-edged fix.**
Running a long computation inside Serializable or Repeatable Read prevents anomalies inside that computation, but holds locks (or accumulates MVCC overhead) for the duration. This blocks concurrent writers and risks deadlocks. Prefer shorter transactions: read data first, perform computation, open transaction only for the write window.
---
## Examples
### Scenario 1 — Spring Boot service with @Transactional defaults
**Trigger:** Team reports intermittent "lost updates" on inventory records during high load. Stack: Spring Boot + PostgreSQL (READ COMMITTED default). No version columns.
**Process:**
1. Grep finds `@Transactional` on service methods with no isolation override → READ COMMITTED confirmed.
2. Table 5.1 mapping: Dirty Read prevented; Unrepeatable Read permitted; Phantom permitted.
3. Scan finds `itemRepo.findById(id)` → `item.setStock(item.getStock() - qty)` → `itemRepo.save(item)` with no version check in 3 locations.
4. No `SELECT FOR UPDATE` anywhere in the persistence layer.
5. ACID check: Atomicity — @Transactional handles rollback (green); Isolation — READ COMMITTED + no version column (red).
6. No multi-request business transaction detected (all operations complete in one request).
**Output excerpt:**
```
Permitted Anomalies: Unrepeatable Read (permitted), Phantom (permitted),
Lost Update (VULNERABLE — 3 locations)
VULN-01: ItemService.java:87 — read-modify-write without version check
Fix: Add version column to items table; use optimistic lock in UPDATE statement
Recommended: Add @Version column (JPA) to Item entity. PostgreSQL READ COMMITTED
default is adequate once lost-update protection is added at the application layer.
No isolation level upgrade needed.
```
---
### Scenario 2 — Financial posting service needing range-scan consistency
**Trigger:** Audit requirement: nightly balance report must reflect a consistent snapshot of all postings. Stack: Python + SQLAlchemy + PostgreSQL. Report runs a SUM across millions of rows.
**Process:**
1. Config shows `isolation_level="READ_COMMITTED"` on the session factory.
2. Table 5.1: Phantom Read permitted at READ COMMITTED — range scans can return different totals if concurrent inserts occur mid-query.
3. Report query is a single large SELECT SUM … GROUP BY — no write path, no version check needed.
4. Phantom risk: concurrent posting inserts can change the aggregate mid-report.
5. ACID check: Durability — WAL enabled (green); Isolation for reports — READ COMMITTED insufficient (red for correctness requirement).
6. Immutability opportunity: Consider running the report against a read replica or using `SET TRANSACTION ISOLATION LEVEL REPEATABLE READ` only for this session.
**Output excerpt:**
```
VULN-01: balance_report.py — Phantom Read exposure on SUM aggregates
Fix Option A: SET TRANSACTION ISOLATION LEVEL REPEATABLE READ for this session only
Fix Option B: Run report against a read replica after a logical checkpoint (immutability escape hatch)
No Lost Update risk (report is read-only). No business-transaction ACID gap.
```
---
### Scenario 3 — Multi-step checkout workflow with no offline lock
**Trigger:** E-commerce platform where users add items to cart over multiple pages before placing an order. Engineers ask whether the system is ACID-compliant.
**Process:**
1. Isolation level: MySQL InnoDB REPEATABLE READ (default).
2. Table 5.1 at Repeatable Read: Dirty Read prevented, Unrepeatable Read prevented, Phantom permitted.
3. No Lost Update vulnerability within individual system transactions (version columns present on Order entity).
4. Business-transaction check: checkout spans 4 HTTP requests (address → payment → review → confirm). No Optimistic Offline Lock or Pessimistic Offline Lock between session start and final confirm.
5. Between session start and confirm, inventory levels can change. No version check on inventory at confirm time → business-transaction Lost Update possible.
**Output excerpt:**
```
System-Transaction ACID: Green across all four properties.
Business-Transaction ACID Gap:
- Isolation: VULNERABLE — inventory levels not version-checked at order confirm.
Two concurrent checkouts for the last item can both succeed.
- Fix: Add Optimistic Offline Lock on inventory.quantity. Check version at confirm step.
See: offline-concurrency-strategy-selector for full lock pattern selection.
```
---
## References
- `references/isolation-anomaly-matrix.md` — Full Table 5.1, anomaly definitions, default isolation levels by database vendor, business vs system transaction ACID breakdown, long-transaction risk guidance
- Source: PEAA Chapter 5 "Concurrency" — sections: Concurrency Problems, Isolation and Immutability, Transactions (pp. 81–94)
---
## License
This skill is licensed under [CC-BY-SA-4.0](https://creativecommons.org/licenses/by-sa/4.0/).
Source: [BookForge](https://github.com/bookforge-ai/bookforge-skills) — Patterns of Enterprise Application Architecture by Martin Fowler, David Rice, Matthew Foemmel, Edward Hieatt, Robert Mee, Randy Stafford.
---
## Related BookForge Skills
Install related skills from ClawhHub:
- `clawhub install bookforge-offline-concurrency-strategy-selector`
- `clawhub install bookforge-data-access-anti-pattern-auditor`
Or install the full book set from GitHub: [bookforge-skills](https://github.com/bookforge-ai/bookforge-skills)
FILE:references/isolation-anomaly-matrix.md
# Isolation Anomaly Matrix Reference
Source: Patterns of Enterprise Application Architecture, Chapter 5 (Fowler et al., 2002)
---
## Table 5.1 — Isolation Levels and the Anomalies They Allow
| Isolation Level | Dirty Read | Unrepeatable Read | Phantom Read |
|---|---|---|---|
| Read Uncommitted | Possible | Possible | Possible |
| Read Committed | Prevented | Possible | Possible |
| Repeatable Read | Prevented | Prevented | Possible |
| Serializable | Prevented | Prevented | Prevented |
**Note:** Lost Update is not an anomaly class in the SQL standard (it is a write-write conflict, not a read anomaly), but it is the most common application-layer bug. It can occur at any isolation level when a read-modify-write pattern is used without an explicit optimistic or pessimistic check. Read Committed does not prevent Lost Update by itself — the application must use a version check (Optimistic Offline Lock) or SELECT FOR UPDATE / NOWAIT.
---
## Anomaly Definitions
### Dirty Read
A transaction reads data written by another transaction that has NOT yet committed. Two failure modes:
1. Read mid-update state — the in-flight transaction has partially modified the data.
2. Read rolled-back data — the in-flight transaction later rolls back, so the data read was never "real."
**Prevented at:** Read Committed, Repeatable Read, Serializable.
**Permitted at:** Read Uncommitted only.
**Practical impact:** Only relevant if you explicitly configure Read Uncommitted. Most databases default to Read Committed or higher.
---
### Unrepeatable Read (Non-Repeatable Read / Inconsistent Read)
Within a single transaction, reading the same row twice returns different values because another transaction committed an update between the two reads.
**Permitted at:** Read Committed, Read Uncommitted.
**Prevented at:** Repeatable Read, Serializable.
**Practical impact:** Default setting in PostgreSQL (READ COMMITTED) allows this. A transaction that reads a customer's address at the start of a long computation and relies on that value not changing can get a different value if it re-reads after another session edits the customer.
**Application-level fix options:**
- Raise isolation to Repeatable Read or Serializable for transactions that need stable reads.
- Use Optimistic Offline Lock (version check) across the whole business transaction.
- Read the value once and pass it through the computation without re-reading.
---
### Phantom Read
Within a single transaction, executing the same range query (WHERE clause over multiple rows) twice returns different row sets because another transaction inserted or deleted matching rows between the two queries.
**Permitted at:** Repeatable Read, Read Committed, Read Uncommitted.
**Prevented at:** Serializable only.
**Practical impact:** Financial aggregate reports, inventory counts, or range-based validation rules run inside a transaction can produce phantom-affected totals if another transaction inserts or deletes matching rows concurrently.
**Application-level fix options:**
- Raise isolation to Serializable for the specific transaction that needs range consistency.
- Use Snapshot Isolation (available in PostgreSQL, SQL Server) as a practical approximation of Serializable with better throughput.
- Execute the range query once, hold results in application memory, avoid re-querying.
---
### Lost Update (Write-Write Conflict)
Two transactions each read the same record, compute changes based on the stale read, and then write back. The second writer overwrites the first writer's changes. The first writer's update is silently discarded.
**Not in the SQL standard anomaly table.** Occurs at any isolation level without write-conflict detection.
**Detection patterns in code:**
```
# Pattern 1: read-then-write with no version check
item = db.query("SELECT * FROM inventory WHERE id=?", id)
item.quantity -= ordered
db.execute("UPDATE inventory SET quantity=? WHERE id=?", item.quantity, id)
# ^ Lost Update if another transaction reads the same row before this writes
```
**Prevented by:**
- `SELECT FOR UPDATE` — acquires a row-level exclusive lock at read time (pessimistic).
- Version column optimistic check:
```sql
UPDATE inventory SET quantity=?, version=version+1
WHERE id=? AND version=? -- fails if another writer committed first
```
- Application-level Optimistic Offline Lock (version column per entity).
- Serializable isolation with database-enforced write conflict detection.
---
## Isolation Level Default by Database
| Database | Default Isolation Level |
|---|---|
| PostgreSQL | Read Committed |
| MySQL (InnoDB) | Repeatable Read |
| SQL Server | Read Committed (or Read Committed Snapshot if RCSI is on) |
| Oracle | Read Committed |
| SQLite | Serializable (exclusive writes) |
Most production enterprise applications run on a database defaulting to **Read Committed**, which permits Unrepeatable Reads, Phantoms, and Lost Updates.
---
## Isolation vs Immutability
Fowler explicitly names immutability as an alternative concurrency-control strategy: "You only get concurrency problems if the data you're sharing can be modified." If a dataset is read-only (or write-once), any isolation level is safe for readers. Read replicas, event-sourced event logs, and CQRS read models exploit this: the read side has no concurrency problems because it only reads from an immutable-once-projected view.
**Practical rule:** Before raising isolation level, ask: "Can I make this data immutable or read from a snapshot?" That eliminates the problem rather than managing it.
---
## Business Transaction vs System Transaction ACID
A **system transaction** is a BEGIN/COMMIT block managed by the database. It provides ACID natively.
A **business transaction** spans multiple user interactions (multiple HTTP requests, multiple system transactions). The database provides no ACID guarantee across requests.
| ACID Property | System Transaction | Business Transaction (application responsibility) |
|---|---|---|
| Atomicity | DB rollback on error | Saga / compensation / undo workflow |
| Consistency | Schema constraints + triggers | Business-rule invariant checks at each commit |
| Isolation | Isolation level setting | Optimistic Offline Lock / Pessimistic Offline Lock |
| Durability | WAL + fsync + replication | Inherited from system transactions (all steps must commit) |
See: `offline-concurrency-strategy-selector` for selecting between Optimistic and Pessimistic Offline Lock for business-transaction isolation.
---
## Long-Transaction Risks at High Isolation Levels
Running a long-running computation under Repeatable Read or Serializable does not just affect throughput — it holds row-level locks (or a read snapshot) that can:
1. Block concurrent writers (Repeatable Read with locking).
2. Accumulate snapshot overhead until commit (MVCC-based Serializable).
3. Trigger lock escalation to table-level if many rows are locked simultaneously.
**Fowler's warning on lock escalation:** Avoid tables that are shared by many objects (e.g., a Layer Supertype object table). These become lock escalation candidates under high-isolation long transactions, shutting all writers out.
**Recommended practice:** Use the minimum isolation level that prevents the specific anomaly you're protecting against. Use Serializable only for transactions that truly need serializability (range scans with correctness requirements). Keep all long computations outside the transaction boundary where possible.
Route session state storage to the right location — Client Session State (cookies, JWT, hidden fields, URL parameters), Server Session State (in-memory or Re...
---
name: session-state-location-selector
description: "Route session state storage to the right location — Client Session State (cookies, JWT, hidden fields, URL parameters), Server Session State (in-memory or Redis session store), or Database Session State (SQL/NoSQL session table) — based on six dimensions: bandwidth cost, security sensitivity, clustering and failover needs, responsiveness, cancellation requirements, and development effort. Use when designing session management for a new web application, debugging sticky-session or node-pinning scaling problems, deciding between JWT vs server session vs database session, choosing a shared session store for a clustered or elastic deployment, handling shopping carts, multi-step forms, auth context, or edit-in-progress across HTTP requests, or auditing for session bloat or unsigned client session state. Applies to stateless session design, distributed session architecture, and HTTP session management in any language or framework. Relevant keywords: session state, session storage, session location, sticky sessions, JWT vs session, shared session store, stateless session, session management, HTTP session, distributed session, session cookie, server session, database session, Redis session, node-pinning, session scaling, client-side session, server-side session, session bloat."
version: 1.0.0
homepage: https://github.com/bookforge-ai/bookforge-skills/tree/main/books/patterns-of-enterprise-application-architecture/skills/session-state-location-selector
metadata: {"openclaw":{"emoji":"🗂️","homepage":"https://github.com/bookforge-ai/bookforge-skills"}}
status: draft
source-books:
- id: patterns-of-enterprise-application-architecture
title: "Patterns of Enterprise Application Architecture"
authors:
- Martin Fowler
- David Rice
- Matthew Foemmel
- Edward Hieatt
- Robert Mee
- Randy Stafford
chapters:
- "Chapter 6. Session State (narrative: The Value of Statelessness + Session State)"
- "Chapter 17. Session State Patterns (Client / Server / Database Session State)"
domain: software-architecture
tags:
- session-state
- web-application
- scalability
- security
- design-patterns
- distributed-systems
- enterprise-patterns
depends-on: []
execution:
tier: 2
mode: hybrid
inputs:
- type: description
description: "Description of the state that must persist across requests: what data, size estimate, security sensitivity, and deployment topology (single server vs cluster vs elastic)."
- type: codebase
description: "Web/session layer source files. Optional — improves anti-pattern detection (node-pinning config, session object size, unencrypted cookie content)."
tools-required:
- Read
- Grep
- Write
tools-optional:
- Glob
- Edit
mcps-required: []
environment: "Enterprise web application. Can work from description alone. Codebase improves anti-pattern detection via Grep on session configuration, load balancer config, and session object definitions."
discovery:
goal: "Assign each session state concern to the right storage location (Client / Server / Database) and produce a session state design record with rationale, implementation sketch, and anti-pattern warnings."
tasks:
- "Challenge whether session state is actually necessary (stateless redesign option)"
- "Enumerate the distinct state concerns that must persist across requests"
- "Apply the six-dimension scorecard to each concern"
- "Route each concern to Client, Server, or Database Session State"
- "Flag security requirements for Client SS (sign/encrypt); latency requirements for Database SS (cache layer)"
- "Audit for node-pinning and session-bloat anti-patterns"
- "Produce a session state design record"
audience:
roles:
- software-architect
- senior-backend-engineer
- tech-lead
- platform-engineer
experience: intermediate
when_to_use:
triggers:
- "Designing session management for a new web application or API"
- "Application fails or loses sessions when scaled horizontally or deployed to a new node"
- "Deciding between JWT and server-side sessions for auth context"
- "Shopping cart, multi-step form, or edit-in-progress must survive page navigation"
- "Load balancer config uses sticky sessions (node-pinning symptom)"
- "Session data is large and causing bandwidth or memory problems"
- "Sensitive data is stored in cookies without signing or encryption"
- "Migrating from single-server to clustered or cloud-elastic deployment"
prerequisites: []
not_for:
- "Selecting persistence patterns for record data (use data-source-pattern-selector)"
- "Selecting web presentation patterns (use web-presentation-pattern-selector)"
- "Designing transaction isolation or offline locking (use offline-concurrency-strategy-selector)"
environment:
codebase_required: false
codebase_helpful: true
works_offline: true
quality:
scores:
with_skill: null
baseline: null
delta: null
tested_at: null
eval_count: null
assertion_count: 14
iterations_needed: null
---
# Session State Location Selector
## When to Use
Use this skill when your web application must remember user state across HTTP requests — shopping carts, multi-step forms, auth context, edits-in-progress, wizard progress, or any data that belongs to a specific user interaction but is not yet committed to the database of record.
The skill routes each state concern to one of three storage locations: **Client Session State** (stored on the client and sent with each request), **Server Session State** (held in server memory or an external cache), or **Database Session State** (persisted as rows in a durable store). Different concerns within the same application can legitimately use different locations.
**Typical triggers:** scaling problems caused by sticky sessions, JWT vs server-session debate, shopping cart durability requirements, auth token design, migrating from a single server to a cluster.
**NOT for:** record data persistence (use `data-source-pattern-selector`), web presentation patterns (use `web-presentation-pattern-selector`), or transaction concurrency (use `offline-concurrency-strategy-selector`).
**Prerequisites:** none. Works with a description of the session data and deployment topology.
---
## Context & Input Gathering
Before routing, gather the following. Ask if not provided.
**Required:**
- What state must persist across requests? List each concern separately (auth token, shopping cart, form progress, user preferences, etc.)
- Estimated size per concern (bytes/KB — critical for Client SS feasibility)
- Security sensitivity of each concern (roles, prices, personal data = high sensitivity)
- Deployment topology: single server, fixed-size cluster, or elastic/auto-scaling?
- Cancellation requirement: does the user need to abandon a multi-step operation and roll it back?
**Observable from codebase:**
- Session configuration files (`application.yml`, `settings.py`, `config/initializers/session_store.rb`, `web.config`)
- Load balancer config for sticky sessions (`JSESSIONID` routing, `ip_hash`, `sticky_cookie`)
- Session object definitions or `HttpSession.setAttribute` calls for size estimation
- Cookie settings (`httpOnly`, `secure`, `sameSite`, signed/encrypted flags)
**Defaults if unknown:**
- Assume cluster topology unless told otherwise (conservative: design for clustering even on single server)
- Assume high security sensitivity for any session data beyond a session ID
- Assume session state exists if there is any multi-request user flow
**Sufficiency check:** proceed when you know the state concerns, their sizes, and the deployment topology.
---
## Process
### Step 1 — Challenge: Can This Be Stateless?
*WHY: Session state is a compromise forced by inherently stateful business transactions. Some apparent session state can be eliminated entirely — pushed into client-initiated request parameters, derived from the database on each request, or encoded in the URL. Stateless server objects can be pooled: 10 objects can serve 100 concurrent users at 10% activity. Eliminating session state is always preferable to choosing its location.*
For each state concern, ask:
- Can this be re-derived from a database query on each request without meaningful performance cost?
- Can this be passed explicitly as a request parameter (URL token, pagination cursor)?
- Is this truly mid-transaction state, or is it already committed data the server can look up?
If any concern can be made stateless: note it as "eliminate — stateless redesign" and remove it from further routing.
---
### Step 2 — Apply the Six-Dimension Scorecard
*WHY: The three session state patterns make different trade-offs across six dimensions. Evaluating each concern on all six dimensions before routing prevents "default to Server SS" anti-patterns and surfaces the actual constraints that drive the decision.*
For each remaining state concern, score it on:
| Dimension | Client SS | Server SS | Database SS |
|---|---|---|---|
| **Bandwidth** | Costly — full state sent per request | Free — session ID only | Free — session ID only |
| **Security** | Risky — client can inspect and tamper | Safe — server-side only | Safe — server-side only |
| **Clustering / Failover** | Excellent — fully stateless | Poor without external store | Good — shared DB |
| **Responsiveness** | Fast — no server lookup | Fast — in-memory | Slower — DB roundtrip |
| **Cancellation** | Easy — client stops submitting | Clear server-side object | Delete rows by session ID |
| **Dev Effort** | Low for tiny state; grows rapidly | Low for single server; high for cluster | High — schema design + cleanup |
Mark each dimension as a **constraint** (hard requirement), **preference** (nice to have), or **not relevant** for this concern.
---
### Step 3 — Route Each Concern
*WHY: Different state concerns have different dimension profiles. Auth tokens are small and security-sensitive but benefit from stateless serving. Shopping carts are medium-sized and need durability. Edit-in-progress may be large and complex. Routing each concern independently produces a better overall design than choosing one location for all session state.*
**Route to Client Session State when:**
- State is small (session ID alone: always Client SS; up to a few KB total: viable with care)
- Stateless server / maximal clustering is the priority
- State is not sensitive OR it will be signed and encrypted on every round-trip
- Failover resilience is critical (state survives node death automatically)
**Route to Server Session State when:**
- State is large or complex (object graphs, binary data, things that don't serialize to small blobs)
- Deployment is a fixed single server or small cluster with an external session store (Redis, Memcached)
- Responsiveness is the priority and a DB roundtrip per request is unacceptable
- Dev simplicity outweighs clustering concerns (and the deployment topology allows it)
**Route to Database Session State when:**
- State must survive server restarts and deploys
- Deployment is elastic (nodes come and go — no permanent home for in-memory state)
- Cancellation/rollback of the whole business transaction is required (delete rows by session ID)
- State concerns overlap with record data (pending orders alongside committed orders)
**Mixing rule:** assigning different concerns to different locations is not only allowed but common and recommended. Examples: session ID → Client SS (required), auth JWT → Client SS (signed cookie), shopping cart → Database SS (durability), UI preferences → Server SS (fast, low risk of loss).
---
### Step 4 — Flag Security, Size, and Latency Requirements
*WHY: Each location has a distinct failure mode that must be addressed in implementation. These are non-optional implementation constraints, not optional enhancements.*
**For Client Session State:**
- Sign and encrypt any data beyond a session ID. Use HMAC signatures for tamper detection; use AES or equivalent encryption for confidentiality. Never send roles, prices, or personally identifiable data without both.
- Re-validate all data returning from the client. Do not trust it.
- Keep within size limits: cookies ≤ 4KB. For more data, use URL parameters for minimal state (session ID, cursor) or hidden fields for form wizard state.
- Set `HttpOnly`, `Secure`, and `SameSite` attributes on session cookies.
**For Server Session State on a cluster:**
- Must use an external session store (Redis, Memcached, or equivalent) accessible to all nodes. In-memory-only Server SS on a cluster requires sticky sessions — a node-pinning anti-pattern (see Step 5).
- Set appropriate TTL on the external store to expire abandoned sessions.
**For Database Session State:**
- Add a session ID column to any table that may hold pending (uncommitted) data, and filter it out of all "record data" queries.
- Implement a cleanup mechanism: a background job or TTL that deletes rows for abandoned sessions.
- Consider a cache layer (Server SS cache backed by Database SS persistence) to absorb the DB roundtrip cost.
---
### Step 5 — Audit for Anti-Patterns
*WHY: Session anti-patterns are common and have serious consequences — scaling failures (node-pinning), bandwidth or memory exhaustion (session bloat), and security vulnerabilities (unsigned client state). Catching them here prevents incidents.*
**Node-Pinning (sticky sessions as a scaling crutch):**
- Symptom: load balancer configured for sticky sessions (`ip_hash`, sticky cookie, `JSESSIONID` routing) with no external session store.
- Consequence: horizontal scaling is compromised (adding nodes doesn't distribute sessions), failover loses sessions (the pinned node dying means session loss), load is uneven.
- Fix: move Server SS to an external shared store (Redis), or move state to Database SS, or move state to Client SS.
**Session Bloat:**
- Symptom: session object contains large lists, entire domain object graphs, cached query results, or binary resources.
- Consequence in Client SS: cookie or hidden-field payload approaching or exceeding size limits on every request. Massive bandwidth overhead.
- Consequence in Server SS: high memory pressure per active session; slow serialization for clustering/failover.
- Fix: store only keys and small scalar values in the session. Re-query the database for object graphs when needed. Move large state to Database SS with explicit pending rows.
**Unsigned/Unencrypted Client Session State:**
- Symptom: session cookie or hidden field contains roles, prices, user IDs, or other sensitive values in plaintext or base64 without HMAC or encryption.
- Consequence: user can inspect the data (disclosure) or modify it (privilege escalation, price manipulation).
- Fix: sign with HMAC (tamper detection) and encrypt (confidentiality). Use a platform-provided signed/encrypted cookie store (Rails `encrypted_cookie_store`, Django signed cookies, Express `cookie-session` with `secret`).
**Server Session State on a Cluster Without External Store:**
- Symptom: multi-node deployment + in-process session memory.
- Consequence: requests routed to a different node find no session → random authentication failures, lost cart state, broken wizard flows.
- Fix: same as node-pinning — external shared session store.
---
### Step 6 — Produce the Session State Design Record
*WHY: A written record of the routing decisions and rationale enables team alignment, review, and future change without re-litigating the decision from scratch.*
Write a session state design record (see Outputs).
---
## Inputs
- List of session state concerns with: data description, estimated size, security sensitivity
- Deployment topology: single server / fixed cluster / elastic auto-scaling
- Cancellation / rollback requirements
- Framework and session infrastructure in use (optional — improves specificity of recommendations)
- Codebase session configuration and object definitions (optional — enables anti-pattern detection)
---
## Outputs
### Session State Design Record
```
## Session State Design: [System Name]
### Deployment Context
- Topology: [single server | fixed cluster | elastic]
- Framework / runtime: [e.g., Express + Node.js, Django, Spring Boot]
- Current session infrastructure: [e.g., HttpSession in-memory, Redis, none]
### Statelessness Opportunities
- [Concern A]: [Can be eliminated — re-derive from DB on each request]
- [Concern B]: [Must remain stateful — mid-transaction edit]
### Routing Decisions
| Concern | Location | Rationale | Size | Security Controls |
|---|---|---|---|---|
| Session ID | Client SS | Always client — just one token | ~32 bytes | HttpOnly, Secure, SameSite=Strict |
| Auth context (roles, user ID) | Client SS | Signed JWT in HttpOnly cookie — stateless, cluster-friendly | ~300 bytes | HMAC-signed + encrypted |
| Shopping cart | Database SS | Durability + cluster support; cancelable by deleting rows | Variable | Server-side only |
| Multi-step form progress | Server SS | Large object, short-lived, Redis external store | ~10KB | External Redis store |
### Implementation Notes
- [Concern]: [specific implementation sketch]
- Auth JWT: use short-lived access token (15 min) + HttpOnly refresh token cookie; validate on every request
- Shopping cart: `cart_items` table with `session_id` FK; cleanup daemon runs every 30 min deleting rows with last_activity > 2h
### Anti-Pattern Status
- Node-pinning: [NOT PRESENT | PRESENT — fix required: ...]
- Session bloat: [NOT PRESENT | PRESENT — fix required: ...]
- Unsigned Client SS: [NOT PRESENT | PRESENT — fix required: ...]
### Open Questions
- [Any unresolved decisions or constraints that need clarification]
```
---
## Key Principles
**1. Stateless by default, stateful only where the business transaction requires it.**
Server objects that hold no inter-request state can be pooled and freely load-balanced. Session state forces one-to-one affinity or external coordination. Start by questioning whether the state is genuinely necessary.
**2. Client Session State scales perfectly but costs bandwidth and security diligence.**
Every byte stored on the client is sent on every request. Works well for a session ID or a signed JWT. Becomes a liability for shopping cart contents or form state measured in kilobytes. The security contract is firm: sign everything, encrypt anything sensitive, re-validate everything on return.
**3. Server Session State is simple but requires an external store for any clustered deployment.**
In-memory Server Session State is the simplest programming model. It breaks the moment there is more than one server node. The fix (external Redis/Memcached store) is straightforward but must be done proactively — retrofitting it after a scaling incident is painful. Never accept sticky sessions as a substitute for a shared store.
**4. Database Session State is the most durable and cluster-friendly but has real costs.**
Every request pays a database roundtrip. Schema design must carefully separate pending session rows from committed record data, or queries for record data will accidentally include in-progress session data. A background cleanup mechanism for abandoned sessions is not optional.
**5. Mix patterns per concern, not per application.**
A session ID belongs in Client SS (always). Auth context likely belongs in Client SS (small, signed JWT). A shopping cart belongs in Database SS (durable, cancelable). Edit-in-progress on a large domain object belongs in Server SS (complex, short-lived). Forcing all session state into one location to avoid mixing is a false simplification.
**6. The six dimensions are constraints, not preferences.**
Clustering/failover is not optional for elastic deployments. Security controls are not optional for sensitive data. Size limits are not optional for Client SS. Evaluate all six before routing — skipping a dimension is how node-pinning and session bloat enter production.
---
## Examples
### Example 1 — E-Commerce on an Elastic Cluster
**Scenario:** Online retailer with variable traffic (3x on sale days). Three-node cluster behind a load balancer with auto-scaling.
**Trigger:** New feature: multi-item shopping cart that must survive page refresh and browser close. Auth is already in-place using a session cookie.
**Process:**
1. Challenge statelessness: cart contents cannot be derived without the user's choices — genuinely stateful.
2. Scorecard: cart is medium-sized (~5–50 items), not security-sensitive (product IDs and quantities), needs durability (survive node death), needs cancellation (user can empty cart), cluster topology.
3. Routing: auth session ID → Client SS (existing, HttpOnly cookie). Cart → Database SS (elastic cluster + durability + cancellation). UI preferences → Client SS (small, not sensitive, fine to lose on client failure).
4. Security: session ID cookie: HttpOnly + Secure + SameSite=Strict. No sensitive data in Client SS beyond signed session ID.
5. Anti-pattern audit: confirm load balancer is NOT using sticky sessions for cart requests. Add `session_id` column to `cart_items` table. Implement background cleanup for abandoned carts (TTL 48h).
**Output:** Design record routing cart to Postgres `cart_items` table with session_id FK. Auth cookie stays Client SS. No node-pinning. Cleanup daemon specified.
---
### Example 2 — Multi-Step Insurance Policy Editor (LOB App, Single Server)
**Scenario:** Line-of-business app for insurance agents. Single application server (no clustering). Agents edit complex policies across 6 wizard steps over 10–20 minutes. Policies have 80+ fields and nested objects.
**Trigger:** Current approach stores the entire partially-edited policy as a Java serialized object in `HttpSession`. Server runs out of memory under load.
**Process:**
1. Challenge statelessness: multi-step wizard with partial validation — genuinely stateful.
2. Scorecard: state is large (serialized policy graph ~200KB), not security-sensitive to external exposure (internal LOB app), single server (no clustering concern), slow serialization causing memory pressure.
3. Session-bloat anti-pattern detected: 200KB per active session in memory is the root cause.
4. Routing options: Database SS (persist pending policy rows with `session_id` field) is preferable — survives server restart, eliminates memory pressure, makes partial edits queryable (admin can see in-progress edits). Client SS is ruled out (200KB far exceeds cookie limits).
5. Implementation: add `is_pending` flag and `session_id` column to policy and policy_line tables. All "committed policies" queries add `WHERE session_id IS NULL`. Cleanup daemon deletes rows with `session_id IS NOT NULL AND last_activity < NOW() - INTERVAL '4 hours'`.
6. Note: single server means Server SS with external Redis is also viable as a simpler migration step. Document the trade-off.
**Output:** Design record recommending Database SS for policy editor state. Session-bloat anti-pattern resolved. Migration path from current HttpSession noted.
---
### Example 3 — SPA with JWT Authentication
**Scenario:** React SPA with a Node.js/Express API backend. Team is debating JWT in localStorage vs JWT in HttpOnly cookie vs server-side session.
**Trigger:** Security review flagged localStorage JWT as vulnerable to XSS. Team wants guidance on the session state trade-off.
**Process:**
1. Challenge statelessness: auth context must persist — genuinely stateful.
2. Auth concern: small (user ID, roles, expiry ~300 bytes), security-sensitive (roles determine authorization), cluster topology (multiple API nodes behind a load balancer).
3. Scorecard: Client SS (HttpOnly cookie with signed JWT) wins on clustering (stateless API nodes), responsiveness (no server lookup), and size (tiny). Beats Server SS on clustering; beats Database SS on latency.
4. Security controls: HttpOnly cookie (XSS cannot read it), Secure flag, SameSite=Strict (CSRF protection). JWT signed with HMAC-SHA256. Short TTL (15 min access token) + longer HttpOnly refresh token (7 days). Re-validate JWT signature on every request.
5. Audit: localStorage flagged as anti-pattern for auth tokens — XSS exposure. Recommend HttpOnly cookie.
6. If refresh tokens need server-side revocation: store refresh token IDs in a Redis set per user (Server SS with external store). On logout, delete the Redis entry. Access tokens remain stateless until expiry.
**Output:** Design record: access JWT in HttpOnly cookie (Client SS). Refresh token revocation list in Redis (Server SS with external store). No localStorage. Anti-pattern (localStorage JWT) flagged and resolved.
---
## References
- `references/six-dimension-scorecard.md` — Full scoring table with worked examples for all three patterns
- `references/anti-pattern-detection-checklist.md` — Node-pinning, session-bloat, unsigned Client SS: detection criteria and fixes
- `references/pending-data-schema-patterns.md` — Pending field, pending tables, and session ID column approaches for Database SS
- `references/modern-session-store-map.md` — Fowler's patterns mapped to Redis, Postgres, DynamoDB, JWT, and framework-native session stores
---
## License
This skill is licensed under [CC-BY-SA-4.0](https://creativecommons.org/licenses/by-sa/4.0/).
Source: [BookForge](https://github.com/bookforge-ai/bookforge-skills) — Patterns of Enterprise Application Architecture by Martin Fowler, David Rice, Matthew Foemmel, Edward Hieatt, Robert Mee, Randy Stafford.
---
## Related BookForge Skills
Install related skills from ClawhHub:
- `clawhub install bookforge-web-presentation-pattern-selector`
- `clawhub install bookforge-enterprise-architecture-pattern-stack-selector`
Or install the full book set from GitHub: [bookforge-skills](https://github.com/bookforge-ai/bookforge-skills)
FILE:references/anti-pattern-detection-checklist.md
# Anti-Pattern Detection Checklist: Session State
Source: *Patterns of Enterprise Application Architecture* — Fowler et al., Ch 6 + Ch 17
---
## Anti-Pattern 1: Node-Pinning (Sticky Sessions as a Scaling Crutch)
### What It Is
Using load balancer sticky sessions (server affinity) to route each user's requests to the same server node, instead of using a shared external session store. Treated as a "solution" to in-memory Server Session State on a cluster.
### Fowler's Term
"Server affinity" — Fowler identifies this as a consequence of basic in-memory Server Session State: "it also assumes that there's only one application server — that is, no clustering."
### Detection Signals
- Load balancer config contains: `sticky_sessions`, `ip_hash`, `JSESSIONID` cookie routing, `sticky_cookie`, `session persistence`, or equivalent sticky settings
- No external session store (Redis, Memcached, shared DB) configured
- Application server session data is stored in local JVM heap or local process memory
- Cluster size is fixed (no auto-scaling) — elastic scaling would break sticky sessions
### Consequences
- Horizontal scaling is compromised: adding nodes does not distribute existing sessions
- Failover loses sessions: when a pinned node dies, its sessions die with it
- Uneven load distribution: users with active sessions stay on specific nodes regardless of load
- Elastic auto-scaling is impossible: new nodes can't handle pinned sessions
### Fix
Choose one:
1. Move to external shared session store (Redis, Memcached) — remains Server SS but cluster-safe
2. Move to Database Session State — all nodes access the same DB
3. Move to Client Session State — signed JWT in HttpOnly cookie; fully stateless
---
## Anti-Pattern 2: Session Bloat
### What It Is
Storing large objects — entire domain model graphs, cached query results, lists of business objects, binary resources — in the session store rather than keeping the session minimal (IDs and small scalars).
### Fowler's Reference
Fowler notes Client SS issues grow "exponentially with the amount of data involved" and cites bandwidth cost and storage limits as the binding constraints.
### Detection Signals (Client SS)
- Session cookie approaching or exceeding 4KB
- Hidden form fields containing serialized object graphs
- JWT payload over ~1KB
- Session data includes arrays of objects, nested structures, or binary data
### Detection Signals (Server SS)
- Session object graph > ~50KB per user
- Session contains cached query result sets
- High memory pressure on application server correlated with number of active users
- Slow session serialization/deserialization during clustering or passivation
### Consequences (Client SS)
- Cookie exceeds 4KB browser limit — state silently truncated or cookie rejected
- Every request carries multi-KB payload — bandwidth overhead scales with active users
- Serialization/deserialization cost per request
### Consequences (Server SS)
- Memory pressure: heap exhausted under moderate concurrent user count
- Slow serialization for external store sync
- Large BLOB in session table if persisting to DB
### Fix
- Store only keys (IDs, small scalars) in the session
- Re-query the database for full objects when needed — don't cache them in the session
- Move large state concerns to Database Session State with explicit pending rows
- If state must be large, ensure Server SS with an appropriately sized external Redis instance and memory limits
---
## Anti-Pattern 3: Unsigned/Unencrypted Client Session State
### What It Is
Storing sensitive data (user IDs, roles, prices, permissions, personal data) in client-side storage (cookies, hidden fields, URL parameters, localStorage) without cryptographic signing or encryption.
### Fowler's Warning
"Realize that cookies are no more secure than anything else, so assume that prying of all kinds can happen." And: "Fingers can pry too, so don't assume that what got sent out is the same as what gets sent back. Any data coming back will need to be completely revalidated."
### Detection Signals
- Cookie value is plaintext, base64-encoded (not encrypted), or JWT without signature verification
- Hidden form fields contain role names, prices, discount amounts, or user IDs
- URL parameters contain authorization-relevant data
- JWT `alg: none` or weak signing key
- No server-side re-validation of returned client data
### Consequences
- **Tampering:** user changes their role to `admin`, changes a price to `0.01`, or changes a discount to `100%`
- **Disclosure:** user reads their session data or another user's data if session ID is predictable
- **Session hijacking:** attacker reads auth token from non-HttpOnly cookie via XSS
### Fix
For tamper detection: sign with HMAC (e.g., HMAC-SHA256). Verify signature on every request before trusting any value.
For confidentiality: encrypt the payload (AES-GCM). Use authenticated encryption to get both properties in one operation.
Platform options:
- Rails: `encrypted_cookie_store` (AES-GCM + HMAC automatically)
- Django: signed cookies (`SESSION_COOKIE_SIGNED = True`) or encrypted with django-encrypted-cookie-session
- Express: `cookie-session` with `secret` (HMAC-signed) or Iron (encrypted)
- JWT: use RS256 or HS256 with a strong secret; never use `alg: none`; set short TTL (15 min)
Always: set `HttpOnly` (XSS cannot read), `Secure` (HTTPS only), `SameSite=Strict` or `Lax` (CSRF protection).
---
## Anti-Pattern 4: Server Session State on a Cluster Without External Store
### What It Is
Running multiple application server nodes with in-process (local heap) Server Session State and no shared session store, relying on luck or rare sticky-session misconfiguration rather than a proper architectural fix.
### Detection Signals
- Multi-node deployment (Kubernetes, Docker Swarm, multiple EC2 instances, etc.)
- Session stored in local application process memory (e.g., Express `memorystore`, Django `django.contrib.sessions.backends.cache` with a local cache)
- Random authentication failures or "session not found" errors reported by users
- Errors are intermittent and correlate with deployments or node restarts
### Consequences
- Users get random 401 / 403 / session-not-found errors when load balanced to a different node
- Session loss on any node restart or redeploy
- Debugging is painful — intermittent, hard to reproduce in single-node test environments
### Fix
Same as node-pinning fix: external shared session store. Redis is the standard choice for Server SS on a cluster.
---
## Quick Detection Table
| Signal in Codebase | Anti-Pattern | Severity |
|---|---|---|
| `ip_hash` / `sticky_sessions` in LB config | Node-pinning | High |
| `HttpSession.setAttribute("cart", cartObject)` with full object | Session Bloat | Medium |
| Session cookie > 4KB | Session Bloat (Client SS) | High |
| Cookie without `HttpOnly` flag containing auth data | Unsigned/Unencrypted Client SS | Critical |
| JWT with `alg: none` or base64-only encoding | Unsigned/Unencrypted Client SS | Critical |
| `MemoryStore` session backend + Kubernetes deployment | Server SS on cluster without external store | High |
| Hidden field with `role=admin` or `price=99.99` | Unsigned/Unencrypted Client SS | Critical |
FILE:references/six-dimension-scorecard.md
# Six-Dimension Session State Scorecard
Source: *Patterns of Enterprise Application Architecture* — Fowler et al., Ch 17 Session State Patterns
---
## The Six Dimensions
| Dimension | What It Measures | Why It Matters |
|---|---|---|
| **Bandwidth** | Does session data travel over the network on every request? | Client SS sends full state each round-trip — prohibitive for large state |
| **Security** | Is session data protected from client inspection and tampering? | Client-held data can be read and modified without server-side controls |
| **Clustering / Failover** | Can any node handle any request without coordination? | In-memory Server SS breaks on clusters — requires sticky sessions or external store |
| **Responsiveness** | How fast is session state access per request? | Database SS adds a DB roundtrip; Server SS is in-memory; Client SS needs no server lookup |
| **Cancellation** | How easily can an in-progress business transaction be abandoned and rolled back? | Database SS has explicit rows to delete; Server SS clears an object; Client SS requires no server-side action |
| **Dev Effort** | How much infrastructure and code is required? | Server SS is simple for single server; Database SS requires schema design and cleanup; Client SS requires signing/encryption |
---
## Full Scoring Table
| Dimension | Client Session State | Server Session State | Database Session State |
|---|---|---|---|
| **Bandwidth** | HIGH COST — full state in every request/response. Grows with state size. Prohibitive above a few KB. | NONE — only session ID crosses the wire | NONE — only session ID crosses the wire |
| **Security** | RISKY — data is client-accessible. Must sign (HMAC) for tamper detection; encrypt for confidentiality. Re-validate all returning data. | SAFE — data never leaves server | SAFE — data never leaves server |
| **Clustering / Failover** | EXCELLENT — server holds no state. Any node handles any request. Perfect failover. | POOR without external store — in-memory state is node-local. Requires sticky sessions (node-pinning) or external shared store (Redis). GOOD with external store. | GOOD — all nodes read the same DB. No node-pinning. Survives node death. |
| **Responsiveness** | FAST — no server-side lookup needed. Slight overhead from deserializing the cookie/token. | FAST — in-memory access. Sub-millisecond for local map lookup. Adds network latency if external store (Redis ~1ms). | SLOWER — DB roundtrip on every request. Can be reduced by caching the server object, but write cost always paid. |
| **Cancellation** | EASY — client simply does not submit. No server cleanup needed. | CLEAR — discard or invalidate the session object. Cleanup on timeout. | EXPLICIT — delete all rows WHERE session_id = X. Need daemon for abandoned sessions (timeout cleanup). |
| **Dev Effort** | LOW for tiny state (session ID only). Grows rapidly with size — serialization, size management, signing, encryption. | LOW for single server — platform-provided HttpSession. HIGH for clustered — external store setup, serialization, TTL management. | HIGH — schema design (pending vs committed rows), pending data separation logic, all-queries modification or views, timeout daemon. |
---
## Decision Guide by Scenario
### When Client SS wins
- State is ≤ a few KB total
- Stateless server / elastic scaling is the top priority
- State is not sensitive OR will be signed + encrypted
- Failover without any coordination is required (e.g., multi-region, active-active)
### When Server SS wins
- State is large or complex (object graphs, binary data)
- Responsiveness is critical and DB latency is unacceptable
- External session store (Redis) is already available and the team is comfortable with it
- Single-server deployment where simplicity outweighs clustering concerns
### When Database SS wins
- State must survive server restarts, deploys, or node failures
- Deployment is elastic (nodes appear and disappear)
- Cancellation/rollback of the entire business transaction is required
- State overlaps with domain record data (pending orders, draft documents)
- No external cache infrastructure is available and DB access is already fast
---
## Worked Example: E-Commerce on 3 Nodes
**State concern: shopping cart (20 items, ~2KB serialized)**
| Dimension | Score | Notes |
|---|---|---|
| Bandwidth | Acceptable at 2KB — but grows with cart size | Not a blocking concern unless cart grows large |
| Security | Not sensitive — product IDs and quantities | No signing/encryption required for cart contents |
| Clustering | Required — 3-node cluster, auto-scaling | Client SS or Database SS; Server SS needs external store |
| Responsiveness | DB roundtrip acceptable (~5ms) | Not a sub-10ms requirement |
| Cancellation | Required — user can clear cart | Database SS: DELETE WHERE session_id = X |
| Dev Effort | Medium — session_id FK on cart_items table | Already have a database; no new infrastructure |
**Verdict:** Database Session State. Cart items in `cart_items` table with `session_id` FK. Cleanup daemon with 48-hour TTL.
---
## Worked Example: JWT Auth Token (~300 bytes)
**State concern: user ID, roles, JWT expiry**
| Dimension | Score | Notes |
|---|---|---|
| Bandwidth | Negligible — 300 bytes | No concern |
| Security | HIGH sensitivity — roles determine authorization | Must sign (HMAC) + encrypt. HttpOnly cookie required. |
| Clustering | Required — elastic API nodes | Client SS with signed JWT: perfect stateless scaling |
| Responsiveness | Stateless verify — no server lookup | JWT verify is a local HMAC check |
| Cancellation | Logout requires token invalidation | Access token: wait for TTL (15 min). Refresh token: revocation list in Redis |
| Dev Effort | Low — JWT library + HttpOnly cookie | Standard infrastructure |
**Verdict:** Client Session State (HttpOnly signed JWT cookie). Refresh token revocation via Redis set (Server SS with external store).
Use when offline-concurrency-strategy-selector (or your team) has chosen Pessimistic Offline Lock and you need to implement it correctly end-to-end. Handles:...
---
name: pessimistic-offline-lock-implementer
description: "Use when offline-concurrency-strategy-selector (or your team) has chosen Pessimistic Offline Lock and you need to implement it correctly end-to-end. Handles: pessimistic locking, pessimistic offline lock, record locking, exclusive lock, lock manager design, lock timeout policy, acquire and release lock, stale lock cleanup, force release of abandoned locks, editing session lock, concurrent edit lock, edit lock implementation, record check-out pattern, lock table schema, durable lock storage (database-backed vs Redis vs in-memory), lock owner identity (user+session), coarse-grained lock at aggregate root (shared-version token vs root lock), implicit lock via base-class mapper / LockingMapper decorator / ORM interceptor / AOP aspect. Three-phase implementation: (1) choose lock type (exclusive-write vs exclusive-read vs read-write), (2) build durable lock manager (acquire/release/releaseAll/timeout), (3) define lock protocol (when to acquire, scope, release triggers, force-release, deadlock avoidance). Anti-pattern audit: SELECT FOR UPDATE across user think-time, in-memory lock table in clustered deployment, missing timeout policy, no owner identity, implicit-lock gaps, mixing optimistic with pessimistic carelessly. Produces a Pessimistic Offline Lock implementation plan covering lock type, storage choice, manager API, protocol spec, Coarse-Grained Lock integration, Implicit Lock wiring, UX spec (who holds lock + force-release), and test plan."
version: 1.0.0
homepage: https://github.com/bookforge-ai/bookforge-skills/tree/main/books/patterns-of-enterprise-application-architecture/skills/pessimistic-offline-lock-implementer
metadata: {"openclaw":{"emoji":"🔐","homepage":"https://github.com/bookforge-ai/bookforge-skills"}}
status: draft
source-books:
- id: patterns-of-enterprise-application-architecture
title: "Patterns of Enterprise Application Architecture"
authors:
- Martin Fowler
- David Rice
- Matthew Foemmel
- Edward Hieatt
- Robert Mee
- Randy Stafford
chapters: [5, 16]
domain: enterprise-application-architecture
tags:
- pessimistic-locking
- concurrency
- locking
- transactions
- design-patterns
- data-integrity
- offline-lock
- record-locking
depends-on:
- offline-concurrency-strategy-selector
execution:
tier: 2
mode: hybrid
inputs:
- type: codebase
description: "Persistence layer (mapper, repository, ORM entities), session management code, and schema files for the entities requiring concurrent-edit protection"
- type: user-description
description: "Stack and ORM, which entities need locking, deployment topology (single-server vs clustered), aggregate boundaries, expected edit duration, and whether schema changes are possible"
tools-required:
- Read
- Grep
- Edit
- Write
tools-optional:
- Glob
mcps-required: []
environment: "Application with multi-step editing workflows (record opened in one HTTP request, saved in a later one) where conflict cost is high. Relational database with ORM or hand-rolled persistence. Stack-agnostic: Java/Spring, C#/.NET, Python/Django, Node.js, Ruby on Rails all apply."
discovery:
goal: "Implement Pessimistic Offline Lock with correct lock type, durable lock manager, protocol, Coarse-Grained Lock for aggregates, and Implicit Lock for safety."
tasks:
- "Confirm offline-concurrency-strategy-selector chose Pessimistic (or confirm from context)"
- "Choose lock type: exclusive-write (most common), exclusive-read, or read-write"
- "Design durable lock table schema with owner identity, acquired_at, and optional expires_at"
- "Implement atomic acquire (INSERT with uniqueness enforcement) and release (DELETE) operations"
- "Define timeout policy: absolute timeout + session-invalidation-triggered release"
- "Define the protocol: acquire before load, scope per entity-ID, abort immediately on unavailability"
- "Add Coarse-Grained Lock if aggregate integrity matters (shared-version token or root lock)"
- "Add Implicit Lock via LockingMapper decorator or base-class repository to prevent gaps"
- "Specify force-release authorization and lock-owner display UX"
- "Audit anti-patterns: SELECT FOR UPDATE long-held, in-memory lock in cluster, no timeout, no owner"
- "Write concurrent-acquire test and timeout test"
- "Produce Pessimistic Offline Lock implementation plan"
audience:
roles:
- backend-engineer
- software-architect
- tech-lead
experience: intermediate
when_to_use:
triggers:
- "offline-concurrency-strategy-selector output specifies Pessimistic Offline Lock"
- "Users lose significant work when a save is rejected at commit time (high rework cost)"
- "Implementing record check-out / edit-lock to prevent concurrent editing"
- "Designing a lock manager with acquire, release, and timeout for long-running edits"
- "Auditing an existing pessimistic locking implementation for correctness"
- "Adding aggregate-level (Coarse-Grained) locking so all members lock together"
- "Preventing implicit-lock gaps by moving lock acquisition into the framework layer"
- "Designing the 'locked by Alice' UX and force-release admin flow"
prerequisites:
- "offline-concurrency-strategy-selector has been run (or Pessimistic is confirmed appropriate)"
not_for:
- "Systems where all business transactions fit in a single DB transaction — use isolation levels instead"
- "Low-collision, low-rework-cost workflows — use optimistic-offline-lock-implementer"
- "Thread-level concurrency within a single request (use language-level synchronization)"
- "Distributed consensus problems (Raft, Paxos, saga patterns)"
environment:
codebase_required: false
codebase_helpful: true
works_offline: true
quality:
scores:
with_skill: null
baseline: null
delta: null
tested_at: null
eval_count: null
assertion_count: 14
iterations_needed: null
---
# Pessimistic Offline Lock Implementer
## When to Use
This skill implements the Pessimistic Offline Lock pattern after `offline-concurrency-strategy-selector` (or your team) has confirmed it is the right concurrency strategy. The pattern applies when a business transaction spans multiple HTTP requests — user opens a record, edits for minutes or hours, then saves — AND the rework cost of a late collision rejection is unacceptably high.
The core trade-off: Optimistic Offline Lock detects conflicts at commit time (user loses work already done). Pessimistic Offline Lock prevents conflicts at load time (user sees "locked by Alice" immediately, before doing any work). Use Pessimistic when late failure is genuinely unacceptable — insurance policy underwriting, complex order editing, legal document authoring.
**Do not use** if collisions are rare and rework cost is low (use optimistic instead). **Do not use** if the entire workflow fits in a single DB transaction (use isolation levels).
## Context & Input Gathering
Gather the following before proceeding. Grep the codebase or ask the user directly.
**Required:**
1. **Lock type decision** — does the business transaction need to lock on READ, on WRITE, or both? (see Phase 1)
2. **Entities requiring locking** — which tables/domain classes must be locked during editing?
3. **Aggregate boundaries** — are multiple objects edited together as a unit (Order + LineItems, Policy + Coverages)?
4. **Deployment topology** — single-server or clustered? (determines lock storage choice)
5. **Session management** — HTTP sessions, JWT tokens, or custom session IDs? (determines owner identity)
6. **Schema mutability** — can a lock table and a version table be added?
**Observable from codebase:**
- `SELECT FOR UPDATE` or database-native lock hints → likely held across user think-time (anti-pattern — flag it)
- Existing `app_lock` or `lock_table` → partial implementation; audit for missing timeout, owner identity
- Session management code → determines how to register release-on-session-end listener
- Aggregate relationships in domain objects → identifies Coarse-Grained Lock candidates
**Sufficiency:** Proceed when lock type, entity list, and deployment topology are known.
---
## Process
### Phase 1 — Choose Lock Type
Choose the lock type before writing any code. This is a domain decision that must be validated with domain experts, not a purely technical one.
**Step 1 — Evaluate the three options:**
**Exclusive write lock (most common — default unless a specific need requires otherwise):**
A business transaction must acquire a lock only to EDIT a record. Multiple sessions may read concurrently; only the editor holds a lock. If stale reads are acceptable (a reader seeing slightly out-of-date data is tolerable), use this. Most enterprise systems need only this level.
**Exclusive read lock (most restrictive):**
A business transaction must acquire a lock to READ OR EDIT. Only one session accesses the record at a time. Use when the correctness of the business transaction depends on having the most recent data even for reads — for example, an insurance underwriter whose calculations are based on the values they loaded. Warning: this severely limits concurrency (all readers serialize behind each other).
**Read/write lock (most complex):**
Multiple shared read locks may coexist. An exclusive write lock blocks all read and write locks. No write lock can be granted while any read lock exists. Use when: read activity is heavy, editing is occasional, and read freshness matters for readers. More complex to implement (requires counting read lock holders) and harder for domain experts to reason about.
**WHY:** The lock type controls who can proceed concurrently and who gets blocked. An exclusive read lock applied system-wide makes the system behave like a single-user system. A wrong lock type cannot be rescued by a correct technical implementation — it will either create unacceptable data contention or fail to prevent conflicts. Fowler: "the wrong lock type can't be saved by a proper technical implementation."
**Decision rule:**
- DEFAULT → Exclusive write lock
- Readers must always see latest data → Exclusive read lock
- High concurrent read traffic + occasional editing + read freshness → Read/write lock
---
### Phase 2 — Build the Lock Manager
**Step 2 — Choose lock storage**
The lock table must be durable and visible to all application nodes. Choose one:
| Storage | Best for | Caution |
|---------|----------|---------|
| Same relational DB (dedicated table) | Most enterprise apps; reuses existing DB; leverages DB uniqueness constraints for atomicity | Adds load to primary DB; acceptable at normal scale |
| Redis with persistence (AOF/RDB) | Systems already using Redis; built-in TTL simplifies timeout | Must configure persistence (eviction policies); Redis failure = all locks lost |
| Dedicated lock service (Zookeeper, etcd) | Large distributed systems with strict consistency requirements | Heavy infrastructure; rarely justified for typical enterprise apps |
| In-process memory (Singleton map) | Only on single-server deployments with no restart concern | Locks lost on restart; invisible to other nodes — NEVER use in clustered deployments |
**WHY:** Lock durability is not optional. A server restart or process crash must not silently release all locks and allow concurrent editing to proceed unchecked. If the lock store is in-process memory and the application is clustered, node A's locks are invisible to node B — the entire scheme fails silently.
**Step 3 — Design the lock table schema**
```sql
CREATE TABLE app_lock (
lockable_id BIGINT NOT NULL, -- primary key of the locked entity
owner_id VARCHAR(255) NOT NULL, -- session ID or user+session composite
lock_type VARCHAR(20) NOT NULL, -- 'exclusive_write', 'exclusive_read', 'read', 'write'
acquired_at TIMESTAMP NOT NULL DEFAULT NOW(),
expires_at TIMESTAMP, -- NULL = no absolute expiry (rely on session listener)
PRIMARY KEY (lockable_id, owner_id, lock_type)
);
CREATE INDEX idx_app_lock_owner ON app_lock(owner_id);
CREATE INDEX idx_app_lock_expires ON app_lock(expires_at);
```
See [Lock Table Reference](references/lock-table-reference.md) for per-storage-backend DDL and Redis key structures.
**Step 4 — Implement the lock manager API**
The lock manager exposes exactly four operations. Business transactions interact only with the lock manager — never with the lock table directly.
```
acquireLock(lockableId, ownerId, lockType) → void | throws ConcurrencyException
releaseLock(lockableId, ownerId) → void
releaseAllLocksFor(ownerId) → void (session-end cleanup)
getLockOwner(lockableId) → ownerId | null
```
**Atomic acquire (database-backed implementation):**
```sql
-- Exclusive write: succeeds if no row exists for this lockableId
INSERT INTO app_lock (lockable_id, owner_id, lock_type, acquired_at)
VALUES (?, ?, 'exclusive_write', NOW())
-- ON CONFLICT / duplicate key exception → lock held by another session → throw ConcurrencyException
```
**Read/write lock acquire requires a read of the lock table inside a serializable transaction** — the lock table must not have conflicting reads. Prefer exclusive write or exclusive read unless read/write semantics are required; do not implement read/write if the added complexity is unnecessary. See [Lock Manager Reference](references/lock-manager-reference.md) for the full read/write implementation.
**WHY:** The `INSERT` approach uses the database's uniqueness constraint as the atomic "compare-and-set." A separate SELECT + conditional INSERT has a race condition. Throwing immediately (rather than waiting) eliminates deadlock — a business transaction that cannot acquire a lock aborts at the start, before the user has done any work.
**Owner identity:** Use the HTTP session ID as the owner identifier for web applications. For applications without HTTP sessions, use a composite of `userId + businessTransactionId`. The owner identifier must be retrievable from any request within the same business transaction. See Fowler's `AppSession` pattern: store the owner ID in a thread-local or request-scoped container bound to the HTTP session.
**Step 5 — Implement timeout and release mechanisms**
Three release triggers must ALL be wired:
1. **Explicit release on save or cancel:** the command that completes the business transaction calls `releaseLock()` (or `releaseAllLocksFor()`) before returning.
2. **Session-invalidation listener (most critical for web apps):**
```java
// Register on HTTP session creation
httpSession.setAttribute("lockRemover", new LockRemover(sessionId));
class LockRemover implements HttpSessionBindingListener {
public void valueUnbound(HttpSessionBindingEvent e) {
lockManager.releaseAllLocksFor(sessionId);
}
}
```
This fires when the HTTP session expires OR when the user's browser is closed (server-side session timeout). If you omit this, a user closing their browser mid-edit holds the lock until an admin intervenes.
3. **Timestamp-based expiry sweep (defensive backstop):**
Set `expires_at = NOW() + N_minutes` on acquire. A background job (or lazy check on acquire) cleans up expired locks:
```sql
DELETE FROM app_lock WHERE expires_at < NOW();
```
This backstop handles crashed application nodes where the session listener never fired.
**WHY:** Session abandonment is the most common real-world failure mode. Users close browser tabs, network connections drop, laptops sleep. Without a session-end listener and expiry sweep, a single abandoned session locks a record indefinitely. Fowler: "This is a big deal for a web application where sessions are regularly abandoned by users."
**Timeout duration:** Consult domain experts. A typical policy: absolute lock timeout of 60–120 minutes for long business transactions + idle-detection via heartbeat if the UI supports it (client pings `/session/heartbeat` every 30s; if absent for N minutes, session expires).
---
### Phase 3 — Define the Lock Protocol
**Step 6 — When to acquire**
Acquire the lock BEFORE loading the data, within the same system transaction as the load:
```
1. Begin system transaction
2. acquireLock(entityId, sessionId, lockType) ← if fails: throw ConcurrencyException, rollback
3. Load entity from DB ← guaranteed fresh (lock acquired first)
4. Commit system transaction
5. Present data to user (business transaction now in progress)
```
**WHY:** Acquiring after load creates a window where another session may have modified the record between your load and your lock acquisition. Loading after lock acquisition guarantees you see the most recent committed state. Fowler: "Generally, the business transaction should acquire a lock before loading the data, as there's not much point in acquiring a lock without a guarantee that you'll have the latest version of the locked item."
**Step 7 — Scope and granularity**
Lock on the entity's primary key (the ID, not the in-memory object). This allows acquiring the lock before loading, and ensures the lock can be checked by any code path without requiring the object to be in memory.
For entities that form aggregates, see Phase 4 (Coarse-Grained Lock).
**Step 8 — How to act when a lock is unavailable**
Throw an exception immediately. Never wait for the lock to become available.
```java
public void acquireLock(Long lockableId, String ownerId) throws ConcurrencyException {
if (hasLock(lockableId, ownerId)) return; // idempotent: already own it
try {
execute("INSERT INTO app_lock (lockable_id, owner_id, ...) VALUES (?, ?, ...)", lockableId, ownerId);
} catch (DuplicateKeyException e) {
String currentOwner = getLockOwner(lockableId);
throw new ConcurrencyException("Record is currently being edited by " + currentOwner);
}
}
```
**WHY:** Waiting for a lock is only sensible if the wait duration is bounded and short (seconds). A business transaction might take 20 minutes. No user will wait 20 minutes for a lock to become available. Waiting also enables deadlock: user A holds lock on record X and waits for Y; user B holds lock on Y and waits for X — both block forever. "Simply have your lock manager throw an exception as soon as a lock is unavailable. This removes the burden of coping with deadlock" (Fowler).
**Step 9 — Deadlock prevention for multi-record locking**
If a business transaction must acquire locks on multiple records (or multiple aggregate roots), enforce a consistent acquisition order across all business transactions:
- Order by entity type, then by primary key within the type.
- Acquire all locks before presenting any data to the user.
**WHY:** Deadlock arises when transaction A holds lock on entity 1 and needs entity 2, while transaction B holds lock on entity 2 and needs entity 1. With immediate-throw (Step 8), deadlock produces a fast ConcurrencyException for one party rather than an indefinite hang. Consistent ordering further reduces the frequency.
---
### Phase 4 — Add Coarse-Grained Lock for Aggregates
**Step 10 — Determine if aggregate locking applies**
An aggregate is a cluster of related objects treated as a unit for data changes (for example, a Lease and its Assets, an Order and its LineItems, a Policy and its Coverages). If editing any member of the group without locking the others could produce inconsistent data, a Coarse-Grained Lock is needed.
IF aggregate exists → add Coarse-Grained Lock. IF objects are independently lockable → skip this phase.
**Step 11 — Choose Coarse-Grained Lock implementation**
**Option A — Shared version token (preferred when also using Optimistic as a complement):**
Create a single `version` table row per aggregate. Every entity in the aggregate references the same version row by ID. To lock the aggregate, lock the version row's ID in the lock table. Acquiring this one lock effectively locks all members.
```sql
CREATE TABLE version (id BIGINT PRIMARY KEY, value BIGINT, modified_by VARCHAR, modified TIMESTAMP);
-- Each aggregate member stores: version_id BIGINT REFERENCES version(id)
```
Lock acquisition: `acquireLock(version.id, sessionId)` — one lock covers the entire aggregate.
**Option B — Root lock (navigate to aggregate root):**
Designate the aggregate root (the entity that provides the single access point to the group). Lock the root's ID. Any code path locking any member must navigate to the root first and lock that instead. Fowler: "locking either the asset or the lease ought to result in the lease and all of its assets being locked."
Navigation tip: use Lazy Load when traversing to the root to avoid loading the entire object graph. Cache the root ID in each member (direct reference) to avoid recursive traversal performance costs.
**WHY:** Without Coarse-Grained Lock, locking individual members requires every code path to enumerate all members of the group. As the group grows, this becomes error-prone. "Having a separate lock for individual objects presents a number of challenges. First, anyone manipulating them has to write code that can find them all in order to lock them" (Fowler). A single lock point eliminates this complexity and prevents the scenario where two sessions lock different members concurrently, neither aware of the other's intent.
---
### Phase 5 — Add Implicit Lock Safety Net
**Step 12 — Identify the mandatory lock tasks**
Compile the list of tasks that must happen for the locking scheme to be correct:
- **For exclusive read lock:** acquire lock before any `find()` call; release all locks on session end.
- **For exclusive write lock:** acquire lock before any `update()` or `delete()` call; verify write lock is held at commit.
- **For read/write lock:** acquire read lock before `find()`; acquire write lock before `update()`/`delete()`.
**Step 13 — Move mandatory tasks into the framework layer**
The goal: a developer writing a new repository method or command object cannot accidentally skip the lock call.
**LockingMapper decorator (Fowler's approach):**
```java
class LockingMapper implements Mapper {
private final Mapper impl;
private final LockManager locks;
public DomainObject find(Long id) {
locks.acquireLock(id, AppSessionManager.getSession().getId());
return impl.find(id); // acquireLock is idempotent if already held
}
public void update(DomainObject obj) {
// For write lock: verify lock is held; throw assertion if not
if (!locks.hasLock(obj.getId(), currentSessionId())) {
throw new ConcurrencyException("Write attempted without acquiring lock first");
}
impl.update(obj);
}
}
// Wire in the mapper registry:
class LockingMapperRegistry {
public Mapper getMapper(Class cls) {
return new LockingMapper(rawMappers.get(cls), lockManager);
}
}
```
**Alternative integration points:**
- **Abstract repository base class:** `AbstractRepository.findForEdit(id)` always acquires; concrete repos inherit.
- **ORM lifecycle hooks:** Hibernate `@PostLoad` event, EF Core `ChangeTracker.Tracked` event, SQLAlchemy `after_bulk_update` event.
- **AOP aspect / interceptor:** annotate editing methods `@RequiresLock`; an aspect acquires the lock transparently.
**WHY:** "The key to any locking scheme is that there are no gaps in its use. Forgetting to write a single line of code that acquires a lock can render an entire offline locking scheme useless" (Fowler). "If an item might be locked anywhere it must be locked everywhere." Implicit Lock moves the lock call out of the developer's hands for mandatory operations; for write locks (which require a user-facing decision point), the framework validates that the lock is already held rather than acquiring it implicitly.
**Write lock limitation:** Do not implicitly ACQUIRE write locks — only verify they were acquired. Acquiring a write lock implicitly (e.g., in `update()`) presents the user with a failure mid-work if the lock is unavailable. The intent of Pessimistic Offline Lock is to fail early (at edit start), not mid-work.
---
### Phase 6 — Anti-Pattern Audit
**Step 14 — Check for these failure modes:**
- [ ] **`SELECT FOR UPDATE` held across user think-time:** A DB-native lock held for 20 minutes ties up a DB connection for 20 minutes, serializing all other DB access. Replace with the application-level lock table.
- [ ] **In-memory lock table in clustered deployment:** Locks visible only on node A. Node B has no knowledge of them. Fix with DB-backed or Redis-backed lock store.
- [ ] **No timeout policy:** User closes browser; lock held forever. Fix with session-invalidation listener + expiry sweep.
- [ ] **No owner identity:** Cannot display "locked by X" in the UI. Cannot force-release someone else's stale lock. Fix by recording `owner_id` (user+session) with every lock row.
- [ ] **Implicit-lock gap:** A new code path (admin endpoint, background job, raw SQL) accesses a locked entity without acquiring the lock. Fix with the LockingMapper or framework hook.
- [ ] **Mixing optimistic and pessimistic on the same record:** Sessions using Optimistic version checks do not see Pessimistic locks — they read and modify records that appear "free" while another session holds a pessimistic lock on them. Pick one strategy per record type and apply it consistently.
- [ ] **Waiting for locks instead of throwing immediately:** Lock wait → deadlock risk + long blocking UI. Always throw `ConcurrencyException` immediately on lock unavailability.
- [ ] **Multi-lock acquisition without consistent ordering:** Two sessions locking (A, B) and (B, A) respectively → deadlock. Enforce canonical ordering.
---
### Step 15 — Define Lock-Owner UX and Force-Release
**Lock-owner display (required):**
When a user attempts to load a locked record and gets a `ConcurrencyException`, the error must tell them:
- WHO holds the lock (display name, not just session ID)
- SINCE WHEN the lock was acquired
- WHEN it will expire (if timeout policy is configured)
- How to request force-release (if admin override exists)
Example: "Policy 12345 is currently being edited by Bob Smith (since 10:47 AM). It will be available after his session ends or at 12:47 PM. [Request force-release] [Try again]"
**Force-release authorization:** Admin users call `lockManager.releaseLock(lockableId, currentOwner)` without being the owner (implement `/admin/locks/{id}/release`). The original owner releases via save or cancel. The expiry sweep releases stale locks automatically.
**WHY:** Without the "locked by X" display, users experience opaque failures. Without force-release, an administrator cannot recover from a crashed session before timeout fires.
---
### Step 16 — Write Concurrency Tests
Four required tests (see [Lock Manager Reference](references/lock-manager-reference.md) for full code):
1. **Concurrent acquire:** two sessions attempt `acquireLock(same_id)` — exactly one succeeds, one gets `ConcurrencyException`.
2. **Idempotent re-acquire:** same session acquires twice — must not throw (hasLock check fires).
3. **Release + re-acquire:** after release, a new session can acquire the same lock.
4. **Timeout/expiry:** lock with short `expires_at`; after expiry, a new session can acquire (sweep cleaned up stale lock).
---
### Step 17 — Produce the Implementation Plan Artifact
Output the Pessimistic Offline Lock Implementation Plan (see Outputs section). A written plan makes all decisions reviewable before code is written and serves as the checklist for implementation review.
---
## Inputs
- Entity/table list requiring concurrent-edit protection
- Stack, ORM, language, and deployment topology (single-server vs clustered)
- Aggregate boundaries (determines Coarse-Grained Lock need)
- Session management mechanism (determines owner identity and release listener)
- Existing persistence code (mapper, repository, or ORM entities)
## Outputs
### Pessimistic Offline Lock Implementation Plan
```markdown
## Pessimistic Offline Lock Implementation Plan: [Feature/Entity Name]
**Date:** YYYY-MM-DD | **Stack:** [ORM / language] | **Entities:** [list]
### 1. Lock Type
**Choice:** Exclusive write / Exclusive read / Read-write
**Rationale:** [why this type fits the access pattern and domain need]
### 2. Lock Storage
**Choice:** Database table / Redis / other
**Rationale:** [single-server vs cluster, existing infrastructure]
**DDL / config:** [schema or Redis key structure]
### 3. Lock Manager API
- acquireLock(lockableId, ownerId [, lockType]) → void | ConcurrencyException
- releaseLock(lockableId, ownerId) → void
- releaseAllLocksFor(ownerId) → void
- getLockOwner(lockableId) → ownerId | null
### 4. Protocol
- **Acquire point:** [on edit-view entry / on EditCommand.init()]
- **Release point:** [on SaveCommand / CancelCommand / session invalidation listener]
- **Timeout policy:** [absolute N minutes; expires_at stored in lock table]
- **Session-end listener:** [HttpSessionBindingListener / session lifecycle hook]
- **Force-release:** [admin endpoint; sweep job for expired locks]
### 5. Coarse-Grained Lock
**Required:** Yes / No
**Scope:** [aggregate root + all members]
**Implementation:** Shared version token / Root lock
**Lock point:** [version.id or root entity ID in lock table]
### 6. Implicit Lock Integration
**Required:** Yes / No
**Integration point:** LockingMapper decorator / abstract repo base class / ORM hook / AOP aspect
**Mandatory tasks automated:** [acquire on find() / verify on update() and delete()]
### 7. UX Specification
- **On lock unavailable:** "Record is currently being edited by [name] (since [time]). Try again or [request force-release]."
- **On session timeout:** "Your editing session has expired. Please reload the record."
- **Force-release flow:** [admin UI / endpoint]
### 8. Anti-Pattern Checklist
- [ ] No SELECT FOR UPDATE held across user think-time
- [ ] Lock store is durable and cluster-visible
- [ ] Timeout policy configured (session listener + expiry sweep)
- [ ] Owner identity stored (display name + session ID)
- [ ] All code paths go through LockingMapper / Implicit Lock
- [ ] No optimistic/pessimistic mixing on the same record type
- [ ] Lock unavailability throws immediately (no wait)
- [ ] Multi-lock ordering enforced
### 9. Test Plan
- [ ] Concurrent acquire: only one session succeeds
- [ ] Idempotent re-acquire by same session
- [ ] Release + re-acquire by new session
- [ ] Timeout/expiry: expired lock is cleaned up and available
- [ ] Session-end listener fires: releaseAllLocksFor called on session invalidation
- [ ] Implicit Lock: find() without prior lock → verifies lock acquired; update() without lock → throws assertion
```
## Key Principles
**1. Pessimistic fails EARLY — that is its entire value proposition.**
The purpose of Pessimistic Offline Lock is to prevent a user from investing 45 minutes in a business transaction only to have it rejected at commit time. Acquiring the lock at edit start means the user knows immediately that the record is locked, before doing any work. Any design that delays lock acquisition diminishes this benefit.
**2. Three phases, in order: lock type → lock manager → protocol.**
Fowler's three-phase implementation is non-negotiable. Choosing the wrong lock type produces either unacceptable contention (exclusive read applied everywhere) or insufficient protection (exclusive write where reads must also be fresh). The lock manager must exist before the protocol can be defined. Protocol defines discipline, not mechanism.
**3. The lock manager must throw immediately — never wait.**
Lock contention in an offline (multi-request) context cannot be resolved by waiting. The holder might be at lunch. A lock wait degrades into a timeout + error anyway — but a late timeout defeats the "fail early" goal and introduces deadlock risk. Throw `ConcurrencyException` on first unavailability, every time.
**4. Durable, cluster-visible lock storage is non-negotiable.**
An in-memory lock table fails silently in three ways: locks lost on restart, locks invisible across cluster nodes, and no audit trail for stale locks. A database lock table costs one extra table and one extra row per active edit session — a trivial overhead compared to the correctness guarantee.
**5. Release must be wired to session end, not only to explicit save/cancel.**
Users abandon sessions constantly: browser closes, laptop sleeps, network drops. If release depends only on explicit save or cancel, every abandoned session holds a lock indefinitely. The session-invalidation listener (HTTP session binding event or equivalent) is the most important release trigger — it fires even when the user does nothing explicit.
**6. Coarse-Grained Lock is required when aggregate integrity matters.**
Locking an Order without locking its LineItems allows another session to modify a LineItem while the Order is held — the aggregate is inconsistent from the first session's perspective. One lock on the aggregate root or shared version token eliminates this class of bug entirely with a single lock acquisition instead of one lock per member.
**7. Implicit Lock is not optional in non-trivial systems.**
A single developer adding a new command object or admin endpoint that bypasses the lock call defeats the entire scheme. The scheme's security is proportional to the thoroughness of its enforcement, and thoroughness requires framework-level enforcement. "The risk of a single forgotten lock is too great" (Fowler).
## Examples
### Example 1: Java/Spring — Insurance Underwriting System
**Scenario:** Underwriters edit complex insurance policies. Sessions last 45–90 minutes. Policies have Coverages, Endorsements, and Named Insureds. Two underwriters occasionally assigned the same policy. Work loss cost: very high.
**Trigger:** "Underwriters are furious about 409 errors after 90 minutes of work. Need to lock policies at edit start."
**Process:**
- Phase 1: Correctness depends on reading fresh data (actuarial tables) → **Exclusive read lock**.
- Phase 2: Single DB, existing Postgres → **DB lock table**. Schema: `app_lock(lockable_id BIGINT PK, owner_id VARCHAR, lock_type VARCHAR, acquired_at TIMESTAMP, expires_at TIMESTAMP)`.
- Phase 3: Protocol — `EditPolicyCommand` acquires lock before `policyMapper.find(id)`. `SavePolicyCommand` releases after commit. `LockRemover` registered on HTTP session as `HttpSessionBindingListener`. Expiry: 120 minutes.
- Phase 4: Policy + Coverages + Endorsements + Named Insureds = aggregate → **Shared version token** (one `version` row per Policy; all members reference it). Lock on `version.id`.
- Phase 5: Multiple command objects in the application → **LockingMapper decorator** wraps all policy-family mappers. `find()` acquires; `update()` verifies lock held.
- UX: "Policy 12345 is being edited by Bob Smith (since 10:47 AM). Available after 12:47 PM. [Request force-release]"
**Output:** DB lock table, 120-minute expiry, LockingMapper, shared version token for Policy aggregate, admin `/admin/locks/policy/{id}/release` endpoint, `LockRemover` session listener.
---
### Example 2: Node.js CMS — Article Editing with Redis
**Scenario:** CMS where editors write and edit published articles. Sessions typically 20–30 minutes. Articles consist of Article + Sections + Tags + Metadata. Node.js, no JVM session management.
**Trigger:** "Add edit locking to prevent two editors opening the same article simultaneously."
**Process:**
- Phase 1: Stale reads acceptable (viewing out-of-date draft is OK) → **Exclusive write lock**.
- Phase 2: Redis already in stack → **Redis lock** with TTL. Key: `lock:article:{id}`, value: `{ ownerId, ownerName, acquiredAt }`, TTL: 2400s (40 min).
- Phase 3: `GET /articles/:id/edit` acquires Redis lock before loading. `PUT /articles/:id` releases after save. Express middleware registers session-end cleanup via `req.session.on('destroy', releaseAllLocksFor(sessionId))`. Heartbeat: client pings `/session/ping` every 30s; absence for 5 minutes triggers server-side expiry.
- Phase 4: Article + Sections + Tags + Metadata → use **root lock** on Article ID (all children navigate to Article as root). One Redis key per article covers the aggregate.
- Phase 5: All article repository methods go through `ArticleRepository` base class. `findForEdit(id)` acquires lock; `update(article)` asserts lock is held.
- UX: "This article is currently being edited by Jane (since 2:10 PM). Try again in ~25 minutes or ask Jane to release the lock."
**Output:** Redis lock with TTL + heartbeat, root lock on Article (aggregate), `ArticleRepository.findForEdit()` acquires implicitly, session-destroy listener.
---
### Example 3: Python/Django — Order-Picking System
**Scenario:** Warehouse pickers claim orders from a queue. Once a picker opens an order, it must be locked so two pickers don't pick the same items. Sessions are short (5–15 min, order completion). DB: PostgreSQL.
**Trigger:** "Two pickers sometimes pick the same order. Add a 'picked by X' locking mechanism with visible status."
**Process:**
- Phase 1: Only pickers who intend to edit (pick) need a lock; browsing the queue is read-only → **Exclusive write lock**.
- Phase 2: Single Postgres DB → **DB lock table**. Include `expires_at` for 30-minute absolute timeout.
- Phase 3: `POST /orders/{id}/claim` acquires lock before loading. `POST /orders/{id}/complete` releases. Django signal `request_started` + session middleware for release-on-session-end. Picker's name stored as `owner_id` (human-readable for UI).
- Phase 4: Order + LineItems + Inventory Reservations = aggregate. Use **root lock** on Order ID. All members navigable from Order. Lock the Order ID only.
- Phase 5: `OrderRepository.claim_for_picking(order_id, picker_id)` is the single entry point; implicit lock built in. No LockingMapper needed (single access path).
- UX: Order card in queue shows "PICKED BY: John D. (since 9:05 AM)" badge. Admin dashboard shows all active locks. Auto-released after 30 minutes if order not completed.
**Output:** `app_lock` table with `expires_at`, expiry sweep task, Order root lock, `claim_for_picking()` as single locked access point, UI "PICKED BY" badge, admin lock dashboard.
## References
- [Lock Table Schema and Storage Backends](references/lock-table-reference.md) — DDL for Postgres/MySQL/SQLite lock table; Redis key structure + TTL config; Zookeeper/etcd notes
- [Lock Manager Implementation Reference](references/lock-manager-reference.md) — Java, Python, and Node.js lock manager implementations; read/write lock state machine; atomic acquire patterns
- [Coarse-Grained Lock Reference](references/coarse-grained-lock-reference.md) — Shared version token implementation (Java example from Fowler); root lock navigation patterns; trade-offs between the two approaches
- [Anti-Pattern Detection Checklist](references/anti-pattern-checklist.md) — Grep patterns and code audit queries for each of the 8 anti-patterns with example buggy code and correct fix
## License
This skill is licensed under [CC-BY-SA-4.0](https://creativecommons.org/licenses/by-sa/4.0/).
Source: [BookForge](https://github.com/bookforge-ai/bookforge-skills) — Patterns of Enterprise Application Architecture by Martin Fowler, David Rice, Matthew Foemmel, Edward Hieatt, Robert Mee, Randy Stafford.
## Related BookForge Skills
Install related skills from ClawhHub:
- `clawhub install bookforge-offline-concurrency-strategy-selector`
- `clawhub install bookforge-transaction-isolation-level-auditor`
Or install the full book set from GitHub: [bookforge-skills](https://github.com/bookforge-ai/bookforge-skills)
Use when offline-concurrency-strategy-selector (or your team) has chosen Optimistic Offline Lock and you need to implement it correctly end-to-end. Handles:...
---
name: optimistic-offline-lock-implementer
description: "Use when offline-concurrency-strategy-selector (or your team) has chosen Optimistic Offline Lock and you need to implement it correctly end-to-end. Handles: adding a version column (integer, not timestamp), version-conditioned UPDATE and DELETE SQL (WHERE id=? AND version=?), row-count-zero collision detection, ConcurrencyException with modifiedBy+modified context, stale-version prevention, version round-tripping from server to client and back, Unit of Work commit integration (checkConsistentReads → insertNew → deleteRemoved → updateDirty with rollback on exception), ORM-native version support (@Version annotation JPA/Hibernate, [ConcurrencyCheck] or [Timestamp] EF Core, version_id_col SQLAlchemy, lock_version Rails, django-concurrency), and collision UX design (merge / force-save / abandon — not just a 409 error). Also handles: optimistic locking, optimistic offline lock, concurrent edit collision detection, lost update prevention, conditional update, concurrency version check, OptimisticLockException, DbUpdateConcurrencyException, StaleObjectError, inconsistent read protection, checkCurrent early-failure, anti-pattern audit (missing WHERE clause, non-incremented version, stale in-memory object retry, timestamp versioning). Produces an implementation plan covering schema, ORM config, version round-trip path, collision UX spec, test plan, and anti-pattern checklist."
version: 1.0.0
homepage: https://github.com/bookforge-ai/bookforge-skills/tree/main/books/patterns-of-enterprise-application-architecture/skills/optimistic-offline-lock-implementer
metadata: {"openclaw":{"emoji":"🔢","homepage":"https://github.com/bookforge-ai/bookforge-skills"}}
status: draft
source-books:
- id: patterns-of-enterprise-application-architecture
title: "Patterns of Enterprise Application Architecture"
authors:
- Martin Fowler
- David Rice
- Matthew Foemmel
- Edward Hieatt
- Robert Mee
- Randy Stafford
chapters: [5, 16]
domain: enterprise-application-architecture
tags:
- optimistic-locking
- concurrency
- transactions
- design-patterns
- data-integrity
- offline-lock
- lost-update-prevention
- version-column
depends-on:
- offline-concurrency-strategy-selector
execution:
tier: 2
mode: hybrid
inputs:
- type: codebase
description: "Persistence layer source code (mapper, repository, or ORM entity classes), database schema files, and any existing concurrency mechanism"
- type: user-description
description: "Stack and ORM in use, entities that need concurrent-edit protection, whether schema changes are possible, and whether client (web/mobile) must round-trip the version"
tools-required:
- Read
- Grep
- Edit
- Write
tools-optional:
- Glob
mcps-required: []
environment: "Application with at least one multi-request editing workflow (record loaded in one HTTP request, saved in a later one). Relational database with ORM or hand-rolled persistence. Stack-agnostic: Java/Spring, C#/.NET, Python, Ruby on Rails, Node.js all apply."
discovery:
goal: "Implement Optimistic Offline Lock with correct version-conditioned writes, collision detection, UX, and Unit of Work integration."
tasks:
- "Confirm offline-concurrency-strategy-selector chose Optimistic (or confirm directly from context)"
- "Identify all entities and tables requiring concurrent-edit protection"
- "Design or audit the version column (integer preferred; check ORM annotation requirements)"
- "Plumb version capture on read: domain object carries version; API/DTO includes version; client round-trips it"
- "Implement version-conditioned UPDATE and DELETE (WHERE id=? AND version=?); increment version on success"
- "Implement collision detection: check row count; on 0, re-query for modifiedBy+modified; throw ConcurrencyException with context"
- "Design collision UX: error message, merge view, force-save, or abandon options"
- "Integrate with Unit of Work commit sequence; ensure rollback on ConcurrencyException"
- "Audit for common anti-patterns: missing WHERE, non-incremented version, stale retry, timestamp versioning"
- "Write two-writer concurrency test"
- "Produce Optimistic Offline Lock implementation plan"
audience:
roles:
- backend-engineer
- software-architect
- tech-lead
experience: intermediate
when_to_use:
triggers:
- "offline-concurrency-strategy-selector output specifies Optimistic Offline Lock"
- "Implementing version-based concurrency control for the first time"
- "Auditing an existing optimistic lock implementation for correctness"
- "Getting 'lost update' bugs where one user's save overwrites another's silently"
- "Need to add @Version annotation, lock_version, or version_id_col to entities"
- "Designing the collision UX: what happens when two users edit the same record"
- "Integrating optimistic version checks into a Unit of Work commit"
- "Adding ORM-native optimistic locking to Hibernate, EF Core, SQLAlchemy, or Rails"
prerequisites:
- "offline-concurrency-strategy-selector has been run (or Optimistic is confirmed appropriate)"
not_for:
- "Systems where all business transactions fit in a single DB transaction — use isolation levels instead"
- "High-collision, high-rework-cost workflows — use pessimistic-offline-lock-implementer"
- "Thread-level concurrency within a single request (use language-level synchronization)"
environment:
codebase_required: false
codebase_helpful: true
works_offline: true
quality:
scores:
with_skill: null
baseline: null
delta: null
tested_at: null
eval_count: null
assertion_count: 13
iterations_needed: null
---
# Optimistic Offline Lock Implementer
## When to Use
This skill implements the Optimistic Offline Lock pattern after `offline-concurrency-strategy-selector` (or your team) has confirmed it is the right concurrency strategy. The pattern applies when a business transaction spans multiple HTTP requests — user loads a record, edits for seconds or minutes, then saves — and the collision frequency is low enough that detecting conflicts at commit time is acceptable.
**Do not use** if conflicts are frequent or rework cost is unacceptably high (use pessimistic locking instead). **Do not use** if the entire workflow fits in a single request/transaction (use database isolation levels).
## Context & Input Gathering
Collect this before proceeding. Grep the codebase or ask the user directly.
**Required:**
1. **Stack and ORM** — Java/Hibernate, C#/EF Core, Python/SQLAlchemy, Ruby on Rails, Node.js/Knex, or hand-rolled SQL?
2. **Entities needing protection** — which tables/domain classes have concurrent-edit risk?
3. **Schema mutability** — can version columns be added, or is the schema frozen (legacy)?
4. **Client round-trip** — does a web/mobile client need to hold the version between GET and PUT/POST?
5. **Unit of Work present?** — is there an explicit UoW or does each repository method own its transaction?
6. **Inconsistent read risk?** — are there reads whose correctness the commit depends on (not just writes)?
**Observable from codebase:**
- Existing `version` or `lock_version` columns → implementation may be partially in place; audit for correctness
- `@Version`, `[ConcurrencyCheck]`, `version_id_col` annotations → ORM-native path
- UPDATE statements without WHERE version clause → lost-update vulnerability
- API response DTOs → check whether `version` field is included
**Sufficiency:** If ORM and entity list are known, proceed. If schema is frozen, plan the all-fields-WHERE fallback (see References).
---
## Process
### Step 1 — Confirm strategy selection
Verify that `offline-concurrency-strategy-selector` chose Optimistic Offline Lock for this workflow.
- IF the selector skill was not run → invoke `offline-concurrency-strategy-selector` first, OR ask: "Is collision frequency low AND is rework cost acceptable if a save is rejected?" If yes, Optimistic applies.
- IF Pessimistic was chosen → use `pessimistic-offline-lock-implementer` instead.
**WHY:** This skill implements Optimistic-specific mechanics. Applying it to a high-collision, high-rework-cost workflow produces a poor user experience — users lose significant work on every collision.
---
### Step 2 — Identify entities and design the version column
List every entity (table) that participates in concurrent-edit business transactions.
For each entity:
**Integer version column (primary approach):**
```sql
ALTER TABLE {table} ADD COLUMN version INTEGER NOT NULL DEFAULT 0;
ALTER TABLE {table} ADD COLUMN modified_by VARCHAR(255);
ALTER TABLE {table} ADD COLUMN modified_at TIMESTAMP;
```
**ORM-native equivalents (preferred when ORM is in use):**
| ORM | Annotation / Config | Exception thrown |
|-----|---------------------|-----------------|
| Hibernate / JPA | `@Version` on `int version` field | `OptimisticLockException` |
| EF Core (.NET) | `[ConcurrencyCheck]` on field, or `[Timestamp]` (rowversion) | `DbUpdateConcurrencyException` |
| SQLAlchemy | `__mapper_args__ = {"version_id_col": version_col}` | `StaleDataError` |
| Rails ActiveRecord | `lock_version INTEGER DEFAULT 0` column (auto-detected) | `ActiveRecord::StaleObjectError` |
| Django (no built-in) | Manual: filter on version + check `updated_count`, or `django-concurrency` package | Custom exception |
**WHY:** Using an integer version is deterministic and monotonically increasing. Timestamps are "simply too unreliable, especially if you're coordinating across multiple servers" (Fowler) — clock skew causes missed conflicts and false collisions. The `modified_by` and `modified_at` columns are not used in the WHERE clause; they provide context for the error message shown to the user.
If the schema cannot be changed → fall back to the all-fields WHERE clause approach (see References).
---
### Step 3 — Plumb version capture on read
Every place the entity is loaded must capture and carry the version.
**Domain object / entity:** The version field must be part of the object, not a separate variable. This ensures the Identity Map returns the same version consistently within a business transaction.
```java
// Java domain object base class
class DomainObject {
private int version;
private String modifiedBy;
private Timestamp modifiedAt;
// setSystemFields() called by mapper after load — NOT constructor
}
```
**API/DTO round-trip (for web/mobile clients):** If a client loads the record in one HTTP request and saves in another, the version must travel to the client and back.
```json
// GET /customers/42
{ "id": 42, "name": "Smith", "version": 7, ... }
// PUT /customers/42
{ "id": 42, "name": "Smythe", "version": 7, ... }
```
The server extracts `version` from the request body and uses it in the WHERE clause.
**WHY:** If the version is not round-tripped, the server has no way to know what version the client read. It would either have to reject all saves (too strict) or skip the check (defeats the pattern). The client must be a faithful carrier of the version it received.
**Identity Map check:** In the persistence layer, `find(id)` must check the Identity Map before querying the DB. Loading the same record twice at different version values within one business transaction produces undefined behavior in version checks.
---
### Step 4 — Implement version-conditioned writes
**UPDATE (modify):**
```sql
UPDATE customer
SET name = ?, modified_by = ?, modified_at = ?, version = ?
WHERE id = ? AND version = ?
-- Parameters: (newName, currentUser, now, oldVersion+1, id, oldVersion)
```
**DELETE:**
```sql
DELETE FROM customer WHERE id = ? AND version = ?
-- Parameters: (id, oldVersion)
```
**Check row count immediately:**
```java
int rowCount = stmt.executeUpdate();
if (rowCount == 0) {
throwConcurrencyException(object); // see Step 5
}
```
**For ORM stacks:** Enable the built-in version field (Step 2 annotation). The ORM generates version-conditioned SQL automatically and throws its native exception. Catch that exception at the service/controller boundary (Step 5).
**WHY:** The WHERE clause atomically validates the version AND applies the data change in a single SQL statement. This is the only reliable way to ensure "nobody changed this between my read and my write" without holding a database lock across user think-time. A separate SELECT + UPDATE pair has a TOCTOU race condition.
The version increment (`version = oldVersion + 1`) is essential: it marks the row as changed so any concurrent session holding the old version will see row count 0 on their save.
---
### Step 5 — Implement collision detection and error context
When row count = 0, determine WHY before throwing:
```java
protected void throwConcurrencyException(DomainObject obj) {
// Re-query to differentiate "modified" from "deleted"
ResultSet rs = execute("SELECT version, modified_by, modified_at FROM customer WHERE id=?", obj.getId());
if (rs.next()) {
int dbVersion = rs.getInt("version");
String who = rs.getString("modified_by");
Timestamp when = rs.getTimestamp("modified_at");
throw new ConcurrencyException(
"Customer " + obj.getId() + " was modified by " + who +
" at " + format(when) + ". Please reload and re-apply your changes."
);
} else {
throw new ConcurrencyException(
"Customer " + obj.getId() + " was deleted by another session."
);
}
}
```
**WHY:** A bare "concurrency error" leaves the user confused and helpless. Informing them WHO changed the record and WHEN lets them make an informed decision: was it a conflicting edit or just a background sync? The re-query is safe here because it runs inside the same system transaction that is about to be rolled back — it reads the committed DB state.
---
### Step 6 — Design collision handling UX
Catching the exception at the controller/service boundary is not enough — the user must understand what happened and have a meaningful path forward.
**Minimum acceptable UX (abort + inform):**
> "This record was modified by Alice at 2:34 PM. Your changes could not be saved. Please reload the record and re-apply your changes."
> [Reload] [Cancel]
**Better UX (show conflict):**
Show the user's proposed changes alongside the current DB state side by side. Let them choose which values to keep field by field.
**Advanced UX (merge or force-save):**
- **Merge:** auto-merge non-overlapping field changes (works when two users edited different fields — similar to how source control merges non-conflicting lines).
- **Force-save:** user explicitly accepts that their version will overwrite the current DB state. Requires a deliberate action (not the default). Load the latest version, then re-apply the user's changes on top.
**API semantics:** Return HTTP 409 Conflict with a body that includes `currentVersion`, `modifiedBy`, `modifiedAt`, and the conflicting field values.
**WHY:** "A proper application will tell when the record was altered and by whom" (Fowler). Silent failure or a bare error code is the most common implementation mistake — users lose work and don't know why. The quality of the collision UX often determines whether Optimistic Locking is tolerable in practice.
---
### Step 7 — Integrate with Unit of Work commit
If the system uses a Unit of Work pattern, the commit sequence must:
```
checkConsistentReads() ← version-check read-set objects (if inconsistent read protection needed)
insertNew()
deleteRemoved()
updateDirty() ← version-conditioned UPDATEs live here
```
On ANY `ConcurrencyException` during commit: **rollback the system transaction before re-throwing.**
```java
public void commit() {
try {
checkConsistentReads();
insertNew();
deleteRemoved();
updateDirty();
} catch (ConcurrencyException e) {
rollbackSystemTransaction(); // CRITICAL — do not forget
throw e;
}
}
```
**Inconsistent read protection** (optional but recommended): if the commit's correctness depends on a value that was READ but not WRITTEN (e.g., customer address used for tax calculation), register that object in a read-set and version-check it at commit:
```java
public void registerRead(DomainObject obj) { reads.add(obj); }
public void checkConsistentReads() {
for (DomainObject obj : reads) {
obj.getVersion().increment(); // forces version check even for read-only objects
}
}
```
**WHY:** Without rollback, partial writes enter the database — some records updated, others not — leaving data in an inconsistent state. The UoW is the natural integration point because it already owns the system transaction boundary and the commit sequence.
If the Unit of Work skill (`unit-of-work-implementer`) is available, reference its commit sequence and add version-conditioned writes in the `updateDirty()` and `deleteRemoved()` stages. If that skill has not been run, implement the commit loop directly in the repository or service class.
---
### Step 8 — Audit for common anti-patterns
Run this checklist against the codebase before marking implementation complete:
- [ ] **Every UPDATE and DELETE on versioned tables includes `AND version=?`** — grep for `UPDATE {table}` and verify WHERE clause
- [ ] **Version is incremented on every successful save** — verify `version = version+1` (or ORM equivalent), not `version = version`
- [ ] **Row count is checked after every UPDATE/DELETE** — no unchecked `executeUpdate()` calls
- [ ] **Collision error message includes who and when** — not just "concurrency error"
- [ ] **Version is included in API responses and request bodies** (for web clients) — check DTO classes and OpenAPI spec
- [ ] **No timestamp used as version substitute** — search for `modified_at` or `updated_at` in WHERE clauses
- [ ] **Retry logic (if any) reloads the object first** — stale in-memory object with old version must not be reused directly
- [ ] **No raw SQL UPDATE paths that bypass ORM version mechanism** — grep for raw SQL on ORM-managed tables
- [ ] **System transaction is rolled back on ConcurrencyException** — not just re-thrown
**WHY:** Each of these is a known failure mode. Missing the WHERE clause is the most dangerous — it silently permits Lost Updates, the exact problem this pattern exists to prevent. Non-incremented version means subsequent writers see no new version and can overwrite without conflict. Stale retry immediately throws another collision without making progress.
---
### Step 9 — Write the two-writer concurrency test
A test that exercises the actual collision scenario:
```python
# Pseudocode — adapt to your framework's test infrastructure
def test_optimistic_lock_collision():
record = create_record(name="original", version=0)
# Session A loads at version 0
session_a_record = load(record.id) # version=0
# Session B loads at version 0, edits, saves first
session_b_record = load(record.id) # version=0
session_b_record.name = "session_b_edit"
save(session_b_record) # succeeds, version becomes 1
# Session A tries to save — version 0 no longer matches DB version 1
session_a_record.name = "session_a_edit"
with raises(ConcurrencyException):
save(session_a_record) # must raise — row count = 0
# Verify DB holds session B's value, not session A's
current = load(record.id)
assert current.name == "session_b_edit"
assert current.version == 1
```
**WHY:** Unit tests on individual UPDATE SQL are insufficient. The collision scenario requires two independent sessions reading the same version and one writing first. Without this test, it is easy to ship an implementation that increments correctly in isolation but fails to detect collisions in practice (e.g., version not in WHERE clause but version correctly incremented).
---
### Step 10 — Produce the implementation plan artifact
Output the Optimistic Offline Lock Implementation Plan (see Outputs section).
**WHY:** A written plan consolidates all decisions made in Steps 1–9 into a reviewable artifact. It serves as the spec for implementation (or review checklist for existing code), makes the anti-pattern audit explicit, and gives the team a shared reference for schema changes, ORM config, and UX behavior.
## Inputs
- Entity/table list requiring concurrent-edit protection
- Stack, ORM, and language (determines implementation path)
- Schema mutability (can version columns be added?)
- API contract (does client need to round-trip version?)
- Existing persistence code (mapper, repository, or ORM entities)
- Unit of Work implementation (if present)
## Outputs
### Optimistic Offline Lock Implementation Plan
```markdown
## Optimistic Offline Lock Implementation Plan: [Feature/Entity Name]
**Date:** YYYY-MM-DD | **Stack:** [ORM / language] | **Entities:** [list]
### 1. Schema Changes
- [ ] ALTER TABLE {table} ADD COLUMN version INTEGER NOT NULL DEFAULT 0;
- [ ] ALTER TABLE {table} ADD COLUMN modified_by VARCHAR(255);
- [ ] ALTER TABLE {table} ADD COLUMN modified_at TIMESTAMP;
### 2. ORM Config
[@Version / [ConcurrencyCheck] / version_id_col / lock_version]
### 3. Version Round-Trip Path
- GET /resource/{id} → includes "version" in response
- PUT /resource/{id} → client sends "version" back in body
- Server extracts version from: [request.body.version / session state]
### 4. Write Implementation
- UPDATE: WHERE id=? AND version=? with version=oldVersion+1
- DELETE: WHERE id=? AND version=?
- Row-count check: rowCount == 0 → throwConcurrencyException()
### 5. Collision Handling
- Exception: [ConcurrencyException / OptimisticLockException / custom]
- Re-query: SELECT version, modified_by, modified_at WHERE id=?
- UX: [error+reload / diff view / force-save] | API: 409 Conflict
### 6. Unit of Work Integration
- Commit: checkConsistentReads → insertNew → deleteRemoved → updateDirty
- Rollback on ConcurrencyException: [yes / repository-per-request N/A]
### 7. Anti-Pattern Checklist
[Completed from Step 8]
### 8. Test Plan
- [ ] Two-writer test: session B saves first → session A gets ConcurrencyException
- [ ] Delete collision: record deleted → "deleted" message
- [ ] Retry: reload + re-save succeeds after collision
```
## Key Principles
**1. The version WHERE clause is the lock — it must be in every UPDATE and DELETE.**
A version column stored and incremented but absent from the WHERE clause provides zero protection. The atomic "validate + write" in a single SQL statement is what makes the pattern work without holding a DB connection across user think-time. Any write path missing the version WHERE clause silently permits Lost Updates.
**2. Integer version, not timestamp — always.**
System clocks are unreliable across servers. Sub-millisecond updates can share a timestamp. An integer version is monotonically increasing and immune to clock skew. Fowler: "system clocks are simply too unreliable, especially if you're coordinating across multiple servers."
**3. Round-trip the version — client must carry it faithfully.**
The version must travel from the GET response to the PUT/POST request body. A server that re-reads the version from DB at save time defeats the pattern — it always gets the latest version and never detects conflicts.
**4. The collision message must name who and when.**
"Your save failed" is not acceptable. Users need to know WHO changed the record and WHEN. Store `modified_by` and `modified_at` and re-query on collision. Implementing only the technical check without actionable UX is a half-implementation.
**5. Roll back the system transaction on collision — without exception.**
If any write fails the version check, roll back the system transaction before re-throwing. Partial commits leave data inconsistent (some records written, others not). Fowler: "Do not forget this step!"
**6. Stale retry fails again — always reload before retry.**
Catching ConcurrencyException and re-saving the same domain object immediately fails again (it still holds the old version). Reload the object from DB first, re-apply changes, then save.
**7. Move version mechanics into the abstract mapper — Implicit Lock prevents gaps.**
One developer omitting a version check on one entity silently breaks the scheme for that entity. The abstract `AbstractMapper` supertype (or ORM base config) makes version-conditioned writes mandatory — concrete mappers cannot accidentally skip them.
## Examples
### Example 1: Java/Spring Data JPA — CRM Customer Entity
**Scenario:** CRM where 15 sales reps edit customer records, 2–5 min sessions, low collision rate. Optimistic chosen.
**Trigger:** "Lost updates on customer records when two reps edit the same customer."
**Process:**
- Step 2: Add `@Version private int version;` to `Customer` entity. Hibernate auto-generates version-conditioned SQL.
- Step 3: Include `"version": 7` in `CustomerDTO`. Controller extracts it from PUT body.
- Step 4: `customerRepository.save(customer)` → Hibernate issues `UPDATE customer SET ... WHERE id=? AND version=?`.
- Step 5: Catch `OptimisticLockException` in `@ExceptionHandler`. Re-query for `modifiedBy`/`modifiedAt`. Return HTTP 409.
- Step 6: UI shows: "This customer was updated by Bob at 3:12 PM. Please reload and re-apply your changes."
- Step 7: `@Transactional` handles rollback automatically.
**Output:** `@Version` on Customer entity, DTO round-trips version, 409 handler with who/when, two-writer test.
---
### Example 2: Ruby on Rails — Inventory Management
**Scenario:** Warehouse staff edit product records via Rails admin. 1–3 min sessions.
**Trigger:** "Two staff sometimes update the same product simultaneously — add optimistic locking."
**Process:**
- Step 2: `rails generate migration AddLockVersionToProducts lock_version:integer`. Rails auto-detects `lock_version`.
- Step 3: Hidden field in form: `<%= f.hidden_field :lock_version %>`. Permit in strong params.
- Step 4: `product.update!(params)` → Rails: `UPDATE products SET ... WHERE id=? AND lock_version=?`.
- Step 5: `rescue ActiveRecord::StaleObjectError` in controller. Re-query for `updated_by`/`updated_at`.
- Step 6: Flash: "Someone else updated this product. Here's what changed — please review and re-submit."
**Output:** Migration, hidden field, `rescue StaleObjectError`, diff-style conflict display.
---
### Example 3: Node.js + Knex — Custom Repository
**Scenario:** Node.js SaaS, hand-rolled Knex repositories, no ORM. Entities: `contracts`, `line_items`.
**Trigger:** "Add version-based optimistic locking to our contract editing API — no concurrency protection today."
**Process:**
- Step 2: `ALTER TABLE contracts ADD COLUMN version INTEGER NOT NULL DEFAULT 0`, `modified_by`, `modified_at`.
- Step 3: GET response includes `version`. PUT body sends it back. Repository receives `claimedVersion`.
- Step 4: `knex('contracts').where({ id, version: claimedVersion }).update({ ...fields, version: claimedVersion+1 })` → check `count === 0`.
- Step 5: `ConcurrentModificationError` re-queries `modified_by`/`modified_at` and attaches to error.
- Step 6: Express error handler returns 409 `{ error: "Conflict", modifiedBy, modifiedAt }`.
- Step 7: Multi-table commits use `knex.transaction()` with rollback on `ConcurrentModificationError`.
**Output:** Migration, version-conditioned repository, `ConcurrentModificationError`, 409 handler, two-writer test.
## References
- [Version Column SQL and ORM Config Reference](references/version-column-reference.md) — SQL DDL for version columns, ORM-specific annotations and config, all-fields WHERE fallback for frozen schemas
- [Collision UX Patterns](references/collision-ux-patterns.md) — abort+inform, diff view, merge, force-save — with UI copy templates and API 409 response schema
- [Anti-Pattern Detection Checklist](references/anti-pattern-checklist.md) — grep patterns and code audit queries for each of the 9 anti-patterns, with example buggy code and correct fix
- [Unit of Work Integration Guide](references/unit-of-work-integration.md) — commit sequence, read-set registration, rollback wiring, and Java example from the book
## License
This skill is licensed under [CC-BY-SA-4.0](https://creativecommons.org/licenses/by-sa/4.0/).
Source: [BookForge](https://github.com/bookforge-ai/bookforge-skills) — Patterns of Enterprise Application Architecture by Martin Fowler, David Rice, Matthew Foemmel, Edward Hieatt, Robert Mee, Randy Stafford.
## Related BookForge Skills
Install related skills from ClawhHub:
- `clawhub install bookforge-offline-concurrency-strategy-selector`
- `clawhub install bookforge-unit-of-work-implementer`
- `clawhub install bookforge-transaction-isolation-level-auditor`
Or install the full book set from GitHub: [bookforge-skills](https://github.com/bookforge-ai/bookforge-skills)
FILE:references/anti-pattern-checklist.md
# Anti-Pattern Detection Checklist
Run this checklist on any Optimistic Offline Lock implementation — new or existing.
---
## AP-1: Version Not in UPDATE/DELETE WHERE Clause
**Risk:** Critical — Lost Updates occur silently.
**Detection:**
```bash
# Find UPDATE statements on versioned tables that lack AND version=
grep -rn "UPDATE customer" src/ | grep -v "AND version"
grep -rn "DELETE FROM customer" src/ | grep -v "AND version"
```
**Correct:**
```sql
UPDATE customer SET name=?, version=? WHERE id=? AND version=?
```
**Broken:**
```sql
UPDATE customer SET name=?, version=? WHERE id=? -- version check missing
```
---
## AP-2: Version Not Incremented on Save
**Risk:** High — Subsequent writers see the same version, all saves succeed without conflict.
**Detection:** Check that the new version value passed to UPDATE is `oldVersion + 1`, not `oldVersion`:
```bash
grep -rn "version = ?" src/ | grep -v "version + 1\|version+1\|version=version+1"
```
**Correct:** `version = oldVersion + 1` or `@Version`-managed auto-increment.
**Broken:** `UPDATE ... SET version=? WHERE id=? AND version=?` with params `(oldVersion, id, oldVersion)`.
---
## AP-3: Row Count Not Checked
**Risk:** High — Collisions go silently undetected.
**Detection:**
```bash
# Find executeUpdate() or execute() calls not followed by a row count check
grep -n "executeUpdate\|stmt.execute" src/ | grep -v "rowCount\|affected\|count"
```
Every `UPDATE` and `DELETE` on a versioned table must be followed by `if (rowCount == 0) throwConcurrencyException(...)`.
---
## AP-4: Timestamp Used as Version
**Risk:** Medium — Sub-millisecond updates or cross-server clock skew cause missed conflicts.
**Detection:**
```bash
grep -rn "WHERE.*modified_at\|WHERE.*updated_at\|WHERE.*last_modified" src/
```
Replace with an integer `version` column in the WHERE clause. Keep `modified_at` as an informational column only (for error messages).
---
## AP-5: Stale In-Memory Object Retried After Collision
**Risk:** High — Retry loop immediately fails again; may loop indefinitely.
**Detection:** Look for catch blocks that call save() again without reloading the entity:
```java
// BROKEN
try {
save(customer);
} catch (ConcurrencyException e) {
save(customer); // same object, same old version — will fail again
}
// CORRECT
try {
save(customer);
} catch (ConcurrencyException e) {
customer = reload(customer.getId()); // get current version from DB
customer.applyChanges(pendingChanges);
save(customer);
}
```
---
## AP-6: Version Not Round-Tripped to Client
**Risk:** High — Server cannot detect conflicts for web/mobile clients.
**Detection:**
```bash
# Check that version appears in API response DTOs and request body handling
grep -rn "class.*DTO\|class.*Request" src/ | xargs grep -l "version"
```
GET responses must include `version`. PUT/POST request bodies must accept and use `version`. Strong parameters / model binders must permit `version`.
---
## AP-7: No Rollback on ConcurrencyException
**Risk:** High — Partial commits leave data inconsistent.
**Detection:**
```bash
grep -rn "catch.*ConcurrencyException\|catch.*OptimisticLock\|catch.*StaleObject" src/
```
Verify the catch block calls `transaction.rollback()` or `session.rollback()` before re-throwing.
---
## AP-8: Raw SQL Bypasses ORM Version Mechanism
**Risk:** High — ORM's @Version protection is invisible to raw SQL writes.
**Detection:**
```bash
# Find raw SQL UPDATE statements in ORM-managed projects
grep -rn "nativeQuery\|createNativeQuery\|execute_sql\|raw_sql" src/
grep -rn "update_columns\|update_all" app/ # Rails bypasses lock_version
```
Any raw SQL write to an ORM-managed table must manually include the version WHERE clause and check row count.
---
## AP-9: Identity Map Not Used — Multiple Versions in One Session
**Risk:** Medium — Two loads of the same record in one business transaction return different version values, making version checks non-deterministic.
**Detection:** Check that `find(id)` checks a session-scoped Identity Map before querying DB. Most ORMs handle this automatically (Session/DbContext cache). Hand-rolled mappers may not.
**Correct:** `find()` returns the cached object if already loaded in this business transaction.
---
## License
CC-BY-SA-4.0 — Source: BookForge / Patterns of Enterprise Application Architecture by Fowler et al.
FILE:references/collision-ux-patterns.md
# Collision UX Patterns
When Optimistic Offline Lock detects a conflict (row count = 0), the user must receive actionable information. Three levels of UX quality, from minimum to ideal.
---
## Level 1 — Abort + Inform (Minimum Acceptable)
Re-query for `modified_by` and `modified_at` before throwing. Display:
> **Your changes could not be saved.**
> This record was last modified by **{modifiedBy}** at **{modifiedAt}**.
> Please reload and re-apply your changes.
>
> [Reload Record] [Cancel — Discard My Changes]
**What NOT to say:**
- "A concurrency error occurred." (no context)
- "Please try again." (unhelpful without reload)
- "Error 409." (technical, user-hostile)
**API 409 response schema:**
```json
{
"error": "Conflict",
"message": "Record was modified by Alice at 2025-04-20T14:34:00Z",
"currentVersion": 8,
"modifiedBy": "[email protected]",
"modifiedAt": "2025-04-20T14:34:00Z"
}
```
---
## Level 2 — Show Conflict Diff
After detecting a collision, fetch both versions:
- **Their version** (current DB state, version N)
- **Your proposed changes** (what the user submitted)
Display side-by-side or highlighted diff:
| Field | Current Value (Alice's save) | Your Proposed Value |
|-------|------------------------------|---------------------|
| Name | Smith Jr. | Smythe |
| Phone | 555-1234 (unchanged) | 555-1234 (unchanged)|
| Email | [email protected] | [email protected] |
Actions: [Apply My Changes] [Keep Current] [Pick Per Field] [Cancel]
---
## Level 3 — Merge (Most Powerful, Most Complex)
Auto-merge non-conflicting field changes. Only surface the user for genuinely conflicting fields (both sessions changed the same field to different values).
Logic:
```
For each field:
if onlyUserChanged(field): auto-apply user's value
if onlyTheirChanged(field): auto-apply their value
if bothChanged(field): present conflict to user
if neitherChanged(field): keep as-is
```
Fowler on merge: "A quality merge strategy makes Optimistic Offline Lock very powerful … users rarely have to redo any work." Notes that enterprise business objects can merge — it is "a pattern unto its own" and per-entity complexity varies.
Implementation cost: high. Each entity type needs a merge strategy. Reserve for high-value, frequently-edited records.
---
## Force-Save
Allow the user to explicitly overwrite the current DB state with their version. Must be intentional — never the default.
Implementation:
1. User triggers "Save Anyway" action.
2. Client re-sends request with the CURRENT version (fetched after collision, not the original).
3. Server applies user's changes on top of the current version.
```
GET /customers/42 → version: 8 (current after Alice's save)
PUT /customers/42 → body: { ...user's fields, version: 8 }
```
The force-save succeeds because the client now holds the latest version. Functionally, the user is saying "I know Alice changed this; overwrite with my values."
---
## Early Conflict Detection: checkCurrent
For long-running workflows, detect conflicts early (before commit):
```java
public boolean checkCurrent(DomainObject obj) {
int dbVersion = queryVersion(obj.getId());
return dbVersion == obj.getVersion(); // false = someone changed it
}
```
Call this at natural pause points in the workflow (e.g., between steps of a multi-page wizard). If a conflict is already visible, fail early and save the user from completing 20 more minutes of work that will not commit.
Caveat: `checkCurrent` never guarantees success at commit time. It is an early-warning mechanism, not a guarantee.
---
## License
CC-BY-SA-4.0 — Source: BookForge / Patterns of Enterprise Application Architecture by Fowler et al.
FILE:references/unit-of-work-integration.md
# Unit of Work Integration Guide
## Commit Sequence
The Unit of Work (UoW) owns the system transaction boundary. Optimistic Offline Lock checks must run inside the same system transaction that commits the data writes. The sequence from the book:
```
checkConsistentReads()
insertNew()
deleteRemoved()
updateDirty() ← version-conditioned UPDATEs happen here
```
## Rollback Is Mandatory
```java
public void commit() {
try {
checkConsistentReads();
insertNew();
deleteRemoved();
updateDirty();
} catch (ConcurrencyException e) {
rollbackSystemTransaction(); // MUST happen before re-throw
throw e; // propagate to caller/controller
}
}
```
Fowler: "Do not forget this step!" Without rollback, partial writes (some records updated, others not) enter the database in an inconsistent state.
## Inconsistent Read Protection (Optional)
Standard version checks cover the change set (objects that were modified). If the commit's correctness depends on objects that were READ but not WRITTEN (e.g., reading customer address to calculate tax), register them in a read-set:
```java
// Register a read-only dependency
public void registerRead(DomainObject obj) {
reads.add(obj);
}
// Check at commit time
public void checkConsistentReads() {
for (DomainObject obj : reads) {
obj.getVersion().increment(); // aggressive: increments even for read-only
}
}
```
**Why increment (not just re-read)?** Re-reading the version requires repeatable-read or stronger isolation to avoid false positives. Since we can't always guarantee the isolation level, incrementing the version forces the check to work at any isolation level. The trade-off: it marks the object as modified in the DB even though no business data changed.
**Alternative (less aggressive):** Add read objects to the change set. The mapper re-reads their version at commit and throws ConcurrencyException if changed, without incrementing. Requires repeatable-read isolation.
## Integration with unit-of-work-implementer Skill
If `unit-of-work-implementer` has been applied to this codebase:
1. Locate the `commit()` method in the UoW class.
2. Add version-conditioned SQL in the `updateDirty()` loop (or ensure the mapper's `update()` method already includes it).
3. Wrap the entire `commit()` body in a try/catch that rolls back on `ConcurrencyException`.
4. Optionally add `registerRead()` and `checkConsistentReads()` for inconsistent read protection.
If `unit-of-work-implementer` has not been applied: implement the rollback wrapper at the repository or service class level wherever `save()` is called.
## Java Example (from the book, abbreviated)
```java
class UnitOfWork {
private List<DomainObject> dirty = new ArrayList<>();
private List<DomainObject> reads = new ArrayList<>();
public void registerDirty(DomainObject obj) { dirty.add(obj); }
public void registerRead(DomainObject obj) { reads.add(obj); }
public void commit() {
Connection conn = ConnectionManager.INSTANCE.getConnection();
try {
conn.setAutoCommit(false);
checkConsistentReads();
insertNew();
deleteRemoved();
updateDirty();
conn.commit();
} catch (ConcurrencyException e) {
conn.rollback(); // rollback BEFORE re-throw
throw e;
} catch (Exception e) {
conn.rollback();
throw new SystemException("commit failed", e);
}
}
private void checkConsistentReads() {
for (DomainObject obj : reads) {
obj.getVersion().increment(); // version check via increment
}
}
private void updateDirty() {
for (DomainObject obj : dirty) {
mapperFor(obj).update(obj); // mapper issues version-conditioned UPDATE
}
}
}
```
## License
CC-BY-SA-4.0 — Source: BookForge / Patterns of Enterprise Application Architecture by Fowler et al.
FILE:references/version-column-reference.md
# Version Column SQL and ORM Config Reference
## Standard Schema (SQL)
```sql
-- Add to every table requiring concurrent-edit protection
ALTER TABLE {table}
ADD COLUMN version INTEGER NOT NULL DEFAULT 0,
ADD COLUMN modified_by VARCHAR(255),
ADD COLUMN modified_at TIMESTAMP;
```
`modified_by` and `modified_at` are NOT used in the WHERE clause. They provide context for the collision error message.
## Version-Conditioned SQL Statements
### UPDATE
```sql
UPDATE {table}
SET field1 = ?,
...,
modified_by = ?,
modified_at = ?,
version = ? -- new value: old_version + 1
WHERE id = ?
AND version = ?; -- claimed (loaded) version
```
Parameters: `(field1, ..., currentUser, now, oldVersion+1, id, oldVersion)`
### DELETE
```sql
DELETE FROM {table}
WHERE id = ?
AND version = ?;
```
### Re-query on Collision (row count = 0)
```sql
SELECT version, modified_by, modified_at
FROM {table}
WHERE id = ?;
```
- Row returned → record was modified by someone else. Use `modified_by` and `modified_at` in error message.
- No row returned → record was deleted by another session.
---
## ORM-Native Configuration
### Hibernate / JPA (Java)
```java
@Entity
public class Customer extends DomainObject {
@Version
private int version;
@Column(name = "modified_by")
private String modifiedBy;
@Column(name = "modified_at")
private Instant modifiedAt;
}
```
Hibernate auto-generates:
```sql
UPDATE customer SET name=?, modified_by=?, modified_at=?, version=?
WHERE id=? AND version=?
```
Exception: `javax.persistence.OptimisticLockException` (wraps `StaleObjectStateException`).
Catch at service/controller boundary:
```java
@ExceptionHandler(OptimisticLockException.class)
public ResponseEntity<?> handleOptimisticLock(OptimisticLockException ex) {
// Re-query for modifiedBy + modifiedAt; return 409
}
```
### EF Core (.NET)
**Option A — ConcurrencyCheck on a version int:**
```csharp
[ConcurrencyCheck]
public int Version { get; set; }
```
**Option B — rowversion (SQL Server) / timestamp (PostgreSQL xmin):**
```csharp
[Timestamp]
public byte[] RowVersion { get; set; }
```
Exception: `Microsoft.EntityFrameworkCore.DbUpdateConcurrencyException`.
### SQLAlchemy (Python)
```python
from sqlalchemy import Column, Integer
from sqlalchemy.orm import declarative_base
Base = declarative_base()
class Customer(Base):
__tablename__ = "customer"
__mapper_args__ = {"version_id_col": version}
id = Column(Integer, primary_key=True)
version = Column(Integer, nullable=False, default=0)
# ... other fields
```
Exception: `sqlalchemy.orm.exc.StaleDataError`.
### Rails ActiveRecord
Add migration:
```ruby
add_column :customers, :lock_version, :integer, default: 0, null: false
```
Rails auto-detects `lock_version` column — no model code needed. Exception: `ActiveRecord::StaleObjectError`.
Form must include: `<%= f.hidden_field :lock_version %>`
Strong params must permit: `:lock_version`
### Django (manual implementation)
Django has no built-in optimistic locking. Two approaches:
**Approach A — Filtered update:**
```python
updated = Customer.objects.filter(pk=pk, version=claimed_version).update(
name=new_name,
version=claimed_version + 1,
modified_by=request.user,
modified_at=timezone.now()
)
if updated == 0:
raise ConcurrentModificationError(pk, claimed_version)
```
**Approach B — django-concurrency package:**
```python
from concurrency.fields import IntegerVersionField
class Customer(models.Model):
version = IntegerVersionField()
```
---
## All-Fields WHERE Clause (Frozen Schema Fallback)
When the schema cannot be altered (legacy system), omit the version column and include every field in the WHERE clause:
```sql
UPDATE customer
SET name = ?, phone = ?, email = ?
WHERE id = ?
AND name = ? -- original loaded value
AND phone = ?
AND email = ?;
```
Drawbacks:
- Larger WHERE clause may lose PK index benefits (database-dependent)
- More complex SQL construction; harder to detect which field caused the conflict
- Cannot distinguish "deleted" from "modified to match" if all fields happen to match a later insert
Use only when schema modification is truly impossible.
---
## License
CC-BY-SA-4.0 — Source: BookForge / Patterns of Enterprise Application Architecture by Fowler et al.
Use when designing concurrency control for long-running edits where a business transaction spans multiple system transactions — user opens a record, edits fo...
---
name: offline-concurrency-strategy-selector
description: "Use when designing concurrency control for long-running edits where a business transaction spans multiple system transactions — user opens a record, edits for minutes or hours, then saves. Selects between optimistic locking (version column, collision detection at commit) vs pessimistic locking (record check-out, conflict prevention at load time) and decides whether to add Coarse-Grained Lock (aggregate-root lock for multi-object edits) and Implicit Lock (framework-enforced locking to prevent gaps). Handles: lost update prevention, concurrent edit collision detection, offline lock strategy, long-running transaction concurrency, version column design, lock table design, lock timeout policy, aggregate lock, editing concurrency, lock type selection (exclusive-read vs exclusive-write vs read-write). Diagnoses mis-configurations: DB-level locks held across user think-time, implicit-lock gaps, optimistic/pessimistic mixing on overlapping data, timestamp-based versioning pitfalls."
version: 1.0.0
homepage: https://github.com/bookforge-ai/bookforge-skills/tree/main/books/patterns-of-enterprise-application-architecture/skills/offline-concurrency-strategy-selector
metadata: {"openclaw":{"emoji":"🔒","homepage":"https://github.com/bookforge-ai/bookforge-skills"}}
status: draft
source-books:
- id: patterns-of-enterprise-application-architecture
title: "Patterns of Enterprise Application Architecture"
authors:
- Martin Fowler
- David Rice
- Matthew Foemmel
- Edward Hieatt
- Robert Mee
- Randy Stafford
chapters: [5, 16]
domain: enterprise-application-architecture
tags:
- concurrency
- locking
- transactions
- design-patterns
- data-integrity
- offline-lock
- optimistic-concurrency
- pessimistic-concurrency
depends-on: []
execution:
tier: 2
mode: hybrid
inputs:
- type: codebase
description: "Enterprise application codebase (domain, persistence, web layers) or description of the system and its editing workflows"
- type: user-description
description: "Description of the business transaction: what the user edits, how long, how many concurrent users, how often they edit the same records, cost of discarding in-progress work"
tools-required:
- Read
- Grep
- Edit
- Write
tools-optional:
- Glob
mcps-required: []
environment: "Enterprise application with at least one multi-step editing workflow (record opened in one request, saved in a later request). Relational database with ORM or hand-rolled persistence layer. Stack-agnostic: Java, C#, Python, TypeScript, Ruby all apply."
discovery:
goal: "Select the right offline concurrency strategy for each editing workflow and produce a concurrency decision record with infrastructure specifications."
tasks:
- "Identify whether the system has genuine offline concurrency (business transaction spans multiple system transactions)"
- "Apply the collision-frequency × rework-cost framework to choose Optimistic vs Pessimistic Offline Lock"
- "Within Pessimistic: select lock type (exclusive-write, exclusive-read, or read/write)"
- "Determine whether Coarse-Grained Lock is needed for aggregate editing"
- "Determine whether Implicit Lock is needed as a safety net"
- "Flag anti-patterns: DB-level long locks, implicit-lock gaps, optimistic/pessimistic mis-mix, timestamp versioning"
- "Produce a concurrency decision record with infrastructure requirements and UX specification"
audience:
roles:
- software-architect
- senior-backend-engineer
- tech-lead
experience: intermediate
when_to_use:
triggers:
- "Designing or auditing any feature where a user opens a record in one HTTP request and saves it in a later one"
- "Reports of 'lost updates' — one user's changes silently overwriting another's"
- "Choosing between optimistic and pessimistic locking for a new entity"
- "Adding concurrency control to a system that currently has none"
- "Deciding whether to lock at the aggregate root vs individual entity level"
- "Evaluating whether SELECT FOR UPDATE is safe for long-running user workflows"
- "Designing collision-detection UX (error message, merge UI, force-reload)"
prerequisites: []
not_for:
- "Single-request workflows where the entire business transaction fits in one system transaction — use database isolation levels instead"
- "Thread-level concurrency within a single request (use Java synchronization, Go mutexes, etc.)"
- "Distributed systems consensus (use Raft, Paxos, saga patterns)"
- "Read-only query concurrency (no writes, no concurrency control needed)"
environment:
codebase_required: false
codebase_helpful: true
works_offline: true
quality:
scores:
with_skill: null
baseline: null
delta: null
tested_at: null
eval_count: null
assertion_count: 13
iterations_needed: null
---
# Offline Concurrency Strategy Selector
## When to Use
This skill applies whenever a **business transaction spans multiple system transactions** — the user loads a record in one HTTP request, works with it (seconds to hours), and saves in a later request. During that gap, the database cannot protect against another user modifying the same data. You need application-level concurrency control.
Use this skill when designing new editing workflows, diagnosing "lost update" bugs, or auditing existing lock strategies. It selects among four patterns from Fowler's *Patterns of Enterprise Application Architecture* Ch 16 and routes to child implementation skills when needed.
**Do not use** if the entire user workflow fits in a single request/transaction — standard database isolation levels are sufficient and far simpler.
## Context & Input Gathering
Gather the following before proceeding. If working from a codebase, grep for session state, version columns, and lock tables. Otherwise, ask the user directly.
**Required:**
1. **Edit duration** — How long does a typical editing session last? (seconds, minutes, hours)
2. **Concurrent user count** — How many users work with the same records simultaneously?
3. **Collision frequency estimate** — How often do two users edit the same record within the same time window? (rare / occasional / frequent)
4. **Rework cost** — If a user's save is rejected due to a conflict, how painful is it to redo? (low: a few fields; high: 30-minute insurance form, complex calculations)
5. **Aggregate structure** — Are records edited in groups? (e.g., Order + LineItems, Customer + Addresses, Lease + Assets)
6. **Infrastructure readiness** — Does a version column or lock table already exist?
**Observable from codebase:**
- Version/timestamp columns in schema files → Optimistic may already be partially in place
- `SELECT FOR UPDATE` or similar → potential long-lock anti-pattern to flag
- Session state storage → helps identify business transaction boundaries
**Sufficiency check:** If collision frequency and rework cost are both unknown, use the heuristic: small internal team + low data overlap → default Optimistic. High-value financial/legal records with multiple editors → investigate Pessimistic.
## Process
### Step 1 — Confirm offline concurrency applies
Check: does the editing workflow span multiple system transactions?
- User loads record in Request A → edits → saves in Request B → YES, offline concurrency applies
- Entire workflow in one request (e.g., API endpoint with no user think-time) → NO, use database isolation levels
**WHY:** All four patterns add complexity and infrastructure. If a single system transaction suffices, the added complexity is pure overhead. The book is explicit: "If you can make all your business transactions fit into a system transaction … then do that."
If offline concurrency applies, proceed. If not, recommend appropriate isolation level and stop.
---
### Step 2 — Apply the primary fork: Optimistic vs Pessimistic
Evaluate:
| Signal | Toward Optimistic | Toward Pessimistic |
|--------|------------------|--------------------|
| Collision frequency | Rare | Frequent |
| Rework cost if conflict | Low (few fields, quick redo) | High (hours of work, complex re-entry) |
| Edit duration | Short (seconds to a few minutes) | Long (30+ minutes, multi-step forms) |
| Concurrency need | High (many users, maximize throughput) | Lower (can accept serialized access) |
| Implementation budget | Lower | Higher (lock manager, timeouts, protocol) |
**Decision:**
- **Low collision × low rework cost → Optimistic Offline Lock** (version-based optimistic concurrency): Add a `version` integer column. On save, include `WHERE version = :loaded_version` in UPDATE/DELETE. If row count = 0 → collision → rollback and surface error with who-modified-and-when.
- **High collision OR high rework cost → Pessimistic Offline Lock** (application-managed pessimistic lock): Acquire a durable application-level lock when the user opens the record. Other users see "locked by Alice" and cannot proceed. Lock released on save or session timeout.
- **Mixed (most records low-risk, some critical) → both** as complements: Optimistic as the default, Pessimistic only for identified high-contention/high-value record types.
**WHY:** Optimistic concurrency trades late failure (discovered at commit) for better throughput and simpler implementation. Pessimistic concurrency trades reduced concurrency for early failure (user knows immediately the record is locked). The correct choice is a UX and domain decision as much as a technical one — it shapes the entire user experience.
**Fowler's default:** "Consider [Optimistic] as the default approach to business transaction conflict management in any system you build. The pessimistic version works well as a complement."
---
### Step 3 — Within Pessimistic: select lock type
If Pessimistic was chosen in Step 2, pick the lock type:
- **Exclusive write lock:** Business transaction must hold a lock to EDIT. Reading is unrestricted. Multiple users can read concurrently; only the editor holds a lock. Best concurrency, simplest. Use when: stale reads are acceptable (viewing slightly out-of-date data is OK).
- **Exclusive read lock:** Business transaction must hold a lock to READ OR EDIT. Only one user accesses the record at a time. Use when: the business transaction's correctness depends on having the latest data even for reads (e.g., an insurance underwriter who builds calculations on what they loaded).
- **Read/write lock:** Read locks are shared (multiple concurrent readers); write lock is exclusive (blocks all read and write locks). Most powerful, most complex. Use when: high concurrent read activity AND occasional editing AND read freshness matters.
**WHY:** The lock type directly controls system concurrency. Exclusive read locks are severe — they serialize ALL access. Most enterprise systems need only exclusive write locks. Read/write locks are a compromise but require careful implementation and are harder for domain experts to reason about.
---
### Step 4 — Check aggregate integrity
Examine the editing workflow:
- Are multiple related objects edited together as a unit? (Order + LineItems, Customer + Addresses, Policy + Coverages)
- Would editing just one member of the group while another session edits a different member cause data integrity problems?
If YES → add **Coarse-Grained Lock** (aggregate-root-level lock / cluster lock):
- **For Optimistic:** Create a shared `Version` object/row that all aggregate members point to (same instance, not equal value). Incrementing the shared version locks the entire group atomically.
- **For Pessimistic:** Use the shared version's ID as the lockable token in the lock table. Locking any member locks all members.
- Alternative: **root lock** — navigate child-to-parent to the aggregate root and lock it; acquiring a root lock locks all descendants by definition.
If NO (objects are independently lockable) → skip Coarse-Grained Lock.
**WHY:** Without Coarse-Grained Lock, per-object locking requires all code paths to know and enumerate every member of the group. This breaks down as the group grows and introduces subtle bugs when a developer forgets to lock one member. Fowler: "locking either the asset or the lease ought to result in the lease and all of its assets being locked."
---
### Step 5 — Check implicit lock safety
Examine the codebase (or planned architecture):
- Are there multiple code paths that access locked records? (different repository methods, admin commands, background jobs, raw SQL paths)
- Could a new developer add a feature without knowing to acquire/release the lock?
If YES → add **Implicit Lock** (framework-enforced lock):
- Move mandatory locking into the abstract Data Mapper / repository base class / ORM lifecycle hooks so it cannot be omitted
- For Optimistic: base mapper supertype handles version storage on load, version check on UPDATE/DELETE
- For Pessimistic: a `LockingMapper` decorator wraps `find()` to always acquire read lock before loading; validates write lock is held before `update()`/`delete()`
- Lock release: register a session lifecycle listener (HTTP session invalidation hook) to call `releaseAllLocks(sessionId)` automatically
If the codebase is tiny or the locking scheme is minimal → may skip, but Fowler: "the risk of a single forgotten lock is too great" in most enterprise applications.
**WHY:** Offline concurrency bugs are extremely hard to reproduce and test. A single missed `acquireLock()` call defeats the entire scheme. "Generally, if an item might be locked anywhere it must be locked everywhere."
---
### Step 6 — Flag anti-patterns
Check for these failure modes in the current or proposed design:
1. **DB-level locks held across user think-time:** `SELECT FOR UPDATE` held open for the duration of a multi-request editing session → DB connection held for minutes/hours, destroys scalability. Flag and replace with application-level lock table.
2. **Timestamp versioning:** Using `modified_at` timestamp as the version marker → unreliable across servers, clock skew causes false-positives and missed conflicts. Replace with an integer `version` counter.
3. **Optimistic/Pessimistic mis-mix on overlapping data:** If the same record is accessed by some sessions using Optimistic and others using Pessimistic, the Pessimistic lock is invisible to the Optimistic sessions — they can read/modify without acquiring the lock. Ensure lock strategy is consistent per record type.
4. **Implicit-lock gaps:** Any code path (admin tool, background job, raw SQL, new command object) that accesses locked records without acquiring the lock. Fix with Implicit Lock.
5. **In-memory lock table in clustered deployment:** Locks on node A are invisible to node B. Fix with database-backed lock table.
6. **Unreleased locks on abandoned sessions:** Users close browsers mid-edit. Locks must expire via timeout policy or session invalidation listener.
**WHY:** Each anti-pattern either silently defeats the locking scheme (invisible gaps) or creates a different operational disaster (DB bottleneck, deadlock in clustered nodes). These are the most common failure modes in production concurrency implementations.
---
### Step 7 — Produce concurrency decision record
Output the artifact (see Outputs section).
## Inputs
- Business transaction description (edit duration, concurrent users, collision frequency, rework cost)
- Domain model / schema (to identify aggregates, existing version columns, lock tables)
- Codebase access (optional, helps detect anti-patterns and existing infrastructure)
- Deployment topology (single-server vs clustered, for lock table implementation choice)
## Outputs
### Concurrency Decision Record (markdown)
```markdown
## Concurrency Decision Record: [Feature/Entity Name]
**Decision date:** YYYY-MM-DD
**Applicable entities:** [list]
### 1. Primary Strategy
**Choice:** Optimistic Offline Lock / Pessimistic Offline Lock / Both (hybrid)
**Rationale:** [collision frequency assessment] × [rework cost assessment] → [reasoning]
### 2. Lock Type (if Pessimistic)
**Choice:** Exclusive write / Exclusive read / Read-write
**Rationale:** [why this lock type fits the access pattern]
### 3. Aggregate Locking
**Coarse-Grained Lock:** Required / Not required
**Scope:** [which objects share the lock]
**Implementation:** Shared version object / Root lock
### 4. Framework Enforcement
**Implicit Lock:** Required / Not required
**Integration point:** Abstract mapper supertype / Mapper decorator / ORM hook / Session listener
### 5. Infrastructure Requirements
- [ ] Version column: `ALTER TABLE <table> ADD COLUMN version INTEGER NOT NULL DEFAULT 0`
- [ ] Lock table: `CREATE TABLE app_lock (lockable_id BIGINT PRIMARY KEY, owner_id VARCHAR(255), acquired_at TIMESTAMP)`
- [ ] Lock timeout policy: [N minutes; auto-release on session invalidation]
- [ ] modifiedBy + modified columns for error messages
### 6. UX Specification
- **On Optimistic collision:** [show error with "Modified by [user] at [time]. Please reload and re-apply your changes."]
- **On Pessimistic lock unavailable:** [show "This record is currently being edited by [user]. Please try again later."]
- **On lock timeout:** [show "Your editing session has expired. Please reload the record."]
### 7. Anti-Pattern Warnings
[List any flagged issues from Step 6]
### 8. Child Implementation Skills Needed
- [ ] optimistic-offline-lock-implementer (version column + mapper mechanics)
- [ ] pessimistic-offline-lock-implementer (lock manager + protocol)
```
## Key Principles
**1. The offline concurrency problem is a domain problem, not just a technical one.**
Collision frequency, rework cost, and lock granularity require domain expert input — not just a DBA. Which records are high-contention? How much time does a user typically spend on a workflow? What's the cost of losing 30 minutes of insurance underwriting? These answers drive the technical choice.
**2. Optimistic is the default; Pessimistic is the exception.**
Optimistic Offline Lock is easier to implement, has no lock infrastructure to maintain, and gives better concurrency. Pessimistic introduces lock managers, timeout policies, session listeners, and deadlock-avoidance concerns. Only adopt Pessimistic when the Optimistic failure mode (late collision discovery) is genuinely unacceptable.
**3. The version counter must be an integer, not a timestamp.**
System clocks are unreliable, especially across multiple servers. A monotonically incrementing integer column provides deterministic conflict detection. Including `modifiedBy` and `modified` columns alongside the version enables user-facing error messages ("Modified by Alice at 2:34pm") but should not replace the integer version in the WHERE clause.
**4. Never hold a database lock across user think-time.**
`SELECT FOR UPDATE` and similar database-native locks hold a DB connection open for the duration. A business transaction that takes 20 minutes would hold that DB resource for 20 minutes, serializing all other access and destroying scalability. Application-level lock tables are the correct tool for cross-request locking.
**5. Coarse-Grained Lock preserves aggregate integrity atomically.**
When an aggregate spans multiple rows/objects (Order + LineItems), locking each object independently creates a window where two sessions lock different members concurrently — neither knows about the other's intent on the group. A shared version object makes this impossible: a single version increment blocks all concurrent attempts on the entire aggregate.
**6. Implicit Lock prevents the #1 failure mode: the forgotten lock call.**
One developer writing one new method without a lock call defeats the entire scheme — and because concurrency bugs are hard to reproduce, it may not be caught in testing. Implicit Lock at the framework level means the developer cannot forget: the base mapper always acquires the lock before loading.
**7. Pessimistic lock managers must never block — always throw immediately.**
When a lock is unavailable, throw an exception instantly. Never wait for the lock to become available. Business transactions that span multiple system transactions cannot reasonably wait: the current holder might be gone for coffee. Immediate failure + early abort is the only practical design; it also eliminates the possibility of deadlock.
## Examples
### Example 1: CMS Article Editor (low collision, low rework cost)
**Scenario:** A content management system where editors write articles. Typical edit session: 10–30 minutes. Team of 5 editors; each article usually has one assigned editor. Collisions rare but possible.
**Trigger:** "We sometimes lose edits when two people accidentally open the same draft. Should we add locking?"
**Process:**
- Step 1: Multi-request workflow (open draft → write → publish) → offline concurrency applies.
- Step 2: Collision frequency = rare; rework cost = moderate (losing 20 min is painful but recoverable) → **Optimistic Offline Lock**.
- Step 3: N/A (Pessimistic not chosen).
- Step 4: Article is a standalone entity; no aggregate integrity concern → no Coarse-Grained Lock.
- Step 5: Single article mapper; small team → **Implicit Lock in abstract mapper** for safety.
- Step 6: No anti-patterns identified if schema is new.
**Output:** Add `version INTEGER NOT NULL DEFAULT 0` + `modified_by` + `modified_at` to articles table. Abstract article mapper includes version in UPDATE WHERE clause. On row count 0 → show "This article was modified by [user] at [time]. Please copy your changes, reload, and re-apply." No lock table needed.
---
### Example 2: Insurance Policy Underwriting (high rework cost)
**Scenario:** Underwriters edit complex insurance policies. Editing a policy takes 45–90 minutes (data gathering, actuarial calculations, document review). Two underwriters might be assigned the same policy. If an underwriter finishes after 90 minutes and their save is rejected, the work is genuinely lost — not a minor inconvenience.
**Trigger:** "Underwriters are furious about rejected saves. Is there a better approach?"
**Process:**
- Step 1: Multi-request, 45-90 min sessions → offline concurrency applies.
- Step 2: Collision rate: low (same policy rarely assigned twice) BUT rework cost: very high → **Pessimistic Offline Lock**.
- Step 3: Underwriters need latest data (coverage amounts, actuarial tables) → **Exclusive read lock** (other underwriters cannot even load the policy while one holds it).
- Step 4: Policy includes Coverages, Endorsements, Named Insureds → these form an aggregate → **Coarse-Grained Lock** with shared version on the Policy root.
- Step 5: Multiple command objects (EditBasicInfo, AddCoverage, RemoveEndorsement) → **Implicit Lock** via LockingMapper decorator.
- Step 6: Flag if existing code uses `SELECT FOR UPDATE` on policy table.
**Output:** Database lock table + shared version on Policy aggregate + LockingMapper + HTTP session expiration listener. UX: "Policy 12345 is currently being edited by Bob Smith. It will be available after his session ends or at [timeout time]."
---
### Example 3: E-commerce Order Management (aggregate integrity)
**Scenario:** Customer service agents edit orders. An order has LineItems, ShippingAddress, and PromoCodes. One agent might add a LineItem while another changes the ShippingAddress at the same time. Each object in isolation is low-risk, but the order must be consistent as a whole.
**Trigger:** "We have a bug where the order total is wrong — looks like two people edited it at the same time."
**Process:**
- Step 1: Multi-request order editing → offline concurrency applies.
- Step 2: Collision frequency: occasional (busy customer service team); rework cost: low → **Optimistic Offline Lock**.
- Step 3: N/A.
- Step 4: Order + LineItems + ShippingAddress + PromoCodes = one aggregate → **Coarse-Grained Lock** with shared version on Order root. Any change to any member increments the shared version, blocking any concurrent session's commit.
- Step 5: Order service has multiple command handlers → **Implicit Lock** in abstract repository.
- Step 6: Verify no per-entity version columns (would create inconsistent locking if mixed with shared version).
**Output:** Single `version` table row per order, referenced by all members. Abstract mapper `update()` calls `order.getVersion().increment()` before any member update. Conflict error: "Order 8834 was modified by [user] at [time]. Please reload to see current state."
## References
- [Offline Concurrency Pattern Details](references/offline-concurrency-patterns.md) — per-pattern mechanics, version column SQL, lock table schema, session listener code sketches
- [Anti-Pattern Catalog](references/concurrency-anti-patterns.md) — detailed detection criteria and remediation for each of the 6 anti-patterns
- [Lock Type Decision Matrix](references/lock-type-matrix.md) — trade-off table for exclusive-write vs exclusive-read vs read-write locks with implementation complexity notes
## License
This skill is licensed under [CC-BY-SA-4.0](https://creativecommons.org/licenses/by-sa/4.0/).
Source: [BookForge](https://github.com/bookforge-ai/bookforge-skills) — Patterns of Enterprise Application Architecture by Martin Fowler, David Rice, Matthew Foemmel, Edward Hieatt, Robert Mee, Randy Stafford.
## Related BookForge Skills
Install related skills from ClawhHub:
- `clawhub install bookforge-optimistic-offline-lock-implementer`
- `clawhub install bookforge-pessimistic-offline-lock-implementer`
- `clawhub install bookforge-transaction-isolation-level-auditor`
Or install the full book set from GitHub: [bookforge-skills](https://github.com/bookforge-ai/bookforge-skills)
FILE:references/offline-concurrency-patterns.md
# Offline Concurrency Pattern Details
Source: *Patterns of Enterprise Application Architecture*, Ch 16 (David Rice)
## Optimistic Offline Lock — Infrastructure
### Version Column Schema
```sql
-- Add to every table that needs offline concurrency protection
ALTER TABLE customer ADD COLUMN version INTEGER NOT NULL DEFAULT 0;
ALTER TABLE customer ADD COLUMN modified_by VARCHAR(255);
ALTER TABLE customer ADD COLUMN modified_at TIMESTAMP;
```
### Version-Conditioned SQL
```sql
-- UPDATE must include version in WHERE
UPDATE customer
SET name = ?, modified_by = ?, modified_at = ?, version = version + 1
WHERE id = ? AND version = ?;
-- DELETE must include version in WHERE
DELETE FROM customer WHERE id = ? AND version = ?;
-- After executing: check rowsAffected. 0 = conflict.
```
**Never use `modified_at` as the version marker.** System clocks are unreliable, especially across multiple servers. Always use a monotonic integer counter.
### Conflict Error Lookup
When row count = 0, query to build the error message:
```sql
SELECT version, modified_by, modified_at FROM customer WHERE id = ?;
```
If row exists: "Record modified by [modified_by] at [modified_at]."
If row missing: "Record has been deleted by another session."
### Inconsistent Read Extension
If the business transaction's correctness depends on data it READ (not just wrote), register those reads for version check too:
```python
# In Unit of Work commit phase:
for obj in self.dirty:
self._check_or_increment_version(obj) # blocks concurrent writes
for obj in self.reads_that_matter:
self._check_version(obj) # blocks if someone else modified
```
NOTE: Version reread-only (no increment) requires REPEATABLE READ or stronger isolation. If isolation level is unknown, increment to be safe.
---
## Pessimistic Offline Lock — Lock Table Schema
```sql
-- Application-managed lock table (NOT the DB's internal locking mechanism)
CREATE TABLE app_lock (
lockable_id BIGINT NOT NULL,
owner_id VARCHAR(255) NOT NULL,
acquired_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (lockable_id) -- uniqueness constraint enforces exclusivity
);
```
For **read/write locks**, a more complex schema is needed (lock_type column + application-side logic to enforce mutual exclusion).
### Lock Manager Protocol
```python
class LockManager:
def acquire(self, lockable_id: int, owner_id: str) -> None:
"""Raises ConcurrencyException if lock unavailable. NEVER blocks."""
if self._has_lock(lockable_id, owner_id):
return # idempotent — already held by this session
try:
db.execute("INSERT INTO app_lock VALUES (?, ?, NOW())",
lockable_id, owner_id)
except UniqueConstraintViolation:
owner = db.scalar("SELECT owner_id FROM app_lock WHERE lockable_id=?",
lockable_id)
raise ConcurrencyException(f"Locked by {owner}")
def release(self, lockable_id: int, owner_id: str) -> None:
db.execute("DELETE FROM app_lock WHERE lockable_id=? AND owner_id=?",
lockable_id, owner_id)
def release_all(self, owner_id: str) -> None:
db.execute("DELETE FROM app_lock WHERE owner_id=?", owner_id)
```
### Session Expiry Listener (HTTP)
```python
# Django signal / Flask session teardown / Spring HttpSessionBindingListener
def on_session_destroyed(session_id: str):
with transaction():
lock_manager.release_all(session_id)
```
### Lock Timeout Policy
Option A: Application-side check on acquire:
```sql
DELETE FROM app_lock WHERE acquired_at < NOW() - INTERVAL '30 minutes';
```
Run before every acquire, or in a background job.
Option B: DB-level expiry via event or scheduled job.
---
## Coarse-Grained Lock — Shared Version Pattern
### Shared Version Table
```sql
CREATE TABLE aggregate_version (
id BIGINT PRIMARY KEY,
value BIGINT NOT NULL DEFAULT 0,
modified_by VARCHAR(255),
modified_at TIMESTAMP
);
-- Each member table references the shared version
ALTER TABLE order_line_item ADD COLUMN version_id BIGINT REFERENCES aggregate_version(id);
ALTER TABLE order_header ADD COLUMN version_id BIGINT REFERENCES aggregate_version(id);
```
All members of an Order aggregate share the same `aggregate_version` row (same `version_id`).
### Version Increment (Optimistic)
```sql
-- Increment the SHARED version; all members are now "locked" for concurrent sessions
UPDATE aggregate_version
SET value = value + 1, modified_by = ?, modified_at = NOW()
WHERE id = ? AND value = ?;
-- rowCount = 0 → conflict on the aggregate
```
### Root Lock (Pessimistic complement)
Use the aggregate root's primary key as the `lockable_id` in the lock table. All child objects navigate to the root to acquire/check the lock. Requires child-to-parent navigation in the domain model.
---
## Implicit Lock — Mapper Decorator Pattern
```python
class LockingRepository:
"""Decorator that wraps any Repository to acquire locks transparently."""
def __init__(self, inner: Repository, lock_manager: LockManager):
self._inner = inner
self._lm = lock_manager
def find(self, entity_id: int) -> DomainObject:
# Acquire lock BEFORE load (guarantees currency)
self._lm.acquire(entity_id, current_session_id())
return self._inner.find(entity_id)
def update(self, obj: DomainObject) -> None:
# Validate lock is held before committing (write lock variant)
if not self._lm.has_lock(obj.id, current_session_id()):
raise ConcurrencyException(
f"Write lock not held for {type(obj).__name__} {obj.id}. "
"This is a programmer error — acquire the lock before editing."
)
self._inner.update(obj)
def delete(self, obj: DomainObject) -> None:
if not self._lm.has_lock(obj.id, current_session_id()):
raise ConcurrencyException(f"Write lock not held for {obj.id}")
self._inner.delete(obj)
```
Register the locking decorator in your repository registry / DI container so all callers receive it transparently.
Object-relational mapping structural patterns guide. Use when designing or auditing how domain objects map to relational tables — identity fields, foreign ke...
---
name: object-relational-structural-mapping-guide
description: "Object-relational mapping structural patterns guide. Use when designing or auditing how domain objects map to relational tables — identity fields, foreign key mapping, association table mapping for many-to-many relationships, dependent mapping for child objects with cascade delete, embedded value for value object mapping, and serialized LOB for JSON column or blob storage. Applies when choosing ORM associations (Hibernate, SQLAlchemy, EF Core, ActiveRecord, Django ORM), deciding between a join table and nested foreign keys, mapping address or money value objects as inline columns, or detecting serialized LOB overuse on queryable data. Covers the six PEAA structural patterns: Identity Field (surrogate key vs meaningful key), Foreign Key Mapping (single-valued reference), Association Table Mapping (many-to-many via join table), Dependent Mapping (child lifecycle owned by parent), Embedded Value (value object as columns), Serialized LOB (graph serialized to JSON/BLOB column)."
version: 1.0.0
homepage: https://github.com/bookforge-ai/bookforge-skills/tree/main/books/patterns-of-enterprise-application-architecture/skills/object-relational-structural-mapping-guide
metadata: {"openclaw":{"emoji":"🗂️","homepage":"https://github.com/bookforge-ai/bookforge-skills"}}
status: draft
source-books:
- id: patterns-of-enterprise-application-architecture
title: "Patterns of Enterprise Application Architecture"
authors: ["Martin Fowler", "David Rice", "Matthew Foemmel", "Edward Hieatt", "Robert Mee", "Randy Stafford"]
chapters: [3, 12]
domain: persistence
tags:
- object-relational-mapping
- persistence
- orm
- database-design
- design-patterns
- associations
- value-objects
- cascade-delete
depends-on: []
execution:
tier: 2
mode: hybrid
inputs:
- type: codebase
description: "Domain model classes, existing ORM mappings, schema SQL/migrations, and build files (to detect ORM in use)"
- type: user description
description: "Domain model relationships to map, current pain points (N+1, cascade failures, unmappable queries), and ORM stack"
tools-required:
- Read
- Glob
- Grep
- Edit
- Write
tools-optional:
- Bash
mcps-required: []
environment: "Enterprise application codebase with OO domain model and relational database. ORM stack detectable from build files or imports. Schema available as .sql, migrations, or ORM model definitions."
discovery:
goal: "Map every relationship and value structure in a domain model to the correct PEAA structural pattern, producing a structural mapping design document with schema sketches and ORM config for each."
tasks:
- "Classify each domain class relationship by structural type (identity / 1:1 / 1:N / N:M / dependent / embedded / graph)"
- "Route each to the correct structural pattern using the decision table"
- "Generate schema sketch (table columns, FK constraints, join tables)"
- "Provide idiomatic ORM configuration for the detected stack"
- "Flag anti-patterns: meaningful keys, Serialized LOB on queryable data, nested FK attempts at N:M, dependent with external FK references"
audience:
roles:
- software-architect
- senior-backend-engineer
- tech-lead
- framework-designer
experience: intermediate
when_to_use:
triggers:
- "Designing a new domain model's persistence layer from scratch"
- "Auditing an existing ORM configuration for structural anti-patterns"
- "Deciding between a join table and a FK column for a new relationship"
- "Mapping a value object (Address, Money, DateRange) — should it be its own table or inline columns?"
- "Evaluating whether to use a JSON/JSONB column for nested data"
- "N+1 or cascade-delete bug traced to wrong mapping choice"
- "ORM config uses meaningful primary keys and team wants to refactor"
prerequisites: []
not_for:
- "Inheritance mapping decisions (use inheritance-mapping-selector for Single/Class/Concrete Table Inheritance)"
- "Choosing between Table Data Gateway, Active Record, or Data Mapper (use data-source-pattern-selector)"
- "Concurrency and locking design (use offline-concurrency-strategy-selector)"
environment:
codebase_required: false
codebase_helpful: true
works_offline: true
quality:
scores:
with_skill: "{filled by tester}"
baseline: "{filled by tester}"
delta: "{filled by tester}"
tested_at: "{filled by tester}"
eval_count: "{filled by tester}"
assertion_count: 13
iterations_needed: "{filled by tester}"
---
# Object-Relational Structural Mapping Guide
Six PEAA patterns that bridge between OO domain objects and relational tables: Identity Field, Foreign Key Mapping, Association Table Mapping, Dependent Mapping, Embedded Value, and Serialized LOB.
## When to Use
Use this skill when you are:
- Designing how a domain model maps to a relational schema for the first time
- Auditing an existing ORM configuration and schema for structural problems
- Deciding how to map a specific relationship type (1:1, 1:N, N:M, value object, child graph)
- Evaluating whether a JSON/BLOB column is the right choice for nested data
- Debugging N+1 queries, cascade failures, or query-impossible data buried in a LOB
**Not for:**
- Inheritance hierarchies → use `inheritance-mapping-selector`
- Data-source gateway selection (Active Record vs Data Mapper) → use `data-source-pattern-selector`
## Context & Input Gathering
Gather before proceeding:
**Required:**
- List of domain classes with their relationships and cardinalities (1:1, 1:N, N:M)
- Which objects are value objects (no identity, owned by another object) vs. entities (independent identity)
- ORM stack in use (Hibernate/JPA, EF Core, SQLAlchemy, Django, ActiveRecord, TypeORM, or hand-rolled)
**Observable from codebase:**
- Existing ORM annotations / model definitions (detect from `@Entity`, `models.Model`, `Column`, etc.)
- Schema migrations or DDL files (detect FK patterns, join tables, LOB columns)
- Build files (`pom.xml`, `requirements.txt`, `*.csproj`, `Gemfile`) to confirm ORM version
**Ask if absent:**
- "Which classes have independent lifecycles (can be loaded/deleted on their own)?"
- "Are there many-to-many relationships? Do the associations carry their own attributes (e.g., a role or start date)?"
- "Is there any data currently stored as XML, JSON, or binary blob? What SQL queries run against it?"
**Sufficiency check:** Proceed once you have the domain class list, relationship cardinalities, and a yes/no on value object identity. ORM stack helps with the output but is not blocking.
## Process
### Step 1 — Identify all domain structures
For each domain class and relationship, classify it into one of these structural types:
| Type | Signal |
|------|--------|
| **Entity with identity** | Can be loaded/deleted independently; has a unique ID |
| **Value object** | No independent identity; always belongs to one owner (Money, Address, DateRange) |
| **Single-valued reference** | One object holds a reference to exactly one other entity |
| **Collection reference (1:N)** | One object holds a collection of other entities; each child knows its parent |
| **Many-to-many reference** | Both sides hold collections pointing at each other |
| **Dependent child** | Child exists only in the context of an owner; no external references to child |
| **Complex nested graph** | Hierarchical or graph structure that would require many joins relationally |
*WHY:* Each structural type maps to exactly one pattern. Misclassifying here leads to wrong pattern choice (e.g., treating a Value Object as an entity creates an unnecessary table and Identity Map entry).
### Step 2 — Apply the pattern routing table
| Structure | Pattern | Key rule |
|-----------|---------|----------|
| Every persistable entity | **Identity Field** | Use surrogate (auto-assigned) key — never meaningful keys |
| Single-valued reference (1:1, N:1) | **Foreign Key Mapping** | FK lives in the "many" or "child" table |
| 1:N collection (entity children) | **Foreign Key Mapping** | FK lives in child table; parent has collection in memory, not in the table |
| N:M relationship | **Association Table Mapping** | Always use a join table — even if no attributes today |
| Child with no independent identity | **Dependent Mapping** | No Identity Field on child; owner mapper handles all persistence |
| Value object (DDD Value Object) | **Embedded Value** | Map value's fields as columns on the owner's table |
| Non-queryable complex subgraph | **Serialized LOB** | Only when SQL queries will NEVER need to filter by content |
*WHY:* This routing table encodes the core insight that objects and relations have fundamentally different link representations. Without this explicit classification, teams default to wrong choices: adding FK columns for N:M (violates first normal form), creating tables for value objects (unnecessary complexity), or Serialized LOB for data later needing SQL queries (queryability trap).
### Step 3 — Apply Identity Field to all entities
For every entity class (not value objects, not dependents):
1. **Always prefer a surrogate key** (auto-assigned integer or UUID). Meaningful keys (email, SSN, order number) appear stable but fail in practice: human input errors break both uniqueness and immutability.
2. **Prefer simple (single-column) keys.** They enable a Layer Supertype with uniform key handling. Compound keys require per-class handling and carry implicit meaning that tends to leak.
3. **Key type:** 64-bit integer (`BIGINT`) is the best default — fast equality check, fast increment, effectively unlimited range. UUIDs/GUIDs provide database-wide uniqueness at the cost of larger index size and slower inserts (random insertion order).
4. **Inheritance caveat:** With Class Table or Concrete Table Inheritance, keys must be unique across the hierarchy, not just per-table, to avoid Identity Map collisions.
*WHY:* The Identity Field is the bridge between the object graph and the relational schema. Without it, you cannot map FK references back to in-memory objects. Without surrogate keys, you inherit the fragility of the real world into your database contract.
### Step 4 — Map relationships
**Foreign Key Mapping (1:1 and N:1):**
- The FK column lives in the table of the class that holds the reference in memory.
- For a collection (1:N), the FK lives in the child table (structural inversion: the parent holds a collection in OO, but the child table holds the FK in SQL).
- Write order: insert parent first, then children, to satisfy FK constraints.
**Association Table Mapping (N:M):**
- Create a link table with two FK columns (one per side).
- The link table has no corresponding domain object and no Identity Field of its own.
- Its PK is the compound of both FKs.
- Treat the link table like a Dependent Mapping — delete all links for one side and re-insert on update.
- If the association acquires its own attributes (role, start_date), promote the link table to a first-class entity with its own Identity Field and Foreign Key Mappings on both sides.
*WHY for Association Table:* There is no alternative for N:M in relational databases. Attempts to model N:M with a list-of-IDs column violate first normal form and make queries and updates extremely painful. Even if the association has no attributes today, using a join table preserves schema flexibility for when attributes appear.
### Step 5 — Map child structures
**Dependent Mapping:**
- Apply when: child object has no independent identity, is always loaded with its owner, and is never referenced by foreign keys from other tables.
- The child class has no Identity Field, no Identity Map entry, and no independent finder methods.
- The owner's mapper (or ORM cascade config) handles all inserts, updates, and deletes.
- On update: delete all dependents for the owner, then re-insert (safe because no external FK references exist).
- If another table needs a direct FK to the child, the child is not truly dependent — give it an Identity Field and use Foreign Key Mapping instead.
**Embedded Value:**
- Apply to all DDD Value Objects (Money, Address, DateRange, GeoPoint, etc.).
- Map each value field as a column on the owner's table (e.g., `employment.salary_amount`, `employment.salary_currency`).
- The value class has no persistence methods of its own; the owner saves/loads it.
- Do NOT use Embedded Value if: (a) the value is shared across multiple owners, (b) there can be a variable number of values per owner, or (c) you need to sort/filter on the value's fields via SQL independently of the owner.
*WHY for Embedded Value:* Value Objects have no identity and should never have their own table — a table of Money values or Address values is meaningless without context. Embedding them preserves the OO semantics (the value is part of the owner, not related to it) while avoiding extra joins and unnecessary tables.
### Step 6 — Evaluate any graph / LOB candidates
For complex hierarchical or graph structures:
1. Can the structure be represented with a self-referencing FK (e.g., `parent_id` on an organization table)? If yes, prefer this — it keeps data queryable.
2. If the structure is truly too complex to normalize, and **you are certain SQL will never need to filter or join on the internal fields**, consider Serialized LOB.
3. Choose format: JSON/JSONB (PostgreSQL, MySQL 5.7+) is preferred over XML for readability and tooling; binary BLOB is compact but opaque and fragile to class changes.
4. Verify the anti-pattern checklist for Serialized LOB (see Key Principles).
*WHY:* Serialized LOB sacrifices SQL queryability for schema simplicity. This trade-off is acceptable for truly private, complex, non-queryable subgraphs. It is a trap when applied to data that reporting queries, search, or business logic will need to inspect.
### Step 7 — Produce the structural mapping design document
For each entity and relationship, output:
```
[ClassName / Relationship]
Pattern: <pattern name>
Schema: <table/columns/constraints sketch>
ORM config: <annotation or model field>
Rationale: <why this pattern fits>
Anti-pattern warning: <if applicable>
```
Review cross-cutting concerns:
- Write ordering for inserts (parent before child for all FK relationships)
- Cascade delete configuration (Dependent Mapping → cascade all; FK Mapping → decide per relationship)
- Identity Map interaction (only entities with Identity Field enter the Identity Map; dependents and value objects do not)
## Inputs
- Domain model class list with relationships and cardinalities
- Value object identification (which classes lack independent identity)
- ORM stack and version
- Existing schema (if mapping to a pre-existing database)
- Any current LOB/JSON columns and the SQL queries that run against them
## Outputs
A **structural mapping design document** containing:
1. **Pattern assignment table** — every entity, value object, and relationship mapped to a pattern with rationale
2. **Schema sketch** — table definitions (columns, types, FK constraints, join tables)
3. **ORM configuration** — idiomatic annotations/model fields for the detected stack
4. **Write-order dependency graph** — insert/update ordering to satisfy FK constraints
5. **Anti-pattern flags** — any meaningful keys, LOB-queryability risks, or orphaned FK references identified
**Output template (per structure):**
```markdown
## [Structure Name]
**Pattern:** [Pattern Name]
**Schema:**
[table_name]([pk] BIGINT PK, [fk] BIGINT FK → [other_table.pk], [field] TYPE, ...)
**ORM:**
[stack-specific annotation/field declaration]
**Rationale:** [1-2 sentences on why this pattern fits]
**Warning:** [if anti-pattern risk exists]
```
## Key Principles
**1. Surrogate keys over meaningful keys — always.**
Meaningful keys (SSN, email, order number) require uniqueness AND immutability from the real world, which human error and business rule changes routinely violate. Surrogate auto-assigned keys give you control over both. Fowler's framing: "take a rare stand on the side of meaninglessness."
**2. Association Table Mapping is the only correct answer for N:M.**
Any attempt to encode a many-to-many with a list-of-IDs column violates first normal form and will make future queries impossible. Use a join table, even if the association has no attributes today — you will thank yourself when it acquires them.
**3. Dependent Mapping requires no-external-FK discipline.**
A child object only qualifies as a dependent if no other table holds a FK reference to its table. The moment another entity needs a direct reference to the child, the child needs its own Identity Field and becomes a standalone entity mapped via Foreign Key Mapping.
**4. All Value Objects should use Embedded Value.**
DDD Value Objects (Money, Address, DateRange) have no independent identity. Giving them their own table and Identity Field is wrong — it implies identity they don't have and adds joins where none are needed. The owner table absorbs their columns.
**5. Serialized LOB is a trap for queryable data.**
The check is binary: will SQL ever need to filter, sort, or join on data inside the column? If yes, normalize it. PostgreSQL JSONB operators and XPath do not change this calculus — they are not portable and do not perform at scale like indexed normalized columns.
**6. The structural inversion rule for 1:N.**
In OO, the parent holds the collection (album has tracks). In the relational model, the FK is on the child (track.album_id). This inversion is the source of most ORM confusion. The rule: **the FK always lives on the "many" side of the relationship**, regardless of which direction the OO association points.
**7. When a join table acquires attributes, promote it to an entity.**
The moment a link table needs its own data (start_date, end_date, role, weight), it should become a first-class entity with its own Identity Field and explicit Foreign Key Mappings on both sides. This is a DDD relationship-as-entity promotion.
## Examples
### Example 1: E-Commerce Order Domain
**Trigger:** Team is designing the persistence layer for an order management system. Domain classes: Customer, Order, LineItem, Address (value object for shipping and billing).
**Process:**
- Customer: entity → Identity Field (surrogate `customer_id BIGINT`)
- Order: entity → Identity Field; references Customer → Foreign Key Mapping (`order.customer_id FK → customers.customer_id`)
- LineItem: child with no independent identity, only exists within Order → Dependent Mapping (`order_id, sequence` composite PK or surrogate; cascade all)
- ShippingAddress / BillingAddress: Value Objects → Embedded Value (columns `shipping_street`, `shipping_city`, `shipping_zip`, `billing_street`, etc. on the `orders` table)
- Order ↔ Promotion (a customer's Order can use multiple Promotions, and a Promotion applies to multiple Orders): N:M → Association Table Mapping (`order_promotions(order_id, promotion_id)`)
**Output:**
```sql
customers(customer_id BIGINT PK, name VARCHAR)
orders(order_id BIGINT PK, customer_id BIGINT FK,
shipping_street VARCHAR, shipping_city VARCHAR, shipping_zip VARCHAR,
billing_street VARCHAR, billing_city VARCHAR, billing_zip VARCHAR)
line_items(line_item_id BIGINT PK, order_id BIGINT FK, product_id BIGINT FK,
quantity INT, unit_price DECIMAL)
order_promotions(order_id BIGINT FK, promotion_id BIGINT FK, PRIMARY KEY(order_id, promotion_id))
```
LineItem uses cascade-delete. ShippingAddress and BillingAddress are Embedded Value — no join needed to load them. Order↔Promotion uses a join table with no attributes (yet).
---
### Example 2: Music Library with Dependent Tracks
**Trigger:** Mapping the Artist/Album/Track domain from PEAA Chapter 12.
**Process:**
- Artist: entity → Identity Field (`artist_id BIGINT`)
- Album: entity, references Artist → Identity Field + Foreign Key Mapping (`album.artist_id FK → artists.artist_id`)
- Track: child of Album with no identity outside Album context; no other table references Track directly → Dependent Mapping. Album mapper loads/saves/deletes all Tracks. Track has no independent finder.
- Track has no Identity Field; Album mapper deletes-and-reinserts all Tracks when Album is saved.
**Output schema:**
```sql
artists(artist_id BIGINT PK, name VARCHAR)
albums(album_id BIGINT PK, artist_id BIGINT FK, title VARCHAR)
tracks(album_id BIGINT FK, sequence INT, title VARCHAR, duration INT,
PRIMARY KEY(album_id, sequence))
```
ORM (Hibernate): `@OneToMany(cascade = CascadeType.ALL, orphanRemoval = true)` on Album → tracks. Tracks loaded when Album is loaded.
---
### Example 3: Legacy LOB Anti-Pattern Detection
**Trigger:** Existing system stores customer contact preferences as XML CLOB in the `customers` table. Support team needs to query "all customers who prefer email contact." Currently impossible via SQL.
**Process:**
- Identify: `customers.preferences_xml CLOB` — a Serialized LOB.
- Apply check: Do SQL queries need to filter on data inside the LOB? Yes — `preferred_channel = 'email'` must be queryable.
- Verdict: Anti-pattern. Serialized LOB used for queryable data.
- Recommendation: Normalize to a `customer_preferences` table: `(customer_id BIGINT FK, preference_key VARCHAR, preference_value VARCHAR)` or `(customer_id, channel ENUM, enabled BOOLEAN)`. Apply Foreign Key Mapping.
- Exception path: If the preference structure is complex and evolving AND a reporting-only database handles the queries, Serialized LOB with JSONB can remain in the operational DB while the reporting DB normalizes the structure.
## References
- [Pattern Decision Table](references/pattern-decision-table.md) — Full routing table with edge cases
- [ORM Mapping Cheatsheet](references/orm-mapping-cheatsheet.md) — Per-pattern ORM config for Hibernate/JPA, EF Core, SQLAlchemy, Django, Rails, TypeORM
- [Anti-Pattern Checklist](references/anti-pattern-checklist.md) — Detection signals for all 5 structural anti-patterns
## License
This skill is licensed under [CC-BY-SA-4.0](https://creativecommons.org/licenses/by-sa/4.0/).
Source: [BookForge](https://github.com/bookforge-ai/bookforge-skills) — Patterns of Enterprise Application Architecture by Martin Fowler, David Rice, Matthew Foemmel, Edward Hieatt, Robert Mee, Randy Stafford.
## Related BookForge Skills
Install related skills from ClawhHub:
- `clawhub install bookforge-data-source-pattern-selector`
- `clawhub install bookforge-inheritance-mapping-selector`
- `clawhub install bookforge-data-access-anti-pattern-auditor`
Or install the full book set from GitHub: [bookforge-skills](https://github.com/bookforge-ai/bookforge-skills)
FILE:references/anti-pattern-checklist.md
# Anti-Pattern Checklist: O/R Structural Mapping
Five structural anti-patterns flagged in PEAA Chapter 3 and 12.
## 1. Meaningful Key Leakage
**Signal:** The primary key of a table is a business identifier (SSN, email, order-number, product-code, username).
**Why it fails:**
- Real-world identifiers are assigned by humans or external systems — both uniqueness and immutability depend on actors outside your control.
- A mistyped SSN creates a row that is neither unique nor correctable without a key migration.
- Business rules change: email addresses change, product codes get reused, order numbering schemes change.
**Detection in codebase:**
```
Grep for: @Id.*email | @Id.*username | @Id.*name | primary_key=True.*CharField
Schema: PRIMARY KEY (email), PRIMARY KEY (username), PRIMARY KEY (sku)
```
**Fix:** Introduce a surrogate `BIGINT` or UUID PK. Retain the meaningful value as a unique-constrained non-key column (`UNIQUE INDEX`).
---
## 2. Association Table Mapping Bypass (N:M via Repeated FKs)
**Signal:** A many-to-many relationship is modeled as an array/JSON column of IDs, or by adding multiple FK columns (`skill_1_id`, `skill_2_id`, `skill_3_id`).
**Why it fails:**
- Arrays in a column violate first normal form — you cannot join, filter, or count on them reliably with SQL.
- Fixed FK columns have a hard-coded upper bound on the number of associations.
- Neither approach is scalable when relationships grow.
**Detection:**
```
Schema: columns named *_1_id, *_2_id, *_3_id
Code: storing JSON array of IDs in a VARCHAR/TEXT column
```
**Fix:** Create a proper join table: `entity_a_entity_b(entity_a_id FK, entity_b_id FK, PRIMARY KEY(entity_a_id, entity_b_id))`.
---
## 3. Dependent with External FK Reference
**Signal:** A class that is treated as a dependent (no Identity Field, saved by owner) is also referenced by a FK from another table.
**Why it fails:**
- Dependent Mapping's safe delete-and-reinsert strategy breaks when other rows reference the dependent — cascade delete would orphan them or fail the FK constraint.
- Identity ambiguity: if the same dependent row is referenced from two places, which owner is responsible for it?
**Detection:**
```sql
-- FK pointing at what should be a dependent table
REFERENCES tracks(track_id) -- from a playlist_tracks table, while Track is treated as Album dependent
```
**Fix:** Give the child class an Identity Field and change all owner references to Foreign Key Mapping. Establish explicit cascade rules per relationship.
---
## 4. Embedded Value for Shared or Variable Data
**Signal:**
- A value object's columns appear in multiple tables (sharing violation), OR
- The owner table has a variable number of embedded value instances (e.g., `phone_1_number`, `phone_2_number`, `phone_3_number`).
**Why it fails:**
- Shared embedded data becomes inconsistent when one owner's copy is updated but not the others.
- Variable-count embedded values create nullable column sprawl and make "find all customers with a fax number" impossible without checking all numbered columns.
**Detection:**
```
Schema: columns like phone_1_type, phone_1_number, phone_2_type, phone_2_number
Code: same Address class embedded in both Customer and Order tables (duplicated)
```
**Fix for shared data:** Normalize the value into its own table; use Foreign Key Mapping.
**Fix for variable count:** Use a one-to-many child table (Foreign Key Mapping or Dependent Mapping depending on identity needs).
---
## 5. Serialized LOB for Queryable Data
**Signal:** Data stored as XML, JSON blob, or binary BLOB in a column that SQL queries need to filter, sort, or join on.
**Why it fails:**
- SQL cannot efficiently filter or sort on data buried in a LOB without full deserialization at the application layer.
- PostgreSQL JSONB operators (`@>`, `#>>`) can query JSON fields, but this is not portable, and JSONB GIN indexes do not scale as well as normalized table indexes for high-cardinality filtering.
- Reporting tools, BI systems, and data warehouses expect normalized columns.
**Detection:**
```sql
-- Filtering on a CLOB/JSON column with application-side parsing
SELECT * FROM orders WHERE preferences_xml LIKE '%email%'
-- Or: Application code deserializes LOB to filter in memory
customers.filter { deserialize(it.prefs_blob).channel == 'email' }
```
**Fix:** Normalize the queryable fields into columns or a child table. If the LOB is large and only some fields are queried, consider a hybrid: store the full LOB for display but project the queried fields into regular indexed columns.
**Legitimate Serialized LOB uses:**
- Storing a snapshot of an order's line-item state at the time of checkout (immutable, never queried by content).
- Storing user-defined form responses where the schema varies per form and reports run against a separate reporting DB.
- Configuration or settings that are always loaded and deserialized as a whole object, never partially queried.
FILE:references/orm-mapping-cheatsheet.md
# ORM Mapping Cheatsheet
Per-pattern ORM configuration for six major stacks.
## Identity Field
| Stack | Configuration |
|-------|--------------|
| Hibernate/JPA | `@Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id;` |
| EF Core | `public long Id { get; set; }` (convention) or `[Key]` attribute |
| SQLAlchemy | `id = Column(BigInteger, primary_key=True, autoincrement=True)` |
| Django | `id = models.BigAutoField(primary_key=True)` (default since Django 3.2) |
| Rails | Auto-generated `id :bigint` on `create_table` |
| TypeORM | `@PrimaryGeneratedColumn('increment') id: number;` or `@PrimaryGeneratedColumn('uuid')` |
**UUID variant:**
- Hibernate: `@GeneratedValue(strategy = GenerationType.UUID)` (JPA 3.1+) or `@GenericGenerator(name="uuid", strategy="uuid2")`
- EF Core: `public Guid Id { get; set; }` + `ValueGeneratedOnAdd()`
- SQLAlchemy: `id = Column(UUID(as_uuid=True), primary_key=True, default=uuid4)`
- Django: `id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)`
---
## Foreign Key Mapping
### Single-valued reference (N:1, Album → Artist)
| Stack | Owner side (FK column) | Referenced side |
|-------|------------------------|-----------------|
| Hibernate/JPA | `@ManyToOne @JoinColumn(name="artist_id") Artist artist;` | `@OneToMany(mappedBy="artist") List<Album> albums;` |
| EF Core | `public long ArtistId { get; set; }` + `public Artist Artist { get; set; }` | `public ICollection<Album> Albums { get; set; }` |
| SQLAlchemy | `artist_id = Column(BigInteger, ForeignKey('artists.id'))` + `artist = relationship('Artist', back_populates='albums')` | `albums = relationship('Album', back_populates='artist')` |
| Django | `artist = models.ForeignKey(Artist, on_delete=models.PROTECT, related_name='albums')` | (reverse available as `artist.albums.all()`) |
| Rails | `belongs_to :artist` in Album; `has_many :albums` in Artist | |
| TypeORM | `@ManyToOne(() => Artist) @JoinColumn() artist: Artist;` | `@OneToMany(() => Album, a => a.artist) albums: Album[];` |
---
## Association Table Mapping (N:M)
### Employee ↔ Skill example
| Stack | Configuration |
|-------|--------------|
| Hibernate/JPA | `@ManyToMany @JoinTable(name="employee_skills", joinColumns=@JoinColumn(name="emp_id"), inverseJoinColumns=@JoinColumn(name="skill_id")) Set<Skill> skills;` |
| EF Core | Explicit join entity: `modelBuilder.Entity<EmployeeSkill>().HasKey(es => new { es.EmployeeId, es.SkillId });` |
| SQLAlchemy | `Table('employee_skills', Base.metadata, Column('emp_id', ForeignKey('employees.id')), Column('skill_id', ForeignKey('skills.id')))` then `skills = relationship('Skill', secondary=employee_skills)` |
| Django | `skills = models.ManyToManyField(Skill)` (auto join table) or `skills = models.ManyToManyField(Skill, through='EmployeeSkill')` |
| Rails | `has_many :employee_skills` + `has_many :skills, through: :employee_skills` |
| TypeORM | `@ManyToMany(() => Skill) @JoinTable() skills: Skill[];` |
**When join table needs attributes:** Always use an explicit through/join entity and give it an Identity Field.
---
## Dependent Mapping
### Album → Tracks (Album mapper owns Track persistence)
| Stack | Configuration |
|-------|--------------|
| Hibernate/JPA | `@OneToMany(cascade = CascadeType.ALL, orphanRemoval = true) @JoinColumn(name="album_id") List<Track> tracks;` |
| EF Core | `HasMany(a => a.Tracks).WithOne().HasForeignKey("AlbumId").OnDelete(DeleteBehavior.Cascade)` |
| SQLAlchemy | `tracks = relationship('Track', cascade='all, delete-orphan', passive_deletes=True)` |
| Django | `class Track(models.Model): album = models.ForeignKey(Album, on_delete=models.CASCADE, related_name='tracks')` |
| Rails | `has_many :tracks, dependent: :destroy` |
| TypeORM | `@OneToMany(() => Track, t => t.album, { cascade: true, eager: true }) tracks: Track[];` |
**Note:** The Track model should not have a reverse `album` reference that is independently navigable by other domain services — enforce access only through Album.
---
## Embedded Value
### Employment → Money + DateRange
| Stack | Configuration |
|-------|--------------|
| Hibernate/JPA | `@Embeddable class Money { BigDecimal amount; String currency; }` + `@Embedded Money salary;` (columns: `salary_amount`, `salary_currency` on `employment` table) |
| EF Core | `[Owned] class Address { string Street; string City; }` + `modelBuilder.Entity<Customer>().OwnsOne(c => c.Address)` |
| SQLAlchemy | `composite(Address, employment.c.addr_street, employment.c.addr_city, employment.c.addr_zip)` or just inline columns |
| Django | Direct fields on the model: `salary_amount = models.DecimalField(...)`, `salary_currency = models.CharField(...)` |
| Rails | `store_accessor :address_data, :street, :city, :zip` or plain columns |
| TypeORM | `@Column() salaryAmount: number; @Column() salaryCurrency: string;` or `@Embedded(() => Money) salary: Money;` |
---
## Serialized LOB
### Department hierarchy as CLOB (use with caution)
| Stack | Configuration |
|-------|--------------|
| Hibernate/JPA | `@Lob @Column(name="dept_hierarchy") String deptHierarchyXml;` |
| EF Core | `public string DeptHierarchyJson { get; set; }` + `HasConversion<string>()` or custom value converter |
| SQLAlchemy | `dept_hierarchy = Column(Text)` or `Column(JSONB)` (PostgreSQL) |
| Django | `dept_hierarchy = models.JSONField()` (Django 3.1+) or `models.TextField()` |
| Rails | `store :dept_hierarchy, coder: JSON` or just `text :dept_hierarchy` |
| TypeORM | `@Column({ type: 'jsonb' }) deptHierarchy: DeptNode;` |
**PostgreSQL JSONB query example (limited use):**
```sql
SELECT * FROM customers WHERE preferences @> '{"channel": "email"}';
-- Only acceptable if: (1) PostgreSQL-only deployment, (2) GIN index on preferences column,
-- (3) query patterns are bounded and known at schema design time
```
FILE:references/pattern-decision-table.md
# Pattern Decision Table
Full routing table for PEAA structural mapping patterns, including edge cases.
## Primary Routing Table
| Domain Structure | Default Pattern | Edge Case / Override |
|-----------------|-----------------|----------------------|
| Persistable entity with independent lifecycle | Identity Field (surrogate) | Existing DB with meaningful key: map it but document the risk |
| Single-valued reference to another entity (1:1) | Foreign Key Mapping | If the referenced entity is private and never shared → consider Dependent Mapping |
| One-to-many collection (parent owns children) | Foreign Key Mapping (FK on child side) | If children have no identity and no external FK references → Dependent Mapping |
| Many-to-many relationship | Association Table Mapping | Always; no alternative in relational DBs |
| Many-to-many with attributes | Association Table Mapping → promote to entity | The link table entity gets its own Identity Field |
| Child object with no independent identity | Dependent Mapping | If another entity needs a direct FK to the child → give child an Identity Field |
| DDD Value Object (Money, Address, DateRange) | Embedded Value | If value is shared across owners → normalize to its own table with FK |
| Complex non-queryable subgraph | Serialized LOB | Only when zero SQL query need on internals; prefer JSON over BLOB |
| Self-referential hierarchy (parent/child same type) | Foreign Key Mapping (`parent_id` self-referencing FK) | If unbounded depth + complex traversal → consider Serialized LOB with tree path |
## Identity Field — Key Type Decision
| Criterion | Choice |
|-----------|--------|
| New schema, no legacy constraints | Surrogate `BIGINT` auto-increment |
| Distributed system, no central sequence | UUID v4 or v7 (ordered) |
| Existing natural key that IS truly immutable and unique | Keep it, but add a surrogate for O/R mapping |
| Class Table or Concrete Table Inheritance hierarchy | Keys must be unique across the hierarchy, not just per-table |
## Association Table Mapping — When to Add Attributes
| Signal | Action |
|--------|--------|
| Join table needs start_date / end_date | Add columns to join table; consider promoting to entity |
| Join table needs role, weight, or type | Promote to a first-class entity with Identity Field |
| Join table rows must be individually updateable | Promote to entity (need Identity Field for row identity) |
| Join table is append-only (immutable history) | Can keep as plain Association Table; add created_at timestamp |
## Dependent Mapping — Qualification Checklist
A child qualifies for Dependent Mapping only if ALL of the following are true:
- [ ] Has exactly one owner
- [ ] No other table holds a FK pointing to the child's table
- [ ] No in-memory entity other than the owner (or its other dependents) holds a persisted reference to the child
- [ ] Child cannot be loaded independently (no finder method)
- [ ] Child's lifecycle is fully controlled by the owner (created/deleted when owner is)
If ANY check fails, the child needs an Identity Field and Foreign Key Mapping.
## Embedded Value — Qualification Checklist
An object qualifies for Embedded Value only if ALL of the following are true:
- [ ] It is a Value Object (no identity — two instances with same values are equal)
- [ ] It belongs to exactly one owning entity
- [ ] The cardinality is 1:1 (or a small fixed set)
- [ ] Its fields will never be the sole target of SQL filtering, sorting, or joining outside the owner
- [ ] It is not shared across multiple owners
## Serialized LOB — Anti-Pattern Detection
Stop and reconsider if any of these signals are present:
| Signal | Problem |
|--------|---------|
| A SQL query filters on a field inside the LOB | The data is queryable — normalize it |
| A report joins to data inside the LOB | Same as above; normalize or replicate to a reporting table |
| The LOB class definition changes frequently | Binary BLOBs will break; JSON CLOBs need migration scripts |
| Multiple objects reference data inside the LOB | LOB identity hazard — the data will be duplicated or become inconsistent |
| The LOB column is on a table with many rows and high read frequency | Deserializing LOBs at scale has significant CPU cost |
## Modern Stack Notes
| ORM | Identity Field | Foreign Key Mapping | Association Table | Dependent Mapping | Embedded Value | Serialized LOB |
|-----|---------------|---------------------|-------------------|-------------------|----------------|----------------|
| Hibernate/JPA | `@Id @GeneratedValue` | `@ManyToOne @JoinColumn` | `@ManyToMany @JoinTable` | `cascade=ALL, orphanRemoval=true` | `@Embedded @Embeddable` | `@Lob String` / `@Column(columnDefinition="jsonb")` |
| EF Core | `[Key]` / convention `Id` | `public int FkId` + navigation | explicit join entity | `HasMany.WithOne.OnDelete(Cascade)` | `[Owned]` / `OwnsOne` | `string JsonField` mapped to JSON column |
| SQLAlchemy | `Column(Integer, primary_key=True)` | `ForeignKey` + `relationship` | association Table + secondary | `cascade="all, delete-orphan"` | `composite()` | `Column(JSON)` or `Column(Text)` |
| Django | auto `id` field | `ForeignKey(on_delete=CASCADE)` | `ManyToManyField` or `through` | `related_name='+'` + cascade | inline fields on model | `JSONField` |
| Rails ActiveRecord | auto `id` | `belongs_to` / `has_many` | `has_many :through` | `has_many dependent: :destroy` | virtual attrs / `store_accessor` | `store :data, accessors: [...]` |
| TypeORM | `@PrimaryGeneratedColumn` | `@ManyToOne @JoinColumn` | `@ManyToMany @JoinTable` | `cascade: true` | `@Column` on embedded | `@Column('jsonb')` |
Implement Lazy Load (deferred loading) correctly in a persistence layer to avoid N+1 queries, ripple loading, and proxy identity traps. Use when encountering...
---
name: lazy-load-strategy-implementer
description: |
Implement Lazy Load (deferred loading) correctly in a persistence layer to avoid N+1 queries, ripple loading, and proxy identity traps. Use when encountering slow object graph loads, N+1 query problems, out-of-memory on eager loading, ORM lazy loading misconfiguration, or deciding between eager vs lazy loading strategies. Applies to: Hibernate FetchType.LAZY / @BatchSize, SQLAlchemy lazy='select'/'selectin'/'subquery', Django select_related / prefetch_related, EF Core Include() vs Load(), TypeORM eager/lazy relations, Rails includes/preload/eager_load, hand-rolled Data Mapper with virtual proxy patterns. Covers all four implementation variants — lazy initialization, virtual proxy, value holder, ghost — with applicability rules and trade-off analysis. Identifies and fixes the ripple loading anti-pattern (N+1 on collections), the proxy identity trap (two proxies, same row, broken equality), and misuse of Lazy Load on small graphs that should just be eagerly loaded. Produces an implementation plan: chosen variant, ORM configuration or code sketch, batch loading config, eager-load overrides for hot paths, Identity Map integration, and a ripple-loading audit. Requires knowing the data-source pattern already in use (Data Mapper / ORM vs Active Record); if unknown invoke data-source-pattern-selector first.
version: 1.0.0
homepage: https://github.com/bookforge-ai/bookforge-skills/tree/main/books/patterns-of-enterprise-application-architecture/skills/lazy-load-strategy-implementer
metadata: {"openclaw":{"emoji":"📚","homepage":"https://github.com/bookforge-ai/bookforge-skills"}}
status: draft
source-books:
- id: patterns-of-enterprise-application-architecture
title: "Patterns of Enterprise Application Architecture"
authors: ["Martin Fowler", "David Rice", "Matthew Foemmel", "Edward Hieatt", "Robert Mee", "Randy Stafford"]
chapters: [3, 11]
domain: persistence
tags: ["lazy-loading", "persistence", "orm", "performance", "design-patterns", "n-plus-one", "deferred-loading", "batch-loading", "prefetch", "virtual-proxy", "object-relational-mapping", "fowler-peaa", "database-performance"]
depends-on: ["data-source-pattern-selector"]
execution:
tier: 2
mode: hybrid
inputs:
- type: codebase
description: "Persistence layer source code (entity/model classes, repositories or mappers, ORM configuration). Provide specific classes exhibiting N+1, slow load symptoms, or memory pressure."
- type: document
description: "Description of which data-source pattern is in use (Data Mapper / ORM, Active Record, hand-rolled), the ORM/framework name, the domain object graph shape (which associations are optional vs always-needed), and observed performance symptoms."
tools-required: [Read, Grep, Write]
tools-optional: [Bash]
mcps-required: []
environment: "Any agent environment with access to the codebase. Works best when the specific slow queries or N+1 symptoms can be located in source code or query logs."
discovery:
goal: "Produce a Lazy Load implementation plan — variant selection, code sketch or ORM config, batch loading, eager-load overrides, Identity Map integration, and ripple-loading audit."
tasks:
- "Identify the data-source pattern and ORM/framework in use"
- "Classify associations: optional-deep (lazy candidate), expensive-field (lazy candidate), always-needed (eager), immediately-iterated (eager with batch)"
- "Detect existing ripple loading: collections of individually-lazy objects iterated in a loop"
- "Select a Lazy Load variant: Virtual Proxy (ORM-provided), Lazy Initialization (hand-rolled Active Record), Value Holder (explicit wrapper), Ghost (instrumentation-heavy stacks)"
- "Identify proxy identity traps and prescribe Identity Map integration"
- "Specify batch loading configuration to prevent ripple loading on lazy collections"
- "Identify hot paths that need eager-load overrides (fetch joins / includes)"
- "Write the implementation plan artifact"
audience:
roles: ["senior-backend-engineer", "software-architect", "tech-lead"]
experience: "intermediate-to-advanced"
when_to_use:
triggers:
- "Profiling reveals N+1 queries: N database calls where 1 batch would suffice"
- "Iterating a collection triggers one query per element"
- "Eager loading a large object graph consumes excessive memory or makes startup slow"
- "ORM lazy loading is configured but equality/hashCode bugs appear with proxy objects"
- "Choosing between FetchType.LAZY and FetchType.EAGER in Hibernate / JPA"
- "Configuring SQLAlchemy relationship lazy parameter"
- "Deciding when to use Django prefetch_related vs select_related vs neither"
- "Hand-rolling deferred loading for a large blob or expensive computed field"
- "Ripple loading crippling an application's performance at scale"
prerequisites:
- "Data-source pattern chosen. If unknown, run `data-source-pattern-selector` first."
not_for:
- "Query optimization, index design, or schema changes unrelated to loading strategy"
- "Concurrency or locking concerns — see `optimistic-offline-lock-implementer`"
- "Choosing the data-source pattern itself — this skill implements Lazy Load within a chosen pattern"
- "Applications with small, fully in-memory datasets that never hit a database"
environment:
codebase_required: false
codebase_helpful: true
works_offline: true
quality:
scores:
with_skill: "{filled by tester}"
baseline: "{filled by tester}"
delta: "{filled by tester}"
tested_at: "{filled by tester}"
eval_count: "{filled by tester}"
assertion_count: 13
iterations_needed: "{filled by tester}"
---
# Lazy Load Strategy Implementer
## When to Use
You have an object graph backed by a relational database. Loading some objects eagerly pulls in far more data than any single use case needs — but loading objects lazily without discipline produces N+1 queries (one database call per object in a collection) or ripple loading (a cascade of individual loads triggered across the graph).
Apply this skill when:
- A query log shows N+1 round-trips for a collection
- A single user action triggers dozens of database calls
- Memory or startup latency is unacceptable due to eager-loading large associations
- You are configuring or auditing ORM fetch strategies
Do not apply Lazy Load when:
- The object is always needed immediately after loading its parent (eager load with a fetch-join is simpler)
- The collection is always iterated in full by the first use case that loads the parent (eager load it)
- The application uses a very small, bounded dataset (load everything upfront)
Prerequisite: know which data-source pattern the codebase uses. If unclear, invoke `data-source-pattern-selector` first, or ask the user. Lazy Load implementation differs significantly between hand-rolled Active Record (Lazy Initialization is simplest) and Data Mapper / ORM (Virtual Proxy or Ghost are standard).
## Context & Input Gathering
Gather the following before proceeding:
**Required:**
- Data-source pattern in use: Active Record / Row Data Gateway / Table Data Gateway / Data Mapper (ORM)
- ORM or framework name (Hibernate, EF Core, SQLAlchemy, Django ORM, TypeORM, ActiveRecord Rails, hand-rolled)
- Which associations or fields are suspected of causing performance problems
**Observable from codebase (Grep/Read):**
- ORM relationship annotations / configuration (`@OneToMany`, `relationship()`, `belongs_to`, etc.)
- Existing fetch strategy declarations (`FetchType.LAZY`, `lazy='select'`, `include:` option)
- Query log patterns: identical SELECT statements repeated N times in a loop context
**Defaults if not provided:**
- If ORM is present: assume it ships a Virtual Proxy implementation; configure rather than hand-roll
- If no ORM: Lazy Initialization is the safest starting variant
**Sufficiency check:** If you know the ORM name, one or two entity classes, and the symptom (N+1 or memory pressure), that is enough to produce the implementation plan. Do not wait for full codebase access.
## Process
### Step 1 — Classify every association as eager or lazy candidate
WHY: Applying Lazy Load blindly to every association trades an eager-loading problem for a ripple-loading problem. The classification determines which associations get Lazy Load, and which get eager loading or fetch-join overrides.
For each association in the entity/model:
- **Lazy candidate:** association is optional for most use cases AND loading it requires an extra database round-trip AND the data is not always needed immediately
- **Eager candidate:** association is always used immediately after the parent loads (address on User, status on Order), OR the collection is always iterated in full by the primary use case
- **Hot-path override candidate:** association is lazy by default but specific use cases (reports, listings) need it eagerly — mark for fetch-join override in those queries
Document the classification in a table: association name | direction | lazy/eager/override | reason.
### Step 2 — Detect existing ripple loading
WHY: Ripple loading is the primary failure mode of naively applied Lazy Load. A collection of individually-lazy objects iterated in a loop causes N database calls where one batch call would suffice. This must be identified before choosing a variant.
Search the codebase for these patterns:
- A loop over a collection, with a field access or method call inside the loop that touches an association on each element
- ORM queries that load a list, followed by property accesses on each element that are not covered by a `JOIN FETCH`, `include`, `prefetch_related`, or batch size config
Flag each occurrence: it is a ripple-loading site. The fix in every case is: make the *collection itself* the lazy unit, loaded in one batch, not each element individually.
### Step 3 — Select a Lazy Load variant
WHY: The four variants differ in how visible the lazy mechanism is to calling code, what identity guarantees they provide, and how much infrastructure they require. The ORM context nearly always determines which variant is practical.
Choose one variant per the decision tree:
**If using a standard ORM (Hibernate, EF Core, SQLAlchemy ORM, Django ORM, TypeORM, Rails ActiveRecord):**
- Use the **Virtual Proxy** the ORM provides natively. Configure via fetch type / relationship option, not hand-rolled code. The ORM's proxy already handles load-on-access.
- Skip to Step 4 to configure correctly and prevent ripple loading.
**If using hand-rolled Data Mapper or Active Record (no ORM proxy available):**
- Choose **Lazy Initialization** for simple cases: a getter checks `if (field == null) { field = load(); }` and returns the field. Simplest, but null must not be a legitimate value — use a sentinel flag or loaded boolean if it can be.
- Choose **Value Holder** when you need an explicit, strongly-typed lazy wrapper and calling code can tolerate calling `.getValue()` instead of accessing the field directly. Useful when you want laziness to be visible in the type system.
- Choose **Ghost** when you need identity preservation without a separate proxy object and your stack supports instrumented field access (aspect-oriented programming, bytecode manipulation, or Python descriptors). Every field accessor triggers a full load on first access.
- Do NOT use Virtual Proxy for domain classes in statically-typed languages unless an AOP or code-generation facility is available — you must create one proxy class per proxied class, which becomes unwieldy.
For collections specifically: regardless of variant, always make the collection itself the lazy unit (one database call loads all elements). Never create a collection of individually-lazy domain objects.
Record: chosen variant + rationale + which associations it applies to.
### Step 4 — Implement or configure the chosen variant
WHY: Abstract variant choice produces no value without an executable implementation or ORM configuration.
**Virtual Proxy via ORM (preferred for ORM stacks):**
Java/Hibernate:
```java
@OneToMany(fetch = FetchType.LAZY, mappedBy = "order")
@BatchSize(size = 50) // prevents ripple loading: loads 50 collections per SQL IN clause
private List<OrderItem> items;
```
Python/SQLAlchemy:
```python
# lazy='select' = default proxy; 'selectin' = batch load (prevents ripple)
items = relationship("OrderItem", lazy="selectin") # preferred for collections
```
Django:
```python
# Don't configure the model — configure the query
# select_related: SQL JOIN for FK / one-to-one (eager for the query)
Order.objects.select_related("customer")
# prefetch_related: separate batch query for reverse FK and M2M (batch lazy)
Order.objects.prefetch_related("items")
```
EF Core (C#):
```csharp
// Enable lazy loading proxies globally (opt-in)
services.AddDbContext<AppContext>(o => o.UseLazyLoadingProxies());
// Or explicit load (Value Holder style):
context.Entry(order).Collection(o => o.Items).Load();
// Or eager with Include for hot paths:
context.Orders.Include(o => o.Items).Where(...);
```
TypeORM:
```typescript
@OneToMany(() => OrderItem, item => item.order, { lazy: true })
items: Promise<OrderItem[]>; // caller awaits to trigger load
```
**Lazy Initialization (hand-rolled):**
```java
class Supplier {
private List<Product> products; // null = not yet loaded
private boolean productsLoaded = false;
public List<Product> getProducts() {
if (!productsLoaded) {
products = ProductMapper.findForSupplier(this.id);
productsLoaded = true;
}
return products;
}
}
```
Use `productsLoaded` flag (not null check) when null is a legitimate value.
**Value Holder (hand-rolled):**
```java
class Supplier {
private ValueHolder<List<Product>> products;
public Supplier(long id) {
this.products = new ValueHolder<>(() -> ProductMapper.findForSupplier(id));
}
public List<Product> getProducts() { return products.getValue(); }
}
```
**Ghost (hand-rolled):** See `references/ghost-implementation-guide.md` for full implementation — requires instrumenting every field accessor in a domain supertype and using a Registry + Separated Interface to avoid domain-to-mapper dependency.
### Step 5 — Add batch loading to prevent ripple loading
WHY: A lazy collection still causes ripple loading if each element in a parent collection triggers its own individual sub-collection load. Batch loading converts N queries into ceil(N/batch_size) queries.
- **Hibernate:** `@BatchSize(size = 50)` on the collection. Hibernate issues one `WHERE id IN (...)` for up to 50 parents at a time.
- **SQLAlchemy:** `lazy='selectin'` on the relationship. SQLAlchemy issues one `SELECT ... WHERE parent_id IN (...)` for all loaded parents.
- **Django:** `prefetch_related('items')` on the QuerySet. Django issues one query per prefetched relation for the entire result set.
- **EF Core:** `.Include(o => o.Items)` on the query (adds a JOIN, not N separate SELECTs).
- **Rails:** `includes(:items)` or `preload(:items)` on the scope.
Batch size guidance: 50 is a safe default. Larger batches reduce round-trips but increase per-query data volume. Set based on average result set size.
### Step 6 — Fix the proxy identity trap with Identity Map
WHY: A Virtual Proxy is a different object than the real domain object it wraps. Two proxies for the same database row have different object references (`proxy1 != proxy2`). Code that tests identity (`==`, `is`, `===`) or uses proxied objects as hash keys will break silently.
Detection: search the codebase for:
- `if (a == b)` where `a` or `b` could be a lazy proxy
- Collections that use domain objects as keys or set members
Fix: ensure the ORM's Identity Map (first-level cache / session cache) is active and returns the same proxy instance for the same primary key within a session. For hand-rolled Virtual Proxy, the mapper's Identity Map must return the same proxy object on repeated finds.
For equality checks: override `equals()`/`__eq__()`/`Equals()` to compare by primary key, not by object reference. This is mandatory for any domain class that participates in sets or maps.
Ghost variant avoids this problem entirely: the ghost IS the domain object, so identity is preserved.
### Step 7 — Add eager-load overrides for hot paths
WHY: A good default lazy strategy still needs targeted eager overrides for known high-traffic queries. Without them, report queries and list views generate ripple loads even with batch loading.
For each hot path identified in Step 1:
- Add a dedicated query method that includes a fetch-join / Include / eager_load / select_related for that use case
- Keep the default fetch strategy lazy; override at the query site, not the mapping
Example: an order detail screen needs customer, items, and product names. The general Order association is lazy. The detail query overrides:
```java
// Hibernate JPQL fetch join — overrides lazy for this query only
em.createQuery("SELECT o FROM Order o JOIN FETCH o.items i JOIN FETCH i.product WHERE o.id = :id")
```
Document hot-path overrides: query location | associations eagerly fetched | reason.
### Step 8 — Write the implementation plan artifact
WHY: The plan persists decisions and gives the team a single document to review before applying changes.
Produce a Markdown document containing:
1. Association classification table (Step 1)
2. Ripple-loading sites found (Step 2)
3. Chosen variant + rationale (Step 3)
4. Code sketch or ORM config diff (Step 4)
5. Batch loading config (Step 5)
6. Identity Map integration notes and equality overrides (Step 6)
7. Hot-path eager-load overrides (Step 7)
8. Testing approach: query count assertions before/after
## Inputs
- Persistence layer source files (entity classes, mappers, repositories)
- ORM configuration files (Hibernate XML/annotations, SQLAlchemy models, EF fluent config)
- Query log excerpt or N+1 symptom description
- Data-source pattern in use (from `data-source-pattern-selector`)
## Outputs
**Primary artifact:** `lazy-load-implementation-plan.md` containing:
- Association classification table
- Ripple-loading audit (specific files and lines)
- Variant selection decision
- Code sketch or ORM config diff
- Batch loading configuration
- Identity Map / equality notes
- Hot-path eager-load overrides
- Query count testing approach
**Secondary:** inline code diffs for ORM annotation changes or hand-rolled getter implementations.
## Key Principles
**1. The collection is the lazy unit, not the elements.**
The ripple-loading anti-pattern arises when individual elements in a collection are each lazy. Loading the collection lazily (one batch query) is correct. Loading the collection eagerly but each element's sub-associations lazily is also acceptable if the sub-associations are genuinely optional. Never put a Lazy Load on each element of a collection that is iterated immediately.
**2. Lazy Load is only worth its complexity cost when the field requires an extra database round-trip.**
Do not apply Lazy Load to a field stored in the same row as the main object — there is no performance benefit, only added code complexity. The value of Lazy Load is strictly about deferring extra database calls. If the field is co-located in the same SELECT, eager-load it.
**3. ORM proxies need Identity Map backing or they break equality.**
The proxy identity trap is a silent correctness bug: two proxies for the same row compare unequal by reference. Always ensure the ORM session's Identity Map (first-level cache) returns the same proxy for the same key. Override equality to compare by primary key for all domain objects used in sets, maps, or equality comparisons.
**4. Different use cases may need different fetch strategies — use query-level overrides.**
A single ORM mapping cannot be simultaneously optimal for a list screen (lazy, batch-prefetched) and a detail screen (eager fetch-join). Configure lazy as the default, and add fetch-join overrides at the query site for high-traffic paths. Two mapper variants (one lazy, one eager) for the same entity is a legitimate design.
**5. Ghost preserves identity; Virtual Proxy does not.**
When identity semantics matter (domain objects used as set members, equality comparisons in business logic), Ghost is the correct variant: it IS the domain object. Virtual Proxy is a different object that impersonates the real one. For most ORM stacks the ORM manages identity via its session cache, making this a non-issue in practice — but the distinction matters for hand-rolled implementations.
**6. Ripple loading cripples applications at scale; batch loading prevents it.**
A collection of N parent objects, each with a lazy child collection, causes N+1 queries without batch loading. At N=1000, that is 1001 queries. Batch loading reduces this to ceil(N/batch_size)+1 queries. This is not a micro-optimization — it is the difference between a feature working and not working under load.
## Examples
### Scenario A: Java/Hibernate — Order with N+1 on OrderItems
**Trigger:** A report listing 200 orders calls `order.getItems()` inside a loop. Query log shows 201 SELECT statements (1 for orders, 200 for items).
**Process:**
1. Classify: `items` is a lazy candidate (not always needed), but this report needs it — mark as hot-path override.
2. Ripple loading confirmed: items association is `FetchType.LAZY` with no `@BatchSize`.
3. Fix 1 (default strategy): add `@BatchSize(size = 50)` — reduces 200 queries to 4.
4. Fix 2 (hot path): add a report-specific JPQL query with `JOIN FETCH o.items`.
5. Equality override: add `equals()`/`hashCode()` on Order comparing by `id`.
**Output:** Implementation plan with `@BatchSize` annotation diff, JPQL fetch-join query, and equality override for Order.
---
### Scenario B: Python/SQLAlchemy — User with posts relationship paginating
**Trigger:** A feed endpoint loads 50 User objects. Accessing `user.posts` inside a Jinja template causes 50 additional SELECT statements.
**Process:**
1. Classify: `posts` is a lazy candidate for profile screens; batch-loadable for feed.
2. Ripple loading confirmed: `relationship("Post", lazy="select")` — default per-access load.
3. Variant: SQLAlchemy ships Virtual Proxy equivalent; configure `lazy="selectin"`.
4. `lazy="selectin"` issues one `SELECT posts WHERE user_id IN (1,2,...,50)` for the entire page.
5. For profile screen (single user): no change needed — single load is fine.
**Output:** One-line model change (`lazy="select"` → `lazy="selectin"`), test showing query count drops from 51 to 2.
---
### Scenario C: Hand-rolled Java DAO — large blob field loaded on every access
**Trigger:** A `Document` entity has a `content` field (a large text blob). Every SELECT of any document loads the blob even when only the title and date are needed.
**Process:**
1. Classify: `content` is a lazy candidate (expensive, not needed for listings; needed for detail view).
2. No ripple loading (single field, not a collection).
3. Variant: Lazy Initialization in the hand-rolled Data Mapper.
- `content` field starts null; a `contentLoaded` boolean flag tracks state.
- `getContent()` checks the flag, issues a dedicated `SELECT content FROM documents WHERE id=?` if not loaded.
4. No proxy identity issue (the Document object itself is the real object; only the field is deferred).
5. Hot-path override: the document edit screen calls the mapper's `findWithContent(id)` which issues a JOIN or two-column SELECT upfront.
**Output:** Updated `Document.java` getter with lazy init pattern, updated `DocumentMapper.java` with `findWithContent()` method.
## References
- `references/ghost-implementation-guide.md` — Full Ghost variant implementation (domain supertype, load states, Registry + Separated Interface wiring, ghost list for collections)
- `references/lazy-load-variant-comparison.md` — Side-by-side comparison of all four variants on: calling-code transparency, identity preservation, ORM support, instrumentation requirements, null-value handling
- `references/orm-lazy-config-cheatsheet.md` — Per-ORM Lazy Load configuration: Hibernate, EF Core, SQLAlchemy, Django ORM, TypeORM, Rails ActiveRecord
## License
This skill is licensed under [CC-BY-SA-4.0](https://creativecommons.org/licenses/by-sa/4.0/).
Source: [BookForge](https://github.com/bookforge-ai/bookforge-skills) — Patterns of Enterprise Application Architecture by Martin Fowler, David Rice, Matthew Foemmel, Edward Hieatt, Robert Mee, Randy Stafford.
## Related BookForge Skills
Install related skills from ClawhHub:
- `clawhub install bookforge-unit-of-work-implementer`
- `clawhub install bookforge-data-access-anti-pattern-auditor`
Or install the full book set from GitHub: [bookforge-skills](https://github.com/bookforge-ai/bookforge-skills)
FILE:references/ghost-implementation-guide.md
# Ghost Variant: Full Implementation Guide
Source: Patterns of Enterprise Application Architecture, Ch. 11 (Fowler et al., 2002) — C# example
The Ghost is the most infrastructure-heavy Lazy Load variant but has the best identity semantics. Use when:
- Domain objects must participate in sets, maps, or identity comparisons
- Proxy classes per domain class are impractical
- AOP or bytecode manipulation is available
## Core Concept
A ghost is a real domain object initialized with only its primary key. All other fields are null/default. On the first access of any field, the ghost loads its full state from the database. After loading, it behaves identically to an eagerly-loaded object.
Unlike a Virtual Proxy, the ghost IS the domain object — no wrapper. Identity is preserved because the ghost can be placed in the Identity Map immediately upon creation, before it is fully loaded.
## Three Load States
```
GHOST ──── first field access ────► LOADING ──── load complete ────► LOADED
│ │
└── safe to place in Identity Map └── normal object
safe to reference cyclically
NOT safe to access fields
```
The LOADING state prevents re-entrant load calls: when loading Employee triggers loading Department (an association), the Department ghost's `Load()` call is a no-op during its LOADING phase.
## C# Implementation (Fowler's Example)
### Domain Supertype
```csharp
public abstract class DomainObject {
public long Key { get; private set; }
private enum LoadStatus { Ghost, Loading, Loaded }
private LoadStatus _status = LoadStatus.Ghost;
public bool IsGhost => _status == LoadStatus.Ghost;
public bool IsLoaded => _status == LoadStatus.Loaded;
public DomainObject(long key) { Key = key; }
public void MarkLoading() {
Debug.Assert(IsGhost, "Can only start loading from Ghost state");
_status = LoadStatus.Loading;
}
public void MarkLoaded() {
Debug.Assert(_status == LoadStatus.Loading);
_status = LoadStatus.Loaded;
}
// Every subclass field accessor calls this
protected void Load() {
if (IsGhost) DataSource.Load(this);
}
}
```
### Domain Class (every property must call Load())
```csharp
public class Employee : DomainObject {
private string _name;
private Department _department;
public Employee(long key) : base(key) { }
// Simple value property
public string Name {
get { Load(); return _name; }
set { Load(); _name = value; }
}
// Association property — returns another ghost (triggers that ghost's load on access)
public Department Department {
get { Load(); return _department; }
set { Load(); _department = value; }
}
// Collection property — uses DomainList (ghost list)
public IList TimeRecords { get; private set; } = new DomainList();
}
```
### Registry + Separated Interface (keeps domain ignorant of mappers)
The Load() call in DomainObject cannot directly reference the mapper layer (that would violate layer isolation). Fowler's solution: a Registry with a Separated Interface.
```csharp
// In domain layer — interface only, no mapper reference
public interface IDataSource {
void Load(DomainObject obj);
}
// In domain layer — Registry (static accessor)
public static class DataSource {
private static IDataSource _instance;
public static void SetInstance(IDataSource instance) { _instance = instance; }
public static void Load(DomainObject obj) { _instance.Load(obj); }
}
// In data source layer — concrete implementation
public class MapperRegistry : IDataSource {
private readonly IDictionary<Type, Mapper> _mappers = new Dictionary<Type, Mapper>();
public void Load(DomainObject obj) {
_mappers[obj.GetType()].Load(obj);
}
}
// Bootstrap: DataSource.SetInstance(new MapperRegistry(...));
```
### Abstract Mapper Base
```csharp
public abstract class Mapper {
protected IDictionary<long, DomainObject> _identityMap = new Dictionary<long, DomainObject>();
// Returns a ghost immediately — places it in Identity Map before loading
public DomainObject AbstractFind(long key) {
if (_identityMap.TryGetValue(key, out var cached)) return cached;
var ghost = CreateGhost(key);
_identityMap[key] = ghost;
return ghost;
}
protected abstract DomainObject CreateGhost(long key);
// Called by DataSource.Load() when a ghost field is first accessed
public void Load(DomainObject obj) {
if (!obj.IsGhost) return;
using var cmd = new OleDbCommand(FindStatement, DB.Connection);
cmd.Parameters.AddWithValue("key", obj.Key);
using var reader = cmd.ExecuteReader();
reader.Read();
LoadLine(reader, obj);
}
public void LoadLine(IDataReader reader, DomainObject obj) {
if (obj.IsGhost) {
obj.MarkLoading();
DoLoadLine(reader, obj);
obj.MarkLoaded();
}
}
protected abstract string FindStatement { get; }
protected abstract void DoLoadLine(IDataReader reader, DomainObject obj);
}
```
### Concrete Mapper
```csharp
public class EmployeeMapper : Mapper {
protected override DomainObject CreateGhost(long key) => new Employee(key);
protected override string FindStatement =>
"SELECT name, departmentId FROM employees WHERE id = @key";
protected override void DoLoadLine(IDataReader reader, DomainObject obj) {
var employee = (Employee) obj;
employee.Name = (string) reader["name"];
var deptMapper = (DepartmentMapper) MapperRegistry.Mapper(typeof(Department));
employee.Department = (Department) deptMapper.AbstractFind((long) reader["departmentId"]);
// ^ Returns a Department ghost — placed in Identity Map immediately
// ^ Department data loads on first access to Department.Name etc.
LoadTimeRecords(employee);
}
private void LoadTimeRecords(Employee employee) {
var listLoader = new ListLoader {
Sql = "SELECT * FROM time_records WHERE employee_id = @key",
Mapper = MapperRegistry.Mapper(typeof(TimeRecord))
};
listLoader.SqlParams.Add(employee.Key);
listLoader.Attach((DomainList) employee.TimeRecords);
// TimeRecords collection stays ghost until first access
}
}
```
### Ghost List (collection lazy unit)
```csharp
// Ghost list: the collection itself is the lazy unit
public class DomainList : IList {
private IList _data;
private bool _isLoaded = false;
public delegate void Loader(DomainList list);
public Loader RunLoader;
private IList Data {
get {
if (!_isLoaded) {
_isLoaded = true;
RunLoader(this);
}
return _data ?? (_data = new ArrayList());
}
}
// Delegate all IList members to Data
public int Count => Data.Count;
public object this[int index] { get => Data[index]; set => Data[index] = value; }
public void Add(object item) => Data.Add(item);
// ... other IList members
}
```
## Python Equivalent (using descriptors)
Python can implement Ghost via descriptors — a more Pythonic alternative to bytecode manipulation.
```python
class LazyField:
"""Descriptor that triggers ghost load on first access."""
def __set_name__(self, owner, name):
self._name = f"_{name}"
def __get__(self, obj, objtype=None):
if obj is None: return self
obj._load_if_ghost()
return getattr(obj, self._name, None)
def __set__(self, obj, value):
setattr(obj, self._name, value)
class DomainObject:
GHOST = "ghost"
LOADING = "loading"
LOADED = "loaded"
def __init__(self, key):
self.key = key
self._status = self.GHOST
def _load_if_ghost(self):
if self._status == self.GHOST:
DataSource.load(self)
def mark_loading(self): self._status = self.LOADING
def mark_loaded(self): self._status = self.LOADED
@property
def is_ghost(self): return self._status == self.GHOST
class Employee(DomainObject):
name = LazyField()
department = LazyField()
```
## When Ghost Is the Right Choice
- You need domain objects usable in sets/dicts by identity without proxy confusion
- Your stack has AOP or descriptor support to instrument field access transparently
- You are building a framework layer that many domain classes will inherit
- You want the ghost in the Identity Map *before* load (prevents circular-load bugs)
## When Ghost Is NOT Worth the Complexity
- An ORM already ships Virtual Proxy and handles identity via its session cache — use that
- The domain model is simple with few associations — Lazy Initialization is sufficient
- The team is unfamiliar with AOP/bytecode instrumentation — the tooling cost is too high
FILE:references/lazy-load-variant-comparison.md
# Lazy Load Variant Comparison
Source: Patterns of Enterprise Application Architecture, Ch. 11 (Fowler et al., 2002)
## Four Variants at a Glance
| Dimension | Lazy Initialization | Virtual Proxy | Value Holder | Ghost |
|---|---|---|---|---|
| **Mechanism** | Getter checks null/flag; loads on first access | Separate proxy object with same interface; loads on first method call | Field is a wrapper object; caller calls `.getValue()` | Real domain object with placeholder state; loads all fields on first access |
| **Calling code must change** | No (transparent via getter) | No (proxy has same interface) | Yes (must call `.getValue()`) | No (transparent via field accessor instrumentation) |
| **Identity preserved** | Yes (the real object holds the field) | No (proxy != real object) | N/A (holder is internal) | Yes (the object IS the ghost) |
| **Proxy identity trap risk** | None | High — two proxies for same row are not `==` | None | None |
| **Null-value safe** | No by default — use a `loaded` flag if null is valid | Yes | Yes | Yes |
| **ORM support** | Native in Active Record ORMs; manual in Data Mapper | Standard ORM lazy proxy (Hibernate, EF Core, TypeORM) | Rare — mostly hand-rolled | Hibernate "extra lazy"; custom bytecode; SQLAlchemy descriptors |
| **Statically-typed language friction** | Low | High (one proxy class per proxied class unless AOP/codegen) | Low | High (requires AOP or bytecode instrumentation) |
| **Dynamically-typed language friction** | Low | Low (single generic proxy possible) | Low | Low |
| **Best for** | Hand-rolled Active Record; simple optional fields | ORM-backed Data Mapper; collections | Explicit lazy-load in Data Mapper without proxy magic | Instrumentation-heavy stacks; when identity correctness is critical |
## Variant Details
### Lazy Initialization
```java
// Java — simplest form
public List<Product> getProducts() {
if (products == null) {
products = productMapper.findForSupplier(this.id);
}
return products;
}
// Safe form when null is a valid value
public List<Product> getProducts() {
if (!productsLoaded) {
products = productMapper.findForSupplier(this.id);
productsLoaded = true;
}
return products;
}
```
Fowler's note: works best with Active Record, Table Data Gateway, Row Data Gateway. Creates a coupling from domain object to database. For Data Mapper, use Virtual Proxy instead to keep domain ignorant of persistence.
### Virtual Proxy
The proxy implements the same interface as the target. On first method call, it loads the real object and delegates. ORM frameworks ship this as their default lazy proxy implementation.
Identity trap: `proxy1 != proxy2` even if both proxy the same database row. Fix by ensuring the ORM Identity Map (session/unit-of-work first-level cache) returns the same proxy instance for a given primary key. Override `equals()` to compare by primary key, not by reference.
For collections: a virtual collection (list proxy) is the correct technique — the whole collection is the lazy unit, not each element.
### Value Holder
```java
public class ValueHolder<T> {
private T value;
private boolean loaded = false;
private final Supplier<T> loader;
public ValueHolder(Supplier<T> loader) { this.loader = loader; }
public T getValue() {
if (!loaded) {
value = loader.get();
loaded = true;
}
return value;
}
}
// Usage in domain class
class Supplier {
private ValueHolder<List<Product>> products;
public List<Product> getProducts() { return products.getValue(); }
}
```
Calling code that directly accesses `products` (not through the getter) will bypass the load. Enforce self-encapsulation strictly.
### Ghost
A ghost is a real domain object in a partial state. It has its primary key but no other data. Every field accessor triggers a full load.
```csharp
// C# — domain supertype approach (Fowler's example)
public abstract class DomainObject {
public LoadStatus Status { get; private set; } = LoadStatus.Ghost;
public bool IsGhost => Status == LoadStatus.Ghost;
protected void Load() {
if (IsGhost) DataSource.Load(this); // Registry + Separated Interface
}
}
// Domain class — every property triggers Load()
public class Employee : DomainObject {
private string _name;
public string Name {
get { Load(); return _name; }
set { Load(); _name = value; }
}
}
```
Advantage over Virtual Proxy: the ghost IS in the Identity Map immediately on creation (even before load). No identity trap. Two references to the same ghost are the same object.
Disadvantage: requires instrumentation of every field accessor. Ideal target for aspect-oriented programming or bytecode post-processing.
Load states: GHOST → LOADING → LOADED. The LOADING state prevents recursive load calls triggered by associations loaded during the load operation itself.
## Ripple Loading: The Critical Anti-Pattern
Ripple loading occurs when a collection contains individually-lazy objects and the collection is iterated:
```
for each order in orders: // 1 query: SELECT * FROM orders
print order.items // N queries: SELECT * FROM items WHERE order_id = ?
```
Result: N+1 queries. At N=500 this causes 501 database round-trips.
**Wrong fix:** make each `item` load itself lazily (this is already the problem).
**Right fix:** make the collection (`items`) the lazy unit — one query loads all items for all orders.
Batch loading approaches:
- Hibernate `@BatchSize(50)`: loads items for up to 50 orders per SQL `IN` clause
- SQLAlchemy `lazy='selectin'`: one `SELECT WHERE parent_id IN (...)` for all loaded parents
- Django `prefetch_related('items')`: one query per relation for the full QuerySet
- EF Core `.Include(o => o.Items)`: JOIN FETCH at query time
FILE:references/orm-lazy-config-cheatsheet.md
# ORM Lazy Load Configuration Cheatsheet
Quick reference for configuring Lazy Load (deferred loading) in popular ORMs and frameworks. All map to the Virtual Proxy variant the ORM ships natively.
## Hibernate / Spring Data JPA (Java)
```java
// Default: collections are LAZY, single-valued associations are EAGER
// Best practice: set everything to LAZY, add EAGER only where proven necessary
@OneToMany(fetch = FetchType.LAZY, mappedBy = "order")
@BatchSize(size = 50) // Critical: prevents ripple loading on parent collections
private List<OrderItem> items;
@ManyToOne(fetch = FetchType.LAZY) // Override EAGER default
private Customer customer;
// Hot-path override — fetch join for specific use case
@Query("SELECT o FROM Order o JOIN FETCH o.items i JOIN FETCH i.product WHERE o.id = :id")
Order findWithItemsAndProducts(@Param("id") Long id);
// Batch fetch for parent collections — prevents N+1 when iterating orders
@Entity
@BatchSize(size = 50) // Also valid at entity level (ghosts/proxies batched)
public class Order { ... }
```
## EF Core (C#)
```csharp
// Option 1: Lazy loading proxies (global opt-in)
services.AddDbContext<AppDbContext>(opt =>
opt.UseLazyLoadingProxies()
.UseSqlServer(connString));
// All virtual navigation properties become lazy proxies
// Option 2: Explicit loading (Value Holder style — no proxy required)
var order = context.Orders.Find(id);
context.Entry(order).Collection(o => o.Items).Load(); // explicit trigger
// Option 3: Eager with Include (hot-path override)
context.Orders
.Include(o => o.Items)
.ThenInclude(i => i.Product)
.Where(o => o.Id == id)
.FirstOrDefault();
// Option 4: Split queries (avoids Cartesian explosion on large collections)
context.Orders
.Include(o => o.Items)
.AsSplitQuery() // EF Core 5+: issues separate SELECT per Include
.ToList();
```
## SQLAlchemy ORM (Python)
```python
from sqlalchemy.orm import relationship
class Order(Base):
# lazy='select' (default): load on first access, one query per parent — RIPPLE RISK
items_default = relationship("OrderItem", lazy="select")
# lazy='selectin': one IN-clause query for all loaded parents — PREFERRED for collections
items = relationship("OrderItem", lazy="selectin")
# lazy='joined': eager join in the same query (use for single-valued associations)
customer = relationship("Customer", lazy="joined")
# lazy='subquery': subquery instead of JOIN (avoids Cartesian explosion for collections)
items_subquery = relationship("OrderItem", lazy="subquery")
# lazy='raise': raises error if accessed without explicit load — enforces discipline
items_strict = relationship("OrderItem", lazy="raise")
# Query-level override (ignores model default)
session.query(Order).options(
joinedload(Order.customer), # eager join for this query
subqueryload(Order.items), # batch subquery for this query
).all()
```
Recommendation: use `lazy="selectin"` for one-to-many collections (batch load, no Cartesian product). Use `lazy="joined"` for many-to-one / one-to-one (single-row joins are safe). Use `lazy="raise"` in performance-critical code paths to catch accidental lazy access.
## Django ORM (Python)
Django ORM does not configure laziness on the model — all ForeignKey and related manager access is lazy by default. You opt into batch loading at the queryset level.
```python
# N+1 — BAD: each order.customer triggers a separate query
orders = Order.objects.all()
for order in orders:
print(order.customer.name) # 1 query per order
# select_related — SQL JOIN for FK / one-to-one (eager for this queryset)
orders = Order.objects.select_related("customer").all()
# prefetch_related — separate batch query for reverse FK, M2M, generic relations
orders = Order.objects.prefetch_related("items").all()
# Chain both
orders = Order.objects.select_related("customer").prefetch_related("items__product")
# Prefetch with custom queryset (filter, order, annotate the prefetch)
from django.db.models import Prefetch
orders = Order.objects.prefetch_related(
Prefetch("items", queryset=OrderItem.objects.filter(active=True).select_related("product"))
)
```
## Rails ActiveRecord (Ruby)
```ruby
# N+1 — BAD
Order.all.each { |o| puts o.customer.name } # 1 query per order
# includes — smart: uses preload or eager_load depending on WHERE/ORDER
Order.includes(:customer).all
# preload — always separate queries (like Django prefetch_related)
Order.preload(:customer, :items)
# eager_load — always LEFT OUTER JOIN (use when filtering on association)
Order.eager_load(:customer).where(customers: { vip: true })
# Nested associations
Order.includes(items: :product).all
```
## TypeORM (TypeScript/Node)
```typescript
// Model: lazy relation uses Promise<T>
@Entity()
export class Order {
@OneToMany(() => OrderItem, item => item.order, { lazy: true })
items: Promise<OrderItem[]>; // caller must await
@ManyToOne(() => Customer, { eager: true }) // always-eager
customer: Customer;
}
// Query builder with explicit join (hot-path override)
const orders = await dataSource
.getRepository(Order)
.createQueryBuilder("order")
.leftJoinAndSelect("order.items", "item")
.leftJoinAndSelect("item.product", "product")
.where("order.id = :id", { id })
.getOne();
// Find options with relations (eager for this query)
const order = await orderRepo.findOne({
where: { id },
relations: { items: { product: true }, customer: true }
});
```
## Summary Decision Matrix
| ORM | Collection default | Fix ripple loading | Single-valued default | Hot-path override |
|---|---|---|---|---|
| Hibernate | LAZY | `@BatchSize(50)` | EAGER (change to LAZY!) | `JOIN FETCH` in JPQL |
| EF Core | Lazy (with proxies) or None | `.AsSplitQuery()` | Lazy or None | `.Include().ThenInclude()` |
| SQLAlchemy | `lazy='select'` (per-access) | `lazy='selectin'` | `lazy='select'` | `joinedload()` option |
| Django | Lazy (always) | `prefetch_related()` | Lazy (always) | `select_related()` |
| Rails | Lazy (always) | `includes()` / `preload()` | Lazy (always) | `eager_load()` |
| TypeORM | Depends on config | `relations:` in find options | `eager: true` | `leftJoinAndSelect()` |
Select the correct ORM inheritance strategy — Single Table Inheritance (STI), Class Table Inheritance (joined table / Multi-Table Inheritance), or Concrete T...
---
name: inheritance-mapping-selector
description: |
Select the correct ORM inheritance strategy — Single Table Inheritance (STI), Class Table Inheritance (joined table / Multi-Table Inheritance), or Concrete Table Inheritance (table per class) — for any OO inheritance hierarchy that needs to be persisted in a relational database. Use when asked: "which inheritance mapping should I use?", "single table vs joined table inheritance", "STI vs CTI vs table per class", "Hibernate inheritance strategy SINGLE_TABLE vs JOINED vs TABLE_PER_CLASS", "@Inheritance JPA", "Rails STI vs multi-table inheritance", "Django model inheritance type", "how to map inheritance in database", "inheritance in database design", "discriminator column inheritance", "ORM polymorphic query performance", "polymorphic table design", "table per class inheritance trade-offs", "joined inheritance vs single table", "inheritance schema design". Applies at greenfield schema design, ORM configuration, or legacy schema refactoring. Routes to the right strategy on six trade-off dimensions: joins on polymorphic read, wasted column space, FK-constraint enforceability, ad-hoc query readability, refactoring impact, and polymorphism-query cost. Identifies when mixing strategies via Inheritance Mappers is warranted (hierarchy branches with divergent trade-offs). Maps each strategy to idiomatic ORM config: Hibernate/JPA `@Inheritance(SINGLE_TABLE/JOINED/TABLE_PER_CLASS)`, Rails STI type column, Django Multi-Table Inheritance vs abstract base. Produces an inheritance mapping decision record with schema sketch and ORM config snippet.
version: 1.0.0
homepage: https://github.com/bookforge-ai/bookforge-skills/tree/main/books/patterns-of-enterprise-application-architecture/skills/inheritance-mapping-selector
metadata: {"openclaw":{"emoji":"📚","homepage":"https://github.com/bookforge-ai/bookforge-skills"}}
status: draft
source-books:
- id: patterns-of-enterprise-application-architecture
title: "Patterns of Enterprise Application Architecture"
authors: ["Martin Fowler", "David Rice", "Matthew Foemmel", "Edward Hieatt", "Robert Mee", "Randy Stafford"]
chapters: [3, 12]
domain: software-architecture
tags: ["inheritance-mapping", "orm", "persistence", "database-design", "design-patterns", "single-table-inheritance", "class-table-inheritance", "concrete-table-inheritance", "polymorphic-query", "object-relational-mapping", "fowler-peaa", "hibernate", "joined-table-inheritance"]
depends-on: []
execution:
tier: 2
mode: hybrid
inputs:
- type: document
description: "Description of the OO inheritance hierarchy (class names, field placement, depth), the ORM/framework in use, whether polymorphic reads are frequent, and any schema constraints (FK enforcement, external consumers, existing legacy schema)."
tools-required: [Read, Write]
tools-optional: [Grep]
mcps-required: []
environment: "Any agent environment. Works from architecture docs, ORM model files, schema files, or a written domain description. Codebase access improves precision but is not required."
discovery:
goal: "Produce a concrete inheritance mapping recommendation with ORM config, SQL schema sketch, and migration path."
tasks:
- "Gather the inheritance hierarchy structure (depth, field distribution, abstract vs concrete classes)"
- "Identify polymorphic query frequency and whether supertype-level finds are common"
- "Assess subclass-specific field divergence (similar fields → STI; divergent fields → CTI or Concrete)"
- "Determine FK constraint requirements and external consumer needs"
- "Score on six trade-off dimensions to select the primary strategy"
- "Check if hierarchy branches differ enough to warrant mixing strategies"
- "Map the recommendation to the team's ORM idiom (Hibernate, Rails, Django, etc.)"
- "Produce an inheritance mapping decision record with schema sketch and ORM config snippet"
audience:
roles: ["software-architect", "senior-backend-engineer", "tech-lead", "database-designer"]
experience: "intermediate"
when_to_use:
triggers:
- "Designing schema for a new OO domain model with inheritance"
- "Choosing the `@Inheritance` strategy annotation in Hibernate/JPA"
- "Deciding whether to use Rails STI or a manual multi-table approach"
- "Noticing that STI tables are becoming excessively wide with many NULLs"
- "Noticing that CTI joins are causing performance problems on high-read endpoints"
- "Refactoring a legacy schema where inheritance was not intentionally designed"
- "Documenting an architecture decision about how inheritance is persisted"
prerequisites: []
not_for:
- "Choosing which data-source access pattern (Active Record vs Data Mapper) — see `data-source-pattern-selector`"
- "Full structural mapping patterns (associations, collections, embedded values) — see `object-relational-structural-mapping-guide`"
- "Query optimization or index tuning — this is a structural design decision, not query-level performance work"
environment:
codebase_required: false
codebase_helpful: true
works_offline: true
quality:
scores:
with_skill: "{filled by tester}"
baseline: "{filled by tester}"
delta: "{filled by tester}"
tested_at: "{filled by tester}"
eval_count: "{filled by tester}"
assertion_count: 13
iterations_needed: "{filled by tester}"
---
# Inheritance Mapping Selector
## When to Use
Relational databases have no native concept of inheritance. Every time an OO hierarchy needs to be persisted, a structural decision must be made: how does the class tree become table(s)? This skill routes that decision.
Apply this skill when:
- You have an OO inheritance hierarchy and need to choose a database schema strategy
- You are configuring `@Inheritance` in Hibernate/JPA or equivalent ORM annotation
- Your current STI table has grown so wide that NULLs dominate most rows
- Your current CTI (joined) queries are slow because every polymorphic read needs a multi-table join
- You are refactoring a legacy schema where inheritance was never explicitly designed
Prerequisites: none. If the domain-logic pattern (Transaction Script / Domain Model) is not yet settled, clarify that first — it affects whether you need inheritance mapping at all.
---
## Context & Input Gathering
Before scoring, collect:
| Input | Why it matters |
|-------|---------------|
| Class hierarchy diagram or description | Reveals depth, abstract vs concrete classes, field placement |
| Subclass-specific field count | High divergence → CTI or Concrete; low divergence → STI |
| Polymorphic read frequency | Frequent supertype queries penalize Concrete Table (UNION) |
| FK constraint requirements | STI cannot enforce FK/NOT NULL on subclass-only columns |
| External DB consumers | Other apps reading the schema favor Concrete (self-contained tables) |
| ORM/framework | Determines which strategies are idiomatically supported |
| Existing schema (if refactoring) | Legacy constraints may limit options |
**Sufficiency check:** You need at minimum the class hierarchy shape and an estimate of polymorphic read frequency. Everything else refines the recommendation.
---
## Process
### Step 1 — Map the hierarchy structure
List all classes: which are abstract, which are concrete, how deep the tree is, and where fields cluster.
*Why:* STI and CTI (joined) require a clear understanding of which classes share common fields. Concrete Table requires knowing all concrete classes, since each gets its own table. Deep hierarchies with many levels favor CTI; shallow hierarchies with low field divergence favor STI.
### Step 2 — Score on six trade-off dimensions
For each of the three candidate strategies, score the trade-offs given this specific hierarchy:
| Dimension | Single Table (STI) | Class Table (CTI) | Concrete Table |
|-----------|-------------------|-------------------|----------------|
| **Joins on polymorphic read** | None — single table query | 1 join per hierarchy level | UNION across all concrete tables |
| **Wasted column space** | High — NULLs for irrelevant subclass columns | None — each row fully relevant | None — each table is self-contained |
| **FK constraint enforcement** | Cannot enforce on subclass-only columns | Full enforcement possible | Per-table only; no FK to abstract supertypes |
| **Ad-hoc query readability** | Poor — sparse rows, mixed types in one table | Good — normalized, clear schema | Good — each table is standalone and readable |
| **Refactoring impact (field moves)** | None — push/pull fields up or down freely | Schema change required per move | Schema change must propagate to all concrete tables |
| **Polymorphism support** | Excellent — single query for any type | Good — join required per level | Poor — UNION or multi-query required |
*Why:* These six dimensions are the primary axes Fowler identifies for distinguishing the three patterns. Scoring them forces an explicit trade-off rather than defaulting to whichever the ORM does by default.
### Step 3 — Apply the primary routing rule
Use the dominant signals to select a strategy:
**→ Single Table Inheritance** when:
- Few subclass-specific columns (most fields live on the superclass)
- Polymorphic reads are frequent and join cost is a concern
- Refactoring the hierarchy is likely (field promotions/demotions should not require schema migration)
- Constraints on subclass columns are not needed
**→ Class Table Inheritance (Joined)** when:
- Subclasses have substantially divergent fields (many NULL columns in STI would be a problem)
- FK integrity on subclass-specific columns is required
- Domain model clarity matters to DBAs or reporting tools
- Polymorphic read frequency is moderate (joins are acceptable)
**→ Concrete Table Inheritance** when:
- Subclasses are largely independent (rarely queried polymorphically)
- Other applications or reporting tools read the database directly and benefit from standalone tables
- Each concrete class stands alone with a full, self-contained schema
*Why:* Fowler offers no single universal recommendation — the decision is genuinely context-dependent. But each strategy has dominant use cases where it wins cleanly.
### Step 4 — Check for mixing
Evaluate whether different branches of the hierarchy have significantly different trade-off profiles.
IF the hierarchy has a stable branch with few subclass fields (STI-favorable) AND a divergent branch with many subclass-specific fields and constraint needs (CTI-favorable):
→ Use Inheritance Mappers as the coordinator layer and apply different strategies per branch.
Fowler explicitly permits this: "The trio of inheritance patterns can coexist in a single hierarchy."
*Why:* Mixing avoids forcing the entire hierarchy into the strategy that fits only the worst-case branch. The Inheritance Mappers pattern provides the implementation scaffold — an abstract mapper per class plus a separate coordinator mapper for the supertype — that makes mixed strategies workable without duplicating load/save logic.
### Step 5 — Map to ORM idiom
Translate the chosen strategy to the team's ORM:
| ORM | STI | CTI (Joined) | Concrete Table |
|-----|-----|-------------|----------------|
| **Hibernate/JPA** | `@Inheritance(strategy = InheritanceType.SINGLE_TABLE)` + `@DiscriminatorColumn` | `@Inheritance(strategy = InheritanceType.JOINED)` | `@Inheritance(strategy = InheritanceType.TABLE_PER_CLASS)` |
| **Rails ActiveRecord** | Built-in: add `type:string` column; subclass AR class | Not natively supported (requires manual joins or gems) | Not natively supported |
| **Django ORM** | Abstract base (no shared table, no polymorphism) | Multi-Table Inheritance (default model inheritance) | Proxy models (no new table, same table) |
| **SQLAlchemy** | `polymorphic_on=type_col`, `single_table_inheritance` | `joined_table_inheritance` | `concrete_table_inheritance` |
| **Doctrine (PHP)** | `@InheritanceType("SINGLE_TABLE")` | `@InheritanceType("JOINED")` | `@InheritanceType("TABLE_PER_CLASS")` |
Note: Hibernate's `TABLE_PER_CLASS` (Concrete) generates UNION queries automatically for polymorphic finds. This convenience hides the performance cost — monitor actual query plans.
*Why:* The choice must translate to idiomatic ORM configuration. Choosing CTI and then fighting the ORM defaults erodes the benefit.
### Step 6 — Produce the decision record
Write the inheritance mapping decision record (see Outputs).
---
## Inputs
- **Required:** OO inheritance hierarchy (class names, field locations, depth, abstract vs. concrete)
- **Required:** Polymorphic read frequency estimate (high / medium / low)
- **Helpful:** ORM/framework in use
- **Helpful:** FK constraint and data integrity requirements
- **Helpful:** Whether other systems read the database directly
- **Helpful:** Existing schema (if refactoring)
---
## Outputs
**Inheritance Mapping Decision Record** containing:
```
## Inheritance Mapping Decision — [Hierarchy Name]
### Hierarchy Summary
[Superclass] → [Subclasses list]
Abstract classes: [list]
Concrete classes: [list]
Field distribution: [X fields on superclass, Y per subclass avg]
### Six-Dimension Score
| Dimension | STI | CTI | Concrete |
|---------------------|-----|-----|----------|
| Joins | ✅ | ⚠️ | ❌ |
| Wasted space | ⚠️ | ✅ | ✅ |
| FK enforcement | ❌ | ✅ | ⚠️ |
| Ad-hoc readability | ⚠️ | ✅ | ✅ |
| Refactoring impact | ✅ | ⚠️ | ⚠️ |
| Polymorphism cost | ✅ | ⚠️ | ❌ |
### Recommendation
**Primary strategy:** [STI / CTI / Concrete / Mixed]
**Rationale:** [2-3 sentences]
### SQL Schema Sketch
[simplified CREATE TABLE statements]
### ORM Configuration
[annotated class snippet for the team's ORM]
### Mixing Note
[IF mixing: which branches use which strategy, and why]
### Migration Path
[steps to get from current state to chosen strategy]
```
---
## Key Principles
**1. Single Table is Fowler's default for simple hierarchies — with explicit caveats.**
STI wins on query simplicity and refactoring tolerance. Its costs (NULLs, no constraints) are real but manageable for hierarchies with few subclass-specific fields. Fowler's own Player/Footballer/Cricketer example uses it as the primary illustration. The caveat: once subclass-specific field count grows substantially, the NULL bloat and constraint inability become genuine liabilities.
**2. Class Table (Joined) buys normalization at the cost of joins — and joins compound with depth.**
CTI is the most "object-aligned" strategy: one table per class mirrors the class hierarchy. Its cost is that loading any object requires touching multiple tables. For a two-level hierarchy (parent + one child table), one join is tolerable. For a four-level hierarchy, you may be joining four tables per load. Monitor query plans; the supertype table also becomes a bottleneck since every query touches it.
**3. Concrete Table's UNION penalty is the silent killer.**
Concrete Table looks attractive in isolation: no joins, no NULLs, self-contained tables. The danger emerges when someone queries at the supertype level. The ORM (or developer) must UNION all concrete tables. As the hierarchy grows, this query becomes increasingly expensive. Concrete Table is appropriate only when polymorphic supertype queries are rare or absent.
**4. FK constraints are incompatible with STI for subclass-specific columns.**
If a Footballer must have a `club` and that column must be NOT NULL and FK-constrained, STI cannot enforce this — the column exists on all rows, including Cricketers who have no club. CTI (or Concrete) is required when FK integrity on subclass fields is non-negotiable.
**5. Mixing is the escape hatch — use it deliberately, not ad-hoc.**
Fowler explicitly endorses mixing strategies within one hierarchy. The right way to implement mixing is the Inheritance Mappers pattern: each class has its own mapper (abstract or concrete), and a separate coordinator mapper handles supertype-level operations. Do not mix strategies without this scaffolding — the result is tangled load/save logic that is difficult to maintain.
**6. Keys must be unique across all tables in Concrete Table — the database won't enforce this for you.**
When using Concrete Table, the database's primary key constraint only guarantees uniqueness within one table. A Footballer with id=42 and a Cricketer with id=42 are valid in the database — but they will collide on any polymorphic Identity Field lookup. You need a cross-table key allocation strategy (e.g., a shared sequence, UUID keys, or application-level key tracking).
---
## Examples
### Example 1: Sports Player Hierarchy (STI recommended)
**Scenario:** SportsDB system models Player (name, dateOfBirth) with three subclasses: Footballer (club, position), Cricketer (battingAverage), Bowler (bowlingSpeed, bowlingStyle). The application frequently loads "all active Players" for reporting and roster views. Hierarchy is unlikely to deepen further.
**Trigger:** "Should we use STI or separate tables for Player/Footballer/Cricketer? We need to query all players at once frequently."
**Process:**
1. Hierarchy: 3 concrete classes, 1 abstract base. Subclass-specific fields: 1–2 per subclass. Field divergence is low.
2. Dimension score: STI wins on joins (none), refactoring tolerance (none), polymorphism (single query). Cost: wasted columns for ~2–3 nullable columns per row — tolerable. No FK constraint requirement on club or battingAverage.
3. Routing: STI. Fowler uses this exact example to demonstrate Single Table Inheritance.
4. Mixing: Not needed — all branches are similar in shape.
5. ORM config: Hibernate `@Inheritance(SINGLE_TABLE)` with `@DiscriminatorColumn(name="type")` on Player; `@DiscriminatorValue("F")` on Footballer.
**Output (schema sketch):**
```sql
CREATE TABLE players (
id BIGINT PRIMARY KEY,
type VARCHAR(1) NOT NULL, -- discriminator: F, C, B
name VARCHAR(100) NOT NULL,
club VARCHAR(100), -- Footballer only
batting_avg DECIMAL(5,2), -- Cricketer only
bowling_speed INT, -- Bowler only
bowling_style VARCHAR(50) -- Bowler only
);
```
---
### Example 2: Organization Hierarchy with Strict Constraints (CTI recommended)
**Scenario:** HR system with LegalEntity (taxId, registeredAddress) as abstract base, Corporation (stockExchange, tickerSymbol), Partnership (partnerCount), SoleTrader (tradingName) as concrete subclasses. Every Corporation must have a non-null stockExchange. The compliance team requires FK integrity on all subtype-specific columns. Polymorphic reads ("load any LegalEntity by id") occur at application startup only; most queries are type-specific.
**Trigger:** "We need strict FK constraints on Corporation fields. Can we still use inheritance?"
**Process:**
1. Hierarchy depth: 2 levels. Subclass fields: 3–5 per subclass, highly divergent.
2. Dimension score: FK enforcement → CTI required (STI cannot enforce NOT NULL on stockExchange for Corporations). Refactoring impact acceptable (hierarchy is stable by design). Polymorphic reads are infrequent → join cost acceptable.
3. Routing: Class Table Inheritance (Joined). Hibernate `@Inheritance(JOINED)`.
4. Mixing: Not needed.
**Output (schema sketch):**
```sql
CREATE TABLE legal_entities (id BIGINT PRIMARY KEY, tax_id VARCHAR(20), address TEXT, type VARCHAR(20));
CREATE TABLE corporations (id BIGINT REFERENCES legal_entities(id), stock_exchange VARCHAR(10) NOT NULL, ticker VARCHAR(10) NOT NULL);
CREATE TABLE partnerships (id BIGINT REFERENCES legal_entities(id), partner_count INT NOT NULL);
CREATE TABLE sole_traders (id BIGINT REFERENCES legal_entities(id), trading_name VARCHAR(100) NOT NULL);
```
---
### Example 3: Independent Product Types, Rarely Queried Polymorphically (Concrete recommended)
**Scenario:** E-commerce catalog with Product as abstract base and three highly divergent concrete types: PhysicalProduct (weight, dimensions, shippingClass), DigitalProduct (fileSize, downloadUrl, licenseType), SubscriptionProduct (billingCycle, trialDays, renewalPolicy). Each has 8–12 unique fields. The reporting team queries each type independently ("all digital downloads this month"). Polymorphic "all products" queries exist only in one admin view.
**Trigger:** "Our Product table is a mess — 40 columns, half NULL on any given row. What's the alternative?"
**Process:**
1. Subclass fields: 8–12 per subclass, highly divergent. Only 3 shared fields on Product (id, name, price).
2. Dimension score: Current STI is the problem (wasted space). Concrete Table wins on no NULLs, self-contained tables, per-class query performance. Cost: polymorphic "all products" query → UNION. But this query is rare (one admin view).
3. Routing: Concrete Table Inheritance. Cross-table key uniqueness → use UUID primary keys.
4. Key uniqueness: UUIDs eliminate the cross-table collision problem.
5. Polymorphic admin view: Accept a UNION query here — document the performance expectation; cache if needed.
**Output (schema sketch):**
```sql
CREATE TABLE physical_products (id UUID PRIMARY KEY, name VARCHAR, price DECIMAL, weight_kg DECIMAL, dim_cm VARCHAR, shipping_class VARCHAR);
CREATE TABLE digital_products (id UUID PRIMARY KEY, name VARCHAR, price DECIMAL, file_size_mb INT, download_url TEXT, license_type VARCHAR);
CREATE TABLE subscription_products (id UUID PRIMARY KEY, name VARCHAR, price DECIMAL, billing_cycle VARCHAR, trial_days INT, renewal_policy VARCHAR);
-- Polymorphic admin query:
SELECT id, name, price, 'physical' AS type FROM physical_products
UNION ALL SELECT id, name, price, 'digital' FROM digital_products
UNION ALL SELECT id, name, price, 'subscription' FROM subscription_products;
```
---
## References
- `references/six-dimension-trade-off-matrix.md` — Full trade-off matrix with rationale per cell and worked examples
- `references/inheritance-mappers-scaffold.md` — Inheritance Mappers implementation pattern for mixed-strategy hierarchies
- `references/orm-config-reference.md` — ORM configuration snippets for Hibernate, Rails, Django, SQLAlchemy, Doctrine
---
## License
This skill is licensed under [CC-BY-SA-4.0](https://creativecommons.org/licenses/by-sa/4.0/).
Source: [BookForge](https://github.com/bookforge-ai/bookforge-skills) — Patterns of Enterprise Application Architecture by Martin Fowler, David Rice, Matthew Foemmel, Edward Hieatt, Robert Mee, Randy Stafford.
---
## Related BookForge Skills
Install related skills from ClawhHub:
- `clawhub install bookforge-data-source-pattern-selector`
- `clawhub install bookforge-object-relational-structural-mapping-guide`
Or install the full book set from GitHub: [bookforge-skills](https://github.com/bookforge-ai/bookforge-skills)
Reference catalog for Fowler's 11 enterprise base patterns from Chapter 18 of PEAA. Use when another skill or user says 'we need a Gateway here', 'this shoul...
---
name: enterprise-base-pattern-catalog
description: "Reference catalog for Fowler's 11 enterprise base patterns from Chapter 18 of PEAA. Use when another skill or user says 'we need a Gateway here', 'this should be a Value Object', 'replace nulls with a Special Case', 'use a Service Stub for testing', or 'separate this interface from its implementation'. Covers all 11 base patterns: Gateway pattern (wrapping external systems), Mapper pattern (decoupling subsystems), Layer Supertype (shared base class per layer), Separated Interface (dependency inversion packaging), Registry (service locator), Value Object (value-identity immutable objects), Money pattern (monetary arithmetic, no floats, allocate-by-ratio), Special Case / Null Object (replace null checks), Plugin pattern (runtime-bound implementation), Service Stub (test double for external services), Record Set (generic tabular data structure). Identifies which pattern fits a described problem, provides canonical definition and modern language parallels, distinguishes Gateway (generic external-access wrapper) from Table Data Gateway (data-access pattern), flags Registry vs DI container tradeoff, and produces a short design note with implementation sketch. Also routes to the appropriate family selector when the problem is not a base pattern."
version: 1.0.0
homepage: https://github.com/bookforge-ai/bookforge-skills/tree/main/books/patterns-of-enterprise-application-architecture/skills/enterprise-base-pattern-catalog
metadata: {"openclaw":{"emoji":"📚","homepage":"https://github.com/bookforge-ai/bookforge-skills"}}
status: draft
source-books:
- id: patterns-of-enterprise-application-architecture
title: "Patterns of Enterprise Application Architecture"
authors: ["Martin Fowler", "David Rice", "Matthew Foemmel", "Edward Hieatt", "Robert Mee", "Randy Stafford"]
chapters: [18]
domain: software-architecture
tags:
- base-patterns
- design-patterns
- software-design
- software-architecture
- enterprise-patterns
- value-object
- gateway-pattern
- null-object
- dependency-inversion
- service-stub
- testing-patterns
depends-on: []
execution:
tier: 1
mode: plan-only
inputs:
- type: text
description: "A pattern name ('what is Special Case?'), a problem description ('I have null checks everywhere for missing customers'), or a cross-skill invocation ('we need a Gateway for the FedEx API here')"
- type: codebase
description: "Optional — the relevant file or snippet helps produce a language-specific implementation sketch"
tools-required:
- Read
- Grep
tools-optional:
- Glob
mcps-required: []
environment: "Works offline and without a codebase. A codebase helps tailor the implementation sketch to the team's language and conventions."
discovery:
goal: "Identify the correct PEAA base pattern for a described problem and produce a concise design note with definition, rationale, and implementation sketch"
tasks:
- "Match user description or pattern name to one of the 11 base patterns"
- "Return canonical definition, why it exists, when to apply, and modern language parallel"
- "Produce a design note with implementation sketch in the user's language"
- "Distinguish adjacent patterns (Gateway vs Table Data Gateway, Value Object vs DTO, Special Case vs Null Object vs Optional)"
- "Route to the appropriate PEAA family selector when the problem is not a base pattern"
audience:
roles:
- software-architect
- senior-backend-engineer
- tech-lead
- framework-designer
experience: intermediate
when_to_use:
triggers:
- "Another PEAA skill says 'introduce a Gateway', 'this is a Value Object', 'use a Service Stub', 'apply Separated Interface here'"
- "User asks what a named base pattern is or when to use it"
- "User describes a problem that a base pattern solves: external API wrapping, scattered null checks, monetary arithmetic bugs, test isolation from external services, runtime implementation switching"
- "User wants to know the modern equivalent of a PEAA base pattern"
- "Code review identifies a missing base pattern (floats for money, null checks scattered, no Gateway around external API)"
prerequisites: []
not_for:
- "Choosing between data-source architectural patterns (Transaction Script, Data Mapper, Active Record) — use data-source-pattern-selector"
- "Choosing domain logic patterns — use domain-logic-pattern-selector"
- "Implementing Unit of Work, Lazy Load, or concurrency patterns — use the dedicated implementer skills"
- "Full implementation walkthroughs — this skill provides sketches and references, not complete code"
environment:
codebase_required: false
codebase_helpful: true
works_offline: true
quality:
scores:
with_skill: null
baseline: null
delta: null
tested_at: null
eval_count: null
assertion_count: 13
iterations_needed: null
---
# Enterprise Base Pattern Catalog
The 11 base patterns in Chapter 18 of *Patterns of Enterprise Application Architecture* are cross-cutting utility patterns that appear everywhere in enterprise application design. They are not architectural patterns (like Data Mapper or Front Controller) — they are the building blocks those patterns are built from. Every other PEAA skill references at least one of these.
This skill is a lookup-and-guide: identify the pattern by name or by problem description, then get the canonical definition, when to apply it, a modern language sketch, and any critical cautions.
---
## When to Use
Use this skill when:
- Another PEAA skill says "introduce a Gateway here" or "this is a Value Object" and you need the definition.
- You're describing a cross-cutting utility problem: wrapping an external API, replacing null checks, making monetary arithmetic correct, isolating tests from external services, or breaking a package dependency cycle.
- You want to name a pattern you already know exists but can't recall precisely.
- You want the modern equivalent of a 2002-era base pattern.
NOT for: choosing among data-source patterns, domain logic patterns, web presentation patterns, concurrency patterns, or session state patterns. For those, see the dedicated selector skills in this set.
---
## Context and Input Gathering
**Accept either:**
1. A pattern name: "What is Registry?", "Explain Special Case", "When do I use Separated Interface?"
2. A problem description: "I have floats for monetary amounts", "null checks for missing customers everywhere", "I want to swap a real tax service with a stub in tests"
3. A cross-skill invocation: "We need a Gateway for the Stripe API — what does that look like?"
**If a codebase is available**, read the relevant file to produce a language-specific sketch. If not, use a stack-agnostic pseudocode sketch.
**Sufficiency check:** The pattern name or problem description alone is sufficient to proceed. No codebase required.
---
## Process
### Step 1 — Identify the Pattern
Match the input against the quick-lookup table. Accept either exact name or problem-description.
WHY: The 11 base patterns have overlapping surface descriptions (Gateway, Mapper, and Adapter all "wrap" something). Disambiguation prevents recommending the wrong pattern.
**If a pattern name is given:** look it up directly.
**If a problem description is given:** match against these trigger signatures:
| Problem signature | Pattern |
|---|---|
| "Wrap a 3rd-party / external API / resource behind a clean interface" | **Gateway** |
| "Decouple two subsystems so neither knows about the other" | **Mapper** |
| "Many classes in the same layer share common behavior" | **Layer Supertype** |
| "Break a dependency cycle between packages / layers" | **Separated Interface** |
| "Global finder / locator for widely-needed objects" | **Registry** |
| "Equality should be based on value, not reference; small immutable object" | **Value Object** |
| "Monetary amounts — avoid rounding errors, multi-currency, allocation" | **Money** |
| "Null checks for the same condition scattered in many places" | **Special Case** |
| "Swap implementation at configuration time (test vs production)" | **Plugin** |
| "External service is unavailable / slow — need to test without it" | **Service Stub** |
| "Generic tabular in-memory data structure for data-aware UI tools" | **Record Set** |
**If multiple patterns could apply:** list them with selection criteria (see Gateway vs Mapper below).
**If no base pattern fits:** route to the appropriate selector. Examples:
- Data access architecture question → `data-source-pattern-selector`
- Domain logic organization → `domain-logic-pattern-selector`
- Web layer structure → `web-presentation-pattern-selector`
- Concurrency → `offline-concurrency-strategy-selector`
WHY: Routing prevents this skill from giving partial answers on topics covered better by other skills in the set.
---
### Step 2 — Deliver the Pattern Response
For the identified pattern, produce:
1. **One-sentence definition** (the PEAA intent statement).
2. **Why it exists** — the problem without the pattern.
3. **When to apply** — specific conditions and contra-indicators.
4. **Critical cautions** — the most common mis-application.
5. **Modern language parallel** — what this looks like in the user's stack today.
6. **Implementation sketch** — 10-20 lines in the user's language (or pseudocode if language is unknown).
WHY: Each component serves a different reader. The definition serves recall; the "why it exists" serves understanding; the cautions prevent misuse; the modern parallel prevents dismissing the pattern as obsolete.
**Always include these cautions by pattern:**
- **Gateway:** Distinguish from Table Data Gateway. Not the same thing.
- **Mapper:** Use only when BOTH subsystems must be unaware. Otherwise use Gateway (much simpler).
- **Separated Interface:** Fowler explicitly warns against applying to every class. Only use to break a specific dependency or support multiple independent implementations.
- **Registry:** Global data. Guilty until proven innocent. Prefer DI container in modern systems.
- **Value Object:** Must be immutable. Aliasing bugs from mutable Value Objects are subtle.
- **Money:** Never use floats. Use the allocate-by-ratio algorithm for splitting sums — simple rounding loses or gains pennies.
- **Special Case:** `Optional<T>` is not equivalent — it makes nullability explicit but doesn't eliminate branching at every call site.
- **Service Stub:** Keep stubs simple. Complexity defeats the purpose.
---
### Step 3 — Produce the Design Note Artifact
Output a short design note (a markdown block) suitable for pasting into a code review comment, ADR, or design doc:
```markdown
## Pattern: [Pattern Name]
**Applied to:** [the specific component in the user's system]
**Why this pattern:** [one sentence connecting the problem to the pattern's intent]
**Implementation shape:**
[10-20 line code sketch in the user's language]
**Cautions:**
- [most relevant caution for this usage]
```
WHY: A concrete artifact makes the guidance actionable. Without a sketch, the recommendation remains abstract. The cautions prevent the most common misapplication of the pattern in this specific context.
---
### Step 4 — Surface Adjacent Patterns (Optional)
If another base pattern complements or must be used alongside the primary recommendation:
- Gateway → mention Service Stub and Plugin (the testing triad).
- Plugin → mention Separated Interface (Plugin requires it).
- Service Stub → mention Gateway (stub replaces a Gateway, not raw code).
- Separated Interface → mention Plugin (for runtime wiring).
- Value Object → mention Money (if the value object involves monetary amounts).
WHY: The base patterns form a triad (Gateway + Plugin + Service Stub) and a pair (Value Object + Money). Mentioning them together prevents the user from applying one pattern while missing the pattern it depends on.
---
## Inputs
- Pattern name or problem description (required)
- Language/stack (optional — used to tailor the implementation sketch)
- Relevant code file or snippet (optional — used to make the design note concrete)
---
## Outputs
1. **Pattern identification** — which of the 11 base patterns matches
2. **Definition and rationale** — intent, why it exists, when to apply
3. **Critical cautions** — most important mis-application to avoid
4. **Modern parallel** — how the pattern appears in the user's language/framework today
5. **Design note artifact** — markdown block with implementation sketch and cautions
---
## Key Principles
**1. Gateway and Table Data Gateway are not the same.**
Table Data Gateway (Chapter 10) is a specific data-access pattern that accesses one database table and returns Record Sets. The generic Gateway (Chapter 18) is a broader pattern for wrapping any external resource. Every Table Data Gateway IS a Gateway, but not all Gateways are Table Data Gateways. Using "Gateway" to mean "database gateway that returns rows" is the most common PEAA vocabulary error.
WHY: Confusion between the two leads engineers to reject the generic Gateway pattern when they already know Table Data Gateway — or to misapply the pattern to a data-access context where Table Data Gateway is the correct choice.
**2. Money pattern means no floats, ever.**
`double` and `float` are binary floating-point types. They cannot exactly represent most decimal fractions. `0.1 + 0.1 + 0.1 != 0.3` in IEEE 754. For monetary arithmetic, use integer cents (`long`) or fixed-point decimal (`BigDecimal`, `decimal`, `Decimal`). The Money pattern wraps these with currency-awareness and safe allocation.
WHY: Float-based monetary arithmetic silently accumulates rounding errors. These errors surface as penny discrepancies in financial reports, which have compliance and audit consequences.
**3. Special Case chains, while Optional terminates.**
When you replace a null customer with a `NullCustomer` Special Case, `nullCustomer.contract` can return a `NullContract` (another Special Case) rather than null. This chain propagates polymorphism through the domain model without any null checks anywhere. `Optional<T>` requires the caller to handle the empty case at each `.get()` — it shifts the null-check burden rather than eliminating it.
WHY: The entire value of Special Case is eliminating branching. Understanding the chain behavior is essential to implementing Special Case correctly.
**4. Registry is service-locator style — prefer DI injection in modern systems.**
Fowler himself says Registry is "global data, guilty until proven innocent" and to use it only as a last resort. Modern DI containers (Spring, .NET DI, Guice) push dependencies into constructors at wiring time, making dependencies explicit, testable, and auditable. Registry is appropriate in a narrow set of cases: deeply nested utility code that cannot be wired via constructor, or lightweight applications without a DI framework.
WHY: Registry is commonly cargo-culted from older codebases into modern ones where DI would be simpler and safer. Flagging the tradeoff prevents this.
**5. Separated Interface has a cost — only use it to break specific dependencies.**
Each Separated Interface requires a factory with its own interface and implementation. Fowler explicitly warns against applying this to every class. For most classes, put the interface and implementation together. Separate them only when (a) you need to break a layer dependency cycle, (b) you need multiple independent implementations, or (c) you're working across team boundaries.
WHY: The pattern's benefit is targeted dependency management. Applied universally, it doubles the boilerplate without adding clarity.
---
## Examples
### Scenario 1: Wrapping a 3rd-Party Shipping API
**Trigger:** "We integrate with FedEx via their proprietary SDK. Every service class that needs shipping rates calls the SDK directly. I want to hide it behind a clean interface."
**Process:**
1. Identify: external resource with awkward API → **Gateway** pattern.
2. Check for confusion: is this a data-access gateway? No — it's a shipping service → generic Gateway (not Table Data Gateway).
3. Recommend: create `ShippingRateGateway` interface + `FedExShippingRateGateway` implementation. The interface reflects your application's needs, not FedEx's full SDK.
4. Surface adjacents: design the Gateway to be replaceable with a Service Stub for testing. Use Plugin or DI to wire the implementation.
**Output (design note):**
```markdown
## Pattern: Gateway
**Applied to:** FedEx shipping rate integration
**Why this pattern:** The FedEx SDK has a complex, proprietary API. Wrapping it in a
Gateway isolates all SDK coupling in one class, enables unit testing via a stub, and
makes future provider changes (FedEx → UPS) a single-class swap.
**Implementation shape (TypeScript):**
interface ShippingRateGateway {
getRates(origin: Address, destination: Address, weight: kg): ShippingRate[];
}
class FedExShippingRateGateway implements ShippingRateGateway {
getRates(origin, destination, weight) {
// translate to FedEx SDK call + translate response
}
}
class StubShippingRateGateway implements ShippingRateGateway {
getRates() { return [{ carrier: "FedEx", service: "Ground", price: Money.dollars(12.50) }]; }
}
**Cautions:**
- This is a generic Gateway (Chapter 18), not a Table Data Gateway (Chapter 10).
Table Data Gateway is for database table access specifically.
```
---
### Scenario 2: Money Stored as Floats
**Trigger:** "Our invoice totals sometimes show $0.01 discrepancies. We store monetary amounts as `double` in the database and in our Java domain objects."
**Process:**
1. Identify: monetary arithmetic with float → **Money** pattern.
2. Root cause: IEEE 754 floating-point cannot represent decimal fractions exactly.
3. Recommend: introduce a `Money` class with `long` (cents) + `Currency` field. Replace all `double`/`float` money fields.
4. Allocation warning: if they split amounts (tax splits, discount allocation), flag Foemmel's Conundrum — simple rounding can gain or lose pennies. Use the `allocate(ratios[])` method.
**Output:**
```markdown
## Pattern: Money
**Applied to:** Invoice total calculation
**Why this pattern:** `double` cannot represent most decimal fractions exactly in
IEEE 754. $0.01 discrepancies are the direct result. Money pattern wraps amount
(stored as integer cents) + Currency and enforces safe arithmetic.
**Implementation shape (Java):**
public record Money(long cents, Currency currency) {
public Money add(Money other) {
assertSameCurrency(other);
return new Money(cents + other.cents, currency);
}
public Money[] allocate(int[] ratios) { /* use ratio algorithm, not rounding */ }
}
**Cautions:**
- Never use double/float for monetary amounts.
- For allocation (70/30 splits): use allocate-by-ratio, not simple rounding.
Rounding 5 cents 70/30 loses or gains a penny. The ratio algorithm distributes
remainders one cent at a time.
```
---
### Scenario 3: Null Checks for Missing Customer
**Trigger:** "When a customer hasn't been identified yet (occupant utility account), we return null from `findCustomer()`. Now we have null checks in 12 places, all doing the same fallback: name='Unknown', balance=0, lastBill=null."
**Process:**
1. Identify: repeated null checks with same fallback behavior → **Special Case** pattern.
2. Distinguish: Null Object (GoF) is Special Case specialized for null. This is the right pattern.
3. Recommend: create `UnknownCustomer extends Customer` with default implementations. Change `findCustomer()` to return `UnknownCustomer()` instead of null. Remove the 12 null checks.
4. Chain note: `unknownCustomer.lastBill` should return a `NullBill` Special Case, not null.
**Output:**
```markdown
## Pattern: Special Case (Null Object variant)
**Applied to:** findCustomer() returning null for unidentified occupant accounts
**Why this pattern:** 12 identical null-check blocks are code duplication. Special Case
eliminates the branching by making the missing case a valid polymorphic subtype.
**Implementation shape (Python):**
class UnknownCustomer(Customer):
@property
def name(self) -> str: return "Unknown"
@property
def balance(self) -> Decimal: return Decimal("0")
@property
def last_bill(self) -> Bill: return NullBill()
def find_customer(id: str) -> Customer:
result = db.find_customer(id)
return result if result else UnknownCustomer()
**Cautions:**
- last_bill should return NullBill(), not None — chain the Special Case pattern.
- Optional[Customer] shifts branching to call sites, not eliminating it.
Special Case removes branching entirely.
```
---
## References
- `references/base-patterns-cheatsheet.md` — per-pattern deep-dive: full definitions, implementation sketches, modern parallels, and Fowler's cautions for all 11 patterns.
**Related PEAA skills that invoke base patterns:**
- `data-source-pattern-selector` — recommends Gateway (wrapping data sources) and Mapper.
- `domain-logic-pattern-selector` — recommends Service Stub for testing domain logic, Value Object, Special Case.
- `object-relational-structural-mapping-guide` — covers Embedded Value (persistence strategy for Value Object, Money).
- `distribution-boundary-designer` — recommends Separated Interface for distribution boundaries.
- `web-presentation-pattern-selector` — references Plugin for theme/environment-based view selection.
---
## License
This skill is licensed under [CC-BY-SA-4.0](https://creativecommons.org/licenses/by-sa/4.0/).
Source: [BookForge](https://github.com/bookforge-ai/bookforge-skills) — Patterns of Enterprise Application Architecture by Martin Fowler, David Rice, Matthew Foemmel, Edward Hieatt, Robert Mee, Randy Stafford.
---
## Related BookForge Skills
Install related skills from ClawhHub:
- `clawhub install bookforge-data-source-pattern-selector`
- `clawhub install bookforge-domain-logic-pattern-selector`
- `clawhub install bookforge-distribution-boundary-designer`
- `clawhub install bookforge-object-relational-structural-mapping-guide`
Or install the full book set from GitHub: [bookforge-skills](https://github.com/bookforge-ai/bookforge-skills)
FILE:references/base-patterns-cheatsheet.md
# Base Patterns Cheatsheet
All 11 patterns from Chapter 18 of *Patterns of Enterprise Application Architecture* (Fowler et al., 2002).
---
## Quick-Lookup Table
| Pattern | One-line intent | When to apply | Modern equivalent |
|---------|----------------|---------------|-------------------|
| **Gateway** | Wrap an external system/resource with a clean OO interface | Any external resource access (API, DB, messaging, XML) | HTTP client wrappers, DAO adapters, adapter classes |
| **Mapper** | Decouple two subsystems by having a third object move data between them | When neither subsystem may know about the other AND the interaction is complex | Data Mapper (DB), AutoMapper, anti-corruption layer |
| **Layer Supertype** | Shared base class for all objects in a layer | Many classes in one layer share behavior (ID, timestamps, change-tracking) | Spring `JpaRepository`, Django `Model`, Rails `ApplicationRecord` |
| **Separated Interface** | Interface lives in a different package from its implementation | Breaking dependency cycles between layers or modules | Repository interface in domain, impl in infrastructure |
| **Registry** | Global finder/locator for widely needed objects or services | Last resort — when dependency injection doesn't fit the call tree | Service Locator (prefer DI container instead) |
| **Value Object** | Small immutable object whose equality is based on field values, not reference | Any domain concept where value-identity makes sense (money, date, address) | Java records, C# structs/records, Kotlin data class |
| **Money** | Value Object for monetary amounts with safe arithmetic | Any monetary calculation | Joda-Money, BigDecimal + Currency, Dinero.js |
| **Special Case** | Subclass that provides harmless default behavior for a particular case | Repeated null checks / special-value checks in multiple places with same behavior | Null Object pattern, Result/Option types |
| **Plugin** | Runtime-selected implementation bound via external configuration | Different implementations per deployment environment | DI container `@Profile`, twelve-factor env config |
| **Service Stub** | In-memory test double for an external service | External service is slow, unreliable, or unavailable during tests | Mockito/Moq mocks, WireMock, in-memory fakes |
| **Record Set** | Generic in-memory tabular structure identical to a SQL result set | Platform provides data-aware UI tools that consume Record Sets | ADO.NET DataSet, JDBC ResultSet, Pandas DataFrame |
---
## Gateway
**Intent:** An object that encapsulates access to an external system or resource.
**Problem it solves:** External systems have awkward APIs (JDBC/SQL for databases, W3C/JDOM for XML, proprietary SDKs for messaging). This awkwardness spreads through your codebase if not contained.
**How it works:**
1. Identify what your application needs to do with the external resource (just your use cases, not all capabilities).
2. Create a clean interface with application-friendly method signatures.
3. Implement the Gateway to translate those calls to the external API.
4. Keep the Gateway minimal — complex logic belongs in its clients.
5. Design the interface to be replaceable with a Service Stub for testing.
**Key distinction from similar patterns:**
- **vs Facade (GoF):** Facade is written by the service author for general use. Gateway is written by the client for its own specific use.
- **vs Adapter (GoF):** Adapter converts an existing interface to another existing interface. Gateway usually creates a new interface where none existed.
- **vs Table Data Gateway:** Table Data Gateway is a specific data-access pattern that returns Record Sets. Generic Gateway is the broader pattern. Table Data Gateway IS a Gateway, but not all Gateways are Table Data Gateways.
- **vs Mapper:** Use Gateway (simpler) unless neither subsystem may be aware of the other interaction.
**Implementation sketch (TypeScript):**
```typescript
// Without Gateway — awkward SDK everywhere
const result = await messagingSDK.send("CNFRM", [orderId, amount, symbol]);
if (result !== 0) throw new Error(`Messaging error: result`);
// With Gateway — application-friendly interface
interface OrderNotificationGateway {
sendConfirmation(orderId: string, amount: number, symbol: string): Promise<void>;
}
class MessagingServiceGateway implements OrderNotificationGateway {
async sendConfirmation(orderId: string, amount: number, symbol: string): Promise<void> {
const result = await messagingSDK.send("CNFRM", [orderId, amount, symbol]);
if (result !== 0) throw new Error(`Messaging error code: result`);
}
}
// Test stub — same interface, in-memory
class StubOrderNotificationGateway implements OrderNotificationGateway {
public sentConfirmations: string[] = [];
async sendConfirmation(orderId: string): Promise<void> {
this.sentConfirmations.push(orderId);
}
}
```
---
## Mapper
**Intent:** An object that sets up communication between two independent objects without either knowing about the mapper.
**Problem it solves:** Two subsystems need to exchange data, but architectural rules prohibit either from depending on the other (or on the mapping mechanism).
**Key rule:** Use Mapper only when you CANNOT allow either subsystem to have a dependency on the interaction. Otherwise, use Gateway (simpler, far more common).
**Most common enterprise use:** Data Mapper (database ↔ domain layer) where the domain model must not depend on persistence infrastructure.
**Modern parallels:** AutoMapper / MapStruct (automated field-to-field mapping), anti-corruption layer (DDD), request/response translators in event-driven systems.
---
## Layer Supertype
**Intent:** A base class for all objects in a layer that contains common layer-level behavior.
**Typical contents per layer:**
- **Domain layer:** ID field + getter/setter, `markDirty()` / `markNew()` / `markDeleted()` for Unit of Work integration, audit timestamps.
- **Data Mapper layer:** Common CRUD SQL helpers, type converters, connection accessors.
- **Web controller layer:** `currentUser()`, response helpers, error rendering.
**Multiple Layer Supertypes:** If a layer has distinct kinds of objects (e.g., Entities and Services), create a separate Layer Supertype for each kind.
**Implementation note:** Fowler's Java example has a `DomainObject` with an ID Long field and getter/setter. Every domain entity extends it, and every Data Mapper can then assume `DomainObject` has an ID.
---
## Separated Interface
**Intent:** Defines an interface in a package separate from its implementation.
**Problem it solves:** Package A depends on Package B, but B needs to call something in A — creating a cycle. Or: domain code should not depend on persistence infrastructure, but the domain needs to query for objects.
**Package placement options:**
1. **Interface in client package** — appropriate when one client defines the contract and the implementation team implements it.
2. **Interface in third package** — appropriate when multiple clients use the same interface, or when the interface team is separate from both client and implementation.
**Fowler's warning:** Do NOT use Separated Interface for every class. It adds factory boilerplate (factories also need Separated Interfaces and implementations). Only use it to break a specific dependency or to support multiple independent implementations.
**Modern parallels:**
```
// DDD example: Repository interface in domain, implementation in infrastructure
package com.myapp.domain.customer; // <-- interface lives HERE
public interface CustomerRepository {
Optional<Customer> findById(CustomerId id);
void save(Customer customer);
}
package com.myapp.infrastructure.persistence; // <-- implementation lives HERE
@Repository
public class JpaCustomerRepository implements CustomerRepository { ... }
```
---
## Registry
**Intent:** A well-known global finder for objects/services that other objects need but can't navigate to via normal object associations.
**Implementation options by scope:**
- **Process-scoped:** Singleton with static methods. Use for immutable/rarely-changed lookup data (country lists, currency tables).
- **Thread-scoped:** ThreadLocal variable. Use for request-scoped data (database connection, current user session).
- **Session-scoped:** Map keyed by session ID stored in thread-local. Use for multi-request session data.
**Critical warning from Fowler:** "Any global data is always guilty until proven innocent." Try passing dependencies explicitly first. Registry is a last resort.
**Registry vs DI Container (modern view):**
- Registry is pull-style: `Registry.getCustomerFinder()` — the caller fetches its dependency.
- DI Container is push-style: dependencies are injected into the constructor at wiring time.
- DI is now the standard recommendation because it makes dependencies explicit and testable. Use Registry only when DI isn't practical (e.g., deeply nested utility code that cannot be wired via constructor).
**Testing note:** Subclass Registry for tests (`RegistryStub`) to swap in Service Stubs. Reset between tests.
---
## Value Object
**Intent:** A small object whose equality is defined by its field values, not its object reference.
**Characteristics:**
- Equality by field values (`equals()` compares fields, not references).
- Immutable — changing a Value Object means creating a new instance.
- Typically small (date, money, address, range, color, measurement).
**Why immutability is critical — the aliasing bug:**
```java
// WRONG: mutable Value Object
Date hireDate = new Date(2024, 3, 18);
employee1.setHireDate(hireDate);
employee2.setHireDate(hireDate); // both share same object
hireDate.setMonth(5); // accidentally mutates BOTH employees' hire date
```
With an immutable Value Object, `setMonth()` doesn't exist — you create a new Date.
**Persistence:** Use Embedded Value (store fields inline in owner's row). Avoid persisting as a separate table with its own primary key — that treats a Value Object like an Entity.
**Modern language support:**
- Java: `record` (immutable by default, equals/hashCode auto-generated)
- C#: `struct` or `record struct` (value semantics built-in)
- Kotlin: `data class` (equals/hashCode auto-generated; mark `val` for immutability)
- Python: `@dataclass(frozen=True)` or `NamedTuple`
- TypeScript: `readonly` interface members + constructor
- Swift: `struct` (value semantics)
**Name collision:** J2EE community incorrectly used "Value Object" to mean Data Transfer Object. These are different: a Value Object is a domain concept with value-based equality; a DTO is an anemic data carrier for crossing process boundaries.
---
## Money
**Intent:** A Value Object representing a monetary amount, with correct currency-aware arithmetic.
**The float trap:**
```java
double val = 0.00;
for (int i = 0; i < 10; i++) val += 0.10;
System.out.println(val == 1.00); // prints FALSE — IEEE 754 rounding
```
Always use integer cents (`long`) or fixed-point (`BigDecimal`). Never `double` or `float`.
**Core fields:**
```java
class Money {
private final long amount; // in smallest currency unit (cents)
private final Currency currency;
}
```
**Arithmetic rules:**
- Addition/subtraction: assert same currency first. Throw if currencies differ (or use a "money bag" for multi-currency sums).
- Multiplication by scalar: use BigDecimal with explicit rounding mode. Force the caller to specify rounding for division/percentage operations.
- Allocation: do NOT use simple rounding. Use the ratio-allocation algorithm.
**Foemmel's Conundrum — the allocation algorithm:**
Problem: Allocate $0.05 between 70% and 30%. Naïve rounding: 3.5¢ → 4¢ + 1.5¢ → 2¢ = 6¢ (gained a penny).
Solution — allocate-by-ratio: compute each share by integer math, then distribute remaining cents one-by-one:
```java
public Money[] allocate(long[] ratios) {
long total = Arrays.stream(ratios).sum();
long remainder = amount;
Money[] results = new Money[ratios.length];
for (int i = 0; i < results.length; i++) {
results[i] = newMoney(amount * ratios[i] / total);
remainder -= results[i].amount;
}
// Distribute remaining cents one-by-one (pseudo-random but lossless)
for (int i = 0; i < remainder; i++) results[i].amount++;
return results;
}
// allocate([7,3]) on $0.05 → [$0.03, $0.02] ✓
```
**Persistence:** Embedded Value — two columns: `amount_cents BIGINT`, `currency_code CHAR(3)`. If all entries for an entity share the same currency, store currency once on the entity and derive it during mapping.
---
## Special Case
**Intent:** Replace repeated null-checks (or special-value-checks) with a subclass that provides valid, harmless default behavior.
**Before (null checks spread everywhere):**
```python
customer = find_customer(id)
if customer is None:
name = "Unknown"
balance = 0
last_bill = None
else:
name = customer.name
balance = customer.balance
last_bill = customer.last_bill
```
**After (Special Case):**
```python
class NullCustomer(Customer):
@property
def name(self): return "Unknown"
@property
def balance(self): return Decimal("0")
@property
def last_bill(self): return NullBill() # chains to another Special Case
def find_customer(id) -> Customer:
customer = db.find(id)
return customer if customer else NullCustomer()
# All call sites become clean — no null checks needed
name = find_customer(id).name
balance = find_customer(id).balance
```
**When Special Cases chain:** `nullEmployee.contract` returns `NullContract` (not None). This propagates polymorphism all the way through the domain model.
**Multiple Special Cases:** Consider separate Special Cases for distinct states — `MissingCustomer` vs `UnknownCustomer` vs `OccupantCustomer` when they have different behaviors.
**Flyweight note:** Usually only one instance of each Special Case needed — implement as singleton unless the Special Case has unique state (e.g., each occupant customer is separate).
**Modern alternatives:**
- `Optional<T>` / `Option<T>` / `Maybe<T>` — makes nullability explicit in the type system but doesn't eliminate branching.
- Result types — for operations that can fail (different use case).
- Special Case is still the cleanest solution when the "no-result" behavior is well-defined and domain-meaningful.
---
## Plugin
**Intent:** Bind interface implementations at configuration time, not compile time. Centralize all environment-specific wiring in one place.
**Problem it solves:** Scattered `if (testMode) { return new TestImpl(); } else { return new ProdImpl(); }` factory methods that require code changes and rebuilds to switch environments.
**How it works:**
1. Define behavior with Separated Interface.
2. Write a PluginFactory that reads a config file mapping interface names → implementation class names.
3. Use reflection (if available) to instantiate the implementation class.
4. Each deployment has its own config file (`test.properties`, `prod.properties`).
```properties
# test.properties
com.myapp.TaxService=com.myapp.stubs.FlatRateTaxService
com.myapp.IdGenerator=com.myapp.stubs.CounterIdGenerator
# prod.properties
com.myapp.TaxService=com.myapp.services.RealTaxService
com.myapp.IdGenerator=com.myapp.db.OracleSequenceIdGenerator
```
**Modern equivalent:** This is exactly what DI container `@Profile` / `@ConditionalOnProperty` does in Spring, or `IServiceCollection` configuration in .NET. The core insight — centralize environment-specific wiring — remains fundamental.
---
## Service Stub
**Intent:** A simple, fast, in-memory test double for an external service that is slow, unreliable, or unavailable during testing.
**The triad (Gateway + Plugin + Service Stub):**
```
Gateway (Separated Interface) → Plugin (factory, reads config) → Real impl (production)
→ Service Stub (test)
```
**Key rule — keep stubs simple:**
- Flat rate stub: 3-5 lines. Returns the same result for all inputs.
- Conditional stub: 10-15 lines. Handles a few well-known test cases.
- Dynamic stub: 20-30 lines. Allows test setup (add exemptions, configure responses).
- If your stub is getting complex, reconsider whether you're testing the right thing.
**Test setup methods that aren't on the real interface:** Add them to the stub, but make the production Gateway implementation throw assertion failures for these methods. This prevents test-only methods from leaking into production.
**Modern landscape:**
- **Mockito (Java) / Moq (.NET) / Jest mocks (JS):** Dynamic mocks with verification semantics. More powerful but also more complex.
- **WireMock / MockServer:** HTTP-level service stubs for integration testing.
- **Testcontainers:** Runs the real service in a container — heavier, more accurate.
- **Pact / contract testing:** Stubs derived from consumer contracts.
- Service Stub is the in-process, simplest option — Fowler's recommended starting point.
---
## Record Set
**Intent:** An in-memory structure that looks and behaves like a SQL query result, enabling data-aware UI tools to work with data generated by domain logic.
**Two essential properties:**
1. Identical interface to a database query result — data-aware UI widgets bind to it seamlessly.
2. Can be built and manipulated by domain logic (not just by querying the database).
**Implicit vs Explicit interface:**
- Implicit: `aReservation["passenger"]` — flexible but error-prone, no type safety, no discoverability.
- Explicit (preferred): `aReservation.Passenger` — typed, discoverable, refactorable.
- ADO.NET strongly-typed DataSets are the gold standard example of explicit Record Sets.
**When it matters:** Only valuable when your platform provides data-aware UI tools that consume Record Sets (ADO.NET Windows Forms, legacy .NET controls). In modern React/Vue SPAs or REST APIs, the pattern is largely obsolete for UI binding — use domain objects and DTOs instead.
**Modern context:** In data science and analytics, Pandas DataFrames and R data.frames are essentially Record Sets. In web backends, the pattern's role is carried by ORM result sets, which are typically mapped immediately to typed domain objects.
Select the right enterprise application architecture patterns for every layer of your system using Fowler's PEAA decision framework. Use this skill when desi...
---
name: enterprise-architecture-pattern-stack-selector
description: "Select the right enterprise application architecture patterns for every layer of your system using Fowler's PEAA decision framework. Use this skill when designing or refactoring an enterprise app and asking: which domain-logic pattern should I use (Transaction Script, Domain Model, Table Module)? Which persistence pattern fits my stack (Active Record, Data Mapper, Table Data Gateway, Row Data Gateway)? Which web-presentation pattern applies (Front Controller, Page Controller, Template View)? How do I combine these into a coherent full-stack architecture? Triggers include: 'help me choose architecture patterns', 'which Fowler pattern for my app', 'enterprise application architecture', 'PEAA pattern selection', 'layer pattern selection', 'domain logic pattern vs persistence pattern', 'refactor enterprise app to patterns', 'how to structure a Spring/Django/Rails/ASP.NET app', 'what persistence pattern should I use', 'enterprise architecture decision', 'full-stack pattern stack', 'patterns of enterprise application architecture'. This is the hub skill — it maps your subsystem context to per-layer family selector skills and produces a consolidated Pattern Stack Decision Record."
version: 1.0.0
homepage: https://github.com/bookforge-ai/bookforge-skills/tree/main/books/patterns-of-enterprise-application-architecture/skills/enterprise-architecture-pattern-stack-selector
metadata: {"openclaw":{"emoji":"📚","homepage":"https://github.com/bookforge-ai/bookforge-skills"}}
status: draft
source-books:
- id: patterns-of-enterprise-application-architecture
title: "Patterns of Enterprise Application Architecture"
authors: ["Martin Fowler", "David Rice", "Matthew Foemmel", "Edward Hieatt", "Robert Mee", "Randy Stafford"]
chapters: [introduction, 1, 8]
domain: software-architecture
tags: ["enterprise-architecture", "software-architecture", "design-patterns", "persistence", "web-application", "domain-logic", "layered-architecture", "pattern-selection"]
depends-on: []
execution:
tier: 2
mode: hybrid
inputs:
- type: codebase
description: "The enterprise application codebase (domain, persistence, web layers) or a description of the subsystem under discussion."
- type: user-description
description: "Stack (language, ORM, web framework), subsystem scope, pain points, and any existing architectural commitments."
tools-required: [Read, Grep, Glob, Write]
tools-optional: []
mcps-required: []
environment: "An enterprise application codebase (OO language + SQL database + web layer), or a description of one. Works offline from description alone. Output: a Pattern Stack Decision Record markdown document."
discovery:
goal: "Produce a per-layer pattern stack recommendation for an enterprise application subsystem, routing each architectural layer to the right pattern family and documenting the rationale."
tasks:
- "Gather stack, subsystem scope, domain complexity, and existing commitments from the user or codebase"
- "Route the domain-logic layer to the appropriate pattern family (Transaction Script / Domain Model / Table Module)"
- "Route the data-source layer based on the domain-logic choice"
- "Route the web-presentation layer to controller and view patterns"
- "Apply the First Law of Distributed Object Design and route session-state and concurrency concerns"
- "Produce a consolidated Pattern Stack Decision Record with per-layer rationale"
audience:
roles: ["software-architect", "senior-backend-engineer", "tech-lead", "staff-engineer"]
experience: "intermediate"
when_to_use:
triggers:
- "Starting greenfield enterprise application design"
- "Evaluating architectural patterns for a new subsystem in an existing app"
- "Refactoring a legacy enterprise app and needing a pattern vocabulary"
- "Team alignment: getting everyone to agree on which patterns apply to each layer"
- "Architecture review: auditing whether the current pattern stack is appropriate for the domain complexity"
prerequisites: []
not_for:
- "Micro-service topology decisions (this skill addresses in-process layering, not service decomposition)"
- "Pure front-end or mobile applications with no server-side persistence"
- "Implementation details of a single pattern (use the family selector skill for that)"
environment:
codebase_required: false
codebase_helpful: true
works_offline: true
quality:
scores:
with_skill: null
baseline: null
delta: null
tested_at: null
eval_count: null
assertion_count: 13
iterations_needed: null
---
# Enterprise Architecture Pattern Stack Selector
## When to Use
Use this skill at the **start of enterprise application design or major refactoring**, when a team needs to select consistent patterns across the domain-logic, data-source, web-presentation, concurrency, and session-state layers of an OO enterprise application backed by a relational database.
This is a **router skill**: it collects your context, routes each architectural layer to the appropriate pattern family, and produces a consolidated Pattern Stack Decision Record. If you have a specific layer question (e.g., only data-source patterns), use the targeted family selector skill instead.
**Prerequisite check before starting:**
- Do you know the target language and framework? (Java/Spring, Python/Django, Ruby/Rails, C#/.NET, TypeScript/Node, etc.)
- Can you describe the domain-logic complexity? (simple CRUD, moderate business rules, complex domain with many variations)
- Are there existing architectural commitments you cannot change? (existing ORM, existing schema, existing framework)
---
## Context and Input Gathering
### Required Context
- **Stack:** language, web framework, ORM or persistence tooling (e.g., Spring Boot + Hibernate, Rails + ActiveRecord, Django + ORM, ASP.NET Core + EF Core, Express + TypeORM).
- **Subsystem scope:** which part of the application are we designing? (all layers, domain only, persistence only, web layer only)
- **Domain-logic complexity:** simple procedural (CRUD, basic calculations) vs moderate (multi-step rules, validations) vs complex (many domain variations, behavior that belongs on entities, revenue recognition style calculations).
- **Existing commitments:** existing schema, team's ORM familiarity, existing framework, whether distribution across processes is already required.
### Observable Context (if codebase is present)
Scan the codebase for:
- `pom.xml`, `Gemfile`, `requirements.txt`, `package.json`, `*.csproj` — detect stack and ORM.
- `src/domain/` or `src/models/` — are there rich entity classes with behavior, or thin data-holder classes?
- `src/persistence/` or `src/repositories/` — hand-rolled SQL vs ORM-mapped classes.
- `src/web/` or `src/controllers/` — one class per route vs a single dispatcher.
- Schema files (`schema.sql`, `migrations/`) — how closely does the schema match the domain model?
### Sufficiency Gate
If domain-logic complexity is unclear, ask: "If I describe two business rules — are they mostly independent procedural steps, or do they interact through shared domain objects with their own behavior?" This distinguishes Transaction Script from Domain Model territory.
---
## Process
### Step 1 — Identify the layers in scope
List which layers the user's question touches: domain logic, data source (persistence), web presentation, concurrency, session state, distribution. If the user said "full stack" or "design my enterprise app", all layers are in scope.
**WHY:** The hub-and-spoke routing only produces value if it's scoped correctly. Selecting data-source patterns without knowing the domain-logic pattern already chosen leads to mismatched recommendations.
---
### Step 2 — Start with the domain-logic layer (the central decision)
Fowler's "Putting It All Together" (Ch 8) is explicit: **the domain-logic choice shapes every downstream layer decision**. The three candidates are:
| Pattern | When to choose |
|---|---|
| **Transaction Script** (procedural service per use-case) | Simple domain logic; team comfortable with procedural style; scripts don't grow complex; low rule-variation count |
| **Domain Model** (rich OO entity graph / DDD-style) | Complex business logic; many domain variations; team has OO modeling skill; willing to invest in O/R mapping |
| **Table Module** (one class per table, record-set oriented) | Moderate complexity; environment with strong record-set tooling (e.g., .NET DataSet, COM+); good middle ground for .NET stacks |
Signals pointing toward Domain Model: duplicate logic spreading across Transaction Scripts; rules that depend on object state (not just database state); complex inheritance in the domain.
If the user's question covers domain logic in detail → invoke `domain-logic-pattern-selector` for deeper decision analysis. If unavailable → ask: "Are your business rules mostly independent per-use-case scripts, or do they involve domain objects with shared state and behavior?"
**WHY:** Getting the domain-logic choice wrong is the most expensive mistake. Choosing Transaction Script when domain complexity demands Domain Model leads to exponential duplication. Choosing Domain Model prematurely for CRUD-only systems adds O/R mapping overhead with no benefit.
---
### Step 3 — Route the data-source layer based on the domain-logic choice
The data-source pattern is **not an independent choice**; it follows from Step 2:
**If Transaction Script was chosen:**
- Prefer **Table Data Gateway** (one class per table, returns record sets) when the platform has strong record-set tooling (DataTable, ResultSet wrappers).
- Prefer **Row Data Gateway** (one object per database row) when you want an explicit typed interface per record.
- Skip Unit of Work and Data Mapper — the script usually wraps its own transaction.
- For the multi-request edit → save pattern, add **Optimistic Offline Lock** (version column). It is almost always the right choice here.
**If Table Module was chosen:**
- **Table Data Gateway** is the natural partner. Table Module + Table Data Gateway "fit together as if it were a match made in heaven" (Fowler, Ch 8).
- No other O/R mapping patterns are typically needed.
**If Domain Model was chosen:**
- Simple Domain Model (schema close to model, few dozen classes) → **Active Record** (each object persists itself) is sufficient.
- Complex Domain Model → **Data Mapper** (separate mapping layer keeps the domain independent of persistence). Adds complexity; use an O/R mapping tool (Hibernate, SQLAlchemy, EF Core, TypeORM) rather than hand-rolling.
- With Data Mapper, add **Unit of Work** (change-tracking session) and **Identity Map** (first-level cache) to avoid stale reads and duplicate-entity bugs. Modern ORMs provide these automatically.
If the user's question is primarily about persistence pattern selection → invoke `data-source-pattern-selector` for full analysis including structural mapping (inheritance, associations). If unavailable → ask: "What is the domain-logic pattern already chosen, and how closely does your class model match your database schema?"
**WHY:** Pairing incompatible domain-logic and data-source patterns is the most common enterprise architecture mistake. Using Data Mapper overhead on a Transaction Script system wastes complexity. Using Active Record on a complex Domain Model creates tight coupling between the domain and the schema.
---
### Step 4 — Route the web-presentation layer
Web-presentation pattern selection is **relatively independent of domain-logic and data-source choices**, but depends on UI complexity and tooling.
**Controller pattern:**
- **Page Controller** (one controller per page or action) — simpler, fits document-oriented sites with few dynamic pages.
- **Front Controller** (single dispatching controller + command objects) — better for complex navigation, many conditional flows, or when shared pre/post-processing is needed (Spring DispatcherServlet, Rails Router, Django URL dispatcher are Front Controllers).
- Add **Application Controller** (workflow/state-machine layer) when navigation logic is complex and involves multi-step workflows. It sits between the web controller and the domain.
**View pattern:**
- **Template View** (JSP, ERB, Jinja2, Razor, Blade) — most common; team uses server-side templates; choose this unless there's a specific reason not to.
- **Transform View** (XSLT-style) — better testability but requires XSLT expertise; uncommon in modern stacks.
- **Two Step View** (logical→physical pipeline) — choose when the same content must render in multiple "skins" or device-specific layouts.
**Recommendation for most stacks:** Front Controller + Template View is the safe default (and what most modern web frameworks implement by default).
If the user's question is primarily about web-presentation → invoke `web-presentation-pattern-selector` for deeper analysis. If unavailable → ask about navigation complexity and whether the application is document-oriented or workflow-oriented.
**WHY:** Choosing Page Controller for a complex, workflow-heavy application creates duplicated pre/post-processing logic across every controller. Choosing Transform View for a team with no XSLT experience creates a skills mismatch.
---
### Step 5 — Apply the First Law of Distributed Object Design
**First Law: Do not distribute.** Run everything in a single process unless you have a hard requirement to separate processes (different security boundary, different deployment cadence, separate team ownership, hardware constraint).
If distribution is required:
- Wrap the domain layer with a **Remote Facade** (coarse-grained service boundary) backed by **Data Transfer Objects** (DTOs) to minimize remote call overhead.
- Never expose fine-grained domain objects directly across a process boundary.
- SQL between application server and database is designed as a remote interface — minimize round-trips with coarse-grained queries.
For session state across requests:
- **Client Session State** (cookie/token-carried state) — stateless server, best scalability; works for small state payloads.
- **Server Session State** (server-memory session) — simpler to implement; creates server affinity; use when state is large or sensitive.
- **Database Session State** — durable, survives server restart; adds DB load; use when session must survive failures.
If the user's question focuses on distribution or session state → invoke `distribution-boundary-designer` or `session-state-location-selector` respectively.
**WHY:** Premature distribution is Fowler's most-cited enterprise architecture anti-pattern. Distributing by domain object class (one process per entity type) makes every business operation a cascade of slow remote calls. The First Law exists because this mistake is pervasive and expensive.
---
### Step 6 — Handle concurrency across requests
For long business transactions that span multiple HTTP requests:
- **Optimistic Offline Lock** (version column / ETag) — default choice; low conflict rate; late detection is acceptable; fits most user-editing workflows.
- **Pessimistic Offline Lock** (application-managed record lock / check-out) — only when conflict rate is high AND the cost of late failure (optimistic collision) is unacceptable to users.
- **Coarse-Grained Lock** — when a business transaction must lock an aggregate (parent + children) atomically; use with either optimistic or pessimistic strategy.
- **Implicit Lock** — enforce locking through framework machinery so callers cannot forget to acquire a lock; the safety net for pessimistic locks.
If the user's question focuses on concurrency → invoke `offline-concurrency-strategy-selector`.
**WHY:** Skipping offline concurrency design leads to lost updates — a persistent source of data corruption in enterprise applications that is hard to diagnose retrospectively.
---
### Step 7 — Produce the Pattern Stack Decision Record
Write a markdown artifact with:
1. **Subsystem:** name and scope.
2. **Stack:** language, framework, ORM.
3. **Domain Logic Layer:** chosen pattern + rationale + forces considered + alternatives rejected.
4. **Data Source Layer:** chosen pattern(s) + rationale + behavioral patterns needed (Unit of Work, Identity Map, Lazy Load).
5. **Web Presentation Layer:** controller pattern + view pattern + rationale.
6. **Concurrency Strategy:** chosen pattern + trigger condition.
7. **Session State Strategy:** chosen pattern + scalability implication.
8. **Distribution:** in-process or Remote Facade + DTO; rationale.
9. **Known Risks:** what to watch for as the system grows (e.g., Transaction Script growing into complex domain logic → upgrade to Domain Model).
10. **Next Steps:** which family selector skills to invoke for detailed implementation guidance.
**WHY:** Without a documented decision record, architectural decisions are re-litigated in every code review. The artifact creates shared vocabulary and makes the forces-and-resolution reasoning visible to the whole team.
---
## Inputs
- **Stack description** (required): language, web framework, ORM/persistence tooling.
- **Domain complexity description** (required): simplicity spectrum from CRUD to complex business rules.
- **Subsystem scope** (required): which layers are in question.
- **Existing commitments** (optional but important): schema, ORM, framework already in use.
- **Codebase** (optional): read `src/`, `schema.sql`, build files to infer stack and patterns in use.
- **Pain points** (optional): N+1 queries, god-object controllers, lost updates, tangled session state — these trigger targeted layer analysis.
---
## Outputs
- **Pattern Stack Decision Record** — a markdown document covering per-layer choices, rationale, forces, and alternatives.
- **Layer routing summary** — which family selector skills to invoke next for implementation depth.
- **Anti-pattern warnings** — if the current stack exhibits known mis-matches (e.g., complex Domain Model with Active Record, Transaction Scripts with no separation from DB logic).
**Output template:**
```markdown
# Pattern Stack Decision Record — [Subsystem Name]
## Stack
- Language / Framework: [e.g., Java 21 / Spring Boot 3]
- ORM / Persistence: [e.g., Hibernate 6 / JPA]
- Web Layer: [e.g., Spring MVC / Thymeleaf]
## Domain Logic Layer
- **Pattern:** [Transaction Script | Domain Model | Table Module]
- **Rationale:** [forces that drove the choice]
- **Alternatives rejected:** [why the others were ruled out]
## Data Source Layer
- **Pattern:** [Table Data Gateway | Row Data Gateway | Active Record | Data Mapper]
- **Behavioral patterns:** [Unit of Work | Identity Map | Lazy Load — as needed]
- **Rationale:** [follows from domain-logic choice + schema complexity]
## Web Presentation Layer
- **Controller:** [Page Controller | Front Controller + Application Controller]
- **View:** [Template View | Transform View | Two Step View]
- **Rationale:** [navigation complexity + team tooling]
## Concurrency Strategy
- **Pattern:** [Optimistic Offline Lock | Pessimistic Offline Lock | Coarse-Grained Lock | Implicit Lock]
- **Trigger:** [when this applies]
## Session State
- **Strategy:** [Client | Server | Database Session State]
- **Scalability implication:** [stateless vs server-affinity vs DB load]
## Distribution
- [Single process — preferred] OR [Remote Facade + DTO — required because: ...]
## Known Risks
- [Growth risks: when to re-evaluate the domain-logic choice]
## Next Steps
- [ ] Invoke `domain-logic-pattern-selector` for implementation depth on domain layer
- [ ] Invoke `data-source-pattern-selector` for persistence pattern detail
- [ ] Invoke `inheritance-mapping-selector` if the schema has inheritance hierarchies
```
---
## Key Principles
**1. Domain-logic choice is the central decision — make it first.**
Every other layer decision is shaped by whether you chose Transaction Script, Domain Model, or Table Module. Reversing this decision later is expensive. Invest time here before designing the persistence or web layers.
**2. The First Law of Distributed Object Design: minimize distribution.**
Every process boundary is a performance tax. Run as much as possible in a single process. Introduce Remote Facade + DTO only where a hard requirement forces a boundary. Do not distribute for the sake of perceived scalability — measure first.
**3. Active Record fits simple Domain Models; Data Mapper fits complex ones.**
Choosing Data Mapper when Active Record would suffice adds unnecessary mapping complexity. Choosing Active Record for a complex Domain Model creates tight coupling that constrains future refactoring. The tipping point is when the schema and the object model begin to diverge significantly.
**4. O/R mapping tools over hand-rolled mappers.**
When Data Mapper is required, use an established mapping tool (Hibernate, SQLAlchemy, EF Core, TypeORM). Hand-rolling a Data Mapper is a significant engineering undertaking — only justified if the tooling is truly unavailable or inappropriate.
**5. Web-presentation patterns are platform defaults — work with them.**
Most modern frameworks have already made the controller choice for you (Spring DispatcherServlet = Front Controller; Rails Router = Front Controller; classic ASP.NET Web Forms ≈ Page Controller). Override the default only when you have a specific reason; fighting the framework's grain is rarely worth the cost.
**6. Optimistic Offline Lock is the right default for multi-request editing.**
Pessimistic locking is harder to implement correctly, creates hanging-lock failure modes, and adds server-affinity pressure. Default to Optimistic Offline Lock (version column) and move to Pessimistic only when you have measured high conflict rates and confirmed that late-failure is unacceptable to users.
**7. Document the pattern stack and keep it visible.**
Architecture decisions fade from team memory. The Pattern Stack Decision Record is a living document — update it when the system grows and a layer's pattern needs to change (e.g., Transaction Scripts outgrowing into Domain Model).
---
## Examples
### Scenario A — Java/Spring Boot + Hibernate enterprise ordering system
**Trigger:** "We're building an order-management system for a B2B wholesaler. Complex pricing rules (tiered discounts, contract pricing, surcharges), multi-step order-approval workflow, 30 developers on multiple teams, Spring Boot + Hibernate stack."
**Process:**
- Step 2 (Domain logic): Complex pricing rules + approval workflow → Domain Model. Transaction Script would produce exponential duplication across pricing rules. Domain Model with Service Layer entry points is the right choice for the approval workflow.
- Step 3 (Data source): Domain Model chosen + complex domain → Data Mapper (Hibernate). Unit of Work (Hibernate Session) + Identity Map (Hibernate L1 cache) + Lazy Load (Hibernate proxy) are provided by the ORM automatically.
- Step 4 (Web): 30 developers + complex navigation → Front Controller (Spring DispatcherServlet, already the default) + Template View (Thymeleaf). Application Controller for the multi-step approval workflow state machine.
- Step 5 (Distribution): Single process preferred. If the order-management system must integrate with an external inventory service → Remote Facade + DTO at that boundary only.
- Step 6 (Concurrency): Multi-request order editing → Optimistic Offline Lock (version column on order entity, enforced by Hibernate `@Version`).
- Step 7 (Session state): Server Session State for the multi-step approval workflow state; Client Session State (JWT) for authentication context.
**Output:** Pattern Stack Decision Record with Domain Model + Data Mapper (Hibernate) + Front Controller + Template View + Optimistic Offline Lock + Server Session State for workflow.
---
### Scenario B — Ruby on Rails / Django application (framework defaults)
**Trigger:** "We're building a SaaS project-management tool. Standard CRUD with some business rules. Small team (5 engineers). Rails (or Django)."
**Process:**
- Step 2 (Domain logic): Moderate complexity — standard project/task CRUD with some validation rules. Table Module or lightweight Domain Model. Rails convention defaults to Active Record pattern at the domain layer (Active Record objects have both data and behavior). Django ORM models follow the same Active Record shape.
- Step 3 (Data source): Active Record is both the domain-logic pattern and the data-source pattern in Rails/Django — the framework has already made this choice. For moderate complexity this is appropriate. Watch for the signal: if business logic starts accumulating in the controller or in callbacks, extract to service objects (Service Layer pattern).
- Step 4 (Web): Front Controller is the default (Rails Router, Django URL dispatcher). Template View is the default (ERB, Django templates). Accept the framework defaults.
- Step 5 (Distribution): Single process. No Remote Facade needed — run Sidekiq/Celery workers in a separate process only for background jobs, not for synchronous domain logic.
- Step 6 (Concurrency): Optimistic Offline Lock — Rails provides `lock_version` column convention; Django provides `select_for_update` or `F` object compare-and-set.
- Step 7 (Session state): Client Session State (signed cookie session — framework default) for most scenarios.
**Output:** Accept framework defaults (Active Record + Front Controller + Template View + Optimistic Offline Lock + Client Session State). Revisit domain-logic pattern if complexity grows.
---
### Scenario C — ASP.NET Core enterprise application (.NET stack)
**Trigger:** "We're migrating a legacy ASP.NET WebForms app to ASP.NET Core + EF Core. Complex lease-calculation logic, 15-person team, SQL Server."
**Process:**
- Step 2 (Domain logic): Complex lease-calculation logic with many rule variations. Fowler explicitly recommends Table Module as the default for .NET (tool ecosystem centered on DataSet/Record Set). However, EF Core enables a proper Domain Model just as easily. Decision: if the team has OO modeling experience and the lease rules are truly complex (not just many fields), Domain Model. If the team is stronger on data-centric thinking, Table Module.
- Step 3 (Data source): Domain Model chosen → Data Mapper (EF Core DbContext is a Unit of Work + Identity Map). Structural mapping (inheritance, associations) configured via EF Core fluent API or data annotations.
- Step 4 (Web): ASP.NET Core MVC is a Front Controller (the framework's default). Template View (Razor). Accept defaults.
- Step 5 (Distribution): Single process (Web + Domain + EF Core in one ASP.NET Core app). Remote Facade only if exposing an API to external consumers (then use a thin API controller as Remote Facade returning DTOs).
- Step 6 (Concurrency): EF Core concurrency tokens (`[Timestamp]` / `rowversion`) → Optimistic Offline Lock by default.
- Step 7 (Session state): Consider Database Session State for lease workflow state (durable across server restarts); Client Session State (JWT) for authentication.
**Output:** Domain Model (or Table Module) + Data Mapper (EF Core) + Front Controller (ASP.NET Core MVC) + Template View (Razor) + Optimistic Offline Lock + Database Session State for durable workflow.
---
## References
- `references/pattern-stack-decision-table.md` — quick-reference routing table: domain-logic pattern → data-source patterns → behavioral patterns needed.
- `references/layer-anti-patterns.md` — common mis-pairings and how to recognize them in a codebase.
- Source: Fowler et al., *Patterns of Enterprise Application Architecture*, Ch. 1 (Layering), Ch. 8 (Putting It All Together), Introduction.
---
## License
This skill is licensed under [CC-BY-SA-4.0](https://creativecommons.org/licenses/by-sa/4.0/).
Source: [BookForge](https://github.com/bookforge-ai/bookforge-skills) — *Patterns of Enterprise Application Architecture* by Martin Fowler, David Rice, Matthew Foemmel, Edward Hieatt, Robert Mee, Randy Stafford.
---
## Related BookForge Skills
Install related skills from ClawhHub:
- `clawhub install bookforge-domain-logic-pattern-selector`
- `clawhub install bookforge-data-source-pattern-selector`
- `clawhub install bookforge-inheritance-mapping-selector`
- `clawhub install bookforge-object-relational-structural-mapping-guide`
- `clawhub install bookforge-web-presentation-pattern-selector`
- `clawhub install bookforge-offline-concurrency-strategy-selector`
- `clawhub install bookforge-session-state-location-selector`
- `clawhub install bookforge-distribution-boundary-designer`
- `clawhub install bookforge-enterprise-base-pattern-catalog`
Or install the full book set from GitHub: [bookforge-skills](https://github.com/bookforge-ai/bookforge-skills)
FILE:references/layer-anti-patterns.md
# Layer Anti-Patterns
Common mis-pairings and architecture mistakes when selecting enterprise application patterns.
## Anti-Pattern 1: Premature Data Mapper
**Symptom:** Team chose Data Mapper (or a full ORM) for a simple CRUD system where the schema closely mirrors the object model.
**What goes wrong:** Data Mapper adds significant complexity — separate mapping classes, configuration, behavioral patterns (Unit of Work, Identity Map). For a simple system, Active Record is sufficient and dramatically simpler.
**Detection:** Domain classes map 1:1 to tables with no meaningful behavior beyond getters/setters. No complex inheritance, no rich associations, no domain logic that needs independence from the schema.
**Fix:** Downgrade to Active Record. Most ORM frameworks support an Active Record style (Hibernate with plain JPA entities behaves like Active Record when there's no complex mapping).
---
## Anti-Pattern 2: Transaction Script with embedded database logic
**Symptom:** Transaction Script methods contain inline SQL or ORM calls mixed with business logic.
**What goes wrong:** Testing becomes impossible (you must hit the database to test any logic). Duplication is hidden inside scripts rather than being centralized.
**Detection:** `service.java` contains `conn.prepareStatement(...)` or `em.createQuery(...)` alongside if-else business rules.
**Fix:** Extract all DB access into a Table Data Gateway or Row Data Gateway. The script calls the gateway, not the database directly.
---
## Anti-Pattern 3: Distributing by domain object class
**Symptom:** Architect puts each domain entity (Customer, Order, Product) in a separate process or service and calls between them for every business operation.
**What goes wrong:** Every business transaction becomes a cascade of slow remote calls. Performance collapses. This is the exact pattern Fowler describes as an "inverted hurricane" in Ch. 7.
**Detection:** Network calls between Customer service and Order service happen 10+ times per request. Latency is measured in hundreds of milliseconds for simple operations.
**Fix:** Consolidate into a single in-process domain layer. Extract service boundaries only where truly justified by deployment or team independence, not by entity type.
---
## Anti-Pattern 4: Active Record for a complex Domain Model
**Symptom:** Domain Model is genuinely rich (complex inheritance, many associations, behavior that varies by subtype) but Active Record is used as the persistence strategy.
**What goes wrong:** The domain model becomes tightly coupled to the schema. Any schema refactoring breaks domain logic. Adding business behavior requires database changes. The object model cannot be tested without hitting the database.
**Detection:** Domain entity classes are littered with `findBy*` and `save()` methods alongside complex business calculations. SQL migration triggers cascading class changes.
**Fix:** Introduce a Data Mapper layer (migrate to an ORM or extract mapping classes). The domain model then has no knowledge of persistence, enabling independent testing.
---
## Anti-Pattern 5: Fat controllers / scriptlet views
**Symptom:** Web controllers contain business logic and database queries. View templates contain conditional loops and calculations.
**What goes wrong:** Domain logic is trapped in the presentation layer. It cannot be reused by other presentation channels (API, batch). It cannot be tested without simulating HTTP requests. Changes to UI require touching business logic.
**Detection:** Controller methods are >50 lines. View templates contain SQL fragments or complex conditional blocks.
**Fix:** Extract domain logic to a Service Layer (thin application facade over the Domain Model). Controller calls Service Layer methods and passes simple DTOs to the view.
---
## Anti-Pattern 6: Missing Optimistic Offline Lock on multi-request edits
**Symptom:** User A and User B both retrieve the same record, edit it in separate HTTP sessions, and both save successfully — the last write silently wins.
**What goes wrong:** Lost updates. The earlier save's changes are overwritten without any error or notification to either user.
**Detection:** No version column, ETag, or timestamp on entities that are edited through multi-step forms. No concurrency conflict exception is ever raised during testing.
**Fix:** Add Optimistic Offline Lock — a version column on the entity, incremented on each save. Any save where the version doesn't match the read version raises a conflict error that the user can resolve.
FILE:references/pattern-stack-decision-table.md
# Pattern Stack Decision Table
Quick-reference routing table for the enterprise-architecture-pattern-stack-selector skill.
## Domain Logic → Data Source Routing
| Domain Logic Pattern | Data Source Pattern(s) | Behavioral Patterns Needed | Notes |
|---|---|---|---|
| **Transaction Script** | Table Data Gateway (when platform has Record Set tooling) | Optional: Unit of Work | Keep scripts thin; extract DB access to a Gateway |
| **Transaction Script** | Row Data Gateway (typed, explicit interface per row) | Optional: Unit of Work | Use when no Record Set tooling; each row = one object |
| **Table Module** | Table Data Gateway | Rarely needed — Record Set carries state | Natural match; Record Set IS the in-memory representation |
| **Domain Model (simple)** | Active Record (object persists itself) | None required | Works when schema closely mirrors the object model |
| **Domain Model (complex)** | Data Mapper (separate mapping layer; use ORM) | **Unit of Work** (required), **Identity Map** (required), Lazy Load (recommended) | ORM (Hibernate, EF Core, SQLAlchemy) provides all three |
## Concurrency Strategy Selection
| Scenario | Pattern |
|---|---|
| Multi-request edit → save (default) | Optimistic Offline Lock (version column / ETag) |
| High conflict rate + late failure unacceptable | Pessimistic Offline Lock (check-out / record lock) |
| Lock an aggregate root + all children | Coarse-Grained Lock (with either optimistic or pessimistic) |
| Team must not forget to acquire locks | Implicit Lock (framework-enforced) |
## Session State Selection
| Scenario | Pattern |
|---|---|
| Small payload; stateless servers; maximum scalability | Client Session State (cookie / JWT / URL token) |
| Large payload; sensitive data; server affinity acceptable | Server Session State (server-memory session) |
| Session must survive server restart / failover | Database Session State (persisted to DB) |
## Web Presentation Routing
| Navigation Complexity | Controller Pattern | View Pattern |
|---|---|---|
| Simple, document-oriented site | Page Controller | Template View |
| Complex navigation, shared pre/post-processing | Front Controller | Template View |
| Multi-step workflows / state machine navigation | Front Controller + Application Controller | Template View |
| Multiple site skins / layouts from single content | Front Controller | Two Step View |
| XSLT-savvy team; high testability priority | Front Controller | Transform View |
## Fowler's First Law of Distributed Object Design
> "Don't distribute your objects." — Ch. 7
Only introduce a process boundary when forced by:
- Different security domains
- Different deployment lifecycles (separate teams deploying independently)
- Hard hardware constraints (separate machines required)
- Integration with external systems (use Remote Facade + DTO at the boundary)
When a boundary IS required:
- Wrap the domain layer with **Remote Facade** (coarse-grained service methods only)
- Use **Data Transfer Object** to carry data across the boundary
- Never expose fine-grained domain objects to a remote caller