Software Dev

Java OOP Anti-Patterns: 20 Design Mistakes That Kill Your Codebase in 2026

Every experienced Java engineer has inherited a codebase riddled with God Objects, Anemic Domain Models, and Circular Dependencies that made them question their career choices. Anti-patterns are proven paths to failure — recurring design mistakes that feel reasonable in the moment but compound into unmaintainable nightmares. This guide identifies all 20, shows you how to detect them in Spring Boot projects, and provides concrete refactoring recipes.

Md Sanwar Hossain April 9, 2026 24 min read OOP Design
Java OOP Anti-Patterns: 20 design mistakes that kill your codebase

TL;DR — The Core Principle

"An anti-pattern is a solution that seems reasonable at first glance but makes things worse over time. Identify them early with SonarQube + code metrics, refactor with small disciplined steps. The God Object, Anemic Domain Model, and Circular Dependency are the top three killers in Spring Boot codebases. Fix these first — everything else follows."

Table of Contents

  1. Class Design Anti-Patterns
  2. Inheritance Anti-Patterns
  3. Coupling Anti-Patterns
  4. State & Lifecycle Anti-Patterns
  5. Structural Anti-Patterns
  6. Detection Tools & Metrics
  7. Case Study: Refactoring a God Service in Spring Boot

1. Class Design Anti-Patterns

These are the most prevalent anti-patterns in Java enterprise codebases. They originate in poorly scoped responsibilities and a reluctance to create new classes or value objects.

Anti-Pattern #1: God Object / God Class

A single class accumulates every responsibility in the system — it knows everything and does everything. The class grows to 2,000+ lines because every new feature is "closest to" existing code. This violates the Single Responsibility Principle directly. Detection signals: >500 lines, >20 methods, high Weighted Methods per Class (WMC) score, >10 injected dependencies.

// ❌ BEFORE — God Service (2000+ lines, 5 unrelated responsibilities)
@Service
public class UserService {
    // User CRUD
    public User createUser(UserDto dto) { ... }
    public User findById(Long id) { ... }
    public void updateProfile(Long id, ProfileDto dto) { ... }
    public void deleteUser(Long id) { ... }

    // Authentication (should be AuthService)
    public String login(String email, String password) { ... }
    public void logout(String token) { ... }
    public void resetPassword(String email) { ... }

    // Email notifications (should be NotificationService)
    public void sendWelcomeEmail(User user) { ... }
    public void sendPasswordResetEmail(User user) { ... }
    public void sendInvoiceEmail(User user, Invoice invoice) { ... }

    // Reporting (should be UserReportService)
    public byte[] exportUsersCsv() { ... }
    public UserStatsDto getStats(DateRange range) { ... }

    // Payment (should be PaymentService)
    public void chargeUser(Long userId, Money amount) { ... }
    public List<Invoice> getInvoices(Long userId) { ... }
}
// ✅ AFTER — Split into focused services
@Service public class UserService { /* CRUD only */ }
@Service public class AuthService { /* login / logout / password reset */ }
@Service public class NotificationService { /* email dispatch */ }
@Service public class UserReportService { /* CSV export, stats */ }
@Service public class PaymentService { /* charge, invoices */ }

Anti-Pattern #2: Anemic Domain Model

Domain objects contain only data (getters/setters) with zero business logic. All logic leaks into service classes, creating fat procedural services. This is the opposite of DDD — your Order class cannot validate itself, cannot compute its own total, and cannot enforce its own state transitions. Result: the same business rules are reimplemented in multiple service methods.

// ❌ BEFORE — Anemic Order (data bag only)
@Entity
public class Order {
    private Long id;
    private List<OrderItem> items;
    private OrderStatus status;
    private BigDecimal total;
    // getters and setters only — zero behavior
}

// Business logic scattered across services
@Service
public class OrderService {
    public BigDecimal calculateTotal(Order order) { ... }
    public boolean canBeCancelled(Order order) { ... }
    public void applyDiscount(Order order, Discount d) { ... }
}
// ✅ AFTER — Rich Domain Model (DDD)
@Entity
public class Order {
    private Long id;
    private List<OrderItem> items;
    private OrderStatus status;

