DEV Community

Jihad Abu Zuhri
Jihad Abu Zuhri

Posted on

Refactoring in the AI Era: Code Smells Software Engineers Should Still Know

AI coding agents have changed the way we write software. Today, you can ask an agent to extract a method, rename variables, reduce duplication, generate tests, or even redesign part of a class. What used to take hours can sometimes be done in minutes.

But there is a catch.

AI can refactor code faster, but it does not automatically know what should be refactored, why it matters, or which design trade-off is best for your system. That responsibility still belongs to us as developers.

The more we understand refactoring and code smells, the better we can guide AI tools instead of blindly accepting their suggestions. Clean code skills are not less important because of AI. They are more important, because now we are not only writing code, we are also reviewing, directing, and validating code produced by tools.

πŸ” Refactoring Is Not Rewriting

Refactoring means improving the internal structure of code without changing its external behavior. This distinction is important because if the behavior changes, then you are adding a feature, fixing a bug, or changing functionality. Refactoring keeps the behavior the same while making the code easier to understand, test, and change.

For example:

public decimal Calc(decimal p, decimal d)
{
    return p - (p * d);
}
Enter fullscreen mode Exit fullscreen mode

This method works, but it forces the reader to guess what p and d mean. A simple refactoring improves the name and intent:

public decimal CalculateDiscountedPrice(decimal price, decimal discountRate)
{
    return price - (price * discountRate);
}
Enter fullscreen mode Exit fullscreen mode

The logic did not change. The code simply became easier to understand. This is exactly the type of improvement AI agents can help with, but only if we know what to ask for.

🧠 Why Refactoring Still Matters

Working code is not always maintainable code. A method can pass all tests and still be difficult to read. A class can work correctly and still have too many responsibilities. A service can produce the right result and still be painful to extend.

Refactoring helps us:

  • πŸ“– Make code easier to read
  • πŸ§ͺ Make code easier to test
  • πŸ”§ Make future changes easier
  • ♻️ Reduce duplication
  • πŸ› Lower the risk of bugs
  • πŸš€ Keep development speed healthy over time

In the AI era, this matters even more. AI tools are very good at producing code quickly, which means teams can also produce messy code quickly. If we do not review, refactor, and simplify continuously, the codebase can become harder to maintain at a much faster rate.

πŸ‘ƒ Code Smells Are Signals, Not Rules

A code smell is a sign that something might be wrong with the design. It does not always mean the code is broken, and it does not mean you must refactor immediately. It simply tells you to slow down and look closer.

Common code smells include poor names, long methods, duplicate code, deeply nested conditionals, primitive obsession, large classes, and hidden dependencies. These smells are especially useful when working with AI agents because they help you give better instructions.

Instead of giving a vague prompt like this:

Clean this code.
Enter fullscreen mode Exit fullscreen mode

You can give the agent a more specific instruction:

This method has too much conditional complexity.
Refactor it using guard clauses and extract smaller methods.
Keep the behavior the same.
Enter fullscreen mode Exit fullscreen mode

That is a much better prompt because it describes the problem, the preferred refactoring direction, and the most important constraint: behavior must stay the same.

🏷️ Smell 1: Poor Names

Poor names make code harder to understand than it needs to be. The code might be technically correct, but if the reader has to guess what each variable or method means, the code is still creating friction.

public decimal Calc(decimal x, decimal y)
{
    return x * y;
}
Enter fullscreen mode Exit fullscreen mode

The method is simple, but the intent is unclear. What are x and y? Are they price and quantity, width and height, or something else?

A better version makes the meaning obvious:

public decimal CalculateTotalPrice(decimal unitPrice, decimal quantity)
{
    return unitPrice * quantity;
}
Enter fullscreen mode Exit fullscreen mode

Nothing changed except the names, but the code is now easier to read. This is one of the safest refactorings to ask an AI agent for.

Rename variables and methods to make their intent clearer.
Do not change the behavior.
Enter fullscreen mode Exit fullscreen mode

