- Lab
-
Libraries: If you want this lab, consider one of these libraries.
- Core Tech
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 Info
Table of Contents
-
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 theShipmenttypes), but some older code remains:Customeris still a plain class with a hand-written constructor and getters.Classifierstill casts anObjecttoParcelafter aninstanceofcheck.Parcel's constructor still stores whatevertagslist it's handed.
Last week that last problem caused a real incident. A
tagslist 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, andtoStringfrom a one-line component list. A data carrier likeAddressorParcelneeds almost no boilerplate to be correct and comparable by value.But "almost no boilerplate" doesn't mean "no work." A record that stores a
Liststill 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 thetagsbug.Pattern matching is the other half of this lab. Modern Java lets
instanceofbind a typed variable and test a condition on it in one expression, and lets aswitchdeconstruct a record's components right in thecaselabel.Combined with a sealed interface, which lists every type allowed to implement it, the compiler can tell you when a
switchover 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
AddressandParcelrecords to work in small reports, then run the app to see an unguarded mutable field leak state. - Step 3: Harden
Parcelwith a validating, defensively copying constructor and a delegating factory, then convertCustomerfrom 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 withinstanceofpatterns. - Step 5: Use the sealed
Shipmenthierarchy inShipment.javato finish exhaustive, deconstructingswitchexpressions 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 withAddress.java. -
Challenge
Step 2: Model parcels and addresses as records
Records as data carriers
Address.javaalready declarespublic 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(), andzip()), 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 happenParcel.javasimilarly declarespublic record Parcel(String id, double weightKg, List<String> tags, Address destination). Notice thattagsis aList<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. WhateverLista caller passes in becomes the parcel'stags, 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
Parceltype itself rather than scattered through caller code. Returning0.0for an empty list means callers don't need anullor size check before calling it. ### Composing accessors across recordstotalWeight()readweightKg()off eachParcelin a list. A single parcel's own report can go further by composing accessors from nested records. AParcelholds anAddress, so a label for one parcel can calldestination().fullAddress(). That reuses the method you wrote in Task 2.1 instead of readingstreet(),city(), andzip()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
AddressandParcelworking as intended when callers behave. Now see what happens when one doesn't. In the Terminal, run:bash runApp.shThe
== Shared tags list ==section at the top builds parcelP3from a caller's list holdingfragile, then the caller addsgift-wrapto that same list. The second line reads:P3 tags after caller edit: [fragile, gift-wrap]The record's
tagsfield isfinal, but theListit 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]. -
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 exampletags = 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
Parcelpasses through. That makes it the right place to fix thetags-sharing bug from Step 2: copy the list here, once, and no caller can change aParcel's tags through it again.The compact constructor is also where triage code should fail fast on bad data, such as a blank
idor a weight of zero, instead of shipping it.destinationneeds no copy, becauseAddressis already immutable. ### Delegating instead of duplicatingNow that
Parcel's compact constructor validates and defensively copies, any other way of building aParcelshould 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 aParcelno 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
Parcelis a record, a parcel thatof()returns isequals()to one built withnew Parcel(id, weightKg, List.of(), destination)from equal values, even with a differentAddressinstance. The Task 3.2 check compares the two. ### From class to recordCustomer.javais still a hand-written class: twoprivate finalfields, a two-argument constructor that assigns them, a one-argument convenience constructor, and two getters. It has noequals(), so twoCustomerobjects with the same name and email aren't equal, andtoString()prints something likelab.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 generatedequals()andhashCode()compare components, so two customers with the same name and email are equal. The generatedtoString()lists the components, for exampleCustomer[name=Alice, [email protected]].Any extra constructor you keep, like the one-argument
Customer(String name), must delegate to the canonical one withthis(...). ### A second record, the same disciplineCustomeris now a record, but it has no validation yet, the same starting pointParcelhad before Task 3.1. The triage tool attaches aCustomerto 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
ParcelandCustomernow refuse bad data, and a caller can't change aParcel'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-matchinginstanceof. -
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
fragileboth need gentler handling, so a method can work out whether a parcel needs careful handling from its weight and tags. ### Layering behavior, not fieldsisFragile()is now a computed property of aParcel'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 callsisFragile()directly, so the two methods always agree about which parcels are fragile. ###instanceofwith pattern variables and conditionsOpen
Classifier.java. It still uses a separate cast after each type check:if (item instanceof Parcel) { Parcel p = (Parcel) item; if (p.isFragile()) ... }Modern
instanceoffolds the cast into the check itself.item instanceof Parcel pistrueonly whenitemis a non-nullParcel, and if so,pis 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 sealedShipmenttype inShipment.javato work on exactly that distinction and finishes theswitchexpressions that route every shipment kind. -
Challenge
Step 5: Wire up exhaustive switches over nested shipment records
Sealed interfaces and exhaustive switch
Shipment.javadeclares 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
Shipmentcan take exactly three shapes. That lets aswitchover aShipmentbe exhaustive with nodefaultbranch. 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
caselabel can also deconstruct the record directly, binding its components as local variables scoped to that branch. Each case can reuseshippingCost()from Task 4.2 instead of rewriting the pricing. The Task 5.1 check fails ifrouteCost()contains adefaultbranch or aninstanceofcheck. ### Guards on deconstructed fieldsOnce a
caselabel has deconstructed a shipment into its components, awhenclause can test those components before the branch is chosen:case SingleParcelShipment(Parcel parcel, Customer customer) when parcel.isFragile() -> ...Java tests
caselabels 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 reportsthis 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 componentsA 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 itsCustomerin the same case label, so the branch getsnamedirectly instead of bindingcustomerand callingcustomer.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 builtThe 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
switchexpressions 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
Shipmentsubtype fails to compile until every switch handles it, and an invalidCustomerthrows the moment it's built, well before it reaches the warehouse floor. -
Challenge
Step 6: Watch records and patterns guard the shipment pipeline
Every task is done and graded.
Mainruns 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, closedIn the Terminal, run:
bash runApp.shThe 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]. TheList.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 thetoString()the record generated in Task 3.3 printed it. Before the conversion, that line printedlab.Customer@followed by a hash code. ### Every shipment kind, routedThe
== 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=trueThe 0.4 kg parcel has no tags, but
isFragile()from Task 4.1 flags it as under 0.5 kg. That gives it the1.0surcharge inshippingCost()(2.0 + 0.6 + 1.0 =3.60) and sends it down yourwhen parcel.isFragile()case inrouteLabel().The blank gift message hits the
GIFT-NO-NOTEguard, 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 theBulkOrderShipment(List<Parcel> parcels, _, boolean expedited)case you wrote in Task 5.3. ### Add a shipment type the switches don't know-
Open
Shipment.java. On the line that startspublic sealed interface Shipment permits, add, ReturnShipmentafterBulkOrderShipment, so it reads:public sealed interface Shipment permits SingleParcelShipment, GiftOrderShipment, BulkOrderShipment, ReturnShipment { -
Add this record at the end of the file:
record ReturnShipment(Parcel parcel, Customer customer) implements Shipment { } -
Run the app again:
bash runApp.shThe build stops with
COMPILATION ERRORand three errors inShipmentRouter.java, one each forrouteCost(),routeLabel(), andbulkSummary():[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 -
Remove
, ReturnShipmentand theReturnShipmentrecord, then runbash runApp.shonce more to confirm it builds.
Your switches have no
defaultbranch, so the compiler lists every place that needs a decision about returns. With adefault, 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
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.