Context

Refactoring a legacy system is risky when the existing behavior is valuable but not fully covered by automated tests. A technical migration can look small in code review and still break business behavior because the old implementation contains implicit rules, edge cases, and data assumptions.

This is common when a team modernizes an existing application. The goal may be technical: split a module, replace an internal dependency, change a contract, or move to a new serialization format. The business expectation is different: the user-visible behavior should stay the same.

Golden master testing helps with that kind of migration. It gives the team a temporary safety net before changing the internals.

What Is Golden Master Testing?

Golden master testing is a regression testing technique based on captured behavior.

The idea is simple:

  1. Run the current system through representative scenarios.
  2. Capture what the system does.
  3. Save that captured output as the golden master.
  4. Change the implementation.
  5. Replay the same scenarios.
  6. Compare the new output with the golden master.

The golden master becomes a reference for the migration. It does not mean the old behavior is perfect. It only means the old behavior is the behavior we intentionally preserve while changing the implementation.

Golden master testing workflow from boundary selection to comparison Golden master testing workflow from boundary selection to comparison

For a backend migration, the captured data can be:

  • API responses
  • facade inputs and outputs
  • generated files
  • messages published to a broker
  • database projections after a command
  • exceptions for known error scenarios

The best capture point is usually a stable boundary. A facade, application service, use case, or public API can work well because it represents a business operation and hides internal dependencies.

Methodology

A useful golden master setup needs more than recording random output. The method should be controlled and easy to repeat.

  1. Choose the boundary. Capture high enough to represent business behavior, but not so high that the test becomes unstable. A facade, application service, use case, or public API is usually better than a low-level helper method.
  2. Define replayable scenarios. Cover important business flows, edge cases, and known failure cases. The scenarios do not need to cover everything, but they must be stable and repeatable.
  3. Capture the golden output. Run the current implementation before the migration and store the result separately. Keep the output readable when possible, because reviewers need to understand what changed.
  4. Migrate the implementation. Change the internals while keeping the public contract and captured boundary stable.
  5. Capture the candidate output. Replay exactly the same scenarios after the migration and store the new output separately.
  6. Compare with domain rules. Compare golden and candidate output with rules adapted to the system. A raw text comparison is often too strict because some fields naturally change between executions.

Common fields to ignore include:

  • generated identifiers
  • timestamps
  • correlation IDs
  • environment-specific paths
  • ordering when order is not part of the contract

Fields that represent business behavior should not be ignored.

When To Apply It

Golden master testing is useful when:

  • the current behavior is important but not fully documented
  • the migration should preserve behavior, not redefine it
  • unit test coverage is weak or too isolated
  • the system has many hidden business rules
  • the team can replay stable scenarios
  • a clear boundary exists for capture and comparison

It is especially useful for migrations such as:

  • monolith internals to service boundaries
  • internal models to DTO contracts
  • direct calls to remote APIs
  • legacy DAO code to a repository layer
  • framework upgrades where behavior must stay stable
  • old serialization formats to new serializers

Avoid it when the old behavior is wrong and must change. In that case, write explicit tests for the new expected behavior instead of preserving the old one.

Example With Code

The runnable example for this article is available on GitHub:

github.com/mustapha-zouari/blog-golden-master-testing

The example is a Jakarta EE invoicing application running on WildFly. It protects one behavior: creating an invoice for an existing order. The migration replaces a direct CDI call from invoicing to order management with a REST call.

The repository contains three branches:

BranchPurpose
legacyOriginal monolith. Invoicing calls order management through CDI.
refactoredSuccessful migration. Invoicing calls order management over REST.
refactored-failedSame split as refactored, but with an intentional REST mapping bug.

The same request scenarios are replayed on each branch:

./startup.sh -d legacy
./startup.sh -d refactored
./startup.sh -d refactored-failed
Golden master migration flow from legacy implementation to refactored implementation Golden master migration flow from legacy implementation to refactored implementation

In the legacy branch, the facade calls OrderService directly:

@ApplicationScoped
public class InvoiceFacade {

    @Inject
    OrderService orderService;

    public InvoiceDto createInvoice(final CreateInvoiceCommand command) {
        final var order = orderService.findOrder(command.orderId());
        final var tax = order.total().multiply(command.taxRate()).setScale(2, RoundingMode.HALF_UP);
        final var total = order.total().add(tax).setScale(2, RoundingMode.HALF_UP);

        return new InvoiceDto("INV-" + order.id(), order.id(), order.total(), tax, total);
    }
}

