Skip to content
Santekno.com | Level Up Your Engineering Skills
EN
📖 0%
06 Aug 2026 · 14 min read ·Article 17 / 208
Go

Spec Drift Detection: When Code Diverges from the Specification

How to detect and prevent spec drift in Go SDD projects. Automated and manual techniques — spec-guard tests, coverage reports, custom linters, and AI audits — that keep code aligned with its specification through the whole development cycle.

IH
Ihsan Arif
Writer at Santekno · Backend Engineer

Spec drift detection golang SDD matters because drift is the phenomenon where code gradually deviates from established specifications — not from intentional changes, but from accumulated small decisions that were never traced back to spec.

The signs are subtle: an error code that gets renamed, a response field dropped because “it seemed unnecessary,” an edge case skipped as a deadline loomed. Each is small. Together they open a gap between what the spec says and what the code does. This article shows how to detect drift before it becomes a production problem, and how to build durable prevention.


17.1 Most Common Causes of Spec Drift

Prevention starts with recognizing where drift comes from. The four Go-flavored examples below show the causes you will meet most often in day-to-day work.

go
 1// Cause 1: "Small fix" without spec update
 2// Spec AC9: error_code: "ORDER_NOT_CANCELLABLE"
 3// Code (drift): error_code: "NOT_CANCELLABLE"  // changed without updating spec
 4
 5// Cause 2: Spec not updated after a team discussion
 6// Sprint: "Let's change timeout from 15 to 30 minutes based on user research."
 7// Decision recorded in Slack, code updated, spec NOT updated
 8
 9// Cause 3: Copy-paste coding
10// Copying an existing handler and forgetting to update error codes for the new spec.
11
12// Cause 4: Different AC interpretation
13// Spec: "validates cart belongs to authenticated user"
14//   Dev A: returns 404 (hides existence)
15//   Dev B: returns 422 (per spec AC9)
16//   Dev C: returns 403 (semantically most correct)
17// All justified, but the spec isn't explicit -> drift

What unites all four causes is that none is malicious — every one is a reasonable local decision that never made it back to the spec. That is exactly why drift is invisible without tooling: no single commit looks wrong.


17.2 Automated Detection: Tests as Spec Guards

The most effective drift detector is a test that asserts the exact string values the spec mandates. The test below fails the moment an error code drifts away from what AC9 requires.

go
 1// This test will fail immediately when the error code drifts
 2func TestCancelOrder_AC9_ErrorCodeMatchesSpec(t *testing.T) {
 3    // ... setup ...
 4
 5    var body map[string]interface{}
 6    json.Unmarshal(rec.Body.Bytes(), &body)
 7
 8    // Assert the EXACT error_code from spec — this prevents drift
 9    assert.Equal(t, "ORDER_NOT_CANCELLABLE", body["error_code"],
10        "error_code must match spec AC9 exactly: 'ORDER_NOT_CANCELLABLE'")
11    // If someone changes the error code in code, this test fails in CI
12}

The distinction that makes this work: a test asserting status == 409 will not catch a renamed error code, but a test asserting the literal "ORDER_NOT_CANCELLABLE" will. Exact-string assertions are the alarm system that fires the instant drift is introduced.


17.3 Spec Coverage Report

You also want to know which acceptance criteria have any test referencing them at all. The shell script below scans the spec for AC/EC identifiers and reports which ones appear in the test suite.

bash
 1#!/bin/bash
 2# scripts/spec-coverage.sh
 3
 4ACS=$(grep -oP 'AC\d+' "$SPEC_FILE" | sort -u)
 5ECS=$(grep -oP 'EC\d+' "$SPEC_FILE" | sort -u)
 6
 7for ac in $ACS; do
 8    if grep -r "$ac" "$TEST_DIR" --include="*_test.go" -q; then
 9        echo "$ac: covered"
10    else
11        echo "$ac: NOT covered in tests"  # drift risk
12    fi
13done
14
15echo "=== Spec Coverage: $COVERED/$TOTAL ($(( COVERED * 100 / TOTAL ))%) ==="

An uncovered AC is a drift risk waiting to happen: with no test pinning it, the behavior can change without anyone noticing. Treat the coverage percentage as a gate — a feature isn’t “done” until every high-priority AC and EC shows up as covered.


17.4 OpenAPI Contract Test for Drift Detection

If you already maintain an OpenAPI spec, you can validate every response against it automatically. The Go test below loads the spec and asserts each handler’s response conforms to it.

