Avoiding God Classes

26 min read

Object-Oriented Design and SOLID Review — identify Java classes that have grown too large, do too many things, and must be split to restore focused responsibility.

1. Introduction

A God Class is a class that knows too much, does too much, and controls too many parts of an application.

In Java enterprise applications, God Classes commonly appear in service-layer code where one class gradually becomes responsible for:

  • Request validation
  • Business rules
  • Database operations
  • DTO conversion
  • External API calls
  • Payment processing
  • Notification handling
  • Logging
  • Error handling
  • Audit creation

The class may initially look convenient because all related logic is available in one place. Over time, however, it becomes difficult to understand, test, change, and review safely.

During code review, a reviewer should not reject a class simply because it has many lines. The real question is whether the class owns too many unrelated responsibilities.

The goal is not to create dozens of tiny classes. The goal is to maintain clear responsibility boundaries so that each component has a focused reason to change.

2. What This Topic Means

Avoiding God Classes means designing Java classes so that one class does not become the central location for unrelated business and technical responsibilities.

Consider an OrderService that performs all of the following:

  • Validates the order request
  • Loads customer information
  • Calculates prices
  • Calculates discounts
  • Reserves inventory
  • Saves orders
  • Charges the customer
  • Sends confirmation emails
  • Creates audit records

Although all these operations are related to order processing, they do not necessarily belong inside the same class.

Each responsibility has different reasons to change.

For example:

  • Discount rules may change because of marketing requirements.
  • Payment logic may change because the payment provider changes.
  • Inventory logic may change because warehouse integration changes.
  • Email logic may change because notification requirements change.
  • Order persistence may change because of database changes.

When one class contains all these responsibilities, every business change increases the risk of affecting unrelated functionality.

A better design separates responsibilities while keeping the overall workflow easy to understand.

3. Why It Matters in Real Projects

Readability

A developer should be able to understand what a class is responsible for without reading hundreds or thousands of lines.

God Classes usually contain many unrelated methods, dependencies, helper functions, and conditional branches. This increases cognitive load.

Maintainability

Changes become risky when multiple features depend on the same large class.

A developer modifying payment logic may accidentally affect order validation or notification behavior.

Debugging

When production failures happen, God Classes make it harder to identify which responsibility caused the failure.

A stack trace pointing to a 1,500-line OrderService provides less useful information than one pointing to a focused PaymentProcessor.

Testability

Large classes usually require many mocked dependencies.

Tests often become difficult to arrange and understand because the class has too many collaborators.

Reliability

Highly coupled responsibilities increase regression risk.

A seemingly small modification can affect unrelated workflows.

Team Development

God Classes create merge conflicts because multiple developers frequently modify the same file.

For example:

  • Developer A modifies payment logic.
  • Developer B modifies discounts.
  • Developer C modifies notification behavior.

All three developers may modify the same service class.

This slows development and makes Pull Requests harder to review.

4. Core Concept

The main problem behind a God Class is excessive responsibility concentration.

A healthy Java class should generally have a clear purpose.

For example:

JAVA
OrderValidator
PricingService
InventoryService
PaymentService
OrderRepository
NotificationService

Each class has a recognizable responsibility.

The coordinating business operation can still be represented through an orchestration service such as:

JAVA
OrderProcessingService

Its purpose is to coordinate the workflow rather than implement every detail itself.

For example:

JAVA
validate order
calculate price
reserve inventory
save order
process payment
send confirmation

The orchestration remains visible, but individual business rules are delegated to focused components.

God Class Is Not Simply a Large Class

A large class is not automatically a God Class.

For example, a class may contain many related methods implementing one cohesive domain responsibility.

Similarly, a small class can still violate responsibility boundaries if it mixes unrelated concerns.

Reviewers should evaluate:

  • Number of responsibilities
  • Number of reasons to change
  • Number of dependencies
  • Cohesion between methods
  • Ownership of business rules
  • Coupling with infrastructure
  • Difficulty of testing the class independently

5. Important Rules

  • A class should have one clear primary responsibility.
  • Group methods based on business responsibility, not merely convenience.
  • Do not place unrelated helper methods into an existing service just because the service is already available.
  • Separate validation when validation rules become substantial.
  • Separate external system integration from core business logic.
  • Separate notification logic from business transaction logic when possible.
  • Keep persistence responsibilities inside repositories or dedicated persistence components.
  • Avoid injecting a very large number of unrelated dependencies into one class.
  • Use orchestration services to coordinate components instead of implementing all responsibilities directly.
  • Do not extract classes only to reduce line count.
  • Prefer meaningful domain boundaries over arbitrary class splitting.
  • Keep transaction boundaries explicit when extracting database-related operations.
  • Preserve business behavior during refactoring.
  • Avoid circular dependencies between newly extracted services.
  • Do not replace one God Class with several poorly designed utility classes.

6. Bad Code Example

Consider an e-commerce application containing the following service:

JAVA
@Service
public class OrderService {
    private final OrderRepository orderRepository;
    private final CustomerRepository customerRepository;
    private final ProductRepository productRepository;
    private final PaymentGatewayClient paymentGatewayClient;
    private final EmailClient emailClient;
    private final AuditRepository auditRepository;
    public OrderService(
            OrderRepository orderRepository,
            CustomerRepository customerRepository,
            ProductRepository productRepository,
            PaymentGatewayClient paymentGatewayClient,
            EmailClient emailClient,
            AuditRepository auditRepository) {
        this.orderRepository = orderRepository;
        this.customerRepository = customerRepository;
        this.productRepository = productRepository;
        this.paymentGatewayClient = paymentGatewayClient;
        this.emailClient = emailClient;
        this.auditRepository = auditRepository;
    }
    @Transactional
    public Order createOrder(CreateOrderRequest request) {
        if (request == null) {
            throw new IllegalArgumentException("Order request cannot be null");
        }
        if (request.customerId() == null) {
            throw new IllegalArgumentException("Customer id is required");
        }
        if (request.items() == null || request.items().isEmpty()) {
            throw new IllegalArgumentException("Order must contain items");
        }
        Customer customer = customerRepository.findById(request.customerId())
                .orElseThrow(() -> new CustomerNotFoundException(request.customerId()));
        BigDecimal total = BigDecimal.ZERO;
        for (OrderItemRequest itemRequest : request.items()) {
            Product product = productRepository.findById(itemRequest.productId())
                    .orElseThrow(() -> new ProductNotFoundException(itemRequest.productId()));
            if (product.getAvailableQuantity() < itemRequest.quantity()) {
                throw new InsufficientInventoryException(product.getId());
            }
            BigDecimal lineTotal = product.getPrice()
                    .multiply(BigDecimal.valueOf(itemRequest.quantity()));
            total = total.add(lineTotal);
            product.setAvailableQuantity(
                    product.getAvailableQuantity() - itemRequest.quantity());
            productRepository.save(product);
        }
        if (customer.isPremium()) {
            total = total.multiply(new BigDecimal("0.90"));
        }
        PaymentRequest paymentRequest = new PaymentRequest(
                customer.getId(),
                total,
                request.paymentToken());
        PaymentResponse paymentResponse =
                paymentGatewayClient.charge(paymentRequest);
        if (!paymentResponse.successful()) {
            throw new PaymentFailedException(paymentResponse.message());
        }
        Order order = new Order();
        order.setCustomerId(customer.getId());
        order.setTotalAmount(total);
        order.setPaymentReference(paymentResponse.reference());
        order.setStatus(OrderStatus.CONFIRMED);
        Order savedOrder = orderRepository.save(order);
        emailClient.sendOrderConfirmation(
                customer.getEmail(),
                savedOrder.getId(),
                total);
        AuditLog auditLog = new AuditLog();
        auditLog.setAction("ORDER_CREATED");
        auditLog.setEntityId(savedOrder.getId());
        auditLog.setCreatedAt(Instant.now());
        auditRepository.save(auditLog);
        return savedOrder;
    }
}

This method may work, but the class has accumulated responsibilities belonging to multiple parts of the application.

7. Problems in the Bad Code

Too Many Responsibilities

OrderService performs:

  • Input validation
  • Customer retrieval
  • Product retrieval
  • Inventory validation
  • Inventory modification
  • Pricing
  • Discount calculation
  • Payment integration
  • Order creation
  • Persistence
  • Email notification
  • Audit logging

This makes the class highly coupled.

Low Cohesion

Many sections of the method depend on completely different collaborators.

Payment logic has little implementation-level relationship with email delivery.

Inventory modification has little relationship with audit persistence.

Difficult Unit Testing

Testing createOrder() requires mocking:

  • OrderRepository
  • CustomerRepository
  • ProductRepository
  • PaymentGatewayClient
  • EmailClient
  • AuditRepository

Additional responsibilities would increase that number further.

Database Calls Inside the Loop

The code executes:

JAVA
productRepository.findById(...)

and:

JAVA
productRepository.save(...)

for each item.

For large orders, this may generate unnecessary database round trips.

Mixed Business Logic and Infrastructure

The method combines:

  • Business rules
  • Persistence
  • HTTP-style external integration
  • Notification infrastructure

Changes to any one concern require modifying the same method.

Transaction Boundary Risk

The method is transactional but invokes an external payment gateway and email service.

A database transaction should generally not remain open unnecessarily while waiting for network calls.

More importantly, successful payment followed by database rollback requires explicit compensation or workflow design.

Notification Failure Risk

If email delivery throws an exception after payment and order persistence, transaction behavior may become confusing or incorrect depending on the exception and implementation.

Notification is usually not part of the core atomic order transaction.

Increased Regression Risk

A modification to discount calculation requires touching the same method responsible for inventory, payment, persistence, and notifications.

Difficult PR Review

A Pull Request containing changes to this method forces reviewers to reason about multiple domains simultaneously.

8. Code Review Findings

A senior reviewer should notice observations such as:

  • OrderService appears to own several independent responsibilities.
  • Validation rules are embedded directly inside orchestration logic.
  • Pricing and premium discount calculation are mixed with persistence.
  • Inventory is queried and updated item by item.
  • External payment execution occurs inside the transaction-controlled service method.
  • Email delivery is part of the synchronous order creation flow.
  • Audit persistence is implemented directly in the order orchestration method.
  • The service has many unrelated dependencies.
  • Testing a single order scenario requires mocking most of the application's infrastructure.
  • Adding another payment provider or discount type would probably make this method significantly larger.
  • Transaction failure after successful external payment needs explicit consideration.
  • Notification failure should not normally invalidate an otherwise successful order.

The reviewer should recommend extracting responsibilities based on behavior and ownership rather than simply saying that the method is too long.

9. Reviewer Comment Example

OrderService is currently handling validation, pricing, inventory, payment, persistence, notification, and auditing. Could we separate these responsibilities and keep this service focused on orchestrating the order workflow?

Another useful review comment:

The payment gateway call is executed inside the transactional workflow. Please review the transaction boundary because a remote call may remain pending while the database transaction is open, and payment success cannot automatically be rolled back with the database transaction.

Another example:

Product lookup and update are performed once per order item. Consider moving inventory reservation behind a dedicated component so we can optimize database access and test inventory behavior independently.

10. Improved Code

A more maintainable design separates the responsibilities.

Order Validator

