Real Example: Building a CRM Feature with Spec-First
Spec-first development is easier to understand through a complete working example than through abstract principles. This is based on a feature I actually shipped — contact deduplication in a CRM system my team built for a mid-market sales platform. This article walks through the full spec for that feature — from the one-sentence goal to the Given/When/Then acceptance criteria, six edge cases, dependency list, and staged rollout plan. Every section shows the decisions the spec forces, and what goes wrong when those decisions are left implicit.
The feature: contact deduplication by email
Let me walk through a real-world example of spec-first development. The feature is contact deduplication in a CRM: when two contact records share the same email address, the system should merge them into one. Simple idea. Non-trivial to specify.
Without a spec, this feature gets built to handle the obvious case and breaks on everything else. With a spec, the team discovers the hard questions before implementation begins: what happens when the email is empty? What if the two records have different names? What if a user has already built automations on both contact IDs? These are not edge cases — they are the whole problem.
Goal and non-goals
Writing the goal forces the first useful decision: what exactly are we merging, and why?
Goal:
When two contact records share the same email address, automatically merge
them into a single canonical record to prevent duplicate outreach and data drift.
This affects contacts created via any channel (import, API, web form).
Non-goals (this spec):
- We are not deduplicating by phone number or name — email only.
- We are not merging contacts across different account tenants.
- We are not handling contacts with no email address (excluded from dedup logic).
- We are not building a UI for manual review of potential duplicates.
- We are not retroactively deduplicating existing records at launch —
this spec covers new-contact creation and import only.The non-goals immediately resolve four arguments that would otherwise happen during implementation. They are not limitations — they are scope decisions made consciously and on record.
Acceptance criteria in Given/When/Then
Each acceptance criterion should describe a concrete scenario that QA can trigger and verify. The goal is zero clarifying questions — every input, action, and expected output is stated explicitly.
Acceptance criteria:
Given a new contact is created via the web form with email "alice@example.com"
When a contact with that email already exists in the account
Then the new contact is not created; the existing contact's updated_at is refreshed;
the response returns HTTP 200 with the existing contact's ID
Given a new contact has a different name but the same email as an existing contact
When deduplication triggers
Then the existing contact's name is NOT overwritten; only fields that are null
on the existing record are populated from the new record
Given a CSV import contains two rows with the same email
When the import job processes them
Then the first row creates the contact; the second row is treated as an update
to the first; a dedup_event is logged with both source rows
Given the existing contact is archived
When a new contact with the same email is created
Then the archived contact is NOT treated as a duplicate;
the new contact is created normally
Given the new contact has an empty email field
When deduplication logic runs
Then deduplication is skipped; the contact is created with a null email
Edge cases
Edge cases for deduplication are where the spec earns its value. This is the category where engineers make quiet decisions during implementation that turn into customer support tickets six months later.
Edge cases:
- Same email, different case ("Alice@example.com" vs "alice@example.com")
→ Treat as duplicate. Normalize to lowercase before comparison.
- Same email, both records have the same updated_at timestamp (e.g., from bulk import)
→ Retain the record with the lower internal ID (creation order).
- Two contacts with same email, one owned by User A and one by User B
→ Merge into the record with the earlier created_at;
update ownership to the owner of the surviving record;
log ownership change in audit trail.
- Dedup target has active automations (sequences, workflows) referencing its ID
→ Do not merge; flag for manual review; create dedup_conflict record.
→ Notify the owning user via in-app notification.
- API creates contact with email that matches existing record, includes X-No-Dedup: true header
→ Create as new record without dedup. Used for testing and data migrations only.
→ Requires admin API key; return 403 if used with standard key.
Dependencies
Naming dependencies before implementation catches integration surprises before they stall the sprint.
The contacts-service must expose a findByEmail(email, accountId) method before this feature can be built. The automations-service must provide a getAutomationsByContactId(contactId) endpoint to check for blockers before merge. Dedup events must be written to the audit-log-service with source_contact_id, target_contact_id, trigger, and timestamp. The feature flag DEDUP_ON_CREATE controls whether dedup runs on contact creation and defaults to false until rollout Stage 2.
Rollout plan
Rollout — Contact deduplication:
Stage 1: Internal accounts only (5 test accounts), DEDUP_ON_CREATE=true
Observation: 48 hours
Success signal: zero unintended merges in audit log; zero support tickets
Stage 2: 10% of paying accounts, randomly sampled
Observation: 7 days
Success signal: dedup_event rate matches expected duplicate rate (<3% of new contacts)
Stop-loss: any data loss report from customer → halt immediately
Stage 3: 100% of accounts
Advance condition: no issues in Stage 2 after 7 days
Rollback:
Step 1: Set DEDUP_ON_CREATE=false (immediate, no deploy required)
Step 2: If merges have already occurred: run restore-merged-contacts.py
with --since flag pointing to rollout timestamp
Data repair owner: data-eng on-call
Point of no return: if >10,000 merges have occurred, restore is manual — escalate
What the spec prevented
Running this spec through review before implementation started surfaced four decisions that would have been made inconsistently without it: how to handle case-sensitive emails, what to do when both records have the same timestamp, whether archived contacts should be treated as duplicates, and what to do when an automation references a contact that would otherwise be merged. Each of those would have been a separate engineering judgment call, buried in a pull request, invisible to QA.
The spec review conversation
The most valuable part of spec-first work is not the document — it is the review conversation the document enables. When this spec went to review, product immediately caught that the "empty email = skip dedup" rule was wrong for their data model (they wanted empty-email contacts flagged, not silently created). That correction cost 10 minutes in a review comment. The same correction during implementation would have cost a full sprint of rework plus a backfill job.
Applying this pattern to your own features
Contact deduplication is a specific example, but the pattern transfers to any feature that touches stored data, applies business rules, or integrates with external services. Start with goal and non-goals. Write your acceptance criteria in Given/When/Then. Force yourself to document what happens on empty input, concurrent requests, invalid state transitions, and missing permissions. Name your dependencies. Write the rollout before you need it. The spec is the cheapest form of insurance you will ever buy for a software delivery.
The same spec structure works as a direct input to AI coding tools. When you hand an AI assistant the goal, non-goals, and Given/When/Then criteria from a spec like this one, the output stays within the boundary you set. The edge cases and the X-No-Dedup escape hatch would not appear in AI-generated code unless they were in the prompt — which is exactly what the spec provides. The spec prevents scope drift from a human engineer and from an AI tool for the same reason: it makes the decisions explicit before implementation, leaving no room for either to fill in the gaps independently.
Keep reading
Editorial note
This article covers Real Example: Building a CRM Feature with Spec-First for software delivery teams. Examples are illustrative engineering scenarios, not legal, tax, or investment advice.
- Author details: Daniel Marsh
- Editorial policy: How we review and update articles
- Corrections: Contact the editor