Air Freight Billing System: An Object-Oriented Design Journey

System Overview

This article documents the evolution of an air cargo billing system across two development iterations. The project emphasizes solid object-oriented principles including Single Responsibility, Liskov Substitution, Open/Closed, and Composition Over Inheritance. The core challenge lies not in algorithmic complexity but in building a flexible, maintainable architecture that accommodates future business rule changes.

Initial Implementation (Iteration 8)

Refactored Code Structure

import java.util.*;
import java.util.stream.*;

interface FreightPricingPolicy {
    double computeTariff(double chargeableMass);
}

interface WeightCapacityValidator {
    boolean validateLoad(double presentLoad, double extraMass, double ceilingLoad);
}

class StandardPricingPolicy implements FreightPricingPolicy {
    @Override
    public double computeTariff(double chargeableMass) {
        if (chargeableMass >= 100) return 15.0;
        if (chargeableMass >= 50) return 25.0;
        if (chargeableMass >= 20) return 30.0;
        return 35.0;
    }
}

class BasicCapacityValidator implements WeightCapacityValidator {
    @Override
    public boolean validateLoad(double presentLoad, double extraMass, double ceilingLoad) {
        return presentLoad + extraMass <= ceilingLoad;
    }
}

class Client {
    private final String clientCode;
    private final String fullName;
    private final String contactNumber;
    private final String deliveryAddress;

    public Client(String code, String name, String phone, String address) {
        this.clientCode = code;
        this.fullName = name;
        this.contactNumber = phone;
        this.deliveryAddress = address;
    }

    public String getFullName() { return fullName; }
    public String getContactNumber() { return contactNumber; }
}

class ShipmentItem {
    private final String itemCode;
    private final String description;
    private final double breadth, depth, height;
    private final double actualMass;
    private final double dimensionalWeight;
    private final double chargeableMass;
    private final FreightPricingPolicy pricingPolicy;

    public ShipmentItem(String code, String desc, double b, double d, double h, double mass,
                       FreightPricingPolicy policy) {
        this.itemCode = code;
        this.description = desc;
        this.breadth = b;
        this.depth = d;
        this.height = h;
        this.actualMass = mass;
        this.dimensionalWeight = (b * d * h) / 6000.0;
        this.chargeableMass = Math.max(mass, dimensionalWeight);
        this.pricingPolicy = policy;
    }

    public double getChargeableMass() { return chargeableMass; }
    public double getTariff() { return pricingPolicy.computeTariff(chargeableMass); }
    public double getItemFreight() { return chargeableMass * getTariff(); }
    public String getDescription() { return description; }
}

class AircraftRoute {
    private final String routeNumber;
    private final String originAirport;
    private final String destinationAirport;
    private final String departureDate;
    private final double maximumPayload;
    private double currentPayload;
    private final WeightCapacityValidator loadValidator;

    public AircraftRoute(String number, String origin, String dest, String date,
                        double maxPayload, WeightCapacityValidator validator) {
        this.routeNumber = number;
        this.originAirport = origin;
        this.destinationAirport = dest;
        this.departureDate = date;
        this.maximumPayload = maxPayload;
        this.currentPayload = 0.0;
        this.loadValidator = validator;
    }

    public boolean canAccept(double mass) {
        return loadValidator.validateLoad(currentPayload, mass, maximumPayload);
    }

    public void registerLoad(double mass) { currentPayload += mass; }
    public String getRouteNumber() { return routeNumber; }
}

class TransportOrder {
    private final String orderReference;
    private final String bookingDate;
    private final String shipperName, shipperPhone, shipperAddress;
    private final String consigneeName, consigneePhone, consigneeAddress;
    private final List<ShipmentItem> items;
    private double totalChargeableMass;
    private double aggregateFreight;

    public TransportOrder(String ref, String date, String sName, String sPhone, String sAddr,
                         String cName, String cPhone, String cAddr) {
        this.orderReference = ref;
        this.bookingDate = date;
        this.shipperName = sName;
        this.shipperPhone = sPhone;
        this.shipperAddress = sAddr;
        this.consigneeName = cName;
        this.consigneePhone = cPhone;
        this.consigneeAddress = cAddr;
        this.items = new ArrayList<>();
        this.totalChargeableMass = 0.0;
        this.aggregateFreight = 0.0;
    }

    public void addItem(ShipmentItem item) {
        items.add(item);
        totalChargeableMass += item.getChargeableMass();
        aggregateFreight += item.getItemFreight();
    }