go
 1func TestAllEndpoints_ConformToOpenAPISpec(t *testing.T) {
 2    doc, _ := loader.LoadFromFile("../../../../api/openapi.yaml")
 3    router, _ := legacyrouter.NewRouter(doc)
 4
 5    // For each test case, validate the response against the OpenAPI spec
 6    err := openapi3filter.ValidateResponse(ctx, &openapi3filter.ResponseValidationInput{
 7        Status: resp.StatusCode,
 8        Header: resp.Header,
 9        Body:   resp.Body,
10    })
11    assert.NoError(t, err, "response must conform to OpenAPI spec [%s]", tc.specRef)
12}

The advantage of this layer is breadth: one test protects every documented field, status, and content type at once. When a handler starts returning a field the OpenAPI contract doesn’t declare — or drops one it does — this test flags it without you writing a per-field assertion.


17.5 Git Hook for Drift Prevention

Some drift is best caught before the commit even lands. The pre-commit hook below warns when a handler changes without a corresponding spec change, forcing an explicit decision.

bash
 1#!/bin/bash
 2# .git/hooks/pre-commit
 3
 4CHANGED_HANDLERS=$(git diff --cached --name-only | grep "_handler.go")
 5CHANGED_SPECS=$(git diff --cached --name-only | grep "^specs/")
 6
 7if [ -n "$CHANGED_HANDLERS" ] && [ -z "$CHANGED_SPECS" ]; then
 8    echo "Handler files changed but no spec files updated."
 9    echo "If this is a behavior change, please update the relevant spec."
10    read -p "Continue anyway? (y/N) " -n 1 -r
11    [[ ! $REPLY =~ ^[Yy]$ ]] && exit 1
12fi

The hook doesn’t block refactors — it just makes “I changed a handler but not the spec” a conscious choice rather than an accident. That small friction is often enough to stop drift at the source, before it reaches review.


17.6 AI Periodic Spec Review

Tools catch mechanical drift; an AI audit catches the semantic kind. The prompt below has Claude Code compare the current spec against the current code and report every divergence.

text
 1Please do a spec compliance audit for the cancel order feature.
 2
 3SPEC (latest version): @specs/order/cancel-order.md
 4CURRENT CODE: @internal/usecase/order/cancel_order.go, @internal/repository/postgres/order_repository.go, @internal/delivery/http/handler/order_handler.go
 5
 6Identify:
 71. ACs/ECs in spec but not implemented in code
 82. Behaviors in code different from spec (response codes, error codes)
 93. Response fields in code not in spec, or vice versa
104. Code comments that are outdated vs spec
11
12Output format:
13## Spec Drift Report — [date]
14### Found: [n] drift items
15| Type | Spec Says | Code Does | Location | Severity |

The structured report this produces is easy to triage, but treat it as a lead, not a verdict: AI can flag a “missing” AC that is actually implemented in a different file or via middleware. Verify each finding before you file a fix.


17.7 Spec Assertion Helper

Repeating exact-string assertions across dozens of tests is tedious and error-prone. The helper below wraps them so every assertion is tied to a named spec reference and prints a clear drift message on failure.

go
 1type SpecAssert struct {
 2    t       *testing.T
 3    specRef string
 4}
 5
 6func (sa *SpecAssert) ErrorCode(got, expected string) {
 7    sa.t.Helper()
 8    if got != expected {
 9        sa.t.Errorf("SPEC DRIFT DETECTED [%s]\nExpected: %q\nGot: %q",
10            sa.specRef, expected, got)
11    }
12}
13
14// Usage
15spec := NewSpecAssert(t, "cancel-order.md v1.3 AC9")
16spec.ErrorCode(body["error_code"].(string), "ORDER_NOT_CANCELLABLE")

The payoff is in the failure output: instead of a bare not equal, a developer sees SPEC DRIFT DETECTED [cancel-order.md v1.3 AC9] and knows exactly which spec clause was violated. Binding every assertion to a spec reference is what turns a test failure into a spec conversation.


17.8 Custom Linter for Error Code Registry

You can go further and reject unregistered error codes at lint time. The Go snippet below defines the allowed set, each mapped to the spec clause that authorizes it.

go
1var AllowedErrorCodes = map[string]string{
2    "ORDER_NOT_FOUND":       "cancel-order.md AC7",
3    "ORDER_NOT_CANCELLABLE": "cancel-order.md AC9",
4    "CANCEL_WINDOW_EXPIRED": "cancel-order.md AC10",
5    // ... all registered codes
6}
7
8// The analyzer reports any error code string literal not in the registry

Because the map doubles as documentation — every code points at the spec that defines it — the registry itself becomes a single source of truth. A new error code literal that isn’t in the map fails the linter, forcing the author to register it (and, by extension, to have a spec for it).


17.9 Recovering from Drift

