Extra pattern · not GoF
Policy object
A policy object holds a domain rule that can change independently of the entity that uses it — refund windows, overtime fees, grace periods. The entity asks the policy; it does not bury the rule in nested ifs.
Example: a refund is “full if you leave within fifteen minutes, half if weather closed the lot, none after payment.” Today that rule is buried in Ticket.exit() as nested ifs. Next week the window is ten minutes. Extract a RefundPolicy so the change is one class.
Where does this rule live when it is not “the algorithm,” just “the rule”?
Buried in the entity
if (minutes < 15) return fee; if (weatherClosed) return half; return zero;
Looks small until weather refunds and staff overrides arrive.
Named rule
ticket.refund(policy, clock)
The lives in GracePeriodRefund. Ticket asks; it does not know 15.
The rule has a name
interface RefundPolicy {
Money refundFor(Ticket ticket, Instant now);
}
class GracePeriodRefund implements RefundPolicy {
private final Duration grace;
GracePeriodRefund(Duration grace) { this.grace = grace; }
public Money refundFor(Ticket ticket, Instant now) {
if (ticket.duration(now).compareTo(grace) <= 0) return ticket.fee();
return Money.zero(ticket.fee().currency());
}
}
class Ticket {
Money refund(RefundPolicy policy, Clock clock) {
return policy.refundFor(this, clock.instant());
}
}Policy and Strategy are cousins
Strategy
How we choose or compute, and we will swap implementations. Hourly vs airport. See Strategy.
Policy
This is the current business rule. Often one class: BookingWindow, CancellationPolicy, OvertimeFee. A , different emphasis.
Specification
Rules you compose with and/or/not. Use Specification when the interviewer will combine constraints.
Keep the policy a pure decision
A takes values in and returns a decision. Freeze the clock, feed a ticket, assert the money. Inject the policy through the constructor — dependency injection — so tests pass a generous grace period and production passes fifteen minutes.
Names that earn their keep
GracePeriodRefund / CancellationPolicy
BookingWindow
OvertimeFee
Burying the rule in the entity
if (minutes < 15) inside Ticket.exit() looks small until weather refunds and staff overrides arrive. Extract the policy while the if is still small.
Policy that writes to the database
A RefundPolicy that also updates a ledger is two jobs. The policy decides. A service or repository applies. Mixing them makes the rule untestable without JDBC.
Protected Variations, said as a product word
Protected Variations says: find the unstable part, hide it behind a stable interface. The unstable part here is the refund window. RefundPolicy is the interface. You do not need to say “I will use Strategy” if the honest name is the policy. Interviewers care that the rule can change without editing Ticket.
Name the rule, not the pattern
Say “RefundPolicy with a fifteen-minute grace.” Do not say “I will use Strategy” unless you will swap implementations in the follow-up. Honest names beat catalog names.
Library overdue fee is a policy
Copy.returnOn(day) should not know “two rupees per day after seven days.” OverduePolicy does. The copy asks. Tests freeze the calendar. That is the same extract as GracePeriodRefund, in a different product.
interface OverduePolicy {
Money feeFor(Loan loan, LocalDate today);
}
class PerDayAfterGrace implements OverduePolicy {
private final int graceDays;
private final Money perDay;
public Money feeFor(Loan loan, LocalDate today) {
long late = ChronoUnit.DAYS.between(loan.dueOn(), today) - graceDays;
if (late <= 0) return Money.zero(perDay.currency());
return perDay.times(late);
}
}Weather refund as a second policy
Do not grow GracePeriodRefund with a weather boolean. Compose at the use-case edge: try grace, then weather, then none. Or inject a CompositeRefund that asks a list. Ticket still only knows refund(policy, clock).
class FirstMatchRefund implements RefundPolicy {
private final List<RefundPolicy> rules;
FirstMatchRefund(List<RefundPolicy> rules) { this.rules = List.copyOf(rules); }
public Money refundFor(Ticket ticket, Instant now) {
for (RefundPolicy rule : rules) {
Money amount = rule.refundFor(ticket, now);
if (!amount.isZero()) return amount;
}
return Money.zero(ticket.fee().currency());
}
}That list is still policies, not a specification. Specs filter candidates. Policies decide money. Keep the verbs different so the whiteboard stays readable.
What to write first
Extract the interface and one implementation while the if is still small. Inject Duration grace so the number 15 is not a magic literal in Ticket. That is enough for a parking-lot follow-up. A full rule engine can wait for the rule engine case study.