Skip to main content
the readable codebase

The Boolean Parameter That Forks Reality

4 min read Chapter 3 of 27

The Boolean Parameter That Forks Reality

The Smell

The logistics platform’s createShipment method takes five parameters. Three of them are booleans.

public Shipment createShipment(Address origin, Address destination,
    boolean isExpress, boolean requiresSignature, boolean isHazardous)

At every call site, the code reads:

shipmentService.createShipment(origin, dest, true, false, true);

No reader can determine what true, false, true means without navigating to the method signature. This is the first problem. The second is worse: inside the method, these three booleans create eight possible execution paths. The method behaves as eight different methods hidden behind one signature. Every reviewer, every debugger, every future maintainer must mentally trace which combination of booleans produces the behavior they are investigating.

The Cognitive Cost

At the call site, the reader must remember the parameter order. true, false, true could mean express, no signature, hazardous. Or it could mean signature required, not express, hazardous. The reader cannot tell without checking the declaration. That is one chunk of working memory spent on parameter ordering.

Inside the method, each boolean creates a fork. Three booleans create $2^3 = 8$ possible paths. But the booleans interact: hazardous express shipments have different carrier restrictions than hazardous standard shipments. The reader cannot evaluate any single boolean’s effect without considering the others. They must hold all three boolean states simultaneously while tracing each conditional.

The SonarQube cognitive complexity of the implementation is 28. The majority of that complexity comes from the nested if statements that check boolean combinations. Each if adds one to the complexity score. Each nesting level adds one more. Three booleans checked across two nesting levels account for 18 of those 28 points.

The Before

// HARD TO READ: Three boolean parameters create eight execution paths.
// At call sites, the meaning of true/false/true is invisible.

public Shipment createShipment(Address origin, Address destination,
                                boolean isExpress, boolean requiresSignature,
                                boolean isHazardous) {
    ShipmentBuilder builder = new ShipmentBuilder()
        .origin(origin)
        .destination(destination);

    // Reader must track: what does isExpress control?
    if (isExpress) {
        builder.serviceLevel(ServiceLevel.EXPRESS);
        builder.maxTransitDays(2);
        if (isHazardous) {
            // Express hazmat uses dedicated carriers only
            builder.carrierPool(CarrierPool.HAZMAT_EXPRESS);
            builder.requiresCertifiedDriver(true);
        } else {
            builder.carrierPool(CarrierPool.EXPRESS);
        }
    } else {
        builder.serviceLevel(ServiceLevel.STANDARD);
        builder.maxTransitDays(7);
        if (isHazardous) {
            builder.carrierPool(CarrierPool.HAZMAT_STANDARD);
            builder.requiresCertifiedDriver(true);
        } else {
            builder.carrierPool(CarrierPool.STANDARD);
        }
    }

    // Reader must now remember: does signature interact with express?
    if (requiresSignature) {
        builder.deliveryConfirmation(DeliveryConfirmation.SIGNATURE);
        if (isExpress) {
            // Express + signature requires adult signature
            builder.deliveryConfirmation(DeliveryConfirmation.ADULT_SIGNATURE);
        }
    } else {
        builder.deliveryConfirmation(DeliveryConfirmation.NONE);
    }

    // Reader must now remember: does hazardous interact with signature?
    if (isHazardous) {
        builder.packagingType(PackagingType.HAZMAT_CERTIFIED);
        // Hazmat always requires signature regardless of the parameter
        builder.deliveryConfirmation(DeliveryConfirmation.ADULT_SIGNATURE);
    }

    return builder.build();
}

Notice the last block: hazardous shipments override the signature parameter entirely. The requiresSignature parameter is meaningless when isHazardous is true. A reader who spent working memory tracking the signature logic discovers it was irrelevant. That wasted cognitive effort is extraneous load in its purest form.

The Fix

// READABLE: Shipment options are explicit. No boolean parameters.
// The call site documents itself. Each option combination is a named concept.

public sealed interface ShipmentOptions {
    Address origin();
    Address destination();

    record Standard(Address origin, Address destination,
                    DeliveryConfirmation confirmation) implements ShipmentOptions {}

    record Express(Address origin, Address destination)
        implements ShipmentOptions {}  // Express always requires adult signature

    record Hazardous(Address origin, Address destination,
                     HazmatClass hazmatClass) implements ShipmentOptions {}

    record HazardousExpress(Address origin, Address destination,
                            HazmatClass hazmatClass) implements ShipmentOptions {}
}
// READABLE: Each shipment type has its own creation logic.
// No boolean interaction. No invisible overrides.
// A reader working on hazardous shipments reads one case.

public Shipment createShipment(ShipmentOptions options) {
    return switch (options) {
        case ShipmentOptions.Standard s -> ShipmentBuilder.standard()
            .origin(s.origin())
            .destination(s.destination())
            .confirmation(s.confirmation())
            .carrierPool(CarrierPool.STANDARD)
            .maxTransitDays(7)
            .build();

        case ShipmentOptions.Express e -> ShipmentBuilder.express()
            .origin(e.origin())
            .destination(e.destination())
            .confirmation(DeliveryConfirmation.ADULT_SIGNATURE)
            .carrierPool(CarrierPool.EXPRESS)
            .maxTransitDays(2)
            .build();

        case ShipmentOptions.Hazardous h -> ShipmentBuilder.hazardous()
            .origin(h.origin())
            .destination(h.destination())
            .hazmatClass(h.hazmatClass())
            .confirmation(DeliveryConfirmation.ADULT_SIGNATURE)
            .carrierPool(CarrierPool.HAZMAT_STANDARD)
            .certifiedDriver(true)
            .maxTransitDays(7)
            .build();

        case ShipmentOptions.HazardousExpress he -> ShipmentBuilder.hazardous()
            .origin(he.origin())
            .destination(he.destination())
            .hazmatClass(he.hazmatClass())
            .confirmation(DeliveryConfirmation.ADULT_SIGNATURE)
            .carrierPool(CarrierPool.HAZMAT_EXPRESS)
            .certifiedDriver(true)
            .maxTransitDays(2)
            .build();
    };
}

The call site now reads:

shipmentService.createShipment(
    new ShipmentOptions.HazardousExpress(origin, dest, HazmatClass.CLASS_3));

No booleans. No parameter ordering to remember. The type name communicates the intent. The sealed interface guarantees exhaustive handling. If a new shipment type is added, the compiler forces every switch expression to handle it. The invisible override where hazardous shipments silently ignored the signature parameter is gone: hazardous shipments always require adult signature, and that rule is visible in the Hazardous record’s creation logic, not buried in a conditional at the bottom of a method.

The Rule

If a method takes more than one boolean parameter, replace the boolean combination with a sealed interface whose subtypes name each meaningful combination. Boolean parameters hide control flow from the call site and from reviewers. Named types make the branching visible before the method is opened. If a boolean parameter is silently overridden by another parameter in some paths, the two parameters should never have been separate.