    public String getOrderReference() { return orderReference; }
    public String getBookingDate() { return bookingDate; }
    public String getShipperName() { return shipperName; }
    public String getShipperPhone() { return shipperPhone; }
    public String getShipperAddress() { return shipperAddress; }
    public String getConsigneeName() { return consigneeName; }
    public String getConsigneePhone() { return consigneePhone; }
    public String getConsigneeAddress() { return consigneeAddress; }
    public double getTotalChargeableMass() { return totalChargeableMass; }
    public double getAggregateFreight() { return aggregateFreight; }
    public List<ShipmentItem> getItems() { return items; }
}

public class FreightProcessor {
    public static void main(String[] args) {
        Scanner inputScanner = new Scanner(System.in);
        
        // Parse client details
        String clientCode = inputScanner.nextLine();
        String clientName = inputScanner.nextLine();
        String clientPhone = inputScanner.nextLine();
        String clientAddr = inputScanner.nextLine();
        Client client = new Client(clientCode, clientName, clientPhone, clientAddr);
        
        // Parse shipment items
        int itemCount = Integer.parseInt(inputScanner.nextLine());
        List<ShipmentItem> shipments = new ArrayList<>();
        FreightPricingPolicy pricingPolicy = new StandardPricingPolicy();
        
        for (int i = 0; i < itemCount; i++) {
            String itemCode = inputScanner.nextLine();
            String description = inputScanner.nextLine();
            double breadth = Double.parseDouble(inputScanner.nextLine());
            double depth = Double.parseDouble(inputScanner.nextLine());
            double height = Double.parseDouble(inputScanner.nextLine());
            double mass = Double.parseDouble(inputScanner.nextLine());
            
            ShipmentItem item = new ShipmentItem(itemCode, description, breadth, depth, height, mass, pricingPolicy);
            shipments.add(item);
        }
        
        // Parse flight details
        String routeNumber = inputScanner.nextLine();
        String origin = inputScanner.nextLine();
        String destination = inputScanner.nextLine();
        String flightDate = inputScanner.nextLine();
        double maxPayload = Double.parseDouble(inputScanner.nextLine());
        
        WeightCapacityValidator validator = new BasicCapacityValidator();
        AircraftRoute flight = new AircraftRoute(routeNumber, origin, destination, flightDate, maxPayload, validator);
        
        // Validate total payload capacity
        double totalMass = shipments.stream().mapToDouble(ShipmentItem::getChargeableMass).sum();
        if (!flight.canAccept(totalMass)) {
            System.out.printf("Route %s cannot accommodate this order due to payload limitations.%n", flight.getRouteNumber());
            return;
        }
        
        // Parse order details
        String orderRef = inputScanner.nextLine();
        String bookingDate = inputScanner.nextLine();
        String shipperAddr = inputScanner.nextLine();
        String shipperName = inputScanner.nextLine();
        String shipperPhone = inputScanner.nextLine();
        String consigneeAddr = inputScanner.nextLine();
        String consigneeName = inputScanner.nextLine();
        String consigneePhone = inputScanner.nextLine();
        
        TransportOrder order = new TransportOrder(orderRef, bookingDate, shipperName, shipperPhone, shipperAddr,
                                                 consigneeName, consigneePhone, consigneeAddr);
        
        // Process shipments
        for (ShipmentItem item : shipments) {
            order.addItem(item);
            flight.registerLoad(item.getChargeableMass());
        }
        
        // Generate report
        System.out.printf("Client: %s (%s) Order Summary:%n", client.getFullName(), client.getContactNumber());
        System.out.println("-----------------------------------------");
        System.out.printf("Route: %s%n", routeNumber);
        System.out.printf("Order Reference: %s%n", order.getOrderReference());
        System.out.printf("Booking Date: %s%n", order.getBookingDate());
        System.out.printf("Shipper: %s | %s | %s%n", order.getShipperName(), order.getShipperPhone(), order.getShipperAddress());
        System.out.printf("Consignee: %s | %s | %s%n", order.getConsigneeName(), order.getConsigneePhone(), order.getConsigneeAddress());
        System.out.printf("Total Chargeable Mass: %.1f kg%n", order.getTotalChargeableMass());
        System.out.printf("Total Freight Amount: %.1f%n", order.getAggregateFreight());
        System.out.println("\nShipment Details:");
        System.out.println("-----------------------------------------");
        System.out.println("Line No\tDescription\tChargeable Mass\tRate\tFreight");
        
        int lineNumber = 1;
        for (ShipmentItem item : order.getItems()) {
            System.out.printf("%d\t%s\t%.1f\t%.1f\t%.1f%n",
                lineNumber++,
                item.getDescription(),
                item.getChargeableMass(),
                item.getTariff(),
                item.getItemFreight());
        }
    }
}