JAVA
@Component
public class OrderValidator {
    public void validate(CreateOrderRequest request) {
        if (request == null) {
            throw new IllegalArgumentException("Order request cannot be null");
        }
        if (request.customerId() == null) {
            throw new IllegalArgumentException("Customer id is required");
        }
        if (request.items() == null || request.items().isEmpty()) {
            throw new IllegalArgumentException("Order must contain items");
        }
        if (request.items().stream().anyMatch(item -> item.quantity() <= 0)) {
            throw new IllegalArgumentException("Item quantity must be greater than zero");
        }
    }
}

Pricing Service

JAVA
@Service
public class PricingService {
    public BigDecimal calculateTotal(
            Customer customer,
            List<PricedOrderItem> items) {
        BigDecimal total = items.stream()
                .map(PricedOrderItem::lineTotal)
                .reduce(BigDecimal.ZERO, BigDecimal::add);
        if (customer.isPremium()) {
            return total.multiply(new BigDecimal("0.90"));
        }
        return total;
    }
}

Inventory Service

JAVA
@Service
public class InventoryService {
    private final ProductRepository productRepository;
    public InventoryService(ProductRepository productRepository) {
        this.productRepository = productRepository;
    }
    public List<PricedOrderItem> reserve(
            List<OrderItemRequest> requestedItems) {
        List<Long> productIds = requestedItems.stream()
                .map(OrderItemRequest::productId)
                .distinct()
                .toList();
        Map<Long, Product> products = productRepository.findAllById(productIds)
                .stream()
                .collect(Collectors.toMap(Product::getId, Function.identity()));
        List<PricedOrderItem> result = new ArrayList<>();
        for (OrderItemRequest requestedItem : requestedItems) {
            Product product = products.get(requestedItem.productId());
            if (product == null) {
                throw new ProductNotFoundException(requestedItem.productId());
            }
            if (product.getAvailableQuantity() < requestedItem.quantity()) {
                throw new InsufficientInventoryException(product.getId());
            }
            product.setAvailableQuantity(
                    product.getAvailableQuantity() - requestedItem.quantity());
            BigDecimal lineTotal = product.getPrice()
                    .multiply(BigDecimal.valueOf(requestedItem.quantity()));
            result.add(new PricedOrderItem(
                    product.getId(),
                    requestedItem.quantity(),
                    product.getPrice(),
                    lineTotal));
        }
        productRepository.saveAll(products.values());
        return result;
    }
}

Payment Service

JAVA
@Service
public class PaymentService {
    private final PaymentGatewayClient paymentGatewayClient;
    public PaymentService(PaymentGatewayClient paymentGatewayClient) {
        this.paymentGatewayClient = paymentGatewayClient;
    }
    public PaymentResult charge(
            Long customerId,
            BigDecimal amount,
            String paymentToken) {
        PaymentRequest request =
                new PaymentRequest(customerId, amount, paymentToken);
        PaymentResponse response = paymentGatewayClient.charge(request);
        if (!response.successful()) {
            throw new PaymentFailedException(response.message());
        }
        return new PaymentResult(response.reference());
    }
}

Notification Listener

JAVA
@Component
public class OrderNotificationListener {
    private final EmailClient emailClient;
    public OrderNotificationListener(EmailClient emailClient) {
        this.emailClient = emailClient;
    }
    @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
    public void handle(OrderCreatedEvent event) {
        emailClient.sendOrderConfirmation(
                event.customerEmail(),
                event.orderId(),
                event.totalAmount());
    }
}

Focused Order Processing Service

JAVA
@Service
public class OrderProcessingService {
    private final OrderValidator orderValidator;
    private final CustomerRepository customerRepository;
    private final InventoryService inventoryService;
    private final PricingService pricingService;
    private final PaymentService paymentService;
    private final OrderRepository orderRepository;
    private final ApplicationEventPublisher eventPublisher;
    public OrderProcessingService(
            OrderValidator orderValidator,
            CustomerRepository customerRepository,
            InventoryService inventoryService,
            PricingService pricingService,
            PaymentService paymentService,
            OrderRepository orderRepository,
            ApplicationEventPublisher eventPublisher) {
        this.orderValidator = orderValidator;
        this.customerRepository = customerRepository;
        this.inventoryService = inventoryService;
        this.pricingService = pricingService;
        this.paymentService = paymentService;
        this.orderRepository = orderRepository;
        this.eventPublisher = eventPublisher;
    }
    @Transactional
    public Order createOrder(CreateOrderRequest request) {
        orderValidator.validate(request);
        Customer customer = customerRepository.findById(request.customerId())
                .orElseThrow(
                        () -> new CustomerNotFoundException(request.customerId()));
        List<PricedOrderItem> items =
                inventoryService.reserve(request.items());
        BigDecimal total =
                pricingService.calculateTotal(customer, items);
        PaymentResult payment =
                paymentService.charge(
                        customer.getId(),
                        total,
                        request.paymentToken());
        Order order = new Order();
        order.setCustomerId(customer.getId());
        order.setTotalAmount(total);
        order.setPaymentReference(payment.reference());
        order.setStatus(OrderStatus.CONFIRMED);
        Order savedOrder = orderRepository.save(order);
        eventPublisher.publishEvent(
                new OrderCreatedEvent(
                        savedOrder.getId(),
                        customer.getEmail(),
                        total));
        return savedOrder;
    }
}

The exact transaction and payment architecture may need additional treatment in a production payment system, but responsibility boundaries are now substantially clearer.

11. Improved Code Explanation

Validation Has a Clear Owner

OrderValidator owns request-level validation.

The orchestration service no longer needs to know every validation rule.

New rules such as maximum order quantity can be added without increasing the complexity of the main workflow.

Pricing Has a Clear Owner

PricingService owns pricing calculations.

Discount changes no longer require modifications to inventory or payment code.