Good names reduce the need for comments and make future changes easier. When naming is clear, the code starts explaining itself.

🧱 Smell 2: Primitive Obsession

Primitive obsession happens when we use primitive types like string, int, or decimal for domain concepts that deserve their own types. This usually makes the code easier to write at first, but harder to protect later.

public void CreateUser(string email, string role)
{
    if (!email.Contains("@"))
        throw new ArgumentException("Invalid email.");

    if (role != "Admin" && role != "Customer")
        throw new ArgumentException("Invalid role.");

    // Create user
}
Enter fullscreen mode Exit fullscreen mode

This method accepts any string. That means invalid emails and invalid roles can easily move through the system unless every caller remembers to validate them correctly. The email validation here is intentionally simplified for the example, but the design issue is still real.

A cleaner model makes the domain concept explicit:

public sealed class EmailAddress
{
    public string Value { get; }

    public EmailAddress(string value)
    {
        if (string.IsNullOrWhiteSpace(value) || !value.Contains("@"))
            throw new ArgumentException("Invalid email address.", nameof(value));

        Value = value;
    }

    public override string ToString() => Value;
}
Enter fullscreen mode Exit fullscreen mode

Now the method becomes clearer:

public void CreateUser(EmailAddress email, UserRole role)
{
    // Create user
}

public enum UserRole
{
    Admin,
    Customer
}
Enter fullscreen mode Exit fullscreen mode

This makes invalid states harder to represent and keeps validation closer to the concept it protects. A useful AI prompt for this smell could be:

Replace primitive parameters with small domain types where it improves clarity.
Keep validation close to the domain concept.
Enter fullscreen mode Exit fullscreen mode

πŸ“ Smell 3: Long Methods

Long methods usually do too much. They often mix validation, business rules, persistence, logging, notifications, and formatting in one place. The result is a method that works, but is difficult to understand and risky to change.

public void ProcessOrder(Order order)
{
    if (order == null)
        throw new ArgumentNullException(nameof(order));

    if (!order.Items.Any())
        throw new InvalidOperationException("Order must contain at least one item.");

    var subtotal = order.Items.Sum(item => item.Price * item.Quantity);

    if (subtotal > 100)
        subtotal -= subtotal * 0.10m;

    order.Total = subtotal;
    order.Status = OrderStatus.Processed;

    _orderRepository.Save(order);
    _emailService.SendOrderConfirmation(order.CustomerEmail, order.Id);
}
Enter fullscreen mode Exit fullscreen mode

This method validates the order, calculates a total, applies a discount, changes state, saves data, and sends an email. A better approach is to extract smaller methods with intention-revealing names.

public void ProcessOrder(Order order)
{
    ValidateOrder(order);

    var total = CalculateTotal(order);

    order.ApplyTotal(total);
    order.MarkAsProcessed();

    _orderRepository.Save(order);
    SendConfirmation(order);
}

private static void ValidateOrder(Order order)
{
    if (order == null)
        throw new ArgumentNullException(nameof(order));

    if (!order.Items.Any())
        throw new InvalidOperationException("Order must contain at least one item.");
}

private static decimal CalculateTotal(Order order)
{
    var subtotal = order.Items.Sum(item => item.Price * item.Quantity);

    return subtotal > 100
        ? subtotal - (subtotal * 0.10m)
        : subtotal;
}

private void SendConfirmation(Order order)
{
    _emailService.SendOrderConfirmation(order.CustomerEmail, order.Id);
}
Enter fullscreen mode Exit fullscreen mode

Now the main method reads like a workflow. The details still exist, but they are hidden behind clear method names. This is a great place to use an AI agent, as long as the instruction is specific.

Refactor this long method into smaller private methods.
Use intention-revealing names.
Keep the public behavior unchanged.
Enter fullscreen mode Exit fullscreen mode

♻️ Smell 4: Duplicate Code

Duplicate code is dangerous because every future change must be repeated in multiple places. Copy and paste can feel productive in the moment, but it often creates maintenance problems later.