    public Money calculateTotal() {
        return items.stream()
            .map(OrderItem::subtotal)
            .reduce(Money.ZERO, Money::add);
    }

    public void cancel() {
        if (status == OrderStatus.SHIPPED)
            throw new IllegalStateException("Cannot cancel shipped order");
        this.status = OrderStatus.CANCELLED;
    }

    public void applyDiscount(Discount discount) {
        // validation + business rule lives here
        discount.applyTo(this);
    }
}

Anti-Pattern #3: Data Clump

Three or more fields always appear together across multiple methods and classes but are never modelled as their own object. Common examples: (String street, String city, String zipCode) or (BigDecimal amount, String currency). Fix: extract a Value Object.

// ❌ BEFORE — Data Clump
public void createOrder(String street, String city,
        String zipCode, String country,
        BigDecimal amount, String currency) { ... }

public void shipOrder(Long id, String street, String city,
        String zipCode, String country) { ... }
// ✅ AFTER — Value Objects
public record Address(String street, String city,
        String zipCode, String country) {}

public record Money(BigDecimal amount, Currency currency) {
    public Money add(Money other) {
        if (!this.currency.equals(other.currency))
            throw new IllegalArgumentException("Currency mismatch");
        return new Money(this.amount.add(other.amount), currency);
    }
}

public void createOrder(Address shippingAddress, Money price) { ... }
public void shipOrder(Long id, Address destination) { ... }

Anti-Pattern #4: Primitive Obsession

Using primitives (String, int, long) to represent domain concepts like email addresses, phone numbers, user IDs, and monetary amounts. These primitives carry no validation and can be accidentally swapped. Fix: wrap with typed classes or Java records.

// ❌ BEFORE — Primitive Obsession
public void sendInvoice(String email, long userId,
        String currency, double amount) { ... }
// Nothing stops: sendInvoice(userId, email, amount, currency)
// ✅ AFTER — Typed domain primitives
public record EmailAddress(String value) {
    public EmailAddress {
        if (!value.matches("^[^@]+@[^@]+\\.[^@]+$"))
            throw new IllegalArgumentException("Invalid email: " + value);
    }
}
public record UserId(Long value) {}
public record Money(BigDecimal amount, Currency currency) {}

public void sendInvoice(EmailAddress email, UserId userId,
        Money amount) { ... }
// Compiler enforces correct argument types — no accidental swaps
Java OOP Anti-Patterns overview diagram — God Object, Anemic Domain Model, Circular Dependency, Spaghetti Code
Java OOP Anti-Patterns — 20 design mistakes categorised by type. Source: mdsanwarhossain.me

2. Inheritance Anti-Patterns

Inheritance is one of Java's most misused features. Deep hierarchies make code impossible to navigate and violate both the Open/Closed Principle and the Liskov Substitution Principle.

Anti-Pattern #5: Yo-Yo Problem

A deep inheritance hierarchy forces developers to jump up and down the hierarchy (like a yo-yo) to understand a single method call. Reading PremiumEnterpriseUserAccountService requires mentally traversing six levels of superclasses. Fix: flatten with composition over inheritance.

// ❌ BEFORE — Deep Yo-Yo hierarchy (6 levels)
class BaseEntity { ... }
class AuditableEntity extends BaseEntity { ... }
class UserEntity extends AuditableEntity { ... }
class AccountUserEntity extends UserEntity { ... }
class PremiumUserEntity extends AccountUserEntity { ... }
class PremiumEnterpriseUser extends PremiumUserEntity { ... }
// To understand processPayment(), you must read ALL 6 classes
// ✅ AFTER — Composition (flat, readable)
@Entity
public class User {
    private Long id;
    private AuditInfo auditInfo;      // composition
    private AccountInfo accountInfo;  // composition
    private SubscriptionTier tier;    // enum replaces hierarchy
}

public enum SubscriptionTier { FREE, PREMIUM, ENTERPRISE }

// Behaviour varies via strategy pattern, not inheritance
public interface PricingStrategy {
    Money calculatePrice(Order order);
}
@Component public class FreePricingStrategy implements PricingStrategy { ... }
@Component public class EnterprisePricingStrategy implements PricingStrategy { ... }

Anti-Pattern #6: Refused Bequest

