DEV Community

Cover image for The Status Field That Grew Three Heads (And How We Fixed It)
Travis Wilson
Travis Wilson

Posted on

The Status Field That Grew Three Heads (And How We Fixed It)

I shipped a "simple" status field six months ago.

Yesterday I deleted it and replaced it with... a status field.

Here's how three fields became one, and why it took 193 files to fix a problem I created.

The Original Sin

When I first built import/export configs, the status was obvious:

type ImportConfig struct {
    Status string `json:"status"` // "draft" | "active" | "paused"
}
Enter fullscreen mode Exit fullscreen mode

Clean. Simple. Done.

Then users started running imports. I needed to know if the last run succeeded or failed:

type ImportConfig struct {
    Status        string `json:"status"`        // "draft" | "active" | "paused"
    LastRunStatus string `json:"lastRunStatus"` // "success" | "failed" | "running"
}
Enter fullscreen mode Exit fullscreen mode

Still manageable. Two fields, two concerns.

Then I built the wizard. Users needed to save progress mid-flow without creating broken configs. Easy fix:

type ImportConfig struct {
    Status        string `json:"status"`
    LastRunStatus string `json:"lastRunStatus"`
    IsDraft       bool   `json:"isDraft"` // wizard in progress
}
Enter fullscreen mode Exit fullscreen mode

Three heads. One monster.

The Combinatorial Nightmare

Here's the problem with three fields: they create a state matrix.

Status: draft | active | paused | disabled | completed
LastRunStatus: success | failed | running | (empty)
IsDraft: true | false
Enter fullscreen mode Exit fullscreen mode

That's 5 × 4 × 2 = 40 possible states.

Most of them were nonsense. What does status=active, lastRunStatus=running, isDraft=true mean?

I had no idea. Neither did my code.

The frontend had this gem:

function getDisplayStatus(config: ImportConfig): string {
  if (config.isDraft) return 'draft';
  if (config.lastRunStatus === 'running') return 'running';
  if (config.status === 'paused') return 'paused';
  if (config.lastRunStatus === 'failed') return 'failed';
  if (config.status === 'active') return 'ready';
  return 'draft'; // ¯\_(ツ)_/¯
}
Enter fullscreen mode Exit fullscreen mode

That function was wrong. I just didn't know which cases were wrong.

The Recognition Moment

I was adding a new feature when I realized I couldn't answer a basic question:

"Is this import ready to run?"

The answer required checking three fields, understanding their precedence, and hoping I got the logic right.

That's when I knew: I hadn't modeled status. I'd accumulated symptoms.

The Fix: One Field to Rule Them All

The refactor was simple in concept:

Before: 3 fields tracking overlapping concerns
After: 1 field with 5 mutually exclusive states

const (
    StatusDraft   = "draft"   // Wizard incomplete, missing required fields
    StatusReady   = "ready"   // Complete config, can be run
    StatusRunning = "running" // Currently executing
    StatusPaused  = "paused"  // User paused execution
    StatusFailed  = "failed"  // Last run failed, needs attention
)

type ImportConfig struct {
    Status string `json:"status"` // One of the above. That's it.
}
Enter fullscreen mode Exit fullscreen mode

No matrix. No precedence. No guessing.

"Is this import ready to run?" → config.Status == StatusReady

But Wait, What About the Wizard?

The isDraft field existed because wizards need to save partial progress.

Removing it meant solving a different problem: where does wizard state live?

Answer: in a DraftData field that only exists when Status == StatusDraft:

type ImportConfig struct {
    Status    string     `json:"status"`
    DraftData *DraftData `json:"draftData,omitempty"` // nil unless Status=draft
}

type DraftData struct {
    CurrentStep     string          `json:"currentStep"`
    PendingSchema   *PendingSchema  `json:"pendingSchema,omitempty"`
    PendingDataType *PendingDataType `json:"pendingDataType,omitempty"`
}
Enter fullscreen mode Exit fullscreen mode

The key insight: pending resources live in draft data, not in real tables.

When you're mid-wizard creating a schema, that schema doesn't exist yet. It's a pending schema stored in DraftData. Only when you finalize does it become real.

No more orphaned schemas from abandoned wizards.

The Damage Report

Fixing this touched:

  • 193 files
  • 11,786 additions
  • 4,179 deletions
  • Backend models, services, handlers
  • Frontend types, hooks, wizards
  • Tests across both

Was it worth it?

The getDisplayStatus function is now:

function getDisplayStatus(config: ImportConfig): string {
  return config.status;
}
Enter fullscreen mode Exit fullscreen mode

Yes. It was worth it.

Lessons

1. State fields multiply.
One becomes two becomes three. Each addition feels small. The complexity is combinatorial.

2. "What state is this?" should be trivial to answer.
If you need a flowchart, you have a modeling problem.

3. Pending resources aren't real resources.
Wizard state is draft data, not partially-created entities.

4. Big refactors are just many small changes.
193 files sounds scary. It was really "update Status constant" × 193.


Building Flywheel - data pipelines for startups. If you've ever watched a "simple" field grow three heads, I'd love to hear your war stories.

Top comments (0)