public void RegisterUser(string email, string password)
{
    if (string.IsNullOrWhiteSpace(email))
        throw new ArgumentException("Email is required.", nameof(email));

    if (string.IsNullOrWhiteSpace(password))
        throw new ArgumentException("Password is required.", nameof(password));

    // Register user
}
Enter fullscreen mode Exit fullscreen mode

If the same validation appears across many methods, we can extract it into a reusable helper:

private static void EnsureNotEmpty(string value, string parameterName)
{
    if (string.IsNullOrWhiteSpace(value))
        throw new ArgumentException($"{parameterName} is required.", parameterName);
}
Enter fullscreen mode Exit fullscreen mode

Then the original method becomes shorter and more consistent:

public void RegisterUser(string email, string password)
{
    EnsureNotEmpty(email, nameof(email));
    EnsureNotEmpty(password, nameof(password));

    // Register user
}
Enter fullscreen mode Exit fullscreen mode

Before removing duplication, make sure it is real duplication. Sometimes two blocks look similar today but represent different business rules that may change independently later. That distinction matters.

Find real duplication in this class.
Extract shared behavior only when the duplicated logic represents the same business rule.
Enter fullscreen mode Exit fullscreen mode

🌲 Smell 5: Deeply Nested Conditionals

Nested conditionals make code harder to scan because the reader has to keep multiple branches in mind. The deeper the nesting gets, the easier it becomes to miss the main purpose of the method.

public decimal CalculateDiscount(Customer customer, Order order)
{
    if (customer != null)
    {
        if (customer.IsActive)
        {
            if (order.Total > 100)
            {
                if (customer.IsPremium)
                {
                    return order.Total * 0.15m;
                }

                return order.Total * 0.10m;
            }
        }
    }

    return 0;
}
Enter fullscreen mode Exit fullscreen mode

Guard clauses make the code flatter by handling invalid or uninteresting cases early.

public decimal CalculateDiscount(Customer customer, Order order)
{
    if (customer == null)
        return 0;

    if (!customer.IsActive)
        return 0;

    if (order.Total <= 100)
        return 0;

    return customer.IsPremium
        ? order.Total * 0.15m
        : order.Total * 0.10m;
}
Enter fullscreen mode Exit fullscreen mode

The behavior is the same, but the main logic is easier to see. This is another refactoring where AI can help quickly, but the prompt should make it clear that all branches must keep the same result.

Reduce nesting in this method using guard clauses.
Do not change the result of any branch.
Enter fullscreen mode Exit fullscreen mode

🏒 Smell 6: Large Classes

A large class often means too many responsibilities. Classes with names like Manager, Helper, or Processor are not always bad, but they often become places where unrelated behavior accumulates over time.

public class OrderManager
{
    public void CreateOrder() { }
    public void ValidatePayment() { }
    public void SaveToDatabase() { }
    public void SendEmail() { }
    public void GenerateInvoice() { }
    public void ApplyDiscount() { }
}
Enter fullscreen mode Exit fullscreen mode

This class may be difficult to test and maintain because many different reasons can cause it to change. Payment rules, invoice generation, email content, persistence, and discounts are separate concerns.

A cleaner design separates responsibilities:

public class OrderService
{
    private readonly IPaymentValidator _paymentValidator;
    private readonly IInvoiceGenerator _invoiceGenerator;
    private readonly IEmailSender _emailSender;

    public OrderService(
        IPaymentValidator paymentValidator,
        IInvoiceGenerator invoiceGenerator,
        IEmailSender emailSender)
    {
        _paymentValidator = paymentValidator;
        _invoiceGenerator = invoiceGenerator;
        _emailSender = emailSender;
    }

    public void CreateOrder(Order order)
    {
        _paymentValidator.Validate(order.Payment);
        _invoiceGenerator.Generate(order);
        _emailSender.SendOrderConfirmation(order.CustomerEmail);
    }
}
Enter fullscreen mode Exit fullscreen mode

Now each dependency has a clearer responsibility. If payment rules change, you know where to look. If invoice generation changes, that logic has its own place. The goal is not to create too many tiny classes, but to avoid one class becoming responsible for everything.

Review this class for multiple responsibilities.
Suggest possible extracted classes, but keep the design simple.
Do not over-engineer it.
Enter fullscreen mode Exit fullscreen mode

πŸ”’ Smell 7: Hidden Dependencies

Hidden dependencies make code harder to test and harder to replace. They often appear when a class creates its own dependencies directly instead of receiving them from the outside.

public class ReportService
{
    public void GenerateReport()
    {
        var fileWriter = new FileWriter();

        fileWriter.Write("report.txt", "Report content");
    }
}
Enter fullscreen mode Exit fullscreen mode

The class creates FileWriter directly, so it is tightly coupled to that implementation. This makes it harder to replace the writer in tests or switch to another storage mechanism later.

A better version depends on an abstraction:

public interface IFileWriter
{
    void Write(string path, string content);
}
Enter fullscreen mode Exit fullscreen mode

Then the dependency is injected through the constructor:

public class ReportService
{
    private readonly IFileWriter _fileWriter;

    public ReportService(IFileWriter fileWriter)
    {
        _fileWriter = fileWriter;
    }

    public void GenerateReport()
    {
        _fileWriter.Write("report.txt", "Report content");
    }
}
Enter fullscreen mode Exit fullscreen mode

Now the dependency is explicit. The class clearly communicates what it needs, and tests can provide a fake implementation.

Identify hidden dependencies created with new inside this class.
Refactor them into constructor-injected abstractions where appropriate.
Enter fullscreen mode Exit fullscreen mode

πŸ€– How to Guide an AI Agent During Refactoring

AI agents are powerful, but vague instructions produce vague results. If you ask an agent to β€œmake this code cleaner,” it may rename things, move logic around, introduce unnecessary patterns, or accidentally change behavior.

A better approach is to give the agent a specific refactoring goal.

Less helpful:

Make this code cleaner.
Enter fullscreen mode Exit fullscreen mode

More helpful:

Refactor this method by extracting smaller private methods.
Keep the behavior unchanged.
Preserve public method signatures.
Do not introduce new design patterns unless necessary.
Enter fullscreen mode Exit fullscreen mode

You can also ask the agent to analyze before changing anything:

Analyze this class for code smells.
List the smells first.
Then suggest a small refactoring plan.
Do not change the code yet.
Enter fullscreen mode Exit fullscreen mode

This keeps you in control. The agent becomes a refactoring assistant, not the designer of your entire system. You decide which smell matters, which refactoring is appropriate, and how much change is safe.

πŸ›‘οΈ Refactor Safely

Whether you refactor manually or with AI, the safety rules are the same:

  • βœ… Start from working code
  • βœ… Make sure tests pass
  • βœ… Refactor in small steps
  • βœ… Run tests after each change
  • βœ… Review the diff carefully
  • βœ… Avoid mixing refactoring with behavior changes

This is especially important with AI-generated changes. An agent may produce code that looks cleaner but subtly changes behavior. It might reorder logic, simplify a condition incorrectly, or introduce a new abstraction that does not match the domain.

Your job is to verify the result, not just admire the diff.

🎯 Final Thoughts

AI has made refactoring faster, but it has not removed the need for clean code skills. Actually, it has made those skills more valuable. Developers who understand code smells can guide AI agents with better prompts, review generated changes more effectively, and avoid turning fast code generation into fast technical debt.

The goal is not to ask AI to β€œclean the code” and accept whatever comes back. The goal is to understand the design well enough to tell the agent what smell you see, what refactoring you want, and what behavior must stay the same.

Clean code in the AI era is not about writing every line yourself. It is about understanding the codebase deeply enough to guide the tools, review the result, and keep the system healthy over time.

πŸ“š References

Top comments (1)

Collapse
 
mousahammouz04 profile image
Mousa Hammouz •

really valuable