JavaClean Architecture
June 30, 2026

Refactoring a Fragile Java Payment Integration Without Changing Production Behaviour

A payment integration can become one of the most feared parts of a Java application. The code still works, customers can complete purchases, and the business depends on it every day. At the same time, the implementation may be difficult to understand, tightly coupled to an old provider, poorly named, and almost impossible to change safely.

The obvious reaction is often one of two extremes. One team wants to leave the code untouched because it is too risky. Another team wants to rewrite the whole integration because the existing design looks hopeless. Both reactions can create unnecessary danger.

A safer approach is to refactor the integration in small steps while protecting its current behaviour. The goal is not to add a new feature, improve performance, or change business rules. The goal is to make the code easier to understand and easier to modify while preserving the same observable result.

The Problem

Assume an online store contains a checkout service that talks directly to an old payment provider called OldBankClient.

The current flow looks like this:

CheckoutService
      |
      v
OldBankClient
      |
      v
External payment provider

The integration has several problems:

  • CheckoutService creates the provider client directly.
  • Provider-specific request and response types leak into business code.
  • The payment call is difficult to replace or test independently.
  • Developers are afraid to modify the code because a failure can block purchases.
  • A new provider must eventually replace the old one.

A simplified version may look like this:

public final class CheckoutService {
    private final OldBankClient client = new OldBankClient();

    public PaymentReceipt charge(Order order) {
        OldBankRequest request = new OldBankRequest(
                order.id(),
                order.totalAmount(),
                order.currency()
        );

        OldBankResponse response = client.submit(request);

        return new PaymentReceipt(
                response.transactionReference(),
                response.approved()
        );
    }
}

This code may be functional, but the business service knows too much about the external provider. Replacing the provider requires editing a class that also contains checkout logic.

What Refactoring Means in This Situation

Refactoring means changing the internal structure without changing externally visible behaviour.

For the payment flow, that means the following must remain true:

  • The same valid order still produces an approved payment when the provider approves it.
  • The same rejected payment still produces a rejected result.
  • The checkout service still returns the same type of receipt.
  • Existing callers do not need to change.
  • No new payment rule is introduced during the structural change.

Several other activities may happen near refactoring, but they are not the same thing.

Refactoring is not feature development

Adding partial refunds, new currencies, or delayed capture changes the system's capabilities. Those are feature changes.

A useful working rule is to keep two separate mental modes:

  1. Improve the structure.
  2. Add or change behaviour.

Switch between them when necessary, but do not treat them as the same activity.

Refactoring is not performance optimization

Changing the provider call to reduce latency is optimization. It may be valuable, but it has a different goal and different verification requirements.

Readable code can sometimes become faster, but speed is not the primary measure of a successful refactoring.

Refactoring is not bug fixing

A bug may become easier to find after the code is reorganized, but correcting the bug changes behaviour. A safer sequence is:

  1. Reproduce and fix the defect.
  2. Verify the correction.
  3. Refactor the surrounding code in a separate step.

This separation makes it easier to understand why the behaviour changed and which edit caused it.

Step 1: Define the Behaviour You Must Preserve

Before changing the design, write down the observable contract.

For this payment integration, the contract may include:

Input situation Expected result
Provider approves the request Return an approved receipt with the provider reference
Provider rejects the request Return a rejected receipt
Provider cannot be reached Return or propagate the existing failure type
Unsupported currency is submitted Preserve the current validation result

This table is not a new specification. It is a description of what the system already does.

The purpose is to prevent accidental feature work. If someone suggests changing error handling or introducing a new response while the refactoring is in progress, move that work into a separate task.

Step 2: Perform Impact Analysis

Impact analysis means identifying which parts of the system may be affected by a change.

Start by locating:

  • Every place that creates OldBankClient.
  • Every class that uses OldBankRequest or OldBankResponse.
  • Checkout flows that depend on the current exception behavior.
  • Logs, monitoring, or reports that use the provider transaction reference.
  • Configuration that contains provider credentials or endpoints.

Then examine the dependencies around those areas.

A useful question is:

What is the smallest structural change that will make the next change easier?

In this case, introducing a provider-neutral interface creates a controlled boundary. That one change makes it easier to replace the provider, test business logic, and isolate provider-specific code.

Step 3: Assess Risk Before Editing

Not every piece of code carries the same risk.