Architecture Analysis

The initial design employs strategy patterns for both pricing and capacity validation, enabling flexible business rule modifications. Key metrics from static analysis reveal:

  • Scale: 318 lines, 172 statements
  • Structural Complexity: 5.2% branch statements, maximum nesting depth of 3
  • Documentation: Critically low at 0.9% comment density
  • Object-Oriented Metrics: 9 types with 9.33 methods per class on average

The quality assessment indicates excellent simplicity but severe documentation deficits. The flat control flow and minimal cyclomatic complexity suggest maintainable logic, though industrial standards mandate comprehensive API documentation and inline comments for critical calculations.

Enhanced Implementation (Iteration 9)

Extended Code Structure

import java.util.*;

interface TariffComputationStrategy {
    double determineRate(double chargeableMass, String goodsCategory);
}

interface LoadConstraintChecker {
    boolean verifyCapacity(double loadedAmount, double extraAmount, double limit);
}

class FlexibleTariffStrategy implements TariffComputationStrategy {
    @Override
    public double determineRate(double mass, String category) {
        return switch (category) {
            case "HAZMAT" -> computeHazmatRate(mass);
            case "PRIORITY" -> computePriorityRate(mass);
            default -> computeStandardRate(mass);
        };
    }
    
    private double computeStandardRate(double mass) {
        if (mass >= 100) return 15.0;
        if (mass >= 50) return 25.0;
        if (mass >= 20) return 30.0;
        return 35.0;
    }
    
    private double computeHazmatRate(double mass) {
        if (mass >= 100) return 20.0;
        if (mass >= 50) return 30.0;
        if (mass >= 20) return 50.0;
        return 80.0;
    }
    
    private double computePriorityRate(double mass) {
        if (mass >= 100) return 30.0;
        if (mass >= 50) return 40.0;
        if (mass >= 20) return 50.0;
        return 60.0;
    }
}

class SimpleLoadChecker implements LoadConstraintChecker {
    @Override
    public boolean verifyCapacity(double loaded, double extra, double limit) {
        return loaded + extra <= limit;
    }
}

class Account {
    private final String accountCode;
    private final String legalName;
    private final String mobile;
    private final String premises;
    private final String accountCategory; // "PERSONAL" or "ENTERPRISE"
    private final String settlementMode; // "Digital", "Card", "Cash"
    
    public Account(String category, String code, String name, String phone, String address, String payment) {
        this.accountCategory = category;
        this.accountCode = code;
        this.legalName = name;
        this.mobile = phone;
        this.premises = address;
        this.settlementMode = payment;
    }
    
    public String getLegalName() { return legalName; }
    public String getMobile() { return mobile; }
    public String getSettlementMode() { return settlementMode; }
    public double getRebateRatio() {
        return "ENTERPRISE".equals(accountCategory) ? 0.80 : 0.90;
    }
}

class Consignment {
    private final String consignmentNote;
    private final String commodityName;
    private final double span, depth, elevation;
    private final double physicalMass;
    private final double volumetricMass;
    private final double billableMass;
    private final String cargoClassification;
    private final TariffComputationStrategy tariffEngine;
    
    public Consignment(String note, String name, double s, double d, double e, double mass,
                      String classification, TariffComputationStrategy engine) {
        this.consignmentNote = note;
        this.commodityName = name;
        this.span = s;
        this.depth = d;
        this.elevation = e;
        this.physicalMass = mass;
        this.volumetricMass = (s * d * e) / 6000.0;
        this.billableMass = Math.max(mass, volumetricMass);
        this.cargoClassification = classification;
        this.tariffEngine = engine;
    }
    
    public double getBillableMass() { return billableMass; }
    public double getBaseTariff() { return tariffEngine.determineRate(billableMass, cargoClassification); }
    public double getBaseCharge() { return billableMass * getBaseTariff(); }
    public String getCommodityName() { return commodityName; }
}

class AviationSegment {
    private final String segmentCode;
    private final String departureTerminal;
    private final String arrivalTerminal;
    private final String scheduleDate;
    private final double payloadLimit;
    private double utilizedPayload;
    private final LoadConstraintChecker payloadValidator;
    
    public AviationSegment(String code, String depart, String arrive, String date,
                          double limit, LoadConstraintChecker validator) {
        this.segmentCode = code;
        this.departureTerminal = depart;
        this.arrivalTerminal = arrive;
        this.scheduleDate = date;
        this.payloadLimit = limit;
        this.utilizedPayload = 0.0;
        this.payloadValidator = validator;
    }
    
