JavaClean Architecture
June 30, 2026

Refactoring a Java Shipment Processor Without Breaking Pricing and Notification Behaviour

A shipment processor often begins as one straightforward method. It validates a request, calculates a delivery fee, creates a shipment, saves it, and sends a confirmation. As requirements accumulate, that method starts handling service levels, insurance, remote-area fees, customer preferences, tracking numbers, and notification rules.

The obvious response is often to rewrite the method in one large change. That creates unnecessary risk. Pricing and notification behaviour may be correct even when the code is difficult to understand. Replacing everything at once makes it harder to prove that the new implementation behaves exactly like the old one.

A safer approach is incremental refactoring. Each step improves structure while preserving externally visible behaviour. The workflow should accept the same valid requests, reject the same invalid requests, calculate the same fees, save the same shipment information, and trigger the same intended notifications.

The Starting Point

Assume a Java application contains this shipment workflow:

public class ShipmentProcessor {

    public String createShipment(
            String customerId,
            String serviceCode,
            String destinationCountry,
            double packageWeight,
            boolean insured,
            String emailAddress) {

        if (customerId == null || customerId.isBlank()) {
            throw new IllegalArgumentException(
                    "Customer id is required");
        } else {
            if (packageWeight <= 0) {
                throw new IllegalArgumentException(
                        "Package weight must be positive");
            } else {
                double price = 0;

                if ("STD".equals(serviceCode)) {
                    price = 8.00 + packageWeight * 1.20;
                } else if ("EXP".equals(serviceCode)) {
                    price = 18.00 + packageWeight * 2.10;
                } else if ("SAM".equals(serviceCode)) {
                    price = 30.00 + packageWeight * 3.00;
                }

                if ("IS".equals(destinationCountry)
                        || "NO".equals(destinationCountry)) {
                    price = price + 6.00;
                }

                if (insured) {
                    price = price + 4.50;
                }

                ShipmentEntity entity = new ShipmentEntity();
                entity.setCustomerId(customerId);
                entity.setServiceCode(serviceCode);
                entity.setDestinationCountry(
                        destinationCountry);
                entity.setPackageWeight(packageWeight);
                entity.setInsured(insured);
                entity.setPrice(price);

                ShipmentRepository repository =
                        new ShipmentRepository();

                String trackingNumber =
                        repository.save(entity);

                if (emailAddress != null) {
                    new EmailService().send(
                            emailAddress,
                            "Shipment " + trackingNumber
                                    + " created. Price: "
                                    + price);
                }

                return trackingNumber;
            }
        }
    }
}

The method combines several responsibilities:

  • input validation
  • delivery service selection
  • base price calculation
  • remote-area pricing
  • insurance pricing
  • entity construction
  • persistence
  • confirmation formatting
  • email delivery

It also relies on string codes, mutable setters, nested conditionals, concrete dependencies, and a nullable email address. These are not independent problems. They reinforce one another and make the method difficult to change safely.

Define the Behaviour That Must Stay Stable

Refactoring changes code structure, not business behaviour. Before moving anything, identify the observable contract.

For this example, the contract includes:

  1. A blank customer identifier is rejected.
  2. A non-positive package weight is rejected.
  3. Each service code produces its existing base calculation.
  4. Selected destination countries receive the existing surcharge.
  5. Insurance adds the existing amount.
  6. A shipment is saved with the calculated price.
  7. A confirmation is sent only when an email destination exists.
  8. The method returns the tracking number produced by persistence.

These expectations should be protected by the existing test suite. When tests are missing, add focused checks around the current public behaviour before restructuring the internals.

The objective is not to prove that every existing business rule is ideal. The objective is to preserve the rules while making them easier to see and change.

Step 1: Replace Nesting with Guard Clauses

The validation logic is buried inside nested else blocks. This forces the reader to track indentation before reaching the main workflow.

Exceptional conditions can terminate the method immediately:

