Real Example: Building a CRM Feature with Spec-First

Real Example: Building a CRM Feature with Spec-First
Daniel Marsh · Spec-first engineering notes

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.

Published on 2026-01-23 · ✓ Updated 2026-03-25 · 7 min read · Author: Daniel Marsh · Review policy: Editorial Policy

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?

CRM Contact Deduplication Spec
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.

Keywords: CRM spec example · spec-first development · contact deduplication · acceptance criteria · technical spec

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.