The God Object That Knew Too Much: Refactoring a 478-Line Class Into Clean Architecture

The God Object That Knew Too Much: Refactoring a 478-Line Class Into Clean Architecture

9 min read Design Patterns
Share:

Every codebase has that one class. The one that started small, grew "just a bit" with each feature, and eventually became the center of gravity for your entire application. In Laravel, these God Objects hide in plain sight — usually as a "presenter" or "service" that quietly absorbs every responsibility nobody else wanted.

I've been building a compliance platform for a few years now. Companies use it to run periodic checks across different categories, each following a decision tree of questions, procedures, and scoring. At the heart of this system sits a class I'll call CategoryStatus.

It was supposed to be a simple presenter. It became the class that does everything.

What Is a God Object?

A God Object is a class that knows too much and does too much. It violates the Single Responsibility Principle so thoroughly that it becomes a dependency magnet — everything in your application flows through it, and changing anything means risking everything.

The tricky part? God Objects rarely start as God Objects. They grow into them, one "quick addition" at a time. A constructor that loads a few relationships. A method to check permissions. A helper for PDF generation. Before you know it, you have a 478-line class with six different responsibilities that gets instantiated in every controller action.

Meet CategoryStatus: A Real-World God Object

Here's the constructor alone:

class CategoryStatus
{
    public $status;
    public $statuses;
    public $progressColor;
    public $score = 0;
    public $level = 1;

    private $category;
    private $companyCategory;
    private $currentCheck;
    private $questions;

    public function __construct($category, $withStatusUpdate = true)
    {
        if ($category instanceof Category) {
            $this->category = $category;
            $this->companyCategory = $category->pivot;
        }

        if ($category instanceof CompanyCategory) {
            $this->category = $category->category;
            $this->companyCategory = $category;
        }

        // Loads questions differently based on the current route (!)
        if (request()->routeIs('company.results')) {
            $this->questions = Question::withTrashed()
                ->with('submissions', 'children')
                ->where('category_id', $this->category->id)
                ->get();
        } else {
            $this->questions = $this->category
                ->load('questions', 'questions.submissions')
                ->questions;
        }

        $this->companyCategory->load('checks', 'todos');

        if ($withStatusUpdate) {
            $this->currentCheck = $this->companyCategory->latestCheck;
            $this->setStatus();
        }
    }
}

Already we can see trouble. The constructor accepts either a Category or a CompanyCategory (polymorphic input without a type hint), checks the current HTTP route to decide how to query data, eagerly loads relationships, and optionally triggers a status update — all before you even call a method.

Red flag: When your constructor uses request() to change its behavior based on the current route, the class has become context-aware in a way that makes it impossible to reason about in isolation. You can't unit test this without simulating HTTP requests.

So What Does This Class Actually Do?

Let me map out every responsibility hiding inside these 478 lines:

  1. Status Calculation — Determines if all questions are answered, calculates scores, updates the database with new statuses
  2. Authorization — Checks if users can view, manage, reset, or perform actions on categories
  3. Question Tree Traversal — Walks the decision tree, finds last answered questions, determines which questions to show next
  4. PDF Generation — Builds PDFs from check results and manages file storage
  5. Display Logic — Generates status messages, color codes, formatted dates, category names
  6. Data Queries — Loads and filters questions, eager-loads relationships, queries previous checks

Six distinct responsibilities. A single class. Used in every controller action across three different controllers. This is the textbook definition of a God Object.

The Real Cost

Here's a method that shows the deepest problem — setStatus() performs a database write inside a presenter:

private function setStatus()
{
    $canReview = $this->currentCheck->status !== 'complete'
        && ($this->currentCheck->status === 'started'
        || $this->currentCheck->level === 4);

    if ($canReview) {
        $result = $this->checkIfAllQuestionsAreAnswered();

        if ($result && $result->count()) {
            // A "presenter" updating the database!
            $this->currentCheck->update([
                'statuses' => $result,
                'status'   => 'for_review',
                'score'    => round($result->sum('score'), 2),
            ]);
        }
    }

    $this->status = $this->currentCheck->status;
    $this->score = round($this->currentCheck->score, 2);
    $this->progressColor = progressColor($this->status);
}

Every time a user views the dashboard — every page load — this "presenter" is recalculating scores, walking decision trees, and writing to the database. The performance implications alone should make you pause, but the bigger issue is cognitive: no developer would expect a Presenter to mutate data.

And the tree-walking method it calls? A recursive function that checks every question, traverses children, verifies procedure completions, and tallies scores — all synchronously during an HTTP request:

private function checkIfAllQuestionsAreAnswered()
{
    $statuses = [];
    $questions = $this->getQuestions();
    $submissions = $this->currentCheck->submissions;

    $questions->each(function (Question $question) use (&$statuses, $submissions) {
        $lastQuestion = $this->getLastAnsweredQuestion($question);

        if ($lastQuestion && $lastQuestion->isFollowup()) {
            if ($lastQuestion->procedures->count() === 0) {
                $statuses[$lastQuestion->id] = [
                    'question_id' => $lastQuestion->id,
                    'score' => $lastQuestion->score,
                    'color' => statusColor($lastQuestion->score),
                ];
            } else {
                $procedureIds = $lastQuestion->procedures->pluck('id')->toArray();
                $procedureSubmissions = $submissions
                    ->where('answerable_type', Procedure::class)
                    ->whereIn('answerable_id', $procedureIds);

                if ($procedureSubmissions->count() !== count($procedureIds)) {
                    return false; // not all procedures complete
                }

                // ... more score tallying logic
            }
        }
    });

    return collect($statuses)->values();
}

This runs on every. single. page load.

The Refactored Approach: Separation of Concerns

The fix isn't a single clever abstraction. It's about giving each responsibility a proper home:

Responsibility God Object Refactored
Status & Score Calculated on every page load Pre-calculated via a queued job on data change
Authorization Methods on the presenter Laravel Policy
Question Tree Recursive methods + display flags Dedicated service class
PDF Generation Inside the presenter Queued job with its own class
Display / View Data Mixed with logic A simple DTO (Data Transfer Object)
Data Queries Scattered in constructor Query scopes on models

Step 1: A DTO for the View Layer

The first question is: what does the view actually need? Not a 478-line object. It needs a bag of data:

readonly class CategoryStatusData
{
    public function __construct(
        public string $id,
        public string $name,
        public ?string $status,
        public float $score,
        public string $progressColor,
        public ?string $statusMessage,
        public ?string $nextCheckDate,
        public bool $isEndingSoon,
        public int $todosCount,
        public int $level,
        public ?array $statuses,
    ) {}

    public static function fromCompanyCategory(
        CompanyCategory $companyCategory
    ): self {
        $check = $companyCategory->latestCheck;

        return new self(
            id: $companyCategory->uuid,
            name: $companyCategory->category->showNameWithLevel(
                $check->level ?? $companyCategory->level
            ),
            status: $check?->status,
            score: round($check?->score ?? 0, 2),
            progressColor: progressColor($check?->status),
            statusMessage: self::resolveStatusMessage($check),
            nextCheckDate: $check?->period_ends_at?->format('d.m.Y'),
            isEndingSoon: $check?->period_ends_at
                ?->lte(now()->addDays(2)) ?? false,
            todosCount: $companyCategory->unfinishedTodos->count(),
            level: $check->level ?? $companyCategory->level,
            statuses: $check?->statuses,
        );
    }
}

Key insight: A DTO is readonly — it cannot mutate state. It's constructed once and passed to the view. No database writes hiding in getters. No queries triggered by accessing a property. What you see is what you get.

Step 2: Pre-Calculate Scores With a Queued Job

The most expensive operation in the God Object was checkIfAllQuestionsAreAnswered() — a recursive tree traversal that ran on every page load. The fix: calculate it once, when data actually changes.

class RecalculateCheckStatus implements ShouldQueue
{
    use Queueable;

    public function __construct(
        public Check $check,
    ) {}

    public function handle(CheckScoreCalculator $calculator): void
    {
        $result = $calculator->calculate($this->check);

        if ($result === null) {
            return; // Not all trees complete yet
        }

        $this->check->update([
            'statuses' => $result,
            'status'   => 'for_review',
            'score'    => round($result->sum('score'), 2),
        ]);
    }
}

Dispatch it whenever a submission is saved:

class SubmissionObserver
{
    public function saved(Submission $submission): void
    {
        RecalculateCheckStatus::dispatch(
            $submission->check
        );
    }
}

Now the dashboard simply reads pre-calculated values from the database. No computation on page load. No surprise writes. The score is always fresh because it's recalculated the moment the underlying data changes.

Step 3: Extract Authorization Into a Policy

The God Object had four permission methods (canPerformActions, canView, canManage, canReset) that all followed the same pattern. This is exactly what Laravel Policies are built for:

class CompanyCategoryPolicy
{
    public function view(User $user, CompanyCategory $category): bool
    {
        return $user->can('manage-team', [
            "category_{$category->category_id}_view",
        ]);
    }

    public function manage(User $user, CompanyCategory $category): bool
    {
        return $user->can('manage-team', [
            "category_{$category->category_id}_access",
        ]);
    }