A payment path deserves careful treatment because failure may affect revenue, customer trust, and operational support. Risk assessment should consider:

  • How difficult the code is to understand.
  • How many callers depend on it.
  • Whether changes can interrupt purchases.
  • Whether sensitive financial data passes through it.
  • Whether the current behaviour is covered by tests or other repeatable checks.
  • How quickly the team can roll back a bad release.

The risk does not mean the code should never be touched. It means the change should be small, reversible, and observable.

Step 4: Introduce an Abstraction Around the Old Provider

The first structural improvement is to place an interface between the checkout logic and the provider implementation.

public interface PaymentGateway {
    PaymentResult authorize(PaymentCommand command);
}

public record PaymentCommand(
        String orderId,
        long amountInMinorUnits,
        String currency
) {
}

public record PaymentResult(
        String reference,
        boolean approved
) {
}

The interface describes what the checkout process needs, not how a particular provider works.

Next, wrap the old client in an adapter:

public final class OldBankGateway implements PaymentGateway {
    private final OldBankClient client;

    public OldBankGateway(OldBankClient client) {
        this.client = client;
    }

    @Override
    public PaymentResult authorize(PaymentCommand command) {
        OldBankRequest request = new OldBankRequest(
                command.orderId(),
                command.amountInMinorUnits(),
                command.currency()
        );

        OldBankResponse response = client.submit(request);

        return new PaymentResult(
                response.transactionReference(),
                response.approved()
        );
    }
}

The existing behaviour remains inside the adapter. The provider-specific translation is now isolated from the business service.

The checkout service can depend on the abstraction:

public final class CheckoutService {
    private final PaymentGateway paymentGateway;

    public CheckoutService(PaymentGateway paymentGateway) {
        this.paymentGateway = paymentGateway;
    }

    public PaymentReceipt charge(Order order) {
        PaymentCommand command = new PaymentCommand(
                order.id(),
                order.totalAmountInMinorUnits(),
                order.currency()
        );

        PaymentResult result = paymentGateway.authorize(command);

        return new PaymentReceipt(
                result.reference(),
                result.approved()
        );
    }
}

At this stage, production can still use OldBankGateway. The internal design is cleaner, but customers should observe no difference.

Step 5: Use Branch by Abstraction for a Long Migration

Replacing a provider may take several weeks. Keeping all work in a long-lived feature branch increases the chance of merge conflicts and makes it difficult to pause the migration.

Branch by Abstraction avoids that problem by allowing both implementations to exist behind one contract.

CheckoutService
      |
      v
PaymentGateway
   /       \
  v         v
OldBank   NewBank
Gateway   Gateway

The migration can proceed in controlled phases:

  1. Introduce the PaymentGateway abstraction.
  2. Put the existing provider behind OldBankGateway.
  3. Build NewBankGateway against the same contract.
  4. Route selected traffic to the new implementation.
  5. Compare behavior and operational results.
  6. Move all traffic to the new provider.
  7. Remove the old implementation.
  8. Keep or remove the abstraction based on future needs.

A routing component can make the transition explicit:

public final class RoutingPaymentGateway implements PaymentGateway {
    private final PaymentGateway oldGateway;
    private final PaymentGateway newGateway;
    private final PaymentRoutePolicy routePolicy;

    public RoutingPaymentGateway(
            PaymentGateway oldGateway,
            PaymentGateway newGateway,
            PaymentRoutePolicy routePolicy
    ) {
        this.oldGateway = oldGateway;
        this.newGateway = newGateway;
        this.routePolicy = routePolicy;
    }

    @Override
    public PaymentResult authorize(PaymentCommand command) {
        if (routePolicy.useNewProvider(command)) {
            return newGateway.authorize(command);
        }

        return oldGateway.authorize(command);
    }
}

The routing rule may be controlled by a feature toggle. A feature toggle allows a team to enable or disable a path without rewriting the surrounding business code. It also supports gradual rollout instead of an immediate all-or-nothing switch.

Step 6: Verify After Every Small Change

A refactoring should be performed through small, reviewable steps.

After each step:

  1. Run the existing automated checks.
  2. Compare the observable payment result.
  3. Review exception behaviour.
  4. Confirm that logging and transaction references are unchanged.
  5. Commit the structural change separately.
  6. Make the next small edit only after the previous one is stable.

A simple compatibility check can compare two implementations with the same command:

public final class GatewayCompatibilityCheck {
    public static void verify(
            PaymentGateway expectedGateway,
            PaymentGateway candidateGateway,
            PaymentCommand command
    ) {
        PaymentResult expected = expectedGateway.authorize(command);
        PaymentResult candidate = candidateGateway.authorize(command);

        if (expected.approved() != candidate.approved()) {
            throw new IllegalStateException("Approval result changed");
        }
    }
}

A real migration may need more detailed comparisons, but the principle is the same: verify the behaviour that callers care about, not private implementation details.

How to Prioritize Refactoring Work

A large legacy system may contain dozens of areas that deserve attention. Trying to clean everything at once usually fails.

Evaluate each candidate using four questions:

  1. Impact: How much of the system or business depends on it?
  2. Urgency: Is a feature, migration, or recurring failure blocked by it?
  3. Risk: What could go wrong if the code is changed?
  4. Value: Will the improvement make future work meaningfully easier?

The payment integration should rank above an awkward internal administration screen when payments are core to the business and the old provider blocks necessary change.

A practical prioritization table may look like this:

Refactoring candidate Impact Urgency Suggested action
Payment provider boundary High High Do now
Duplicate validation in a rarely used tool Low Low Defer or remove
Confusing checkout naming Medium High Schedule soon
Reporting helper used by one team Low Medium Delegate or plan later

The exact labels matter less than the discipline of comparing work instead of choosing based on personal preference.

Refactor or Rewrite?

A rewrite is sometimes justified, but it should not be the automatic response to unattractive legacy code.

Existing code often contains years of real business behaviour, edge cases, and operational knowledge. A replacement may look cleaner while silently losing important behaviour.

Prefer incremental refactoring when:

  • The current system still works.
  • Behaviour can be described and verified.
  • The code can be divided into smaller areas.
  • A safe abstraction can be introduced.
  • The team must continue releasing during the migration.

Consider rewriting a limited component when:

  • The existing design is nearly impossible to understand.
  • Defects are widespread and tightly connected.
  • The cost of preserving the structure is higher than replacing it.
  • The required behaviour can be defined from current user needs.
  • The replacement can be introduced in controlled increments.

Even when rewriting, avoid blindly reproducing every old implementation detail. Start from current business needs and required outcomes. Some legacy behaviour may no longer be useful.

Common Mistakes

Mixing structural changes with new behaviour

A pull request that changes class boundaries, payment rules, error handling, and provider configuration at once is difficult to review and difficult to debug.

Keep the goals separate whenever possible.

Refactoring code you do not need to touch

The Boy Scout Rule encourages leaving code better than you found it, but it does not mean turning every task into a clean-up project. Improve nearby code when the change is small and relevant. Record larger problems for separate work.

Starting a migration without understanding dependencies

Replacing one client class may affect configuration, monitoring, retries, reporting, and customer support tools. Perform impact analysis first.

Using a long-lived branch for a long migration

A branch that stays isolated for weeks accumulates conflicts and becomes difficult to merge. Keep the main code line working and migrate behind an abstraction.

Assuming a rewrite is automatically safer

New code has fewer years of proven behaviour. A rewrite can remove old problems, but it can also remove hidden requirements.

Refactoring Checklist

Before starting:

  • Define the behaviour that must remain unchanged.
  • Identify callers, dependencies, external systems, and data flows.
  • Estimate impact, urgency, risk, and value.
  • Decide whether the work is refactoring, bug fixing, optimization, or feature development.
  • Choose the smallest change that makes the next step easier.

During the work:

  • Make one structural change at a time.
  • Keep provider-specific details behind a boundary.
  • Run checks before and after each change.
  • Use separate commits for separate purposes.
  • Keep the migration reversible.
  • Use feature toggles when gradual routing reduces risk.

Before completion:

  • Confirm that externally visible behaviour is unchanged.
  • Remove obsolete provider code.
  • Review whether the abstraction should remain.
  • Update the team on the new extension point.
  • Record any deferred clean-up work.

Conclusion

Refactoring a fragile Java integration is not a clean-up exercise performed for appearance. It is a controlled way to reduce the cost and risk of future change.

The safest process begins by defining the behaviour that must remain stable, examining dependencies, assessing risk, and choosing a small structural improvement. For long replacements, Branch by Abstraction allows old and new implementations to coexist while the system remains releasable.

The result is not a different payment feature. It is a codebase that expresses its intent more clearly, isolates external dependencies, and gives the team a safer path for the next change.

Share:

Comments0

Home Profile Menu Sidebar
Top