Inventory Logic Is Encapsulated

InventoryService owns:

  • Product loading
  • Stock validation
  • Stock modification
  • Inventory persistence

It can later implement:

  • Optimistic locking
  • Pessimistic locking
  • Bulk updates
  • Reservation expiration

without changing order orchestration.

Database Access Is Improved

Instead of retrieving every product individually, products are loaded using:

JAVA
findAllById(productIds)

The implementation can therefore reduce database round trips.

Payment Integration Is Encapsulated

PaymentService hides payment-gateway-specific behavior from order processing.

If the application changes payment provider, the impact is localized.

Notification Is Decoupled

The order service publishes an OrderCreatedEvent.

The notification listener reacts after transaction commit.

This helps prevent email logic from controlling the order transaction.

Orchestration Remains Visible

OrderProcessingService still clearly shows the business workflow.

The service has not been reduced to meaningless delegation. It provides value by coordinating the use case.

12. Bad Code vs Improved Code

AreaBad CodeImproved Code
ResponsibilityOne class owns almost everythingResponsibilities have clear owners
ReadabilityLarge workflow mixed with implementation detailsMain business flow is visible
MaintainabilityMany unrelated changes affect one classChanges are more localized
TestabilityMany dependencies must be mockedComponents can be tested independently
Database AccessProduct queries occur repeatedlyBulk product retrieval is possible
ExtensibilityNew rules increase service complexityRules can evolve within focused components
PR ReviewReviewer must understand several domains at onceChanges are easier to isolate
Team DevelopmentHigh chance of merge conflictsDevelopers can work on separate components
ReliabilityFailure boundaries are unclearResponsibilities and failure handling are clearer

13. Real Project Scenario

Consider a retail company with an order-management microservice.

Initially the application supports:

  • Standard customers
  • Credit-card payment
  • One warehouse
  • Email confirmation

The OrderService may initially remain manageable.

After two years, requirements expand.

The system now supports:

  • Premium customer discounts
  • Coupon codes
  • Corporate pricing
  • Multiple warehouses
  • Partial inventory reservations
  • Credit cards
  • UPI
  • Wallet payments
  • Retryable payment failures
  • Email notifications
  • SMS notifications
  • Push notifications
  • Fraud checks
  • Audit requirements

If all these features continue to be implemented inside the original OrderService, the service can become thousands of lines long.

Every feature team begins modifying the same class.

A payment-related Pull Request might accidentally affect:

  • Inventory rollback
  • Discount calculation
  • Notification behavior
  • Transaction handling

Separating these responsibilities before the service becomes unmanageable significantly reduces long-term maintenance cost.

14. Production Impact

A God Class does not normally cause a production outage simply because it is large.

The risk comes from excessive coupling and unclear responsibility boundaries.

Real production consequences may include:

Higher Regression Risk

A change intended for one workflow may affect unrelated behavior.

Difficult Incident Diagnosis

Developers may need to inspect a large amount of code to determine whether a failure originated in:

  • Validation
  • Database logic
  • External integration
  • Business calculations
  • Notifications

Longer Deployment Cycles

Teams may become reluctant to modify a critical God Class because many workflows depend on it.

Merge Conflicts

Multiple feature teams modifying the same class can create frequent conflicts.

Transaction Problems

When database operations and network integrations are mixed together, transaction boundaries become difficult to reason about.

Performance Problems

A God Class may hide inefficient operations such as:

  • Database calls inside loops
  • Repeated API calls
  • Repeated object transformation
  • Large in-memory processing

The God Class itself is not a performance problem, but its complexity makes performance problems easier to introduce and harder to detect.

15. Common Developer Mistakes

Using One Service for the Entire Feature

A developer creates:

JAVA
CustomerService

and gradually adds:

  • Registration
  • Authentication
  • Address management
  • Loyalty points
  • Notifications
  • Reporting
  • Audit operations

because all operations involve customers.

Domain relation alone does not mean all responsibilities belong in one class.

Extracting Only Private Methods

Developers sometimes turn a 500-line public method into twenty private methods and assume the God Class problem has been solved.

The code may become easier to read, but responsibility concentration remains.

Creating Generic Utility Classes

Another common mistake is moving unrelated logic into:

JAVA
OrderUtils
CommonUtils
ApplicationHelper

This relocates the problem rather than solving it.

Splitting Classes Only by Line Count

A 300-line cohesive class may be healthier than five 60-line classes with unclear boundaries.

Over-Extracting

Creating a class for every five lines of code creates unnecessary indirection.

Refactoring should follow responsibilities, not arbitrary size rules.

Circular Service Dependencies

Poor extraction may produce:

JAVA
OrderService -> PaymentService
PaymentService -> OrderService

This usually indicates unclear ownership.

Ignoring Transaction Boundaries

Moving methods to another Spring bean may change transactional behavior because Spring transactions commonly depend on proxy interception.

Keeping Shared Mutable State

Extracted singleton Spring services should normally remain stateless.

Shared mutable fields can introduce concurrency problems.

16. Edge Cases

Null or Invalid Requests

Validation must remain explicit after refactoring.

Moving responsibilities should not accidentally remove validation behavior.

Empty Order

The extracted validator should reject orders without items.

Duplicate Product IDs

If the same product appears more than once in an order, inventory logic must define whether quantities are aggregated or processed separately.

Concurrent Inventory Updates

Two customers may order the final available item simultaneously.

Class decomposition alone does not solve this problem.

The inventory component may require:

  • Optimistic locking
  • Pessimistic locking
  • Atomic database updates
  • Reservation-based design

External Payment Failure

The workflow must clearly define what happens when payment fails after inventory reservation.

Database Failure After Payment

This is especially important.

An external payment cannot be rolled back automatically through a local database transaction.