When drift is found, there are exactly two legitimate responses — and choosing between them is a decision, not a default. The two options below show how to fix code to the spec, or update the spec to the code.

text
 1Option A: Fix code to match spec
 21. Update the error code in the handler
 32. Update the test to assert the exact spec string
 43. Check if consumers depend on the old code (breaking change?)
 54. Commit: "fix(order): align error code with spec cancel-order.md AC9"
 6
 7Option B: Update spec to match code (when the code change was intentional but undocumented)
 81. Update spec: AC9 error_code: "NOT_CANCELLABLE" (was "ORDER_NOT_CANCELLABLE")
 92. Update changelog: v1.4 - Changed error code
103. Notify consumers
114. Commit: "docs(spec): update cancel order error code per team decision 2025-07-15"

The one thing both options share is that they end the divergence with a documented decision. There is no valid third option of “leave it” — an unresolved drift compounds, and every later reader has to guess which of the spec or the code is authoritative.


17.10 Spec Version in Code Comments

Traceability is cheap to add and pays off for years. The comment block below anchors an implementation to a specific spec version and records any known, tracked gaps.

go
 1// CancelOrderUseCase implements cancel order business logic.
 2// Spec: specs/order/cancel-order.md
 3// Spec version: v1.3
 4// Last compliance check: 2025-07-15
 5//
 6// Known spec gaps (tracked):
 7// - EC3 timeout: context deadline from HTTP middleware covers this (JIRA-789)
 8func (uc *CancelOrderUseCase) Execute(...) error {
 9    // ...
10}

A future developer reading this instantly knows which spec version the code follows and which gaps are intentional versus accidental. The Known spec gaps line is especially valuable — it converts a silent drift into an acknowledged, ticketed decision.


17.11 Drift Severity Classification

Not all drift deserves the same urgency, and treating every finding as a fire will burn out the team. The classification below sorts drift by consumer impact so you fix the dangerous things first.

text
 1HIGH — Fix before next release:
 2- Error code different from spec (consumers will break)
 3- HTTP status code different from spec
 4- Response field removed
 5- Validation skipped
 6
 7MEDIUM — Address in next sprint:
 8- Response field added (backward-compatible but verify)
 9- Timeout/limit different from NFR
10- Missing observability logging per NFR
11
12LOW — Track and address when convenient:
13- Outdated code comments
14- Variable names not matching spec convention
15- Error message text slightly different (not machine-parsed)

The organizing principle is blast radius: anything a consumer parses (error codes, status codes, fields) is HIGH because it breaks integrations, while human-facing text is LOW. Classifying first keeps drift work proportional to real risk.


17.12 CI/CD Integration

The detection layers are strongest when they run on every pull request. The workflow below chains the coverage check, OpenAPI validation, and error-code linter into one compliance job.

yaml
 1jobs:
 2  spec-compliance:
 3    steps:
 4      - name: Spec coverage check
 5        run: bash scripts/spec-coverage.sh specs/order/cancel-order.md
 6
 7      - name: OpenAPI contract validation
 8        run: go test ./internal/delivery/http/... -run TestContract
 9
10      - name: Error code registry check
11        run: golangci-lint run --enable=errorcodecheck ./internal/delivery/...

Running these three together on every PR means drift is caught before merge, not discovered weeks later. The layers are complementary: coverage catches missing tests, the contract test catches shape mismatches, and the linter catches unregistered codes.


17.13 Handling Long-Standing Drift

Sometimes a team discovers drift that has been live for months, and untangling it needs a plan rather than a quick fix. The prompt below asks Claude Code to produce an audit, a fix priority, and a non-breaking recovery path.

text
 1We just realized cancel order has drifted from spec for 3 months.
 2Undocumented changes:
 31. Error code changed
 42. Response body added to 204 (spec says no content)
 53. Timeout: 15 min in code, 30 min in spec
 6
 7Help create:
 81. Complete audit report of all drift
 92. Fix priority (what to fix first?)
103. Plan to recover without breaking existing consumers
114. How to update spec to accurately document v2.0 while keeping v1.3 as historical record

The critical constraint the prompt encodes is “without breaking existing consumers”: once drift has been in production, consumers may depend on the wrong behavior, so the recovery must be sequenced and communicated. Capturing both the current reality (v2.0) and the original intent (v1.3) keeps the history honest.


17.14 Building Spec Drift Prevention Culture

Tooling helps, but the durable defense is a team that instinctively asks about the spec. The four habits below embed that question into the rhythms you already have.

  • In daily standup: “Anyone planning to change the behavior of existing endpoints today?”
  • In code review: “This changes an error code. Has the spec been updated?”
  • In retrospective: “Any spec drift found this sprint? Root cause?”
  • In onboarding: “Every behavior change needs a spec update. Non-negotiable on our team.”