    public boolean canLoad(double mass) {
        return payloadValidator.verifyCapacity(utilizedPayload, mass, payloadLimit);
    }
    
    public void allocatePayload(double mass) { utilizedPayload += mass; }
    public String getSegmentCode() { return segmentCode; }
}

class ShippingOrder {
    private final String orderNumber;
    private final String orderDate;
    private final Account billTo;
    private final String originatorName, originatorPhone, originatorAddress;
    private final String recipientName, recipientPhone, recipientAddress;
    private final List<Consignment> consignments;
    private double cumulativeBillableMass;
    private double grossCharge;
    private double netCharge;
    
    public ShippingOrder(String number, String date, Account account,
                        String origName, String origPhone, String origAddr,
                        String recvName, String recvPhone, String recvAddr) {
        this.orderNumber = number;
        this.orderDate = date;
        this.billTo = account;
        this.originatorName = origName;
        this.originatorPhone = origPhone;
        this.originatorAddress = origAddr;
        this.recipientName = recvName;
        this.recipientPhone = recvPhone;
        this.recipientAddress = recvAddr;
        this.consignments = new ArrayList<>();
        this.cumulativeBillableMass = 0.0;
        this.grossCharge = 0.0;
        this.netCharge = 0.0;
    }
    
    public void registerConsignment(Consignment cons) {
        consignments.add(cons);
        cumulativeBillableMass += cons.getBillableMass();
        grossCharge += cons.getBaseCharge();
        netCharge = grossCharge * billTo.getRebateRatio();
    }
    
    public String getOrderNumber() { return orderNumber; }
    public String getOrderDate() { return orderDate; }
    public double getCumulativeBillableMass() { return cumulativeBillableMass; }
    public double getNetCharge() { return netCharge; }
    public List<Consignment> getConsignments() { return consignments; }
    public Account getBillTo() { return billTo; }
}

public class CargoBillingApplication {
    public static void main(String[] args) {
        Scanner console = new Scanner(System.in);
        
        // Account setup
        String accountCategory = console.nextLine();
        String accountCode = console.nextLine();
        String accountName = console.nextLine();
        String accountPhone = console.nextLine();
        String accountAddress = console.nextLine();
        
        // Cargo details
        String goodsCategory = console.nextLine();
        int pieceCount = Integer.parseInt(console.nextLine());
        
        TariffComputationStrategy tariffStrategy = new FlexibleTariffStrategy();
        List<Consignment> inventory = new ArrayList<>();
        
        for (int i = 0; i < pieceCount; i++) {
            String note = console.nextLine();
            String description = console.nextLine();
            double width = Double.parseDouble(console.nextLine());
            double length = Double.parseDouble(console.nextLine());
            double height = Double.parseDouble(console.nextLine());
            double mass = Double.parseDouble(console.nextLine());
            
            inventory.add(new Consignment(note, description, width, length, height, mass, goodsCategory, tariffStrategy));
        }
        
        // Flight segment configuration
        String segmentCode = console.nextLine();
        String departure = console.nextLine();
        String arrival = console.nextLine();
        String flightDate = console.nextLine();
        double maxPayload = Double.parseDouble(console.nextLine());
        
        AviationSegment segment = new AviationSegment(segmentCode, departure, arrival, flightDate, maxPayload, new SimpleLoadChecker());
        
        // Payload validation
        double totalMass = inventory.stream().mapToDouble(Consignment::getBillableMass).sum();
        if (!segment.canLoad(totalMass)) {
            System.out.printf("Segment %s payload exceeded. Order rejected.%n", segment.getSegmentCode());
            return;
        }
        
        // Order creation
        String orderNum = console.nextLine();
        String orderDate = console.nextLine();
        String shipperAddr = console.nextLine();
        String shipperName = console.nextLine();
        String shipperPhone = console.nextLine();
        String receiverAddr = console.nextLine();
        String receiverName = console.nextLine();
        String receiverPhone = console.nextLine();
        String paymentMode = console.nextLine();
        
        Account account = new Account(accountCategory, accountCode, accountName, accountPhone, accountAddress, paymentMode);
        ShippingOrder order = new ShippingOrder(orderNum, orderDate, account, shipperName, shipperPhone, shipperAddr,
                                               receiverName, receiverPhone, receiverAddr);
        
        // Order processing
        for (Consignment cons : inventory) {
            order.registerConsignment(cons);
            segment.allocatePayload(cons.getBillableMass());
        }
        
        // Invoice generation
        System.out.printf("Account: %s (%s)%n", account.getLegalName(), account.getMobile());
        System.out.println("========================================");
        System.out.printf("Segment: %s%n", segmentCode);
        System.out.printf("Order #: %s%n", order.getOrderNumber());
        System.out.printf("Date: %s%n", order.getOrderDate());
        System.out.printf("Shipper: %s | %s%n", order.getOriginatorName(), order.getOriginatorPhone());
        System.out.printf("Receiver: %s | %s%n", order.getRecipientName(), order.getRecipientPhone());
        System.out.printf("Total Billable Mass: %.1f kg%n", order.getCumulativeBillableMass());
        System.out.printf("Payment Method: %s%n", translatePaymentMethod(account.getSettlementMode()));
        System.out.printf("Net Amount: %.2f%n", order.getNetCharge());
        System.out.println("\nConsignment Breakdown:");
        System.out.println("========================================");
        System.out.println("Item\tDescription\tMass\tRate\tCharge");
        
        int counter = 1;
        for (Consignment cons : order.getConsignments()) {
            System.out.printf("%d\t%s\t%.1f\t%.1f\t%.2f%n",
                counter++,
                cons.getCommodityName(),
                cons.getBillableMass(),
                cons.getBaseTariff(),
                cons.getBaseCharge());
        }
    }
    
