DEV Community

Cover image for I Gave Gemini 3 My Worst Legacy Code — Here’s What Happened
Jubin Soni
Jubin Soni Subscriber

Posted on

I Gave Gemini 3 My Worst Legacy Code — Here’s What Happened

Introduction: The Digital Archaeology Experiment

We all have that one folder. The one labeled "v1_final_do_not_touch_2016." It is a sprawling ecosystem of spaghetti code, global variables, and comments that simply read // I am sorry. In an era of Large Language Models (LLMs), we often hear about AI writing boilerplate, but can it actually perform digital archeology?

I decided to feed my most "haunted" legacy script—a 2,000-line monolith responsible for processing data—into a hypothetical next-generation model, Gemini 3. The goal wasn't just to see if it could fix the bugs, but to see if it could transform a maintenance nightmare into a modern, scalable architecture.

What followed was a masterclass in software engineering best practices. The AI didn't just move code around; it applied structural patterns that we often neglect in the heat of deadlines. This guide breaks down the core best practices Gemini 3 utilized to transform legacy junk into production-grade software, and why you should apply these practices even if you aren't using an AI assistant.


1. The Single Responsibility Principle (SRP): Deconstructing the Monolith

The first thing the AI flagged was the "God Object" syndrome. In my legacy code, a single function called process_claim() was responsible for:

  1. Validating user input.
  2. Connecting to a MySQL database.
  3. Calculating claim totals with hardcoded tax rules.
  4. Sending an email notification.
  5. Logging errors to a local file.

The Bad Practice (The Monolith)

def process_claim(claim_data):
    # Validation
    if not claim_data.get("id"):
        return "Error"

    # Database logic
    db = connect_to_db("prod_db")
    db.execute(f"INSERT INTO claims VALUES ({claim_data['id']})")

    # Business logic
    total = claim_data['amount'] * 1.15 # Hardcoded tax

    # Notification
    send_email("admin@company.com", f"Claim {total} processed")

    return "Success"
Enter fullscreen mode Exit fullscreen mode

Why This Fails

This code is impossible to test in isolation. If you want to test the tax calculation, you must have a live database connection and an email server ready. Furthermore, a change in the email provider's API forces a change in the business logic file, violating the principle that software should be easy to change without unintended side effects.

The Good Practice (Applying SRP)

Gemini 3 refactored this into distinct services. Validation, Persistence, Calculation, and Messaging were separated.

class ClaimValidator:
    def validate(self, data):
        if not data.get("id"): 
            raise ValidationError("Missing ID")

class TaxCalculator:
    def calculate(self, amount, region_code):
        rate = self._get_rate(region_code)
        return amount * rate

class ClaimService:
    def __init__(self, validator, calculator, repository, notifier):
        self.validator = validator
        self.calculator = calculator
        self.repository = repository
        self.notifier = notifier

    def execute(self, claim_data):
        self.validator.validate(claim_data)
        total = self.calculator.calculate(claim_data['amount'], "US")
        self.repository.save(claim_data)
        self.notifier.send(f"Claim {total} processed")
Enter fullscreen mode Exit fullscreen mode

Why It Matters

By separating concerns, the code becomes modular. You can now swap the TaxCalculator for a different regional version without touching the ClaimService. Testing becomes a matter of passing "mock" objects into the constructor, ensuring your unit tests are fast and reliable.

Checklist for Applying SRP

Task Description
Identify "Ands" If a function does A and B, it needs to be split.
Extract Logic Move business rules into separate, pure functions.
Isolate I/O Keep database and API calls outside of core logic classes.
Limit Lines Aim for functions under 20 lines of code.

2. Decoupling Through Dependency Injection

One of the most profound changes Gemini 3 suggested involved how objects interact. In the legacy code, objects instantiated their own dependencies. If Class A needed Class B, it would simply call b = new ClassB() inside its constructor. This creates "tight coupling."

Visualizing the Transformation

Below is a Flowchart illustrating the decision-making process for decoupling legacy dependencies.

Flowchart Diagram

The Pitfall: The "New" Keyword

When you use new inside a class, you are locking that class to a specific implementation. This makes it impossible to substitute a mock version for testing or a different implementation for a new environment (like a staging server).

The Solution: Dependency Injection (DI)

Instead of creating the dependency inside the class, you "inject" it—usually via the constructor. This practice shifts the responsibility of object creation to the caller or a dedicated DI container.

Comparison: Before vs. After

Bad (Tight Coupling):

class OrderService {
  constructor() {
    this.database = new PostgresDatabase(); // Hardcoded dependency
  }
}
Enter fullscreen mode Exit fullscreen mode

Good (Loose Coupling):

class OrderService {
  constructor(database) { // Injected dependency
    this.database = database;
  }
}
Enter fullscreen mode Exit fullscreen mode

The Benefit: In your production environment, you pass a real PostgresDatabase. In your test environment, you pass an InMemoryDatabase. The OrderService doesn't know the difference, making it highly reusable.


