Featured resource
2026 Tech Forecast
2026 Tech Forecast

1,500+ tech insiders, business leaders, and Pluralsight Authors share their predictions on what’s shifting fastest and how to stay ahead.

Download the forecast
  • Lab
    • Libraries: If you want this lab, consider one of these libraries.
    • Core Tech
Labs

Working with Java Records and Pattern Matching

A warehouse relies on a shipment triage tool. Incoming shipments arrive as a mix of single parcels, gift orders, and bulk orders, each carrying a destination address, a customer, and package details. The team's existing code passes raw lists and casts objects to figure out shipping cost and fragility, and a bug where a shared tags list silently mutates a 'finalized' parcel just caused an incorrect shipment. You will rebuild the core domain with records and pattern matching so shared state can't leak, invalid data is rejected up front, and every shipment type is costed by an exhaustive, self-documenting switch.

Lab platform
Lab Info
Level
Intermediate
Last updated
Nov 11, 2026
Duration
45m

Contact sales

By clicking submit, you agree to our Privacy Policy and Terms of Use, and consent to receive marketing emails from Pluralsight.
Table of Contents
  1. Challenge

    Step 1: Meet the shipment triage problem

    Welcome to the shipment triage tool

    The warehouse runs a shipment triage tool. Every day it ingests a mix of single parcels, gift orders, and bulk orders, each one carrying a destination address, a customer, and package details.

    Part of the domain has already moved to records (Address, Parcel, and the Shipment types), but some older code remains:

    • Customer is still a plain class with a hand-written constructor and getters.
    • Classifier still casts an Object to Parcel after an instanceof check.
    • Parcel's constructor still stores whatever tags list it's handed.

    Last week that last problem caused a real incident. A tags list was handed to a parcel's constructor and stored by reference. The calling code kept changing that same list for the next shipment, which silently changed the tags on a parcel that had already been marked final and shipped.

    That bug isn't a one-off typo. It's what happens by default when a data-holding class stores a reference to a mutable collection instead of copying it. Over the next four steps, you'll rebuild the triage tool's core domain in a style where that kind of bug can't happen quietly. ### Why records and pattern matching

    Java records give you a constructor, accessors, equals, hashCode, and toString from a one-line component list. A data carrier like Address or Parcel needs almost no boilerplate to be correct and comparable by value.

    But "almost no boilerplate" doesn't mean "no work." A record that stores a List still stores whatever reference it was handed, unless you tell it to copy that list first in a compact constructor. You'll write that guard yourself in Step 3, directly against the tags bug.

    Pattern matching is the other half of this lab. Modern Java lets instanceof bind a typed variable and test a condition on it in one expression, and lets a switch deconstruct a record's components right in the case label.

    Combined with a sealed interface, which lists every type allowed to implement it, the compiler can tell you when a switch over shipment types is missing a case. That turns a runtime bug (a shipment type nobody costed) into a compile error. ### The plan

    • Step 2: Put the generated accessors of the Address and Parcel records to work in small reports, then run the app to see an unguarded mutable field leak state.
    • Step 3: Harden Parcel with a validating, defensively copying constructor and a delegating factory, then convert Customer from a class into a record and validate it the same way.
    • Step 4: Add custom methods on top of Parcel's generated accessors, then replace cast-based dispatch with instanceof patterns.
    • Step 5: Use the sealed Shipment hierarchy in Shipment.java to finish exhaustive, deconstructing switch expressions that route every shipment kind.
    • Step 6: Run the finished pipeline, then add a new shipment type and watch the compiler flag every switch that doesn't handle it.

    Open src/main/java/lab/ when you're ready. Step 2 starts with Address.java.

  2. Challenge

    Step 2: Model parcels and addresses as records

    Records as data carriers

    Address.java already declares public record Address(String street, String city, String zip). That one line gives the type private final fields, a canonical constructor, equals(), hashCode(), toString(), and three accessor methods (street(), city(), and zip()), with no getter code written anywhere. Records are built for exactly this: a small bundle of final fields where the generated accessors are the public API.

    Your first task doesn't touch the component list. It adds a method that reads those generated accessors to produce a single formatted line for a shipping report. ### Parcel, and a bug waiting to happen

    Parcel.java similarly declares public record Parcel(String id, double weightKg, List<String> tags, Address destination). Notice that tags is a List<String>, which is mutable by default.

    Right now, Parcel's compact constructor is empty, so construction does nothing but assign each parameter to a field. Whatever List a caller passes in becomes the parcel's tags, by reference. If that caller keeps a handle on the same list and adds to it later, the parcel's tags change too, even though nothing ever called a setter. This is the bug that shipped the wrong order, and you'll fix it in Step 3.

    First, put the record to work the way it's meant to be used: reading its accessors to build a report across many parcels. A report over many parcels belongs on the Parcel type itself rather than scattered through caller code. Returning 0.0 for an empty list means callers don't need a null or size check before calling it. ### Composing accessors across records

    totalWeight() read weightKg() off each Parcel in a list. A single parcel's own report can go further by composing accessors from nested records. A Parcel holds an Address, so a label for one parcel can call destination().fullAddress(). That reuses the method you wrote in Task 2.1 instead of reading street(), city(), and zip() again.

    This is the payoff of generated accessors. Once every record exposes its data through methods instead of raw fields, you can build higher-level code by chaining calls. That code stays correct if a lower-level record changes how it formats itself.

    The label formats the weight with one decimal place, which keeps labels lined up on the printed sheet. ### Next: closing the mutation hole

    You now have Address and Parcel working as intended when callers behave. Now see what happens when one doesn't. In the Terminal, run:

    bash runApp.sh
    

    The == Shared tags list == section at the top builds parcel P3 from a caller's list holding fragile, then the caller adds gift-wrap to that same list. The second line reads:

    P3 tags after caller edit: [fragile, gift-wrap]
    

    The record's tags field is final, but the List it points at isn't, so the record is only shallowly immutable. That's the bug that shipped the wrong order.

    Step 3 closes the hole with a compact constructor that validates input and copies the list before it's stored. Run the app again after Task 3.1 and that line reads [fragile].

  3. Challenge

    Step 3: Lock down state with validating, defensively copying constructors

    Compact constructors

    A record can declare a compact constructor, written as public Parcel { ... } with no parameter list, that runs before the implicit field assignments. Inside it, you can validate parameters and throw if they're invalid. You can also reassign a parameter to a new value, for example tags = List.copyOf(tags);. Whatever the parameter holds at the end of the compact constructor's body is what gets assigned to the field.

    Every other constructor in a record must delegate to this one, so it's the one place every Parcel passes through. That makes it the right place to fix the tags-sharing bug from Step 2: copy the list here, once, and no caller can change a Parcel's tags through it again.

    The compact constructor is also where triage code should fail fast on bad data, such as a blank id or a weight of zero, instead of shipping it. destination needs no copy, because Address is already immutable. ### Delegating instead of duplicating

    Now that Parcel's compact constructor validates and defensively copies, any other way of building a Parcel should reuse it rather than repeat its checks. A second copy of the validation can drift out of sync with the first, and then the two ways of building a Parcel no longer agree on what's valid.

    A static factory method that calls new Parcel(...) gets this for free. It takes fewer arguments for the common case (no tags yet), but every object it returns is still fully validated, because the canonical constructor built it.

    Because Parcel is a record, a parcel that of() returns is equals() to one built with new Parcel(id, weightKg, List.of(), destination) from equal values, even with a different Address instance. The Task 3.2 check compares the two. ### From class to record

    Customer.java is still a hand-written class: two private final fields, a two-argument constructor that assigns them, a one-argument convenience constructor, and two getters. It has no equals(), so two Customer objects with the same name and email aren't equal, and toString() prints something like lab.Customer@1b6d3586.

    A record header, public record Customer(String name, String email), replaces the fields, the two-argument constructor, and the getters, and adds value semantics. The generated equals() and hashCode() compare components, so two customers with the same name and email are equal. The generated toString() lists the components, for example Customer[name=Alice, [email protected]].

    Any extra constructor you keep, like the one-argument Customer(String name), must delegate to the canonical one with this(...). ### A second record, the same discipline

    Customer is now a record, but it has no validation yet, the same starting point Parcel had before Task 3.1. The triage tool attaches a Customer to every shipment (Step 5 uses it), so an invalid customer needs to be rejected the moment it's built, not discovered later when a shipment fails to route.

    The fix is the same tool you just used on Parcel: a compact constructor that validates the incoming values and, where needed, normalizes them. The one-argument constructor delegates to the canonical constructor, so it gets the same checks.

    Each check protects a later step:

    • An order with no customer name can't be routed.
    • A malformed email shouldn't reach the shipment records that hold a Customer.
    • Leading or trailing whitespace in a name shouldn't leak into labels later. ### Next: behavior beyond accessors

    Parcel and Customer now refuse bad data, and a caller can't change a Parcel's tags after it's built. Step 4 builds on that. You'll add business logic, such as fragility checks and pricing, as ordinary methods that call the generated accessors. Then you'll replace the tool's old cast-and-check dispatch with pattern-matching instanceof.

  4. Challenge

    Step 4: Add custom record behavior and replace casts with instanceof patterns

    Custom behavior on a record

    A record isn't limited to the members its component list generates. You can add instance and static methods to its body. The one main restriction is on state: a record can't declare instance fields or instance initializers, so all of its state comes from its components.

    Custom methods read the generated accessors, and each other, to compute something the raw fields don't say directly. For example, on the warehouse floor, light parcels and parcels tagged fragile both need gentler handling, so a method can work out whether a parcel needs careful handling from its weight and tags. ### Layering behavior, not fields

    isFragile() is now a computed property of a Parcel's existing data—it can never disagree with the parcel's real weight or tags because it's derived from them every time it's called, not cached in a field that could go stale. shippingCost() can lean on that same guarantee: instead of re-deriving fragility, it calls isFragile() directly, so the two methods always agree about which parcels are fragile. ### instanceof with pattern variables and conditions

    Open Classifier.java. It still uses a separate cast after each type check:

    if (item instanceof Parcel) {
        Parcel p = (Parcel) item;
        if (p.isFragile()) ...
    }
    

    Modern instanceof folds the cast into the check itself. item instanceof Parcel p is true only when item is a non-null Parcel, and if so, p is already usable in that branch with no separate cast.

    You can go further and add a boolean condition in the same expression:

    item instanceof Parcel p && p.isFragile()
    

    The pattern variable and the check that depends on it now live in one place, in the order they're evaluated. ### Next: whole shipments

    classify() handles one item at a time. Real triage deals in shipments—a single parcel, a gift order, or a bulk order—and each of those needs its own costing rule. Step 5 puts the sealed Shipment type in Shipment.java to work on exactly that distinction and finishes the switch expressions that route every shipment kind.

  5. Challenge

    Step 5: Wire up exhaustive switches over nested shipment records

    Sealed interfaces and exhaustive switch

    Shipment.java declares a sealed interface, and each of the three permitted types is a record that implements it:

    public sealed interface Shipment permits SingleParcelShipment, GiftOrderShipment, BulkOrderShipment {}
    
    record SingleParcelShipment(Parcel parcel, Customer customer) implements Shipment {}
    record GiftOrderShipment(Parcel parcel, Customer customer, String giftMessage) implements Shipment {}
    record BulkOrderShipment(List<Parcel> parcels, Customer customer, boolean expedited) implements Shipment {}
    

    Because the interface is sealed, the compiler knows a Shipment can take exactly three shapes. That lets a switch over a Shipment be exhaustive with no default branch. If you add a fourth shipment type later, every switch missing a case for it fails to compile, instead of quietly sending the new type to a fallback that returns the wrong cost.

    A case label can also deconstruct the record directly, binding its components as local variables scoped to that branch. Each case can reuse shippingCost() from Task 4.2 instead of rewriting the pricing. The Task 5.1 check fails if routeCost() contains a default branch or an instanceof check. ### Guards on deconstructed fields

    Once a case label has deconstructed a shipment into its components, a when clause can test those components before the branch is chosen:

    case SingleParcelShipment(Parcel parcel, Customer customer) when parcel.isFragile() -> ...
    

    Java tests case labels from top to bottom, so a guarded case must come before the unguarded case for the same pattern. If you put the unguarded case first, the code doesn't compile. The compiler reports this case label is dominated by a preceding case label, because the unguarded case would match every value the guarded one could. ### Nested patterns and ignored components

    A record pattern must account for every component of the record it deconstructs, but not every branch uses all of them. The unnamed pattern, _, fills a position without binding it to a name. This case skips the customer, because the branch never reads it:

    case BulkOrderShipment(List<Parcel> parcels, _, boolean expedited) -> ...
    

    A component's position can also hold another record pattern. SingleParcelShipment(_, Customer(String name, _)) matches a single-parcel shipment and deconstructs its Customer in the same case label, so the branch gets name directly instead of binding customer and calling customer.name(). The first _ skips the parcel, and the second skips the customer's email.

    Together, nested patterns and _ keep each branch honest about exactly what it reads. ### What you built

    The triage tool's domain is now a set of records that:

    • Validate themselves on construction.
    • Defensively copy the mutable state that caused the original bug.
    • Expose custom behavior built on generated accessors.
    • Get routed through exhaustive, deconstructing switch expressions instead of a chain of casts.

    Most of the guarantees that used to live only in code review are now enforced in code. A new Shipment subtype fails to compile until every switch handles it, and an invalid Customer throws the moment it's built, well before it reaches the warehouse floor.

  6. Challenge

    Step 6: Watch records and patterns guard the shipment pipeline

    Every task is done and graded. Main runs your records and switches together: one run shows the shared-list bug closed, bad data stopped at the constructor, and every shipment kind routed. Then you'll add a new shipment type and watch the compiler refuse to build until every switch handles it. ### The shared-list bug, closed

    In the Terminal, run:

    bash runApp.sh
    

    The first section is the one you saw at the end of Step 2:

    P3 tags when built:        [fragile]
    P3 tags after caller edit: [fragile]   (the caller added "gift-wrap" to its own list)
    

    Back then, the second line read [fragile, gift-wrap]. The List.copyOf(tags) in your Task 3.1 compact constructor means a parcel no longer shares its list with whoever built it.

    Bad data, stopped at the constructor

    The == Construction checks == section shows each constructor's validation at work. Your error messages may differ from these:

    Parcel id ""                       -> rejected: id must not be blank
    Parcel weight 0.0                  -> rejected: weightKg must be positive
    Customer email "bob-at-example"    -> rejected: email must contain '@'
    Customer name "  Jane Doe  "       -> accepted: Customer[name=Jane Doe, [email protected]]
    

    The first three lines end in rejected: followed by the message you wrote. In the last line, your Task 3.4 constructor trimmed the name, and the toString() the record generated in Task 3.3 printed it. Before the conversion, that line printed lab.Customer@ followed by a hash code. ### Every shipment kind, routed

    The == Routing == section of the same output sends five shipments through your three switches:

    single, 2.5kg    cost=5.75   label=STANDARD-SINGLE       summary=Jane Doe
    single, 0.4kg    cost=3.60   label=FRAGILE-SINGLE        summary=Jane Doe
    gift with note   cost=10.75  label=GIFT: Happy birthday  summary=Jane Doe
    gift, no note    cost=10.75  label=GIFT-NO-NOTE          summary=Jane Doe
    bulk, expedited  cost=11.22  label=BULK-2                summary=2 parcels, expedited=true
    

    The 0.4 kg parcel has no tags, but isFragile() from Task 4.1 flags it as under 0.5 kg. That gives it the 1.0 surcharge in shippingCost() (2.0 + 0.6 + 1.0 = 3.60) and sends it down your when parcel.isFragile() case in routeLabel().

    The blank gift message hits the GIFT-NO-NOTE guard, which has to come before the unguarded gift case for the code to compile.

    The bulk order costs (5.75 + 3.60) Ă— 1.2 = 11.22. Its summary comes from the BulkOrderShipment(List<Parcel> parcels, _, boolean expedited) case you wrote in Task 5.3. ### Add a shipment type the switches don't know

    1. Open Shipment.java. On the line that starts public sealed interface Shipment permits, add , ReturnShipment after BulkOrderShipment, so it reads:

      public sealed interface Shipment permits SingleParcelShipment, GiftOrderShipment, BulkOrderShipment, ReturnShipment {
      
    2. Add this record at the end of the file:

      record ReturnShipment(Parcel parcel, Customer customer) implements Shipment {
      }
      
    3. Run the app again:

      bash runApp.sh
      

      The build stops with COMPILATION ERROR and three errors in ShipmentRouter.java, one each for routeCost(), routeLabel(), and bulkSummary():

      [ERROR] .../ShipmentRouter.java:[20,16] the switch expression does not cover all possible input values
      [ERROR] .../ShipmentRouter.java:[44,16] the switch expression does not cover all possible input values
      [ERROR] .../ShipmentRouter.java:[68,16] the switch expression does not cover all possible input values
      
    4. Remove , ReturnShipment and the ReturnShipment record, then run bash runApp.sh once more to confirm it builds.

    Your switches have no default branch, so the compiler lists every place that needs a decision about returns. With a default, this would have compiled, and returns would have been priced by whatever the fallback happened to do.

    The triage tool's domain is now made of records that reject invalid data and can't be changed through a shared list, routed by switches the compiler keeps complete.

About the author

Jeff Hopper is a polyglot solution developer with over 20 years of experience across several business domains. He has enjoyed many of those years focusing on the .Net stack.

Real skill practice before real-world application

Hands-on Labs are real environments created by industry experts to help you learn. These environments help you gain knowledge and experience, practice without compromising your system, test without risk, destroy without fear, and let you learn from your mistakes. Hands-on Labs: practice your skills before delivering in the real world.

Learn by doing

Engage hands-on with the tools and technologies you’re learning. You pick the skill, we provide the credentials and environment.

Follow your guide

All labs have detailed instructions and objectives, guiding you through the learning process and ensuring you understand every step.

Turn time into mastery

On average, you retain 75% more of your learning if you take time to practice. Hands-on labs set you up for success to make those skills stick.

Get started with Pluralsight