Production payment workflows may require:

  • Idempotency
  • Compensation
  • Payment authorization before capture
  • Saga-style orchestration
  • Reliable messaging

Notification Failure

An email failure should generally not cause the completed order transaction to disappear.

Large Orders

Loading and updating hundreds or thousands of items may require optimized bulk operations.

17. Performance Considerations

Avoiding a God Class is primarily a design and maintainability concern rather than a direct performance optimization.

However, decomposition can expose performance boundaries more clearly.

Database Calls

In the bad implementation, product data is loaded inside a loop.

For n order items, this may result in approximately n product queries and multiple update calls.

Bulk operations can significantly reduce database round trips.

External API Calls

Payment APIs should be clearly isolated because they introduce network latency.

Reviewers can more easily identify:

  • Timeouts
  • Retry policies
  • Circuit breakers
  • Idempotency requirements

when the integration is encapsulated.

Transaction Duration

Long database transactions should not remain open unnecessarily while waiting for remote systems.

Memory Usage

Splitting a class into focused services usually has negligible memory impact.

Spring singleton bean objects themselves are normally insignificant compared with database, network, cache, and collection-processing costs.

Do Not Optimize Class Count

Avoiding God Classes should not become an exercise in minimizing object creation.

Architectural clarity is usually more important than the tiny cost of a few additional singleton service instances.

18. Security Considerations

A God Class is not automatically a security vulnerability.

However, mixed responsibilities can make security checks easier to forget or bypass.

Authorization

If authorization logic is scattered throughout a large service, newly added operations may accidentally bypass required permission checks.

Sensitive Data

Payment tokens, customer information, or authentication data should not be logged casually.

A large service handling many concerns increases the chance of sensitive values leaking into debug logs.

Input Validation

Validation responsibilities should remain explicit and consistently enforced.

External Integration Credentials

Payment or API credentials should remain in secure configuration and dedicated integration components rather than becoming mixed into business services.

Error Exposure

External provider errors may contain sensitive or internal information.

The application should not expose raw provider responses directly to API consumers.

Auditability

Dedicated auditing infrastructure can make security-sensitive operations easier to track consistently.

19. Testing Considerations

Refactoring a God Class should improve testing.

OrderValidator Tests

Positive cases:

  • Valid customer ID
  • Valid item list
  • Positive quantities

Negative cases:

  • Null request
  • Missing customer ID
  • Empty item list
  • Zero quantity
  • Negative quantity

PricingService Tests

Test:

  • Standard customer pricing
  • Premium customer discount
  • Multiple items
  • Decimal values
  • Zero-value scenarios if allowed

InventoryService Tests

Test:

  • Product exists
  • Product does not exist
  • Enough inventory
  • Insufficient inventory
  • Duplicate product requests
  • Concurrent update behavior where applicable

PaymentService Tests

Test:

  • Successful payment
  • Declined payment
  • Timeout
  • Gateway exception
  • Invalid response

OrderProcessingService Tests

The orchestration test should verify the workflow rather than re-testing every implementation detail.

For example:

JAVA
@ExtendWith(MockitoExtension.class)
class OrderProcessingServiceTest {
    @Mock
    private OrderValidator orderValidator;
    @Mock
    private CustomerRepository customerRepository;
    @Mock
    private InventoryService inventoryService;
    @Mock
    private PricingService pricingService;
    @Mock
    private PaymentService paymentService;
    @Mock
    private OrderRepository orderRepository;
    @Mock
    private ApplicationEventPublisher eventPublisher;
    @InjectMocks
    private OrderProcessingService orderProcessingService;
    @Test
    void shouldCreateOrderSuccessfully() {
        CreateOrderRequest request = TestData.validOrderRequest();
        Customer customer = TestData.standardCustomer();
        List<PricedOrderItem> items = TestData.pricedItems();
        BigDecimal total = new BigDecimal("2500.00");
        PaymentResult payment = new PaymentResult("PAY-101");
        Order savedOrder = TestData.savedOrder();
        when(customerRepository.findById(request.customerId()))
                .thenReturn(Optional.of(customer));
        when(inventoryService.reserve(request.items()))
                .thenReturn(items);
        when(pricingService.calculateTotal(customer, items))
                .thenReturn(total);
        when(paymentService.charge(
                customer.getId(),
                total,
                request.paymentToken()))
                .thenReturn(payment);
        when(orderRepository.save(any(Order.class)))
                .thenReturn(savedOrder);
        Order result = orderProcessingService.createOrder(request);
        assertEquals(savedOrder.getId(), result.getId());
        verify(orderValidator).validate(request);
        verify(inventoryService).reserve(request.items());
        verify(paymentService).charge(
                customer.getId(),
                total,
                request.paymentToken());
        verify(eventPublisher).publishEvent(any(OrderCreatedEvent.class));
    }
}

Integration Tests

Integration tests should verify important boundaries such as:

  • Database transaction behavior
  • Repository operations
  • Event publication
  • Payment adapter behavior using a stub or test server
  • Notification behavior after successful commit

20. Refactoring Guidelines

Refactoring a God Class should be incremental.

Step 1: Protect Existing Behavior

Before major refactoring, establish tests around critical existing workflows.

Do not start by moving hundreds of lines without behavioral protection.

Step 2: Identify Responsibilities

Group methods and code sections into meaningful concerns.

For example:

  • Validation
  • Pricing
  • Inventory
  • Payment
  • Notification
  • Persistence

Step 3: Identify Stable Boundaries

Determine which responsibilities can change independently.

Payment integration is usually an obvious candidate because it interacts with an external system.

Step 4: Extract One Responsibility

Move one cohesive responsibility into its own component.

Avoid extracting everything in a single Pull Request unless the codebase and test coverage make that safe.