    private static String translatePaymentMethod(String method) {
        return switch (method) {
            case "Digital" -> "Electronic Payment";
            case "Card" -> "Bank Card";
            case "Cash" -> "Cash Payment";
            default -> method;
        };
    }
}

Critical Design Issues Identified

The enhanced version reveals several architectural weaknesses:

  1. Input Vulnerability: No validation for null values, numeric ranges, or format compliance. Non-numeric input causes unhandled exceptions.
  2. Hardcoded Business Rules: The divisor 6000 for volumetric weight lacks configurability. Tariff tables embedded in code require recompilation for rate adjustments.
  3. Incomplete Strategy Pattern: Discount calcualtions remain hardcoded in the Account class rather than being strategy-based.
  4. Tight Coupling: The main application class handles UI, business logic, and data access simultaneously, violating separation of concerns.
  5. Testability Barriers: Concrete strategy implementations make unit testing with mocks difficult. No dependency injection mechanism exists.
  6. Scalability Limitations: Adding new cargo categories requires modifying the core tariff strategy implementation.

Recommended Architectural Improvements

  • Implement a factory pattern for object creation to centralize instantiation logic
  • Introduce comprehensive input validation with custom exception types
  • Externalize tarifff configurations to property files or a database
  • Convert stateless strategy classes to singletons to reduce memory footprint
  • Apply dependency injection to decouple components and enable testing
  • Establish clear layer boundaries: presentation, service, and data access tiers

Development Challenges and Resolutions

Specification Ambiguities

The provided test cases lacked clarity on fundamental calculations. Determining whether rates applied to actual weight versus chargeable weight required empirical testing. Similarly, the aggregation logic for multiple items—whether summing actual or chargeable weights—necessitated iterative experimentation to match validation results.

Output Formatting Pitfalls

Initial attempts using literal spaces for column alignment failed validation. Switching to tab character (\t) delimiters resolved the issue, highlighting the importance of understanding precise formattting requirements.

Common Implementation Traps

Type Safety: Direct parsing without validation risks NumberFormatException. Implement defensive parsing with fallback mechanisms.

Unit Consistency: Dimensional calculations assume centimeters for volume weight. Explicit unit labeling in prompts prevents user errors.

Multi-Item Processing: Each piece requires independent calculation. Batch operations must correctly accumulate individual results while applying appropriate category-specific tariffs.

Discount Application: Customer segmentation (individual vs. corporate) demands clear rebate application logic, applied consistently across all chargeable items.

Capacity Management: Flight payload limits must be validated against total billable weight before order confirmation, with clear rejection messaging.

Key Takeaways

This exercise reinforced pragmatic application of SOLID principles. The strategy pattern proved invaluable for isolating variable business rules. However, true architectural maturity requires moving beyond patterns to address cross-cutting concerns: validation, logging, and configuration management. The experience demonstrated that clean code is not merely about low complexity metrics, but about creating systems that accommodate change without cascading modifications.

Tags: java Strategy Pattern Air Cargo Management Code Metrics Object-Oriented Design

Posted on Tue, 30 Jun 2026 16:30:14 +0000 by BLottman