DEV Community

Dora
Dora

Posted on • Edited on

The Active Unique Pattern: Better Than Soft Delete

The Active Unique Pattern: Better Than Soft Delete

Problem

Student takes an exam and needs to retry.

Requirements:

  • Only one active attempt at a time
  • Keep history of all previous attempts
  • Related data (answers, submissions, grades) stays linked

Common Approaches

1. Hard delete

old_attempt.delete()
new_attempt = Attempt.objects.create(...)
Enter fullscreen mode Exit fullscreen mode

Lose all history.

2. Soft delete

old_attempt.deleted_at = timezone.now()
Enter fullscreen mode Exit fullscreen mode

Every query needs .filter(deleted_at__isnull=True).

3. Archive table

ArchivedAttempt.objects.create(...)
old_attempt.delete()
Enter fullscreen mode Exit fullscreen mode

Two tables to maintain.

Solution: Active Unique Pattern

Only one active record. Keep all inactive records as history.

class Exam(Model):
    owner = ForeignKey(User, CASCADE)
    honor_code = ForeignKey(HonorCode, CASCADE)
    question_pool = ForeignKey(QuestionPool, CASCADE)
    duration = DurationField()

class Attempt(Model):
    exam = ForeignKey(Exam, CASCADE)
    learner = ForeignKey(User, CASCADE, related_name="+")
    started = DateTimeField()
    active = BooleanField(default=True)
    retry = PositiveSmallIntegerField(default=0)

    class Meta:
        constraints = [
            UniqueConstraint(
                fields=["exam", "learner", "active"],
                condition=Q(active=True),
                name="one_active_attempt"
            )
        ]

class TempAnswer(Model):
    attempt = OneToOneField(Attempt, CASCADE)
    answers = JSONField()

class Submission(Model):
    attempt = OneToOneField(Attempt, CASCADE)
    answers = JSONField()

class Grade(Model):
    attempt = OneToOneField(Attempt, CASCADE)
    grader = ForeignKey(User, CASCADE, null=True, blank=True, related_name="+")
    score = DecimalField(max_digits=5, decimal_places=2)

class Appeal(Model):
    ....
Enter fullscreen mode Exit fullscreen mode

The constraint:

UniqueConstraint(
    fields=["exam", "learner", "active"],
    condition=Q(active=True),
    name="one_active_attempt"
)
Enter fullscreen mode Exit fullscreen mode

Only ONE active attempt per (exam, learner).
Inactive attempts have no limit.

How It Works

Student starts exam:

attempt = Attempt.objects.create(
    exam=exam,
    learner=student,
    active=True,
    retry=0
)
Enter fullscreen mode Exit fullscreen mode

Student needs retry:

# Deactivate old
old = Attempt.objects.get(exam=exam, learner=student, active=True)
old.active = False
old.save()

# Create new
new = Attempt.objects.create(
    exam=exam,
    learner=student,
    active=True,
    retry=old.retry + 1
)
Enter fullscreen mode Exit fullscreen mode

Related data (TempAnswer, Submission, Grade) stays linked to old attempt.
No cascade deletes. No data loss.

Queries

Get active attempt:

attempt = Attempt.objects.get(exam=exam, learner=student, active=True)
Enter fullscreen mode Exit fullscreen mode

Get all attempts:

attempts = Attempt.objects.filter(exam=exam, learner=student).order_by('-started')
Enter fullscreen mode Exit fullscreen mode

No deleted_at__isnull=True everywhere.

Why This Works

  • active=True = current
  • active=False = history
  • Database-enforced uniqueness
  • Clean queries
  • Related data stays linked

Comparison

Hard Delete: No history
Soft Delete: Messy queries
Archive Table: Two tables
Active Unique: History + clean queries

Use Cases

  • Exam retries
  • Draft vs published
  • Session management
  • Subscription renewals
  • Order revisions

Anytime you need "one current + keep all previous."

The full implementation

https://github.com/cobel1024/minima

Top comments (0)