Step 5: Delegate From the Existing Service

Keep the existing public API initially if other components depend on it.

This reduces migration risk.

Step 6: Verify Transaction Behavior

When moving methods between Spring components, confirm:

  • Which method owns @Transactional
  • Whether propagation behavior changed
  • Whether self-invocation previously affected transactions
  • Whether external calls remain inside database transactions

Step 7: Run Existing Tests

Verify that behavior remains unchanged.

Step 8: Add Focused Tests

After extraction, test the new component independently.

Step 9: Remove Dead Logic

Once migration is complete, remove duplicated or obsolete methods.

Step 10: Review Dependency Direction

Ensure extracted classes do not create circular dependencies.

21. Best Practices

Keep Orchestration Separate From Implementation Details

A use-case service should make the workflow understandable.

Example:

JAVA
validate
reserve
calculate
pay
persist
publish

The orchestration should not contain every algorithm and infrastructure detail.

Design Around Business Capabilities

Prefer:

JAVA
PricingService
InventoryService
PaymentService

over vague classes such as:

JAVA
OrderHelper
CommonService
UtilityManager

Keep Integration Logic Behind Dedicated Interfaces

For example:

JAVA
public interface PaymentGateway {
    PaymentResult charge(PaymentCommand command);
}

The business layer should not depend heavily on provider-specific HTTP details.

Keep Spring Services Stateless

Avoid mutable per-request data inside singleton service fields.

Keep Public APIs Small

A service exposing twenty unrelated public methods may indicate poor cohesion.

Review Constructor Dependencies

A rapidly growing constructor can be a useful design warning.

It is not an automatic violation, but it should trigger review.

Extract Behavior, Not Just Code

A good extracted class owns a meaningful responsibility and can be understood independently.

22. Practices to Avoid

One Service Per Entity With Unlimited Responsibilities

Avoid treating an entity name as permission to place every related operation into one service.

Excessive Private Method Extraction

Private methods improve readability but do not automatically improve responsibility separation.

Generic Manager Classes

Examples:

JAVA
OrderManager
ApplicationManager
ProcessManager

These names often hide unclear responsibility boundaries.

Generic Helper Classes

Avoid dumping domain logic into:

JAVA
CommonHelper
Utils
SharedUtils

Static Business Logic Everywhere

Static utility methods make dependency injection, testing, and substitution harder when used for substantial business behavior.

Circular Dependencies

Do not solve decomposition by creating components that depend on each other in both directions.

Excessive Interface Creation

Not every class requires an interface.

Introduce interfaces when they provide useful abstraction boundaries, multiple implementations, integration boundaries, or testability benefits.

Micro-Class Explosion

Do not create a class for every few lines simply to claim compliance with SRP.

Good design optimizes cohesion and changeability, not number of classes.

23. Code Review Checklist

  • Does this class have one clear primary responsibility?
  • Can the responsibility of the class be explained in one concise sentence?
  • Does the class contain business logic belonging to unrelated domains?
  • Does the class mix validation, persistence, external integration, and notification logic?
  • Does the class have an unusually large number of dependencies?
  • Are some dependencies used only by a small subset of methods?
  • Would a change to one business rule require touching unrelated code?
  • Are there cohesive groups of methods that should belong to another component?
  • Is the class acting as both orchestrator and detailed implementation?
  • Are database operations hidden inside loops?
  • Are remote API calls mixed with database transactions?
  • Is notification behavior tightly coupled to the main business transaction?
  • Would focused components be easier to unit test?
  • Are extracted components named according to business responsibilities?
  • Would extraction create circular dependencies?
  • Are transaction boundaries still correct after refactoring?
  • Is the proposed decomposition improving cohesion rather than simply reducing line count?
  • Can multiple developers modify separate responsibilities without repeatedly changing the same file?
  • Are critical business rules located in clear, discoverable components?
  • Is the design becoming unnecessarily fragmented or over-engineered?

24. Common Pull Request Review Comments

  1. > This service currently owns validation, persistence, pricing, and notification logic. Could we extract the independent responsibilities and leave this class focused on orchestration?
  1. > The discount calculation appears to be a separate business responsibility. Moving it behind a pricing component would make the rule easier to test and change independently.
  1. > We are calling the repository once for every item. Could the inventory component load these products in a batch instead?
  1. > This external API call currently executes inside the transactional method. Please verify whether we need the database transaction to remain open during the remote request.
  1. > Notification failure should probably not roll back the completed business transaction. Consider publishing an event after the successful commit.
  1. > This class has dependencies for payment, email, inventory, reporting, and auditing. That suggests it may be coordinating too many unrelated concerns.
  1. > Extracting private methods improves readability, but the class would still own all responsibilities. I suggest moving the payment behavior into a dedicated component.
  1. > Please avoid moving this logic to CommonUtils. The code represents domain behavior and deserves a domain-specific owner.
  1. > Before extracting this code, please preserve the existing transaction semantics and add tests for the current failure cases.
  1. > The new component should remain focused on inventory behavior. Adding payment rollback logic there would create another mixed-responsibility service.

25. Code Review Exercise

Review the following service as if it appeared in a Pull Request.

Identify:

  • Responsibilities owned by the class
  • Code smells
  • Maintainability problems
  • Database problems
  • Failure-handling risks
  • Testing problems
  • Potential refactoring opportunities