    public function reset(User $user, CompanyCategory $category): bool
    {
        return $this->manage($user, $category)
            && $user->hasPermission('reset_categories');
    }
}

In your controllers, this becomes a clean one-liner:

// Before: instantiate God Object just to check permissions
$status = new CategoryStatus($companyCategory);
abort_unless($status->canPerformActions(), 403);

// After: use Laravel's built-in authorization
$this->authorize('manage', $companyCategory);

Step 4: Extract PDF Generation Into Its Own Job

PDF generation is heavy. It has no business living inside a presenter, and it definitely shouldn't block an HTTP request. Move it to a queued job:

class GenerateCheckPdf implements ShouldQueue
{
    use Queueable;

    public function __construct(
        public CompanyCategory $companyCategory,
        public Check $check,
        public string $locale,
    ) {}

    public function handle(): void
    {
        $pdf = Pdf::loadView('pdf.results', [
            'currentCheck'    => $this->check,
            'companyCategory' => $this->companyCategory,
            'exportDate'      => today()->format('d-m-Y'),
        ]);

        Storage::disk('public')
            ->put($this->filePath(), $pdf->output());
    }
}

Now PDF generation happens in the background. Your users don't wait for it, and the logic lives in a single-purpose class that's trivially testable.

Step 5: A Dedicated Service for the Question Tree

The tree traversal logic — finding the last answered question, determining which questions to show, handling cutoff flags — is genuine domain complexity. It deserves its own class:

class QuestionTreeService
{
    public function getVisibleQuestions(
        Category $category,
        Check $check,
        int $level
    ): Collection {
        return $category->questions
            ->where('level', $level)
            ->whereNull('parent_id')
            ->sortBy('order')
            ->pipe(fn ($q) => $this->applyVisibility($q, $check))
            ->pipe(fn ($q) => $this->applyCutoff($q));
    }

    public function getLastAnsweredQuestion(
        Question $question,
        Check $check
    ): ?Question {
        $submission = $question->submission($check);
        $answer = $submission?->answer['value'] ?? null;

        if (! $answer) {
            return null;
        }

        $child = $question->children
            ->firstWhere('parent_answer', $answer);

        if ($child && ! $child->isFollowup()) {
            return $this->getLastAnsweredQuestion($child, $check);
        }

        return $child;
    }
}

The recursive logic is the same, but now it's isolated, injectable, and testable without bootstrapping the entire application.


What the Controllers Look Like After

The most telling sign of a successful refactor is what your controllers look like:

// Before: God Object does everything
public function index()
{
    $categories = $company->categories->map(
        fn ($cat) => new CategoryStatus($cat) // queries + writes + permissions + display
    );
}

// After: each piece has a home
public function index()
{
    $categories = $company->categories->map(
        fn ($cat) => CategoryStatusData::fromCompanyCategory($cat) // reads pre-calculated data
    );
}

The DTO reads values that were pre-calculated by a queued job. Authorization is handled by a policy. PDF generation is a separate job. The question tree has its own service. Each piece is independently testable.

The Smell Checklist: Is Your Class a God Object?

Here are the warning signs I've learned to watch for:

  1. The constructor does work — queries, calculations, or conditional loading based on context
  2. It uses request() or auth() — a sign the class is coupled to HTTP context
  3. Mixed reads and writes — a "presenter" that also updates the database
  4. It's instantiated everywhere — if three controllers all need it, it's doing too many things
  5. Boolean constructor flags$withStatusUpdate = true means the class has two modes, which means it's two classes
  6. Growing import list — when your use statements span from Models to PDF libraries to Storage facades, the class is wearing too many hats

Why Not Refactor All at Once?

If you're staring at a God Object in production right now, resist the urge to rewrite it from scratch. The approach I'd recommend:

  1. Extract the lowest-hanging fruit first. Authorization to a Policy is almost mechanical. Do it today.
  2. Add the queued job for calculations. This gives you the biggest performance win with minimal risk.
  3. Create the DTO. Start using it in new code, keep the God Object for old code temporarily.
  4. Extract the service class last. The tree traversal logic is the most complex — do it when you have tests covering the behavior.

Each step leaves the system in a working state. Each step makes the next one easier. And after all four, the God Object is an empty shell that can be deleted.

The lesson: God Objects aren't born — they're grown. They start as a convenient place to put "just one more thing." The antidote isn't a grand rewrite. It's the discipline to give each responsibility a proper home: DTOs for view data, jobs for heavy work, policies for permissions, and services for domain logic. Your future self — and your page load times — will thank you.

Share: