Software Dev

Code Smells in Java: Complete Detection & Fixing Guide for Production Systems

Code smells are not bugs — they are structural warning signs that indicate deeper problems. Left unaddressed, each smell slows development, invites regressions, and compounds into technical debt that eventually halts new feature delivery. This guide catalogues 15+ of the most impactful Java code smells with their root causes, detection methods, and precise refactoring fixes using production-ready Spring Boot code.

Md Sanwar Hossain April 10, 2026 17 min read Software Dev
Java Code Smells Detection and Fixing Guide

TL;DR

Code smells are signals, not bugs. Each smell suggests a specific fix: God Class → Extract Class; Long Method → Extract Method; Feature Envy → Move Method; Shotgun Surgery → Inline Class. Master the smell-to-fix mapping and every code review becomes systematic engineering.

Table of Contents

  1. What Are Code Smells (and Why They Compound)
  2. Bloaters: God Class, Long Method, Long Parameter List
  3. OO Abusers: Switch Statements & Refused Bequest
  4. Change Preventers: Divergent Change, Shotgun Surgery, Parallel Inheritance
  5. Dispensables: Dead Code, Speculative Generality, Duplicate Code
  6. Couplers: Feature Envy & Message Chains
  7. Code Smell Detection Tools
  8. Smell-to-Refactoring Mapping Table
  9. How Code Smells Compound Over Time
  10. Common Mistakes When Fixing Code Smells
  11. FAQ & Key Takeaways

1. What Are Code Smells (and Why They Compound)

Martin Fowler coined the term in Refactoring: Improving the Design of Existing Code (1999). A code smell is "a surface indication that usually corresponds to a deeper problem in the system." It is not a bug — the code may work correctly today. But a smell signals that future changes will be harder, riskier, or more expensive than they need to be.

The compound effect is the real danger. A single God Class may be manageable in month 1. By month 18, a single change requires understanding 1,200 lines of interleaved concerns, touching six test files, and competing with three teammates editing the same file. Google's engineering research found that files with cyclomatic complexity above 10 contributed to 23% of all production bugs despite representing only 8% of the codebase.

Category Smells in Category Primary Impact
Bloaters God Class, Long Method, Long Param List Unreadability, untestability
OO Abusers Switch Statements, Refused Bequest Rigid type-checking, broken contracts
Change Preventers Divergent Change, Shotgun Surgery High deployment risk
Dispensables Dead Code, Duplicate Code, Speculative Gen. Noise, confusion, divergence
Couplers Feature Envy, Message Chains Fragile dependencies
Java Code Smells — 5 Categories and Fixes
Java Code Smell Categories — Bloaters, OO Abusers, Change Preventers, Dispensables, Couplers — mdsanwarhossain.me

2. Bloaters: God Class, Long Method, Long Parameter List

God Class

A God Class accumulates responsibilities from multiple domains until it becomes the "go-to" class for everything. It is the single biggest impediment to parallel team development because every team member edits the same file. A Spring Boot UserService reaching 1,200 lines typically owns authentication logic, profile management, notification dispatch, order history queries, and audit logging — five different domains, five different reasons to change.

// BAD: God Class — UserService owns everything
@Service
public class UserService {
    // Auth (1 actor)
    public AuthToken login(String email, String password) { /* 40 lines */ }
    public void resetPassword(String email) { /* 30 lines */ }
    // Profile (2nd actor)
    public void updateProfile(Long userId, ProfileDto dto) { /* 25 lines */ }
    public String uploadAvatar(Long userId, MultipartFile file) { /* 35 lines */ }
    // Orders (3rd actor)
    public List<Order> getOrderHistory(Long userId) { /* 20 lines */ }
    // Notifications (4th actor)
    public void sendWelcomeEmail(User user) { /* 15 lines */ }
    public void sendPasswordResetEmail(String email) { /* 15 lines */ }
    // Audit (5th actor)
    public void logUserAction(Long userId, String action) { /* 10 lines */ }
    // ... 1000+ more lines
}

// GOOD: Extract Class — one service per business actor
@Service
public class AuthService {
    public AuthToken login(String email, String password) { /* ... */ }
    public void resetPassword(String email) { /* ... */ }
}

@Service
public class UserProfileService {
    public void updateProfile(Long userId, ProfileDto dto) { /* ... */ }
    public String uploadAvatar(Long userId, MultipartFile file) { /* ... */ }
}

@Service
public class OrderHistoryService {
    public List<Order> getOrderHistory(Long userId) { /* ... */ }
}

@Service
public class UserNotificationService {
    public void sendWelcomeEmail(User user) { /* ... */ }
    public void sendPasswordResetEmail(String email) { /* ... */ }
}

Long Method

A method is too long when you cannot understand its full intent in 30 seconds, when it mixes orchestration with business logic, or when it exceeds 20 lines. The refactoring is always Extract Method — identify cohesive sub-steps and move each to a well-named private method.

// BAD: 50-line processOrder() with mixed concerns
@Service
public class OrderService {
    public OrderResponse processOrder(OrderRequest request) {
        // Validation block (lines 1-15)
        if (request.getItems() == null || request.getItems().isEmpty()) {
            throw new ValidationException("Order must have items");
        }
        for (OrderItem item : request.getItems()) {
            if (item.getQuantity() <= 0) throw new ValidationException("Qty must be positive");
            if (!inventoryRepository.isAvailable(item.getProductId(), item.getQuantity())) {
                throw new InsufficientInventoryException(item.getProductId());
            }
        }
        // Pricing block (lines 16-30)
        BigDecimal subtotal = BigDecimal.ZERO;
        for (OrderItem item : request.getItems()) {
            Product p = productRepository.findById(item.getProductId()).orElseThrow();
            subtotal = subtotal.add(p.getPrice().multiply(BigDecimal.valueOf(item.getQuantity())));
        }
        BigDecimal tax = subtotal.multiply(new BigDecimal("0.08"));
        BigDecimal total = subtotal.add(tax);
        // Persistence block (lines 31-40)
        Order order = new Order(request.getCustomerId(), request.getItems(), total);
        order = orderRepository.save(order);
        // Notification block (lines 41-50)
        emailService.sendOrderConfirmation(order);
        return new OrderResponse(order.getId(), total);
    }
}

// GOOD: Extract Method — each step is a named, testable unit
@Service
public class OrderService {
    public OrderResponse processOrder(OrderRequest request) {
        validateOrder(request);
        BigDecimal total = calculateTotal(request.getItems());
        Order order = persistOrder(request, total);
        notifyCustomer(order);
        return new OrderResponse(order.getId(), total);
    }

    private void validateOrder(OrderRequest request) {
        if (request.getItems() == null || request.getItems().isEmpty()) {
            throw new ValidationException("Order must have items");
        }
        request.getItems().forEach(item -> {
            if (item.getQuantity() <= 0) throw new ValidationException("Qty must be positive");
            if (!inventoryRepository.isAvailable(item.getProductId(), item.getQuantity())) {
                throw new InsufficientInventoryException(item.getProductId());
            }
        });
    }

    private BigDecimal calculateTotal(List<OrderItem> items) {
        BigDecimal subtotal = items.stream()
            .map(item -> productRepository.findById(item.getProductId()).orElseThrow()
                .getPrice().multiply(BigDecimal.valueOf(item.getQuantity())))
            .reduce(BigDecimal.ZERO, BigDecimal::add);
        return subtotal.add(subtotal.multiply(new BigDecimal("0.08")));
    }

    private Order persistOrder(OrderRequest request, BigDecimal total) {
        return orderRepository.save(new Order(request.getCustomerId(), request.getItems(), total));
    }

    private void notifyCustomer(Order order) {
        emailService.sendOrderConfirmation(order);
    }
}

Long Parameter List

// BAD: caller must know the order and meaning of 7 parameters
public void createUser(String name, String email, String phone,
                       String address, String role, boolean active, int tier) { }

// GOOD: Introduce Parameter Object — self-documenting, extensible
public record CreateUserCommand(
    String name,
    String email,
    String phone,
    String address,
    String role,
    boolean active,
    int tier
) {}

public void createUser(CreateUserCommand command) { }

3. OO Abusers: Switch Statements & Refused Bequest

Switch Statements

A switch (or if-else chain) on a type tag is a missed opportunity for polymorphism. Every time you add a new type, you must find and update every switch statement that mentions the type — a classic Shotgun Surgery scenario. The fix is to move the type-specific behaviour into subclasses or implementations.

// BAD: switch on shape type spread across multiple methods
public double area(Shape shape) {
    switch (shape.getType()) {
        case "CIRCLE": return Math.PI * shape.getRadius() * shape.getRadius();
        case "RECTANGLE": return shape.getWidth() * shape.getHeight();
        case "TRIANGLE": return 0.5 * shape.getBase() * shape.getHeight();
        default: throw new IllegalArgumentException("Unknown shape");
    }
}

// GOOD: Replace Conditional with Polymorphism
public interface Shape {
    double area();
}

public record Circle(double radius) implements Shape {
    @Override public double area() { return Math.PI * radius * radius; }
}

public record Rectangle(double width, double height) implements Shape {
    @Override public double area() { return width * height; }
}

public record Triangle(double base, double height) implements Shape {
    @Override public double area() { return 0.5 * base * height; }
}

// Caller — no switch, works with any future Shape implementation
public double totalArea(List<Shape> shapes) {
    return shapes.stream().mapToDouble(Shape::area).sum();
}

Refused Bequest

Refused Bequest occurs when a subclass inherits methods from a parent but throws exceptions or provides empty stubs for them. This violates LSP and is a sign that inheritance was used for code reuse rather than for modelling an "is-a" relationship.

// BAD: Square refuses Rectangle's width/height independence
public class Rectangle {
    protected int width, height;
    public void setWidth(int w) { this.width = w; }
    public void setHeight(int h) { this.height = h; }
    public int area() { return width * height; }
}

public class Square extends Rectangle {
    @Override public void setWidth(int w) {
        // Refused bequest: forces both to change
        this.width = w;
        this.height = w; // breaks caller assumptions!
    }
}

// GOOD: separate interface hierarchy using composition
public interface Shape { int area(); }

public record Rectangle(int width, int height) implements Shape {
    @Override public int area() { return width * height; }
}

public record Square(int side) implements Shape {
    @Override public int area() { return side * side; }
}

4. Change Preventers: Divergent Change, Shotgun Surgery, Parallel Inheritance

Divergent Change

Divergent Change means one class is modified for many different reasons over time. If your ReportService changed last sprint for a new report format, the sprint before for a data source change, and the sprint before that for a new export target, it has three distinct responsibilities that should be separated. The fix is Extract Class for each axis of change.

Shotgun Surgery

Shotgun Surgery is the opposite of Divergent Change: one business change requires editing many classes simultaneously. Adding a new payment method that forces edits to PaymentController, PaymentService, PaymentRepository, PaymentValidator, NotificationService, AuditLogger, and ReportGenerator is pure Shotgun Surgery. Consolidate the scattered behaviour into one class.

// BAD: Adding Stripe payment touches 5 different files
// File 1: PaymentValidator.java — add STRIPE case
// File 2: PaymentService.java — add stripe processing
// File 3: NotificationService.java — add stripe receipt template
// File 4: AuditLogger.java — add stripe audit category
// File 5: ReportGenerator.java — add stripe to report

// GOOD: Encapsulate all payment-provider logic in one strategy
@Component
public class StripePaymentStrategy implements PaymentStrategy {
    @Override public boolean supports(String provider) { return "STRIPE".equals(provider); }
    @Override public void validate(PaymentRequest req) { /* stripe-specific validation */ }
    @Override public PaymentResult process(PaymentRequest req) { /* stripe API call */ }
    @Override public String receiptTemplate() { return "stripe-receipt"; }
    @Override public AuditCategory auditCategory() { return AuditCategory.CARD_PAYMENT; }
}

Parallel Inheritance Hierarchies

Every time you add a subclass in one hierarchy, you are forced to add a corresponding subclass in another hierarchy. Adding Dog extends Animal also forces you to add DogHandler extends AnimalHandler. Fix by merging the handler logic into the Animal hierarchy or using a Visitor pattern.

5. Dispensables: Dead Code, Speculative Generality, Duplicate Code

Dead Code

Dead code — commented-out blocks, unused private methods, unreachable branches — creates cognitive noise. Every reader must parse it to determine whether it is intentional. IntelliJ IDEA's "Find Usages" (Alt+F7) and SonarQube's "unused code" rules detect it automatically. The fix is simple: delete it. Version control preserves history if it is ever needed again.

// BAD: commented-out dead code obscures intent
@Service
public class PricingService {
    public BigDecimal calculate(Product product, int qty) {
        // Old logic — kept "just in case"
        // BigDecimal base = product.getCostPrice().multiply(new BigDecimal("1.3"));
        // if (qty > 10) base = base.multiply(new BigDecimal("0.95"));
        // return base;
        return product.getListPrice().multiply(BigDecimal.valueOf(qty));
    }

    // Never called — remove it
    private BigDecimal applyLegacyDiscount(BigDecimal price) {
        return price.multiply(new BigDecimal("0.9"));
    }
}

// GOOD: clean, intentional code only
@Service
public class PricingService {
    public BigDecimal calculate(Product product, int qty) {
        return product.getListPrice().multiply(BigDecimal.valueOf(qty));
    }
}

Speculative Generality

Speculative Generality happens when developers add abstractions "for future flexibility" that never materialises. Abstract base classes with a single concrete subclass, hook methods that are never overridden, and complex configuration frameworks for a feature that only ever has one variant — these all add complexity with zero present value. Apply YAGNI: build generality when you have a second concrete use case, not before.

Duplicate Code

// BAD: same email validation duplicated in three controllers
@RestController
public class UserController {
    @PostMapping("/users")
    public ResponseEntity<?> createUser(@RequestBody UserRequest req) {
        if (req.getEmail() == null || !req.getEmail().matches("^[\\w.-]+@[\\w.-]+\\.[a-z]{2,}$")) {
            return ResponseEntity.badRequest().body("Invalid email");
        }
        // ...
    }
}

@RestController
public class AdminController {
    @PostMapping("/admins")
    public ResponseEntity<?> createAdmin(@RequestBody AdminRequest req) {
        // Same validation copy-pasted!
        if (req.getEmail() == null || !req.getEmail().matches("^[\\w.-]+@[\\w.-]+\\.[a-z]{2,}$")) {
            return ResponseEntity.badRequest().body("Invalid email");
        }
        // ...
    }
}

// GOOD: Extract Method into shared validator
@Component
public class EmailValidator {
    private static final String EMAIL_REGEX = "^[\\w.-]+@[\\w.-]+\\.[a-z]{2,}$";

    public void validate(String email) {
        if (email == null || !email.matches(EMAIL_REGEX)) {
            throw new ValidationException("Invalid email format: " + email);
        }
    }
}

@RestController
public class UserController {
    private final EmailValidator emailValidator;
    // Inject and reuse — single source of truth
}

6. Couplers: Feature Envy & Message Chains

Feature Envy

Feature Envy is when a method in class A uses more fields and methods from class B than from its own class. This is a strong signal that the method belongs in class B. The refactoring is Move Method.

// BAD: ShippingService is envious of Order's data
@Service
public class ShippingService {
    public BigDecimal calculateCost(Order order) {
        // Uses 5 fields from Order — this logic belongs in Order!
        double weight = order.getTotalWeight();
        String zone = order.getDeliveryAddress().getZone();
        boolean isPremium = order.getCustomer().isPremiumMember();
        int itemCount = order.getItems().size();
        boolean isFragile = order.getItems().stream().anyMatch(OrderItem::isFragile);
        // ... cost calculation using all of Order's data
        return BigDecimal.valueOf(weight * 2.5).add(isFragile ? BigDecimal.TEN : BigDecimal.ZERO);
    }
}

// GOOD: Move Method to Order — the data and behaviour co-locate
public class Order {
    public BigDecimal calculateShippingCost() {
        double weight = this.getTotalWeight();
        boolean isFragile = this.items.stream().anyMatch(OrderItem::isFragile);
        return BigDecimal.valueOf(weight * 2.5).add(isFragile ? BigDecimal.TEN : BigDecimal.ZERO);
    }
}

@Service
public class ShippingService {
    public BigDecimal calculateCost(Order order) {
        return order.calculateShippingCost(); // delegate — no envy
    }
}

Message Chains & the Law of Demeter

// BAD: message chain — fragile, exposes internal structure
String city = order.getCustomer().getAddress().getCity().toUpperCase();
// Any null in the chain throws NullPointerException.
// Change in Address structure breaks all callers.

// GOOD: Hide delegate — Law of Demeter
public class Order {
    public String getCustomerCity() {
        return Optional.ofNullable(customer)
            .map(Customer::getAddress)
            .map(Address::getCity)
            .map(String::toUpperCase)
            .orElse("UNKNOWN");
    }
}
// Caller now: order.getCustomerCity() — clean, null-safe, encapsulated

7. Code Smell Detection Tools

Manual code review catches some smells, but automated tools catch far more consistently. The following tools integrate into your standard Java build pipeline and provide immediate feedback to developers before code reaches review.

Tool Detects Integration
SonarQube Cyclomatic complexity, duplicates, dead code, code smells CI/CD quality gate
IntelliJ Inspections Unused code, method length, redundant expressions IDE real-time feedback
Checkstyle Style issues, method parameter count, line length Maven/Gradle plugin
PMD Long methods, empty catch blocks, dead code, God Class Maven/Gradle plugin
ArchUnit Architectural violations, forbidden dependencies, layer rules JUnit 5 test suite
Pro tip: Configure SonarQube with a Quality Gate that blocks merges when cyclomatic complexity exceeds 10 per method or when code duplication exceeds 3%. Teams that enforce this programmatically see far fewer God Classes emerge over time because the gate prevents the incremental growth that creates them.