@Service public class EmployeeService { private final EmployeeRepository employeeRepository; private final DepartmentRepository departmentRepository; private final PayrollClient payrollClient; private final EmailClient emailClient; private final AuditRepository auditRepository; public EmployeeService( EmployeeRepository employeeRepository, DepartmentRepository departmentRepository, PayrollClient payrollClient, EmailClient emailClient, AuditRepository auditRepository) { this.employeeRepository = employeeRepository; this.departmentRepository = departmentRepository; this.payrollClient = payrollClient; this.emailClient = emailClient; this.auditRepository = auditRepository; } @Transactional public Employee onboardEmployee(EmployeeRequest request) { if (request.name() == null || request.name().isBlank()) { throw new IllegalArgumentException("Name is required"); } if (request.email() == null || !request.email().contains("@")) { throw new IllegalArgumentException("Invalid email"); } Department department = departmentRepository.findById(request.departmentId()) .orElseThrow( () -> new IllegalArgumentException( "Department not found")); if (employeeRepository.existsByEmail(request.email())) { throw new IllegalArgumentException( "Employee email already exists"); } Employee employee = new Employee(); employee.setName(request.name()); employee.setEmail(request.email()); employee.setDepartmentId(department.getId()); employee.setStatus(EmployeeStatus.ACTIVE); Employee savedEmployee = employeeRepository.save(employee); PayrollResponse payrollResponse = payrollClient.createEmployee( savedEmployee.getId(), request.salary()); if (!payrollResponse.success()) { throw new IllegalStateException( "Payroll registration failed"); } emailClient.send( savedEmployee.getEmail(), "Welcome to the company"); AuditLog audit = new AuditLog(); audit.setAction("EMPLOYEE_CREATED"); audit.setEntityId(savedEmployee.getId()); auditRepository.save(audit); return savedEmployee; } }

Review this code before reading the solution.

26. Exercise Solution

The class owns several responsibilities:

  • Input validation
  • Department lookup
  • Duplicate employee validation
  • Employee persistence
  • Payroll integration
  • Welcome email delivery
  • Audit creation

Problem 1: Validation Is Embedded in Orchestration

The service manually validates names and email addresses.

If onboarding validation expands, the method will continue growing.

A dedicated validator can provide a clear owner.

Problem 2: External Payroll Call Runs Inside the Transaction

payrollClient.createEmployee() is a remote operation.

If payroll succeeds but a later database operation fails, the external payroll system cannot automatically participate in the database rollback.

The workflow therefore needs explicit failure semantics.

Problem 3: Email Delivery Is Synchronous

A welcome email failure can potentially interfere with employee onboarding.

Email notification should usually happen after successful transaction completion.

Problem 4: Auditing Is Implemented Directly

Audit creation is another responsibility inside the orchestration service.

If multiple employee operations require auditing, a dedicated audit mechanism is preferable.

Problem 5: Service Is Becoming Difficult to Test

The onboarding test must coordinate multiple repositories and external systems.

As onboarding grows, the test setup will become increasingly complex.

Improved Validator

JAVA
@Component
public class EmployeeOnboardingValidator {
    private final EmployeeRepository employeeRepository;
    public EmployeeOnboardingValidator(
            EmployeeRepository employeeRepository) {
        this.employeeRepository = employeeRepository;
    }
    public void validate(EmployeeRequest request) {
        if (request == null) {
            throw new IllegalArgumentException("Request is required");
        }
        if (request.name() == null || request.name().isBlank()) {
            throw new IllegalArgumentException("Name is required");
        }
        if (request.email() == null || request.email().isBlank()) {
            throw new IllegalArgumentException("Email is required");
        }
        if (employeeRepository.existsByEmail(request.email())) {
            throw new DuplicateEmployeeEmailException(request.email());
        }
    }
}

For robust email validation, the application should use its established validation mechanism rather than relying on a basic string check.

Payroll Service

JAVA
@Service
public class PayrollService {
    private final PayrollClient payrollClient;
    public PayrollService(PayrollClient payrollClient) {
        this.payrollClient = payrollClient;
    }
    public void register(Employee employee, BigDecimal salary) {
        PayrollResponse response =
                payrollClient.createEmployee(
                        employee.getId(),
                        salary);
        if (!response.success()) {
            throw new PayrollRegistrationException(employee.getId());
        }
    }
}

Focused Onboarding Service

JAVA
@Service
public class EmployeeOnboardingService {
    private final EmployeeOnboardingValidator validator;
    private final EmployeeRepository employeeRepository;
    private final DepartmentRepository departmentRepository;
    private final PayrollService payrollService;
    private final ApplicationEventPublisher eventPublisher;
    public EmployeeOnboardingService(
            EmployeeOnboardingValidator validator,
            EmployeeRepository employeeRepository,
            DepartmentRepository departmentRepository,
            PayrollService payrollService,
            ApplicationEventPublisher eventPublisher) {
        this.validator = validator;
        this.employeeRepository = employeeRepository;
        this.departmentRepository = departmentRepository;
        this.payrollService = payrollService;
        this.eventPublisher = eventPublisher;
    }
    @Transactional
    public Employee onboard(EmployeeRequest request) {
        validator.validate(request);
        Department department =
                departmentRepository.findById(request.departmentId())
                        .orElseThrow(
                                () -> new DepartmentNotFoundException(
                                        request.departmentId()));
        Employee employee = new Employee();
        employee.setName(request.name());
        employee.setEmail(request.email());
        employee.setDepartmentId(department.getId());
        employee.setStatus(EmployeeStatus.ACTIVE);
        Employee savedEmployee = employeeRepository.save(employee);
        payrollService.register(savedEmployee, request.salary());
        eventPublisher.publishEvent(
                new EmployeeOnboardedEvent(
                        savedEmployee.getId(),
                        savedEmployee.getEmail()));
        return savedEmployee;
    }
}

Notification Listener

JAVA
@Component
public class EmployeeNotificationListener {
    private final EmailClient emailClient;
    public EmployeeNotificationListener(EmailClient emailClient) {
        this.emailClient = emailClient;
    }
    @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
    public void handle(EmployeeOnboardedEvent event) {
        emailClient.send(
                event.email(),
                "Welcome to the company");
    }
}