3. Defensive Programming and Error Handling

Legacy code often treats error handling as an afterthought, using generic try-catch blocks that swallow exceptions or returning null values that eventually lead to the dreaded "Null Reference Exception."

Gemini 3's refactoring emphasized Defensive Programming: the practice of designing software to continue functioning under unforeseen circumstances.

Sequence Diagram: Proper Error Handling Flow

This Sequence Diagram shows the interaction between a client, a service, and an external API using resilient patterns.

Sequence Diagram

Key Defensive Practices

  1. Fail Fast: Validate inputs at the very beginning of a function. If they are invalid, throw an exception immediately.
  2. Use Meaningful Exceptions: Instead of throwing Error, throw InsufficientFundsError or UserNotFoundError.
  3. Circuit Breakers: If an external service is down, don't keep hammering it. Stop the calls and return a cached result or a graceful failure.

Good vs. Bad Error Handling

Bad Practice:

try:
    result = api.call()
except:
    pass # Silently failing is the worst thing you can do
Enter fullscreen mode Exit fullscreen mode

Good Practice:

try:
    result = api.get_user(user_id)
except ConnectionError as e:
    logger.error(f"Failed to connect to UserAPI for ID {user_id}: {e}")
    raise ServiceUnavailableError("Our user service is temporarily down.")
except UserNotFoundError:
    return None # Explicitly handled
Enter fullscreen mode Exit fullscreen mode

4. Modernizing State Management

In my legacy script, the code relied heavily on global state. A variable like current_user_id was updated by multiple functions across the file. This led to unpredictable bugs where the state would change in the middle of a process due to an asynchronous callback.

Implementation: Using Immutability

Instead of modifying an existing object, create a new one. This ensures that other parts of the system holding a reference to the old object aren't surprised by a sudden change.

Bad (Mutable):

function updatePrice(product, newPrice) {
  product.price = newPrice; // Changes the object everywhere
}
Enter fullscreen mode Exit fullscreen mode

Good (Immutable):

function updatePrice(product, newPrice) {
  return { ...product, price: newPrice }; // Returns a new object
}
Enter fullscreen mode Exit fullscreen mode

By using immutability, you make your code thread-safe and much easier to debug. If a bug occurs, you can inspect the state at any point in time without worrying that it was modified downstream.


5. Refactoring Summary: The Do's and Don'ts

To help you apply these findings to your own legacy codebases, here is a summary table of the transformations Gemini 3 performed.

Area Don't Do This (Legacy) Do This (Modern)
Logic Giant functions with nested if/else. Small, pure functions with early returns.
Data Direct manipulation of global state. Immutable data structures and local state.
Dependencies Hardcoded new instances. Injected dependencies via interfaces.
Errors Generic try-catch with empty bodies. Domain-specific exceptions and logging.
Performance Nested loops with O(n^2) complexity. Optimized algorithms with O(n) or O(log n).
Documentation Comments explaining what code does. Self-documenting code explaining why.

Common Pitfalls to Avoid During Refactoring

Even with an AI as powerful as Gemini 3, refactoring is not without risks. Here are three common pitfalls I encountered during this experiment:

  1. Refactoring Without Tests: Never start refactoring until you have "Characterization Tests"—tests that describe how the code currently behaves. If you change the code and the tests pass, you know you haven't broken existing functionality.
  2. Over-Engineering: It is tempting to apply every design pattern (Factory, Strategy, Observer) at once. Only introduce complexity when it solves a specific problem. If a simple function works, you don't need a class.
  3. The "Big Bang" Rewrite: Resist the urge to rewrite the entire system from scratch. This almost always leads to project failure. Instead, refactor one small module at a time, ensuring the system remains operational throughout the process.

Practical Guidance: An Implementation Roadmap

If you are staring at a mountain of legacy code today, here is the recommended roadmap for modernization:

  1. Identify the Pain Points: Which part of the code breaks most often? Start there.
  2. Write Integration Tests: Capture the current behavior of that module.
  3. Decouple the Core: Identify the business logic and extract it from the infrastructure (database/UI).
  4. Introduce Dependency Injection: Allow your business logic to be tested in isolation.
  5. Clean Up the Syntax: Use modern language features (like Async/Await or Type Hints) to improve readability.

Conclusion: AI as the Ultimate Pair Programmer

Feeding my worst legacy code to Gemini 3 was an eye-opening experience. The AI didn't just "fix" the code; it enforced a level of discipline that is often lost in the day-to-day grind of feature delivery. It reminded me that the most important audience for our code isn't the compiler—it is the human developer who has to maintain it six months from now.

By prioritizing the Single Responsibility Principle, decoupling dependencies through injection, and embracing defensive programming, we can turn even the most frightening legacy scripts into robust, modern systems. Whether you use an AI assistant or your own expertise, these best practices remain the bedrock of professional software engineering.

Further Reading & Resources


Connect with me: LinkedIn | Twitter/X | GitHub | Website

Top comments (0)