Record when the object is its data (DTOs, value objects). Class when it has mutable state or
identity — two accounts with the same balance are not the same account.
publicabstractclassVehicle{protectedfinalStringplate;protectedVehicle(Stringplate){this.plate=plate;}publicabstractinttopSpeed();// subclasses must implementpublicStringdescribe(){return"vehicle "+plate;}}publicclassCarextendsVehicle{privatefinalinthp;publicCar(Stringplate,inthp){super(plate);this.hp=hp;}@OverridepublicinttopSpeed(){return50+hp;}@OverridepublicStringdescribe(){returnsuper.describe()+" with "+hp+" hp";}}
abstract — cannot be instantiated
final — class cannot be extended, method cannot be overridden
@Override — optional, but lets the compiler check you
Composition over inheritance
“A car is a vehicle” → inheritance. “A car has an engine” → a field. When unsure, prefer
interfaces plus composition.
Sealed types
sealed fixes the set of subtypes, so the compiler knows every case:
publicsealedinterfaceEventpermitsSignUp,Order,SignOut{}publicrecordSignUp(Stringuser)implementsEvent{}publicrecordOrder(Stringuser,longcents)implementsEvent{}publicrecordSignOut(Stringuser)implementsEvent{}Stringlog(Evente){returnswitch(e){caseSignUp(Stringu)->u+" signed up";caseOrder(Stringu,longc)->"%s ordered %d cents".formatted(u,c);caseSignOut(Stringu)->u+" left";};// no default needed}
sealed interface + record + pattern matching is the modern way to say “a value is one of
these things”.