Why These Changes Are Useful

  • Validation is independently testable.
  • Payroll integration has a dedicated owner.
  • Notification behavior is separated from core onboarding.
  • Main onboarding workflow is easier to understand.
  • Future payroll-provider changes are localized.
  • The service no longer contains every implementation detail.

However, the payroll/database consistency problem still requires an explicit business strategy.

For high-reliability systems, teams should consider patterns such as:

  • Idempotent remote operations
  • Pending employee states
  • Retry workflows
  • Compensation
  • Reliable messaging
  • Saga orchestration

Class extraction alone does not solve distributed transaction consistency.

27. Interview Perspective

God Classes frequently appear in senior Java, Spring Boot, system design, and code-review interviews.

The interviewer may not directly ask:

"What is a God Class?"

Instead, you may receive a scenario.

For example:

We have a 2,000-line Spring Boot service with 15 injected dependencies that validates requests, accesses multiple repositories, calls external APIs, sends emails, and generates reports. How would you improve it?

A strong answer should discuss:

  • Identifying responsibilities
  • Measuring cohesion
  • Understanding reasons to change
  • Protecting behavior with tests
  • Extracting components incrementally
  • Maintaining transaction boundaries
  • Avoiding circular dependencies
  • Separating infrastructure from business logic
  • Avoiding over-engineering
  • Keeping orchestration understandable

Senior-level discussions may also involve:

  • Distributed transactions
  • Event-driven notification
  • Payment compensation
  • Retry behavior
  • Idempotency
  • Domain boundaries
  • Hexagonal architecture
  • Dependency direction

The interviewer is usually interested in your ability to improve production code safely rather than your ability to quote the Single Responsibility Principle.

28. Interview Questions and Answers

Basic Question

Question: What is a God Class in Java?

Answer:

A God Class is a class that owns too many responsibilities and knows about too many parts of the application.

For example, a service that handles validation, database access, payment integration, email notification, report generation, and auditing is likely to have poor cohesion.

The problem is not simply class length. The real issue is excessive responsibility concentration and coupling.

Intermediate Question

Question: How do you identify a God Class during code review?

Answer:

I look for indicators such as:

  • Many unrelated dependencies
  • Many different reasons for the class to change
  • Methods operating on unrelated concerns
  • Business logic mixed with infrastructure
  • Very complex test setup
  • Large conditional workflows
  • Frequent changes by different feature teams
  • Poor cohesion between methods

I would not classify a class as a God Class based only on line count.

Advanced Question

Question: How would you refactor a God Class safely in a production Spring Boot application?

Answer:

I would first protect existing behavior with tests.

Then I would identify cohesive responsibilities such as validation, pricing, inventory, payment, or notification.

I would extract one responsibility at a time and initially delegate from the existing service to reduce migration risk.

I would verify transaction boundaries carefully because moving methods between Spring beans can change proxy-based transactional behavior.

I would also check:

  • Failure handling
  • Dependency direction
  • Circular dependencies
  • Integration behavior
  • Performance
  • Existing API contracts

I would avoid a large rewrite unless there were strong reasons and sufficient test coverage.

Scenario-Based Question

Question: An OrderService has 18 dependencies. Is that enough to prove it is a God Class?

Answer:

No.

A high dependency count is a warning signal, not proof.

I would inspect whether those dependencies support one cohesive orchestration responsibility or several unrelated responsibilities.

For example, a workflow coordinator may legitimately require several collaborators.

However, if only specific groups of methods use specific dependencies and the class changes for unrelated business reasons, decomposition is probably appropriate.

Code-Review Question

Question: What review comment would you leave on a 1,500-line service containing validation, payment, email, and database code?

Answer:

I would avoid writing only:

"This class is too large."

Instead, I would identify the actual design concern.

For example:

This service currently owns validation, payment integration, persistence, and notification behavior. These responsibilities change independently. Could we extract the payment and notification concerns and keep this service focused on coordinating the order use case?

This gives the developer a concrete technical reason and an actionable direction.

Real-Project Question

Question: Why can splitting a God Class into multiple Spring services still produce a bad design?

Answer:

Because class extraction alone does not guarantee good responsibility boundaries.

Poor refactoring can create:

  • Circular dependencies
  • Excessive delegation
  • Generic helper classes
  • Tiny meaningless services
  • Distributed business logic
  • Unclear transaction ownership
  • Difficult navigation

The goal is high cohesion and clear ownership, not simply more classes.

29. Quick Rule to Remember

If a class changes for several unrelated business or technical reasons, review whether those responsibilities need separate owners.

30. Final Takeaway

Avoiding God Classes is not about enforcing a maximum number of methods or lines.

The real objective is to keep Java code understandable, maintainable, testable, and safe to change.

What the Developer Should Remember

A service should have a clear responsibility.

When validation, persistence, pricing, external APIs, notification, auditing, and other unrelated concerns accumulate in one class, identify meaningful boundaries and extract behavior where it provides real value.

What the Reviewer Should Check

During Pull Request review, examine:

  • Responsibility boundaries
  • Cohesion
  • Dependency count
  • Reasons to change
  • Transaction boundaries
  • Database access patterns
  • External integrations
  • Failure handling
  • Testability
  • Team-maintenance impact

Do not reject code merely because a class is large.

Explain which responsibilities are mixed and why that creates a real maintenance or production risk.

What Should Be Avoided in Production Code

Avoid turning central service classes into dumping grounds for every new requirement.

At the same time, avoid over-engineering the solution into dozens of meaningless classes.

A production-quality design keeps responsibilities focused, dependencies intentional, transaction boundaries clear, and business workflows easy to understand.