A subclass inherits methods from its parent but doesn't need them — it throws UnsupportedOperationException or returns null for methods that don't apply. This violates the Liskov Substitution Principle (LSP). Fix: replace inheritance with delegation or restructure the hierarchy.

// ❌ BEFORE — Refused Bequest
public class Rectangle {
    protected int width, height;
    public void setWidth(int w) { this.width = w; }
    public void setHeight(int h) { this.height = h; }
}

public class Square extends Rectangle {
    @Override
    public void setWidth(int w) {
        // Square must keep sides equal — breaks LSP!
        this.width = this.height = w;
    }
    @Override
    public void setHeight(int h) {
        this.width = this.height = h; // Silent contract violation
    }
}
// ✅ AFTER — Separate classes, shared interface
public interface Shape {
    int area();
}

public class Rectangle implements Shape {
    private final int width, height;
    public Rectangle(int width, int height) {
        this.width = width; this.height = height;
    }
    @Override public int area() { return width * height; }
}

public class Square implements Shape {
    private final int side;
    public Square(int side) { this.side = side; }
    @Override public int area() { return side * side; }
}

Anti-Pattern #7: Parallel Inheritance Hierarchies

Every time you add a subclass in hierarchy A, you must add a corresponding class in hierarchy B. For example, every new PaymentMethod type requires a new PaymentProcessor and a new PaymentValidator. Fix: use the Visitor or Strategy pattern to decouple the hierarchies.

// ❌ BEFORE — Parallel hierarchies (must mirror every addition)
class PaymentMethod { }
  class CreditCardPayment extends PaymentMethod { }
  class PayPalPayment extends PaymentMethod { }

class PaymentProcessor { }
  class CreditCardProcessor extends PaymentProcessor { }  // mirrors above
  class PayPalProcessor extends PaymentProcessor { }      // mirrors above

class PaymentValidator { }
  class CreditCardValidator extends PaymentValidator { }  // mirrors again
  class PayPalValidator extends PaymentValidator { }      // mirrors again
// ✅ AFTER — Single hierarchy with composed behaviour
public interface PaymentHandler {
    boolean supports(PaymentMethod method);
    void process(Payment payment);
    void validate(Payment payment);
}

@Component
public class CreditCardHandler implements PaymentHandler {
    public boolean supports(PaymentMethod method) {
        return method == PaymentMethod.CREDIT_CARD;
    }
    public void process(Payment payment) { ... }
    public void validate(Payment payment) { ... }
}

// Dispatcher routes to correct handler — no parallel trees
@Service
public class PaymentDispatcher {
    private final List<PaymentHandler> handlers;
    public void handle(Payment payment) {
        handlers.stream()
            .filter(h -> h.supports(payment.getMethod()))
            .findFirst()
            .orElseThrow(() -> new UnsupportedPaymentMethodException())
            .process(payment);
    }
}

Anti-Pattern #8: Inappropriate Intimacy (Inheritance)

A subclass accesses and modifies protected fields of its parent directly, bypassing the parent's own logic. This creates brittle coupling — any internal refactor of the parent breaks the child. Fix: expose behaviour through well-defined protected methods or make the class final and use delegation.

3. Coupling Anti-Patterns

High coupling is the primary enemy of testability and change. These four patterns are the most common sources of tight coupling in Spring Boot applications.

Anti-Pattern #9: Feature Envy

A method in class A spends most of its time accessing data and calling methods from class B. This is a sign the method belongs in class B. Detection: a method has more references to another class's fields than its own.

// ❌ BEFORE — Feature Envy (OrderService is envious of Order's data)
@Service
public class OrderService {
    public Money calculateDiscount(Order order) {
        // This method only touches Order's data — it belongs IN Order
        if (order.getItems().size() > 10
                && order.getCustomer().getTier() == CustomerTier.PREMIUM
                && order.getSubtotal().isGreaterThan(Money.of(500, "USD"))) {
            return order.getSubtotal().multiply(0.15);
        }
        return Money.ZERO;
    }
}
// ✅ AFTER — Move method to where the data lives
@Entity
public class Order {
    public Money calculateDiscount() {
        if (items.size() > 10
                && customer.getTier() == CustomerTier.PREMIUM
                && subtotal().isGreaterThan(Money.of(500, "USD"))) {
            return subtotal().multiply(0.15);
        }
        return Money.ZERO;
    }
}

Anti-Pattern #10: Inappropriate Intimacy (Coupling)

Two unrelated classes know too much about each other's internal state. OrderService directly reads and sets private-equivalent fields of InventoryService through package-private accessors or reflection. Fix: define a clear API contract between the two — let each class expose only what is needed.

Anti-Pattern #11: Circular Dependency

ServiceA depends on ServiceB which depends back on ServiceA. Spring Boot detects this and throws BeanCurrentlyInCreationException. Short-term fix: @Lazy. Long-term fix: introduce an intermediate abstraction or event.

// ❌ BEFORE — Circular Dependency
@Service
public class OrderService {
    private final PaymentService paymentService; // depends on Payment
    public OrderService(PaymentService ps) { this.paymentService = ps; }
}

@Service
public class PaymentService {
    private final OrderService orderService; // depends back on Order!
    public PaymentService(OrderService os) { this.orderService = os; }
}
// Spring throws: BeanCurrentlyInCreationException
// ✅ AFTER — Break cycle with an event or facade
// Option A: Spring @Lazy (short-term workaround)
@Service
public class PaymentService {
    @Lazy
    private final OrderService orderService;
    public PaymentService(@Lazy OrderService os) { this.orderService = os; }
}

// Option B: Introduce event (proper fix)
@Service
public class PaymentService {
    private final ApplicationEventPublisher eventPublisher;

    public void processPayment(Payment payment) {
        // do payment logic
        eventPublisher.publishEvent(new PaymentCompletedEvent(payment));
        // OrderService listens via @EventListener — no direct dependency
    }
}

@Service
public class OrderService {
    @EventListener
    public void onPaymentCompleted(PaymentCompletedEvent event) {
        // handle the event — no circular reference
    }
}

Anti-Pattern #12: Hidden Dependencies

A class creates its own dependencies via new, static method calls, or ServiceLocator.getInstance() inside constructors or methods. Hidden dependencies make the class untestable in isolation. Fix: inject all dependencies through the constructor.

// ❌ BEFORE — Hidden Dependencies
@Service
public class ReportService {
    public Report generate(Long orderId) {
        // Hidden: creates its own dependencies
        OrderRepository repo = new OrderRepository();        // violates DI
        PdfRenderer renderer = PdfRenderer.getInstance();   // static call
        EmailClient client = new SmtpEmailClient("smtp.example.com");
        Order order = repo.findById(orderId);
        return renderer.render(order);
    }
}
// ✅ AFTER — Constructor injection (all dependencies explicit)
@Service
public class ReportService {
    private final OrderRepository orderRepository;
    private final PdfRenderer pdfRenderer;
    private final EmailClient emailClient;

    public ReportService(OrderRepository orderRepository,
            PdfRenderer pdfRenderer, EmailClient emailClient) {
        this.orderRepository = orderRepository;
        this.pdfRenderer = pdfRenderer;
        this.emailClient = emailClient;
    }

    public Report generate(Long orderId) {
        Order order = orderRepository.findById(orderId)
            .orElseThrow(() -> new OrderNotFoundException(orderId));
        return pdfRenderer.render(order);
    }
}

4. State & Lifecycle Anti-Patterns

Spring Boot beans are singletons by default. Mismanaging mutable state in singletons and coupling methods through temporal ordering creates race conditions and initialization bugs that are hellishly hard to debug in production.

Anti-Pattern #13: Mutable Shared State in Singleton Beans

Instance variables in a Spring @Service or @Component are shared across all concurrent requests. If any field is mutated per-request, you have a race condition. This is one of the most dangerous bugs in Spring Boot — hard to reproduce, causes data leaks between users.

// ❌ BEFORE — Mutable state in singleton (RACE CONDITION!)
@Service // singleton — one instance shared by all threads
public class InvoiceService {
    private User currentUser;       // ← DANGER: shared mutable state
    private List<Item> cartItems;   // ← DANGER: shared mutable state

    public Invoice generate(User user, List<Item> items) {
        this.currentUser = user;    // Thread A sets user=Alice
        this.cartItems = items;     // Thread B sets user=Bob here
        // Thread A reads Bob's cart — data leak!
        return buildInvoice();
    }
}
// ✅ AFTER — All state is local to method scope
@Service // singleton is safe when stateless
public class InvoiceService {
    private final InvoiceRepository invoiceRepository;
    private final TaxCalculator taxCalculator;

    public Invoice generate(User user, List<Item> items) {
        // state lives on the stack — thread-safe by construction
        Money subtotal = calculateSubtotal(items);
        Money tax = taxCalculator.calculate(subtotal, user.getCountry());
        Invoice invoice = new Invoice(user, items, subtotal, tax);
        return invoiceRepository.save(invoice);
    }
}

Anti-Pattern #14: Blob / Ambiguous Viewpoint

A class is responsible for too many cross-cutting concerns and doesn't clearly belong to any layer. UserOrderPaymentReport is simultaneously a DTO, an entity, a service, and a view model. Developers can't tell where it belongs, so they keep adding to it. Fix: explicitly assign each class to a layer (entity, DTO, service, repository, mapper) and enforce layer rules via ArchUnit.

Anti-Pattern #15: Magic Numbers & Magic Strings

Literal values hardcoded throughout the codebase with no explanation of what they represent. If the value needs to change, you must hunt and replace across dozens of files — and miss some. Fix: named constants and enums.

// ❌ BEFORE — Magic numbers and strings everywhere
if (user.getRole().equals("ADMIN") && sessionAge > 1800) {
    if (requestCount > 100) blockUser(user, 86400);
}
// ✅ AFTER — Named constants and enums
public enum UserRole { ADMIN, USER, MODERATOR }

public final class SecurityConstants {
    public static final int SESSION_TIMEOUT_SECONDS = 1800;
    public static final int MAX_REQUESTS_PER_MINUTE = 100;
    public static final int BLOCK_DURATION_SECONDS = 86400;
    private SecurityConstants() {}
}

if (user.getRole() == UserRole.ADMIN
        && sessionAge > SecurityConstants.SESSION_TIMEOUT_SECONDS) {
    if (requestCount > SecurityConstants.MAX_REQUESTS_PER_MINUTE) {
        blockUser(user, SecurityConstants.BLOCK_DURATION_SECONDS);
    }
}

Anti-Pattern #16: Temporal Coupling

Methods must be called in a specific order but nothing enforces it. If you call setConnection() before open(), it silently fails. Fix: use the Builder pattern or a fluent API that makes valid state transitions explicit and enforces ordering at compile time.

// ❌ BEFORE — Temporal Coupling (order not enforced)
EmailSender sender = new EmailSender();
sender.setTo("alice@example.com");   // must call before send()
sender.setSubject("Hello");          // must call before send()
// Forgot: sender.setBody("...") — silent null body
sender.send();                       // sends email with null body
// ✅ AFTER — Builder enforces valid construction
Email email = Email.builder()
    .to("alice@example.com")
    .subject("Hello")
    .body("Message body here")
    .build(); // build() validates all required fields are present
emailSender.send(email);

5. Structural Anti-Patterns

These anti-patterns are visible at the macro level — in the overall codebase organisation and code quality. They accumulate gradually through feature pressure and missed refactoring opportunities.

Anti-Pattern #17: Spaghetti Code

Tangled logic with deep nesting, multiple early returns, interleaved concerns, and 200-line methods. Cyclomatic complexity >15. Fix: extract methods, apply guard clauses (invert conditions to return early), and separate concerns.

// ❌ BEFORE — Spaghetti (deep nesting, mixed concerns)
public void processOrder(Order order) {
    if (order != null) {
        if (order.getUser() != null) {
            if (order.getUser().isActive()) {
                if (!order.getItems().isEmpty()) {
                    if (inventoryService.hasStock(order)) {
                        // 50 more lines of nested logic...
                        if (paymentService.charge(order)) {
                            // 30 more lines...
                        }
                    }
                }
            }
        }
    }
}
// ✅ AFTER — Guard clauses + extracted methods (flat, readable)
public void processOrder(Order order) {
    validateOrder(order);
    ensureStockAvailable(order);
    chargePayment(order);
    confirmOrder(order);
}

private void validateOrder(Order order) {
    Objects.requireNonNull(order, "Order must not be null");
    if (order.getUser() == null || !order.getUser().isActive())
        throw new InvalidOrderException("User is inactive or missing");
    if (order.getItems().isEmpty())
        throw new InvalidOrderException("Order has no items");
}

private void ensureStockAvailable(Order order) {
    if (!inventoryService.hasStock(order))
        throw new OutOfStockException(order);
}

private void chargePayment(Order order) {
    if (!paymentService.charge(order))
        throw new PaymentFailedException(order);
}

Anti-Pattern #18: Copy-Paste Programming

Identical or near-identical code blocks duplicated across multiple classes. When a bug is found, it must be fixed in every copy — and one is always missed. SonarQube's duplicated blocks metric and PMD's CPD (Copy-Paste Detector) identify this. Fix: Extract Method or Extract Class refactoring.

// ❌ BEFORE — Copy-Paste (same pagination logic in 5 services)
// In OrderService:
public Page<Order> findOrders(int page, int size) {
    if (page < 0) throw new IllegalArgumentException("Page must be >= 0");
    if (size < 1 || size > 100) throw new IllegalArgumentException("Size must be 1-100");
    return orderRepo.findAll(PageRequest.of(page, size));
}
// In ProductService: SAME validation duplicated verbatim
// In UserService: SAME validation duplicated verbatim
// ✅ AFTER — Extract to shared utility
public final class PageRequestFactory {
    public static PageRequest of(int page, int size) {
        if (page < 0) throw new IllegalArgumentException("Page must be >= 0");
        if (size < 1 || size > 100)
            throw new IllegalArgumentException("Size must be 1-100");
        return PageRequest.of(page, size);
    }
}

// All services use shared factory — fix once, fix everywhere
public Page<Order> findOrders(int page, int size) {
    return orderRepo.findAll(PageRequestFactory.of(page, size));
}

Anti-Pattern #19: Dead Code

Methods, fields, and entire classes that are never called, but remain in the codebase "just in case." Dead code increases cognitive load, causes false positives in code reviews, and grows over time. Fix: run IntelliJ IDEA's unused declaration inspection, SpotBugs, or SonarQube's squid:S1144 rule, then delete ruthlessly. Version control saves you if you're wrong.

Anti-Pattern #20: Golden Hammer

Using the same familiar pattern for every problem regardless of fit. "When all you have is a hammer, everything looks like a nail." In Java: making every class a Singleton, using a full microservices mesh for a 3-page CRUD app, or using reactive streams for simple synchronous operations. Fix: match the tool to the problem — evaluate explicitly.

// ❌ BEFORE — Golden Hammer (everything is a Singleton)
public class ConfigManager {
    private static ConfigManager instance;
    private ConfigManager() {}
    public static ConfigManager getInstance() {
        if (instance == null) instance = new ConfigManager(); // not thread-safe
        return instance;
    }
}
// Applied to: UserManager, OrderManager, ReportManager — all singletons
// Problems: untestable, global mutable state, not Spring-managed

// ✅ AFTER — Let Spring manage lifecycle appropriately
@Configuration
public class AppConfig {
    @Bean
    @ConfigurationProperties(prefix = "app")
    public AppProperties appProperties() { return new AppProperties(); }
    // Spring manages scope: @Singleton, @Prototype, @RequestScope as needed
}

6. Detection Tools & Metrics

Spotting anti-patterns manually in a large codebase is impractical. The following tools automate detection and integrate into CI/CD pipelines.

Code Quality Metrics to Track

Anti-Pattern Detection Reference Table

Anti-Pattern SonarQube Rule PMD / SpotBugs Fix Technique
God Object squid:S6539 GodClass Extract Class
Anemic Domain Model squid:S1448 DataClass Move Method to Domain
Circular Dependency squid:S6813 CyclicDependency Events / @Lazy
Feature Envy squid:S5411 Move Method
Spaghetti Code squid:S3776 CyclomaticComplexity Extract Method, Guards
Copy-Paste Code common-java:DuplicatedBlocks CPD Extract Method / Class
Dead Code squid:S1144 UnusedPrivateMethod Delete
Primitive Obsession squid:S1192 AvoidDuplicateLiterals Value Object / Record
Magic Numbers squid:S109 AvoidLiteralsInIfCondition Named Constants / Enum
Mutable Shared State squid:S2885 IS2_INCONSISTENT_SYNC Stateless / ThreadLocal

SonarQube Integration in Spring Boot

// build.gradle — add SonarQube analysis to CI
plugins {
    id "org.sonarqube" version "4.4.1.3373"
}

sonarqube {
    properties {
        property "sonar.projectKey", "my-spring-boot-app"
        property "sonar.host.url", "https://sonarcloud.io"
        property "sonar.java.coveragePlugin", "jacoco"
        // Quality gate: fail build if new code coverage < 80%
        // or cyclomatic complexity > 10
    }
}

// Run analysis:
// ./gradlew sonarqube -Dsonar.token=$SONAR_TOKEN

Architecture Rules with ArchUnit

// Enforce architecture rules at test time with ArchUnit
@AnalyzeClasses(packages = "com.example.app")
public class ArchitectureRulesTest {

    @ArchTest
    ArchRule servicesShouldNotDependOnEachOtherDirectly =
        noClasses().that().resideInAPackage("..service..")
            .should().dependOnClassesThat()
            .resideInAPackage("..service..")
            .because("Services must communicate via events or interfaces");

    @ArchTest
    ArchRule domainShouldNotDependOnSpring =
        noClasses().that().resideInAPackage("..domain..")
            .should().dependOnClassesThat()
            .resideInAPackage("org.springframework..")
            .because("Domain model must be framework-agnostic");

    @ArchTest
    ArchRule repositoriesShouldOnlyBeCalledByServices =
        classes().that().resideInAPackage("..repository..")
            .should().onlyBeAccessed()
            .byClassesThat().resideInAPackage("..service..");
}

7. Case Study: Refactoring a God Service in Spring Boot

Here is a realistic, step-by-step refactoring of a UserManagementService that had grown to 20 methods across 5 unrelated responsibilities — a textbook God Object in production.

Before: The God Service

// ❌ BEFORE — UserManagementService: 1,800 lines, 20 methods, 8 injected deps
@Service
public class UserManagementService {
    // 8 injected dependencies (smell: too many)
    private final UserRepository userRepo;
    private final PasswordEncoder passwordEncoder;
    private final JwtService jwtService;
    private final JavaMailSender mailSender;
    private final TemplateEngine templateEngine;
    private final StorageService storageService;
    private final InvoiceRepository invoiceRepo;
    private final ReportGenerator reportGenerator;

    // Responsibility 1: User CRUD (belongs here)
    public User createUser(CreateUserRequest req) { ... }
    public User findById(Long id) { ... }
    public void updateProfile(Long id, UpdateProfileRequest req) { ... }
    public void deleteUser(Long id) { ... }
    public Page<User> searchUsers(UserSearchCriteria criteria) { ... }

    // Responsibility 2: Authentication (should be AuthService)
    public AuthToken login(String email, String password) { ... }
    public void logout(String token) { ... }
    public void initiatePasswordReset(String email) { ... }
    public void completePasswordReset(String token, String newPassword) { ... }

    // Responsibility 3: Notifications (should be NotificationService)
    public void sendWelcomeEmail(User user) { ... }
    public void sendPasswordResetEmail(User user, String token) { ... }
    public void sendAccountLockedEmail(User user) { ... }

    // Responsibility 4: Reporting (should be UserReportService)
    public byte[] exportUsersCsv(UserFilter filter) { ... }
    public UserStatsDto getActivityStats(DateRange range) { ... }
    public byte[] generateUserReport(Long userId) { ... }

    // Responsibility 5: Profile Media (should be UserProfileService)
    public void uploadAvatar(Long userId, MultipartFile file) { ... }
    public void deleteAvatar(Long userId) { ... }
}