The facade is the golden master boundary. The interceptor records the method call, parameters, returned value, and exception type.

@Interceptor
@CaptureFacadeCall
@Priority(Interceptor.Priority.APPLICATION)
public class FacadeCallInterceptor {

    @AroundInvoke
    public Object capture(final InvocationContext context) throws Exception {
        Object result = null;
        Throwable failure = null;

        try {
            result = context.proceed();
            return result;
        } catch (final Throwable throwable) {
            failure = throwable;
            throw throwable;
        } finally {
            final var trace = new FacadeCallTrace(
                    context.getTarget().getClass().getName(),
                    context.getMethod().getName(),
                    context.getParameters(),
                    result,
                    failure == null ? null : failure.getClass().getName(),
                    Instant.now().toString());

            // The project writes this trace as JSON under a temporary trace directory.
        }
    }
}

After the migration, the facade keeps the same business calculation but obtains the order through a REST client:

@ApplicationScoped
@CaptureFacadeCall
public class InvoiceFacade {

    @Inject
    OrderRestClient orderRestClient;

    public InvoiceDto createInvoice(final CreateInvoiceCommand command) {
        final var order = orderRestClient.findOrder(command.orderId());
        final var tax = order.total().multiply(command.taxRate()).setScale(2, RoundingMode.HALF_UP);
        final var total = order.total().add(tax).setScale(2, RoundingMode.HALF_UP);

        return new InvoiceDto("INV-" + order.id(), order.id(), order.total(), tax, total);
    }
}

The comparison script compares trace ZIP files and generates an HTML report:

python3 scripts/compare_traces.py golden-facade-traces.zip candidate-facade-traces.zip facade-trace-comparison.html

It ignores unstable fields but still reports structural and behavioral differences:

IGNORED_FIELDS = {"id", "createdAt", "updatedAt", "capturedAt", "correlationId"}


def normalize(value):
    if isinstance(value, dict):
        return {
            key: normalize(child)
            for key, child in value.items()
            if key not in IGNORED_FIELDS
        }

    if isinstance(value, list):
        return [normalize(child) for child in value]

    return value

The example repository keeps two comparison results to make the workflow easy to understand.

Passing Comparison

On the refactored branch, the migration is successful. The internal call changed, but the observed facade behavior stayed compatible with the golden master.

ReportStatusCritical differencesWarnings
facade-trace-comparison.htmlPASSED00

This means the replayed scenarios produced candidate traces that match the captured golden traces according to the comparison rules.

Failing Comparison

The refactored-failed branch contains an intentional bug. The order API serializes the order total as amount instead of total:

public record BrokenOrderResponse(String id, String customerName, List<OrderLineDto> lines, BigDecimal amount) {
}

This is a realistic migration bug. The code compiles, and the REST endpoint still returns JSON, but the contract no longer matches what invoicing expects. The invoicing code reads OrderDto.total, so the behavior changes at runtime.

The comparison report exposes that regression:

ReportStatusCritical differencesWarnings
facade-trace-comparison.htmlFAILED40

The failure is useful because it gives reviewers concrete evidence. They do not need to guess whether the refactor is safe; the report shows that the candidate behavior no longer matches the golden master.

Practical Guidance

Keep the setup focused. Golden master testing should help the migration; it should not become a large framework before the migration starts.

Good practices:

  • capture at a stable boundary such as a facade or public API
  • keep traces readable and easy to review
  • clean traces before each replay
  • store golden and candidate traces separately
  • document ignored fields and why they are ignored
  • keep scenario data deterministic
  • include known error cases, not only happy paths
  • review the generated report with the migration pull request
  • remove or disable capture endpoints outside migration and test environments

Common mistakes:

  • comparing raw output without handling timestamps and generated IDs
  • ignoring fields that actually represent business behavior
  • capturing too low in the call stack
  • replaying different scenarios before and after the migration
  • treating the golden master as proof that the old behavior is correct
  • leaving trace endpoints exposed in production

A good golden master report should answer a simple question: did this migration preserve the behavior we decided to preserve?

Conclusion

Golden master testing is a pragmatic safety net for legacy migrations. It works well when the team needs to change technical boundaries while keeping business behavior stable.

The method is simple: choose a boundary, capture the current behavior, migrate the implementation, replay the same scenarios, and compare the result with domain-aware rules.

The example uses Jakarta EE, but the approach is not tied to Jakarta EE. The same method applies whenever a team needs evidence that a refactor preserved behavior: passing traces when behavior is preserved, and a failed report when a contract or implementation change alters the result.