When these questions become natural conversation rather than process theater, spec drift becomes rare. Culture is what fills the gaps that no linter or CI check can reach.


17.15 Sprint Ceremonies with Spec Drift Component

You can weave drift prevention into the ceremonies your team already runs, at almost no extra cost. The outline below assigns a spec check to each stage of the sprint.

text
1Sprint Planning: Review spec for features being implemented; verify spec version is latest
2Mid-Sprint: Quick AI spec review for in-progress features
3Sprint Review: Demo checked against spec; spot-check error codes
4Retrospective: "Any spec drift this sprint? What caused it? How to prevent?"

The point of spreading checks across the sprint is early feedback: catching drift mid-sprint is a five-minute fix, while catching it at review is a re-open. Small, recurring checkpoints beat one heavyweight audit.


17.16 Communicating Drift as a “Good Catch”

Spec drift usually happens because of process, not because developers are careless — and how you talk about it decides whether people report it. The right framing is simple: find drift, fix it, improve the process; not find drift, find who to blame. When drift detection is celebrated as a team win, developers surface and fix it proactively instead of quietly ignoring it.


17.17 Consumer Notification for Breaking Drift Fixes

When drift has been live and consumers depend on the “wrong” behavior, fixing it becomes a breaking change that must be managed like any other migration. Announce the fix with a timeline, provide a migration guide, monitor consumer traffic for the wrong error code or status, and deploy the fix only after verifying that consumers have migrated. Fixing drift blindly can break the very integrations you were trying to protect.


17.18 Tips & Gotchas

Before wrapping up, here is the distilled advice from teams that have fought drift and won. Use it as a quick reference when you set up your own detection stack.

  • Tip 1: Tests asserting exact string values are the best drift detector — assert.Equal(t, "ORDER_NOT_CANCELLABLE", code) fails immediately on drift.
  • Tip 2: Spec version comments in code are a long-term investment — // Spec: cancel-order.md v1.3 helps future developers.
  • Tip 3: Use the spec coverage report as your definition of done — a feature isn’t done until coverage is at least 80%.
  • Tip 4: Frame drift as “good catch,” not blame — it’s a process problem, not a people problem.
  • Gotcha 1: Overly aggressive drift detection becomes a bottleneck — find the balance between enforcement and velocity.
  • Gotcha 2: AI drift detection can produce false positives — the implementation may exist in a different file or via middleware.
  • Gotcha 3: Spec drift is not spec evolution — intentional, documented changes are evolution, not drift.
  • Gotcha 4: Consumer notification is required when fixing breaking drift — communicate before deploying.

The recurring lesson across these eight: automate the mechanical checks, reserve human judgment for the semantic ones, and never let enforcement outrun the team’s ability to keep up.


17.19 The Drift Prevention Stack

A complete defense layers cheap-and-fast checks under slower-but-deeper ones. The stack below arranges the techniques from this article by how early each one gives feedback.

text
 1Layer 1: Tests (fastest feedback)
 2  Exact string assertions for error codes and field names; OpenAPI contract tests
 3
 4Layer 2: Static Analysis (pre-commit)
 5  Custom linter for the error code registry; git hook for handler-without-spec changes
 6
 7Layer 3: CI/CD (every PR)
 8  Spec coverage report; contract test suite
 9
10Layer 4: Periodic Audit (weekly/sprint)
11  AI spec compliance review; manual spot-checks for critical endpoints
12
13Layer 5: Culture (ongoing)
14  Sprint ceremonies with a spec component; "good catch" framing for drift reports

No single layer is sufficient, but together they make drift expensive to introduce and cheap to catch. The lower layers give near-instant feedback; the upper layers catch what automation misses — and Layer 5 is what keeps all the others alive.


17.20 Summary

Spec drift is SDD’s most dangerous enemy because of its silent, incremental nature. Prevention beats detection.

The best prevention is tests that explicitly assert string values from the spec — the alarm system that automatically triggers when drift occurs.

For periodic detection, spec coverage reports, AI audits, and OpenAPI contract tests provide complementary layers. Git hooks, CI checks, and custom linters help, but culture beats tools: the most effective prevention is a team that naturally asks “does this match the spec?” in every code review.

The recovery protocol is fixed — found drift must be resolved by either fixing code to the spec or updating the spec to match the code, with the decision documented. There is no third option of “leave it.”

In the next article, we cover SDD in larger team contexts: spec versioning, onboarding new developers, and code review in a mature SDD ecosystem.

Related Articles

💬 Comments