Primitive Obsession — Practice Tasks¶
Eight exercises in increasing difficulty. Each task gives the starting code, an objective, constraints, and acceptance criteria. The final task ends with a worked solution sketch so you can compare your refactor to a canonical one.
Work each task in three passes: (1) read the snippet and name every primitive that should become a wrapper, (2) sketch the new signature on paper before touching the keyboard, (3) write the wrappers with validation, push them through the API, and add a small test that would have caught the original problem.
Validation reference¶
| Check | What to verify after each refactor |
|---|---|
| Compile-time | Swapping any two parameters of the new signature causes a compile error. |
| Validation | Constructing a wrapper from an invalid primitive throws at construction, not at use. |
| Boundary | Adapter layer (controller, mapper) is the only place that constructs wrappers from raw primitives. |
| Equality | Two wrappers with the same content are equals-equal and have the same hashCode. |
| Immutability | No setter, no mutating method. Operations return new instances. |
| Stable behaviour | Existing tests still pass after the refactor (you didn't change semantics, only types). |
Task 1 — Wrap an email address¶
public class UserService {
public void register(String email, String fullName) {
if (email == null || !email.contains("@")) throw new IllegalArgumentException();
users.save(new User(email, fullName));
}
}
Objective. Replace the two String parameters with typed wrappers.
Constraints. - Email must validate format (must contain @, must not be blank) and normalise to lowercase. - FullName must reject blank values, trim surrounding whitespace. - Both must be records.
Acceptance criteria. - register(fullName, email) (swapped order) fails to compile. - new Email("not-an-email") throws IllegalArgumentException. - new Email("Alice@Example.com").value().equals("alice@example.com") is true. - User stores the wrappers, not raw strings.
Task 2 — Replace int amountCents with Money¶
public class WalletService {
public void deposit(long walletId, int amountCents) { ... }
public void withdraw(long walletId, int amountCents) { ... }
public int balanceCents(long walletId) { ... }
}
Objective. Replace int amountCents with a Money value object that carries amount and currency, and replace long walletId with a WalletId.
Constraints. - Money uses long minorUnits and Currency from java.util.Currency. - Addition and subtraction must use Math.addExact / Math.subtractExact to detect overflow. - Operations on Money of different currencies must throw. - WalletId rejects non-positive values.
Acceptance criteria. - wallet.deposit(walletId, new Money(50_00, USD)) compiles; wallet.deposit(USD, walletId) does not. - Money(100, USD).plus(Money(50, EUR)) throws IllegalArgumentException. - Overflowing addition throws ArithmeticException.
Task 3 — Replace boolean flags with an enum¶
public class NotificationService {
public void send(User u, String message,
boolean urgent,
boolean includeSms,
boolean includeEmail,
boolean dryRun) { ... }
}
Objective. Remove all boolean parameters and replace them with a small set of typed options.
Constraints. - One Priority enum (URGENT, NORMAL). - One Channels type that holds a Set<Channel> where Channel = SMS | EMAIL | PUSH. - One ExecutionMode enum (LIVE, DRY_RUN). - Final signature must have at most 4 parameters.
Acceptance criteria. - The call service.send(u, "Hello", Priority.URGENT, Channels.of(EMAIL), ExecutionMode.DRY_RUN) reads as prose. - No boolean appears in the public method signature. - A new channel (e.g., WEBHOOK) can be added by extending the enum, without changing the signature.
Task 4 — Wrap timestamps with Instant and Duration¶
public class SessionService {
public Session create(long userId, long createdAtMillis, long expiresAfterSeconds) { ... }
public boolean isExpired(long sessionId, long currentTimeMillis) { ... }
}
Objective. Replace all long-encoded times and durations with Instant and Duration, and wrap IDs.
Constraints. - UserId and SessionId are opaque wrappers around long (or UUID). - createdAtMillis becomes Instant. - expiresAfterSeconds becomes Duration. - isExpired should not take currentTimeMillis — inject a Clock instead.
Acceptance criteria. - Swapping userId and sessionId arguments fails to compile. - A unit test can advance time deterministically by constructing a fixed Clock. - The signature no longer contains any long that means "a moment" or "a duration".
Task 5 — Convert a CSV row parser¶
public class TransactionImporter {
public Transaction parse(String[] row) {
long id = Long.parseLong(row[0]);
long ts = Long.parseLong(row[1]);
String currency = row[2];
long amountCents = Long.parseLong(row[3]);
String accountIdFrom = row[4];
String accountIdTo = row[5];
return new Transaction(id, ts, currency, amountCents, accountIdFrom, accountIdTo);
}
}
Objective. Replace the six raw values with typed wrappers, with all validation at construction.
Constraints. - The parser is the boundary — it is the only legitimate place to construct wrappers from String. - Each wrapper validates at construction. - The resulting Transaction exposes only typed values.
Acceptance criteria. - A malformed CSV row (e.g., 2-letter currency, blank account ID) throws at parse time, not at use time. - Transaction has no String or long accessors. - The signature new Transaction(TransactionId, Instant, IsoCurrency, Money, AccountId, AccountId) reads as prose.
Task 6 — A sealed Notification hierarchy¶
public class Notification {
private final String type; // "EMAIL" | "SMS" | "PUSH"
private final String recipient;
private final String subject; // only for EMAIL
private final String message;
private final String phoneNumber; // only for SMS
private final String deviceToken; // only for PUSH
}
Objective. Convert the type-coded "fat" class into a sealed interface with one record per variant, each carrying only the fields that variant needs.
Constraints. - Notification is a sealed interface with three permitted subtypes. - EmailNotification, SmsNotification, PushNotification are records. - Recipient is typed differently in each variant (Email, PhoneNumber, DeviceToken). - A consumer uses exhaustive pattern matching (JEP 441).
Acceptance criteria. - It is impossible to construct an EmailNotification without a subject (compile-time). - Adding a new variant (WebhookNotification) forces the compiler to error out on every existing exhaustive switch. - No field is "valid only for some variants".
Task 7 — Build a Percentage value object¶
public class PricingService {
public BigDecimal applyDiscount(BigDecimal price, int discountPct) { ... }
public BigDecimal applyTax(BigDecimal price, int taxBps) { ... }
public BigDecimal applyFee(BigDecimal price, double feeRate) { ... }
}
Objective. Unify three different percentage encodings into a single Percentage value object.
Constraints. - Percentage holds a BigDecimal fraction in [0, 1]. - Three named factories: ofPercent(int), ofBasisPoints(int), ofFraction(BigDecimal). - applyTo(BigDecimal) returns the discounted/taxed/fee-applied amount. - The service signature becomes applyDiscount(BigDecimal, Percentage) etc.
Acceptance criteria. - Percentage.ofPercent(20) and Percentage.ofBasisPoints(2000) are equals-equal. - Percentage.ofPercent(150) throws (out of range). - The caller chooses the unit at the call site; the service implementation doesn't care.
Task 8 — End-to-end: refactor an entire OrderService¶
public class OrderService {
public long placeOrder(long userId, long productId, int quantity, int unitPriceCents,
String currency, boolean express, String promoCode) {
if (userId <= 0 || productId <= 0) throw new IllegalArgumentException();
if (quantity <= 0 || quantity > 1000) throw new IllegalArgumentException();
if (unitPriceCents <= 0) throw new IllegalArgumentException();
if (currency.length() != 3) throw new IllegalArgumentException();
long total = (long) quantity * unitPriceCents;
if (promoCode != null && promoCode.startsWith("SAVE")) {
total = total * 90 / 100;
}
long orderId = nextId.incrementAndGet();
repo.save(new OrderRow(orderId, userId, productId, quantity, total, currency, express));
return orderId;
}
}
Objective. Apply every refactor from this section in one go.
Constraints. - Every primitive that represents a domain concept becomes a typed wrapper. - Validation moves to the wrapper compact constructors. - The boolean express becomes a ShippingMode enum. - The promo code logic becomes a small PromoCode value object with applyTo(Money). - The method signature has at most 4 parameters; consider a PlaceOrderCommand parameter object.
Acceptance criteria. - The new placeOrder signature reads as prose at the call site. - Each validation rule lives in exactly one place (the wrapper). - The compile-time guard against argument swapping holds.
Worked solution sketch — Task 8¶
A canonical solution (one of several valid shapes):
public record UserId(long value) { public UserId { if (value <= 0) throw new IllegalArgumentException(); } }
public record ProductId(long value) { public ProductId { if (value <= 0) throw new IllegalArgumentException(); } }
public record OrderId(long value) { public OrderId { if (value <= 0) throw new IllegalArgumentException(); } }
public record Quantity(int value) {
public Quantity { if (value <= 0 || value > 1000) throw new IllegalArgumentException(); }
}
public record Money(long minorUnits, Currency currency) {
public Money {
Objects.requireNonNull(currency);
if (minorUnits < 0) throw new IllegalArgumentException();
}
public Money times(int n) { return new Money(Math.multiplyExact(minorUnits, n), currency); }
public Money applyPercentage(int pct) { return new Money(minorUnits * pct / 100, currency); }
}
public enum ShippingMode { STANDARD, EXPRESS }
public record PromoCode(String value) {
public PromoCode {
Objects.requireNonNull(value);
if (!value.matches("[A-Z0-9]{4,16}")) throw new IllegalArgumentException();
}
public Money applyTo(Money m) {
return value.startsWith("SAVE") ? m.applyPercentage(90) : m;
}
}
public record PlaceOrderCommand(UserId user, ProductId product, Quantity quantity,
Money unitPrice, ShippingMode shipping,
Optional<PromoCode> promo) {
public PlaceOrderCommand {
Objects.requireNonNull(user);
Objects.requireNonNull(product);
Objects.requireNonNull(quantity);
Objects.requireNonNull(unitPrice);
Objects.requireNonNull(shipping);
Objects.requireNonNull(promo);
}
}
public class OrderService {
public OrderId placeOrder(PlaceOrderCommand cmd) {
Money base = cmd.unitPrice().times(cmd.quantity().value());
Money total = cmd.promo().map(p -> p.applyTo(base)).orElse(base);
OrderId id = new OrderId(nextId.incrementAndGet());
repo.save(new OrderRow(id, cmd.user(), cmd.product(), cmd.quantity(), total, cmd.shipping()));
return id;
}
}
Key wins:
- The original method's 13 lines of validation are spread across the wrappers, each runnable in isolation.
- The
boolean expressbecameShippingMode, self-documenting at the call site. - The promo logic moved to the type that owns it (
PromoCode.applyTo). - Swapping
userandproductat the construction site is a compile error. - The service method shrank to four lines of orchestration.
Self-grading rubric¶
For each task, score yourself on:
| Criterion | Points |
|---|---|
| Wrappers added for every confusable primitive | 2 |
| Compact constructor with validation in each wrapper | 2 |
record (not handwritten class) where possible | 1 |
| Booleans replaced with enums | 1 |
Time/duration uses Instant/Duration | 1 |
| Test that would have caught the original bug | 2 |
| Adapter layer is the only construction site for primitives | 1 |
| Total | 10 |
A score of 8+ means you've internalised the discipline. Below 6, revisit middle.md.
Memorize this: Eight exercises map onto eight wrapping moves — Email, Money, WalletId, time/duration, CSV-row parsing, sealed notification, Percentage, full-service refactor. Each move follows the same rhythm: wrapper → compact-constructor validation → push through the API → test. After three repetitions the move becomes muscle memory.