public String createShipment(
        String customerId,
        String serviceCode,
        String destinationCountry,
        double packageWeight,
        boolean insured,
        String emailAddress) {

    if (customerId == null || customerId.isBlank()) {
        throw new IllegalArgumentException(
                "Customer id is required");
    }

    if (packageWeight <= 0) {
        throw new IllegalArgumentException(
                "Package weight must be positive");
    }

    // Main shipment workflow continues here.
}

These early checks are guard clauses. They remove invalid cases before the normal path begins.

Guard clauses are especially useful when a method has one expected flow and several exceptional exits. They make the successful path more linear and reduce unnecessary indentation.

Step 2: Extract Variables That Explain Conditions

Some expressions are technically understandable but do not reveal their business meaning.

This condition contains two destination codes:

if ("IS".equals(destinationCountry)
        || "NO".equals(destinationCountry)) {
    price = price + 6.00;
}

Extracting a meaningful variable clarifies why the condition exists:

boolean requiresRemoteAreaSurcharge =
        "IS".equals(destinationCountry)
                || "NO".equals(destinationCountry);

if (requiresRemoteAreaSurcharge) {
    price = price + 6.00;
}

The new variable is useful because it names the decision, not because it saves characters.

Do not extract every expression mechanically. A temporary variable that is used once and merely repeats an obvious operation can add noise. For example:

double result = basePrice + insuranceFee;
return result;

The variable contributes little and can be inlined:

return basePrice + insuranceFee;

Use extraction when a name explains intent. Use inlining when an intermediate name adds no meaning.

Step 3: Extract Methods Around Business Steps

The pricing block performs several distinct operations. Extracting methods creates a readable high-level workflow:

private double calculatePrice(
        String serviceCode,
        String destinationCountry,
        double packageWeight,
        boolean insured) {

    double basePrice =
            calculateBasePrice(serviceCode, packageWeight);

    double destinationFee =
            calculateDestinationFee(destinationCountry);

    double insuranceFee =
            calculateInsuranceFee(insured);

    return basePrice + destinationFee + insuranceFee;
}

The individual methods can remain simple:

private double calculateDestinationFee(
        String destinationCountry) {

    boolean requiresRemoteAreaSurcharge =
            "IS".equals(destinationCountry)
                    || "NO".equals(destinationCountry);

    return requiresRemoteAreaSurcharge ? 6.00 : 0.00;
}

private double calculateInsuranceFee(boolean insured) {
    return insured ? 4.50 : 0.00;
}

This is Extract Method refactoring. It is useful when a block performs a recognizable task and can be named clearly.

A warning sign appears when the extracted method name contains words such as and or or, for example:

validateRequestAndCalculatePriceAndCreateEntity

That name reveals that the extracted block still contains several responsibilities. Reduce the scope until each method has one understandable purpose.

Step 4: Replace Primitive Service Codes with an Enum

The service level is represented by a String. Any caller can pass a typo, an unsupported code, or even a destination code in the wrong parameter position.

An Enum defines the valid values as a type:

public enum DeliveryService {
    STANDARD,
    EXPRESS,
    SAME_DAY
}

The method signature now communicates its allowed input:

public String createShipment(
        String customerId,
        DeliveryService deliveryService,
        String destinationCountry,
        double packageWeight,
        boolean insured,
        String emailAddress) {
    // ...
}

This prevents invalid service strings from moving deep into the workflow. It also stops unrelated string values from being passed accidentally where a delivery service is required.

Enums are appropriate when the concept is a known set of named alternatives. A dedicated class is better when the concept requires richer validation, calculations, or state.

Step 5: Replace Primitive Values with Domain Objects

A package weight is more than an arbitrary double. A delivery price is more than a number. Both have rules and meaning.

Introduce a small value object for weight:

public final class PackageWeight {

    private final double kilograms;

    public PackageWeight(double kilograms) {
        if (kilograms <= 0) {
            throw new IllegalArgumentException(
                    "Weight must be positive");
        }

        this.kilograms = kilograms;
    }

    public double kilograms() {
        return kilograms;
    }
}

Represent money as a cohesive type:

import java.math.BigDecimal;
import java.util.Objects;

public final class Money {

    private final BigDecimal amount;
    private final String currency;

    public Money(BigDecimal amount, String currency) {
        this.amount = Objects.requireNonNull(amount);
        this.currency = Objects.requireNonNull(currency);
    }

    public Money add(Money other) {
        if (!currency.equals(other.currency)) {
            throw new IllegalArgumentException(
                    "Currencies must match");
        }

        return new Money(
                amount.add(other.amount),
                currency);
    }

    public BigDecimal amount() {
        return amount;
    }

    public String currency() {
        return currency;
    }
}

The benefit is not merely wrapping primitives. These objects become the single place for validation and domain-specific operations.

The price calculation can now return Money instead of an ambiguous double:

private Money calculatePrice(
        DeliveryService service,
        String destinationCountry,
        PackageWeight weight,
        boolean insured) {

    Money basePrice =
            calculateBasePrice(service, weight);

    Money destinationFee =
            calculateDestinationFee(destinationCountry);

    Money insuranceFee =
            calculateInsuranceFee(insured);

    return basePrice
            .add(destinationFee)
            .add(insuranceFee);
}

Step 6: Replace Repeated Conditions with Polymorphism

A single switch or conditional can be perfectly readable. The design becomes fragile when the same service-type condition appears in pricing, estimated delivery time, tracking messages, and insurance rules.

Move service-specific behaviour behind a common contract:

public interface ShippingPolicy {

    Money calculateBasePrice(PackageWeight weight);

    String deliveryDescription();
}

Create one implementation for each behavior:

import java.math.BigDecimal;

public final class StandardShippingPolicy
        implements ShippingPolicy {

    @Override
    public Money calculateBasePrice(
            PackageWeight weight) {

        BigDecimal price = new BigDecimal("8.00")
                .add(
                        BigDecimal
                                .valueOf(weight.kilograms())
                                .multiply(
                                        new BigDecimal("1.20")));

        return new Money(price, "EUR");
    }

    @Override
    public String deliveryDescription() {
        return "Standard delivery";
    }
}
import java.math.BigDecimal;

public final class ExpressShippingPolicy
        implements ShippingPolicy {

    @Override
    public Money calculateBasePrice(
            PackageWeight weight) {

        BigDecimal price = new BigDecimal("18.00")
                .add(
                        BigDecimal
                                .valueOf(weight.kilograms())
                                .multiply(
                                        new BigDecimal("2.10")));

        return new Money(price, "EUR");
    }

    @Override
    public String deliveryDescription() {
        return "Express delivery";
    }
}

The processor receives the selected policy instead of asking for a type code and branching repeatedly:

public Money calculatePrice(
        ShippingPolicy shippingPolicy,
        Destination destination,
        PackageWeight weight,
        InsuranceOption insurance) {

    return shippingPolicy
            .calculateBasePrice(weight)
            .add(destination.surcharge())
            .add(insurance.fee());
}

Polymorphism is valuable when several operations vary according to the same type. It centralizes each variant's behaviour and removes repeated conditional logic from callers.

Step 7: Move Behaviour to the Object That Uses the Data

Suppose the processor contains this method:

private Money calculateDestinationFee(
        Destination destination) {

    if (destination.isRemoteArea()) {
        return new Money(
                new BigDecimal("6.00"),
                "EUR");
    }

    return new Money(BigDecimal.ZERO, "EUR");
}

The method works almost entirely with Destination. That suggests the behavior belongs on that object:

public final class Destination {

    private final String countryCode;
    private final boolean remoteArea;

    public Destination(
            String countryCode,
            boolean remoteArea) {
        this.countryCode = countryCode;
        this.remoteArea = remoteArea;
    }

    public Money surcharge() {
        BigDecimal amount =
                remoteArea
                        ? new BigDecimal("6.00")
                        : BigDecimal.ZERO;

        return new Money(amount, "EUR");
    }

    public String countryCode() {
        return countryCode;
    }
}

This is Move Method refactoring. A practical rule is to place a method near the fields and collaborators it uses most.

The same reasoning applies to fields. If a field is stored in one class but used primarily by another, its current owner may be wrong. Moving the field and its related behaviour together improves cohesion.

Move methods and fields in small steps:

  1. Identify dependencies used by the member.
  2. Check whether inheritance relationships rely on it.
  3. Add the member to the target class.
  4. Redirect callers to the new location.
  5. Run tests.
  6. Remove the old member when it is no longer needed.

A temporary delegating method can reduce migration risk:

public Money destinationSurcharge() {
    return destination.surcharge();
}

Once all clients call Destination appropriately, the forwarding method can be removed if it adds no useful abstraction.

Step 8: Hide Deep Object Navigation

Moving methods can expose long call chains:

String countryCode = shipment
        .destination()
        .address()
        .country()
        .code();

The caller now depends on several internal objects. A structural change in the shipment model may break every client that follows this path.

Expose an operation at the appropriate level:

String countryCode = shipment.destinationCountryCode();

The Shipment class can delegate internally:

public String destinationCountryCode() {
    return destination.countryCode();
}

This hides implementation details and reduces coupling.

Do not create forwarding methods for every property. Keep them when they protect callers from internal structure or express a useful domain operation. Remove them when they only add an unnecessary layer.

Step 9: Replace Null with a Special Case

The original method interprets a missing email address as "do not send a notification":

if (emailAddress != null) {
    emailService.send(emailAddress, message);
}

If this check appears throughout the application, callers repeatedly implement the same null handling.

Represent the two notification cases with the same interface:

public interface ShipmentNotifier {
    void shipmentCreated(
            Shipment shipment,
            TrackingNumber trackingNumber);
}

The real implementation sends email:

public final class EmailShipmentNotifier
        implements ShipmentNotifier {

    private final String emailAddress;
    private final EmailService emailService;

    public EmailShipmentNotifier(
            String emailAddress,
            EmailService emailService) {
        this.emailAddress = emailAddress;
        this.emailService = emailService;
    }

    @Override
    public void shipmentCreated(
            Shipment shipment,
            TrackingNumber trackingNumber) {

        emailService.send(
                emailAddress,
                "Shipment "
                        + trackingNumber.value()
                        + " created");
    }
}

The special case intentionally does nothing:

public final class NoShipmentNotifier
        implements ShipmentNotifier {

    @Override
    public void shipmentCreated(
            Shipment shipment,
            TrackingNumber trackingNumber) {
        // No notification was requested.
    }
}

The processor can now call the notifier without a null check:

shipmentNotifier.shipmentCreated(
        shipment,
        trackingNumber);

A special case object is useful when the absence of a value has a valid, well-defined behaviour. It is not appropriate when missing information is an error that should stop the workflow.

Step 10: Separate Queries from Commands

A method becomes surprising when it appears to retrieve information but also changes system state.

Avoid a method like this:

public Money calculateAndEmailPrice(
        ShipmentDraft draft,
        String emailAddress) {

    Money price = pricingService.calculate(draft);
    emailService.send(
            emailAddress,
            "Calculated price: " + price.amount());

    return price;
}

The name hints at both actions, but the caller still cannot request the calculation without triggering an email.

Separate the query from the command:

public Money calculatePrice(ShipmentDraft draft) {
    return pricingService.calculate(draft);
}

public void sendPriceQuote(
        String emailAddress,
        Money price) {

    emailService.send(
            emailAddress,
            "Calculated price: " + price.amount());
}

The query can be called repeatedly without modifying external state. The command makes its side effect explicit.

This separation improves readability and gives callers control over when an action occurs.

Step 11: Remove Setters and Build Valid Objects

The original workflow creates an empty entity and fills it through setters. During construction, the object passes through several incomplete states.

Prefer a constructor that requires the values needed for a valid shipment:

public final class Shipment {

    private final CustomerId customerId;
    private final Destination destination;
    private final PackageWeight weight;
    private final InsuranceOption insurance;
    private final Money price;

    public Shipment(
            CustomerId customerId,
            Destination destination,
            PackageWeight weight,
            InsuranceOption insurance,
            Money price) {

        this.customerId = customerId;
        this.destination = destination;
        this.weight = weight;
        this.insurance = insurance;
        this.price = price;
    }

    public CustomerId customerId() {
        return customerId;
    }

    public Destination destination() {
        return destination;
    }

    public PackageWeight weight() {
        return weight;
    }

    public InsuranceOption insurance() {
        return insurance;
    }

    public Money price() {
        return price;
    }
}

Removing setters limits mutation and prevents callers from changing identifying or calculated values after creation.

For an object with many optional properties, use a builder to construct it gradually while keeping the completed object immutable.

Step 12: Remove Dead Code After Redirecting Callers

Refactoring often leaves old methods, fields, branches, and constants behind. Keeping them "just in case" makes the code harder to understand because future readers cannot tell whether they are still relevant.

After moving pricing into ShippingPolicy, search for:

  • old service-code switches
  • unused pricing helpers
  • obsolete string constants
  • fields that no longer have readers
  • compatibility methods with no callers
  • branches that can no longer execute

Delete dead code only after confirming that it has no remaining use and running the relevant tests.

Version control already preserves history. The active codebase should describe the current design, not every previous design.

The Refactored Workflow

After the incremental changes, the application service becomes a small coordinator:

public final class ShipmentApplicationService {

    private final ShipmentRepository shipmentRepository;
    private final ShipmentNotifier shipmentNotifier;

    public ShipmentApplicationService(
            ShipmentRepository shipmentRepository,
            ShipmentNotifier shipmentNotifier) {

        this.shipmentRepository = shipmentRepository;
        this.shipmentNotifier = shipmentNotifier;
    }

    public TrackingNumber createShipment(
            ShipmentRequest request,
            ShippingPolicy shippingPolicy) {

        Money price = shippingPolicy
                .calculateBasePrice(request.weight())
                .add(request.destination().surcharge())
                .add(request.insurance().fee());

        Shipment shipment = new Shipment(
                request.customerId(),
                request.destination(),
                request.weight(),
                request.insurance(),
                price);

        TrackingNumber trackingNumber =
                shipmentRepository.save(shipment);

        shipmentNotifier.shipmentCreated(
                shipment,
                trackingNumber);

        return trackingNumber;
    }
}

The method now reads as a sequence of domain operations:

  1. calculate the complete price
  2. construct a valid shipment
  3. save it
  4. notify the customer
  5. return the tracking number

Implementation details are placed in objects that own the relevant data or behaviour.

When Generalization Helps

Inheritance-based generalization is useful when related implementations contain a stable common algorithm with a few varying steps.

Suppose all shipping policies calculate a quote by combining a fixed fee and a weight-based fee. A template method can define that shared structure:

public abstract class AbstractShippingPolicy
        implements ShippingPolicy {

    @Override
    public final Money calculateBasePrice(
            PackageWeight weight) {

        BigDecimal amount = fixedFee()
                .add(
                        perKilogramFee().multiply(
                                BigDecimal.valueOf(
                                        weight.kilograms())));

        return new Money(amount, "EUR");
    }

    protected abstract BigDecimal fixedFee();

    protected abstract BigDecimal perKilogramFee();
}

A concrete policy supplies only the varying values:

public final class SameDayShippingPolicy
        extends AbstractShippingPolicy {

    @Override
    protected BigDecimal fixedFee() {
        return new BigDecimal("30.00");
    }

    @Override
    protected BigDecimal perKilogramFee() {
        return new BigDecimal("3.00");
    }

    @Override
    public String deliveryDescription() {
        return "Same-day delivery";
    }
}

This is the Template Method pattern. It pulls shared algorithmic structure into a superclass while leaving specific steps in subclasses.

Use this technique only when the common structure is real and stable. Do not force unrelated classes into inheritance merely because they contain similar lines.

Related refactorings include:

  • pull up a field when every relevant subclass uses the same state
  • push down a field when only one subclass needs it
  • pull up a method when subclasses share the same behaviour
  • push down a method when a superclass exposes behaviour that does not make sense for all subclasses

A superclass should not force subclasses to inherit fields or methods they cannot use meaningfully.

Common Mistakes

Refactoring and Changing Rules at the Same Time

Changing the design and modifying pricing behaviour in one edit makes failures harder to diagnose. Preserve behaviour first, then implement the new business rule as a separate change.

Extracting Tiny Methods with Weak Names

A short method is not automatically readable. doCalculation() hides intent even if it contains only two lines. Names should explain the business purpose.

Replacing Every Conditional with Polymorphism

A small local condition may be clearer than a hierarchy of classes. Use polymorphism when type-based behaviour is repeated or expected to vary across several operations.

Wrapping Primitives Without Adding Meaning

A value object should improve type safety, validation, or behaviour. A class that merely renames a string without protecting anything may add unnecessary ceremony.

Using a Special Case to Hide Invalid Data

A no-op notifier is valid because choosing not to notify is a legitimate case. An unknown customer identifier may not be valid. Do not replace meaningful failures with silent defaults.

Creating Deep Inheritance Trees

Pull-up and template-method refactorings can reduce duplication, but inheritance also couples subclasses to a parent. Prefer a shallow hierarchy and keep only genuinely shared behaviour in the superclass.

Leaving Temporary Delegation Forever

A forwarding method can help during migration. After callers move, decide whether it still protects a useful boundary. Remove it when it becomes a meaningless middle layer.

Exposing Mutable Collections

Returning an internal collection allows callers to change object state without using controlled operations. Expose an unmodifiable view or explicit add and remove methods when mutation is required.

Refactoring Checklist

Use this checklist for a behaviour-preserving clean-up:

  • The externally visible behaviour is covered by tests.
  • Invalid inputs leave the method through clear guard clauses.
  • Extracted methods have names that explain intent.
  • Temporary variables clarify decisions instead of adding noise.
  • Primitive codes are replaced with Enums or domain objects where useful.
  • Type-specific behaviour has one clear owner.
  • Methods are located near the data and dependencies they use.
  • Deep call chains are hidden behind meaningful operations.
  • Valid absence is represented without repeated null checks.
  • Query methods do not trigger hidden commands.
  • Domain objects are created in valid states.
  • Setters are removed when post-construction mutation is unnecessary.
  • Old branches, constants, methods, and fields are deleted after migration.
  • Shared superclass behaviour applies to every relevant subclass.
  • The test suite passes after every small step.

Conclusion

A large legacy method does not need to be replaced in one risky rewrite. It can be improved through a sequence of small refactorings that preserve behaviour at every stage.

Start by flattening invalid cases with guard clauses. Extract names and methods that reveal intent. Replace primitive codes with stronger types. Move behaviour to the objects that own the required data. Use polymorphism when the same type condition appears repeatedly. Separate information queries from state-changing commands. Remove unnecessary setters, null checks, and dead code.

The final design is easier to understand because each decision has a clear location. It is also easier to extend because adding another shipping policy no longer requires editing every branch in one central processor. Most importantly, the improvement can be made incrementally, with tests confirming that pricing, persistence, and notification behaviour remain intact throughout the refactoring.

Share:

Comments0

Home Profile Menu Sidebar
Top