MarkUs: Model Architecture

PR #7681 — Enforcing referential integrity through ActiveRecord association semantics.


The Problem

Deleting a Section record could orphan student records or leave dangling foreign key references to starter file groups. The existing implementation handled cleanup in the controller, scattering data integrity logic across the request-handling layer rather than encoding it in the domain model.

This violates a fundamental Rails principle: models own their invariants. A controller orchestrates the request lifecycle; the model enforces the rules that keep data consistent regardless of how the model is accessed (controller, background job, console, seed script).


Association Lifecycle Semantics

ActiveRecord’s dependent option on has_many controls what happens to associated records when the parent is destroyed. The options encode different integrity strategies:

OptionBehaviorUse Case
:destroyDestroy each child (runs callbacks)Owned resources with their own cleanup
:delete_allSQL DELETE without callbacksPerformance: skip callbacks for bulk deletion
:nullifySet foreign key to NULLSoft dissociation (child survives)
:restrict_with_errorPrevent parent deletion, add errorProtect children from accidental deletion
:restrict_with_exceptionRaise DeleteRestrictionErrorHard prevention (programming error)

The Fix

class Section < ApplicationRecord
  has_many :section_starter_file_groups, dependent: :destroy
  has_many :students, dependent: :restrict_with_error
end

:destroy for starter file groups. These are owned resources: a starter file group belongs to a section and has no meaning without it. When the section is deleted, its starter file groups should cascade-delete. Using :destroy (not :delete_all) ensures that any callbacks on SectionStarterFileGroup execute during deletion.

:restrict_with_error for students. Students should not be orphaned by section deletion. If an instructor attempts to delete a section that still has enrolled students, the deletion fails with a validation error. This forces the instructor to reassign students first, preventing data loss.

The controller-level cleanup code was removed entirely. The model now self-enforces its invariants through declarative association semantics.


Testing Deletion Behavior

Each association strategy requires explicit test coverage:

# Cascade deletion
test "destroying section destroys starter file groups" do
  section = create(:section, :with_starter_files)
  assert_difference("SectionStarterFileGroup.count", -1) do
    section.destroy
  end
end

# Restriction
test "cannot destroy section with enrolled students" do
  section = create(:section, :with_students)
  assert_not section.destroy
  assert_includes section.errors[:base], "Cannot delete record"
end

These tests document the intended behavior and prevent regression if association options are accidentally modified.


Design Principle

Encode invariants in the model, not the controller. If a data integrity rule must hold regardless of how the model is accessed, it belongs in the model layer. Controller-level enforcement is fragile: it protects one access path but leaves others (background jobs, console access, other controllers) unguarded. ActiveRecord’s dependent options, validations, and callbacks provide a declarative vocabulary for expressing invariants that the framework enforces automatically.