8. Smell-to-Refactoring Mapping Table

Code Smell Category Refactoring Technique Priority
God Class Bloater Extract Class Critical
Long Method Bloater Extract Method Critical
Long Parameter List Bloater Introduce Parameter Object High
Switch Statements OO Abuser Replace Conditional with Polymorphism Critical
Refused Bequest OO Abuser Replace Inheritance with Delegation Critical
Shotgun Surgery Change Preventer Move Method / Inline Class Critical
Divergent Change Change Preventer Extract Class High
Feature Envy Coupler Move Method High
Message Chains Coupler Hide Delegate High
Duplicate Code Dispensable Extract Method / Pull Up Method High
Dead Code Dispensable Delete Medium
Speculative Generality Dispensable Collapse Hierarchy / Inline Class Medium

9. How Code Smells Compound Over Time

The growth of a God Class follows a predictable curve that teams only recognize in retrospect. Consider a real scenario we see repeatedly in Spring Boot microservices:

Month 1:  UserService — 150 lines — clean, SRP respected
Month 3:  +Auth logic added — 300 lines — "just for now"
Month 6:  +Notifications added — 500 lines — "no time to refactor"
Month 9:  +Order history query added — 750 lines — "it's related to users"
Month 12: +Audit logging added — 950 lines — "almost everything is here anyway"
Month 18: +Report generation — 1,200 lines — development velocity drops 60%

Feature cycle time in Month 1:  2 days per feature
Feature cycle time in Month 18: 8 days per feature (same complexity features)
Reason: understanding the blast radius of any change takes 2+ days alone

The velocity curve does not drop sharply — it flattens gradually until the team begins blaming "complexity" and "legacy" for slowdowns that are purely structural. At month 18, extracting the five responsibilities is a 2-sprint effort. At month 6, it would have been 2 days. The compound cost of delay is the core argument for incremental, continuous refactoring over big-bang cleanup sprints.

10. Common Mistakes When Fixing Code Smells

Golden rule: Refactoring is a behaviour-preserving transformation. The moment you change what the code does, you have left refactoring and entered feature development. Keep them separate — in your commits, your PRs, and your mental model.

11. FAQ & Key Takeaways

What is the most dangerous code smell in Java?

God Class and Shotgun Surgery are the most damaging in production codebases. God Class prevents parallel team work and makes every change a high-risk event. Shotgun Surgery means a single business change requires coordinating edits across many files and teams, dramatically increasing merge conflict risk.

How do I detect code smells automatically in Java?

Integrate SonarQube or SonarCloud into your CI pipeline as a quality gate. Configure PMD and Checkstyle as Maven or Gradle plugins to run on every build. For architectural smells, add ArchUnit tests to your JUnit 5 test suite to enforce layer boundaries and forbidden dependencies at the package level.

Is duplicate code always a code smell?

Not always. The Rule of Three applies: the first time you write something, write it. The second time you write something similar, duplicate it. The third time you encounter the same pattern, extract a shared abstraction. Premature extraction based on superficial similarity can create worse coupling than the duplication it replaced.

When should I fix code smells vs ship features?

Use the Boy Scout Rule: fix smells in code you are already touching as part of a feature change. If refactoring a class will take longer than the feature itself, time-box the refactoring to 20% of the total sprint capacity. Avoid dedicated "refactoring sprints" — teams lose product confidence when they see nothing shipped for a week.

Key Takeaways:

Code Smell to Refactoring Pattern Mapping
Code Smell to Refactoring Pattern Mapping — mdsanwarhossain.me

Leave a Comment

Related Posts

Software Dev

Code Smells & Refactoring in Java: Detecting and Fixing the 12 Most Dangerous Anti-Patterns

Deep-dive into the twelve most dangerous anti-patterns in Java production systems.

Software Dev

Complete Java Refactoring Techniques: 20+ Patterns to Transform Legacy Code in 2026

Comprehensive catalogue of 20+ refactoring techniques with Java examples.

Software Dev

SOLID Principles in Java: Real-World Refactoring Patterns

Apply SOLID principles to eliminate code smells in Spring Boot microservices.

Software Dev

Code Quality Metrics in Java: SonarQube, Cyclomatic Complexity & Technical Debt 2026

Measure and enforce code quality with SonarQube quality gates and metrics.

Md Sanwar Hossain
Md Sanwar Hossain

Software Engineer · Java · Spring Boot · Microservices

All Posts
Back to Blog
Last updated: April 10, 2026