Refactoring Steps

  1. Identify responsibilities — Group all 20 methods by what they do. Name each group (User CRUD, Auth, Notifications, Reporting, Profile Media).
  2. Create new empty service classesAuthService, NotificationService, UserReportService, UserProfileService. Do not move code yet.
  3. Move dependencies first — Move JavaMailSender + TemplateEngine to NotificationService; move JwtService to AuthService; move StorageService to UserProfileService.
  4. Move methods one at a time — Move one method per commit. Run all tests after each move. If a method calls another in the same class, move the callee first.
  5. Update callers — Update all controllers and other services to inject and call the new specific service. Use your IDE's "Find Usages" to find all call sites.
  6. Delete from God Service — Only after callers are updated. The original class shrinks to 5 focused CRUD methods.
  7. Run SonarQube — Verify WMC, CBO, and LCOM metrics improved. Add ArchUnit test to prevent future violations.

After: Five Focused Services

// ✅ AFTER — Five focused, testable, single-responsibility services

@Service
public class UserService {               // ~200 lines, 5 methods, 2 deps
    private final UserRepository userRepo;
    private final UserMapper userMapper;
    public User createUser(CreateUserRequest req) { ... }
    public User findById(Long id) { ... }
    public void updateProfile(Long id, UpdateProfileRequest req) { ... }
    public void deleteUser(Long id) { ... }
    public Page<User> searchUsers(UserSearchCriteria criteria) { ... }
}

@Service
public class AuthService {               // ~150 lines, 4 methods, 3 deps
    private final UserRepository userRepo;
    private final PasswordEncoder passwordEncoder;
    private final JwtService jwtService;
    public AuthToken login(String email, String password) { ... }
    public void logout(String token) { ... }
    public void initiatePasswordReset(String email) { ... }
    public void completePasswordReset(String token, String password) { ... }
}

@Service
public class NotificationService {      // ~120 lines, 3 methods, 2 deps
    private final JavaMailSender mailSender;
    private final TemplateEngine templateEngine;
    public void sendWelcomeEmail(User user) { ... }
    public void sendPasswordResetEmail(User user, String token) { ... }
    public void sendAccountLockedEmail(User user) { ... }
}

@Service
public class UserReportService {        // ~130 lines, 3 methods, 2 deps
    private final UserRepository userRepo;
    private final ReportGenerator reportGenerator;
    public byte[] exportUsersCsv(UserFilter filter) { ... }
    public UserStatsDto getActivityStats(DateRange range) { ... }
    public byte[] generateUserReport(Long userId) { ... }
}

@Service
public class UserProfileService {       // ~80 lines, 2 methods, 2 deps
    private final UserRepository userRepo;
    private final StorageService storageService;
    public void uploadAvatar(Long userId, MultipartFile file) { ... }
    public void deleteAvatar(Long userId) { ... }
}

The refactored result: the original 1,800-line God Service is replaced by 5 focused services averaging 136 lines each. Each service has ≤3 injected dependencies. Cyclomatic complexity per method dropped from an average of 8.3 to 2.1. Unit test coverage went from 31% (too tangled to mock) to 94% (easy to isolate).

Anti-Pattern Elimination Checklist

  • ☐ No service class has more than 10 public methods or 5 injected dependencies
  • ☐ Domain objects contain their own validation and business rules (no Anemic Model)
  • ☐ All groups of 3+ fields that travel together are modelled as Value Objects
  • ☐ No inheritance tree is deeper than 3 levels — composition preferred
  • ☐ No circular Spring bean dependencies (check with spring.main.allow-circular-references=false)
  • ☐ All Spring @Service classes are stateless (no mutable instance fields)
  • ☐ No magic numbers or strings — all literals are named constants or enums
  • ☐ Cyclomatic complexity < 10 for every method (SonarQube quality gate)
  • ☐ No duplicated code blocks > 5 lines (SonarQube CPD threshold)
  • ☐ ArchUnit tests enforce layer boundaries and prevent future violations

Anti-patterns are fundamentally a maintainability debt. Each one makes your codebase a little harder to read, test, and extend. The good news: they are all fixable incrementally. You don't need a "big bang" rewrite — identify the highest-pain anti-pattern in your codebase today, fix it in one focused sprint, and measure the improvement. Repeat.

Leave a Comment

Related Posts

Md Sanwar Hossain - Software Engineer
Md Sanwar Hossain

Software Engineer · Java · Spring Boot · Microservices · AI/LLM Systems

All Posts
Last updated: April 9, 2026