Feature Envy — Tasks¶
Eight exercises. Do them in order. Each task gives starting code (envious) and a goal. Use the Lanza & Marinescu metrics to verify your fix: target ATFD ≤ 5, LAA ≥ 1/3, sensible FDP. Apply Fowler's Move Method and Extract Method refactorings.
Task 1 — Account balance after fees¶
Starting code.
public class AccountReportService {
public BigDecimal netBalance(Account a) {
BigDecimal gross = a.getDeposits().stream()
.map(Deposit::getAmount).reduce(BigDecimal.ZERO, BigDecimal::add);
BigDecimal withdrawn = a.getWithdrawals().stream()
.map(Withdrawal::getAmount).reduce(BigDecimal.ZERO, BigDecimal::add);
BigDecimal fees = a.getFeeSchedule().monthlyFee()
.multiply(BigDecimal.valueOf(a.getMonthsOpen()));
return gross.subtract(withdrawn).subtract(fees);
}
}
Goal. Move computation into Account. Service becomes one line.
Task 2 — Discount eligibility check¶
Starting code.
public class DiscountChecker {
public boolean qualifiesForLoyaltyDiscount(Customer c) {
return c.getYearsActive() >= 5
&& c.getTotalSpent().compareTo(BigDecimal.valueOf(10_000)) >= 0
&& c.getStatus() == CustomerStatus.ACTIVE
&& !c.getFlags().contains(CustomerFlag.SUSPENDED);
}
}
Goal. Push to Customer.qualifiesForLoyaltyDiscount(). Remove DiscountChecker entirely (or keep as an empty registration point).
Task 3 — Order shipping decision¶
Starting code.
public class ShippingDecider {
public ShippingMethod decide(Order order) {
BigDecimal weight = order.getItems().stream()
.map(i -> i.getProduct().getWeight().multiply(BigDecimal.valueOf(i.getQuantity())))
.reduce(BigDecimal.ZERO, BigDecimal::add);
if (weight.compareTo(BigDecimal.valueOf(50)) > 0) return ShippingMethod.FREIGHT;
if (order.getDestination().isInternational()) return ShippingMethod.INTERNATIONAL_AIR;
if (order.isPriority()) return ShippingMethod.OVERNIGHT;
return ShippingMethod.STANDARD;
}
}
Goal. Add LineItem.weight(), Order.totalWeight(), Order.preferredShippingMethod(). Decider keeps no logic.
Task 4 — Salary report row¶
Starting code.
public class SalaryRow {
public String render(Employee e) {
BigDecimal annual = e.getMonthlySalary().multiply(BigDecimal.valueOf(12));
BigDecimal tax = annual.multiply(e.getTaxBracket().getRate());
BigDecimal net = annual.subtract(tax);
return String.format("%s | %.2f | %.2f", e.getFullName(), annual, net);
}
}
Goal. Two refactorings — annual/net computation onto Employee; row rendering stays in SalaryRow but consumes a CompensationSummary value object returned by Employee.
Task 5 — Cart price with promo¶
Starting code.
public class CartPricingService {
public Money price(Cart cart, PromoCode promo) {
Money sub = Money.ZERO;
for (CartItem item : cart.getItems()) {
sub = sub.plus(item.getProduct().getPrice().times(item.getQuantity()));
}
if (promo.isApplicableTo(cart) && promo.getPercentOff() > 0) {
sub = sub.times(BigDecimal.ONE.subtract(BigDecimal.valueOf(promo.getPercentOff()).divide(BigDecimal.valueOf(100))));
}
return sub;
}
}
Goal. CartItem.lineTotal(), Cart.subtotal(), PromoCode.apply(Money). Service composes: promo.apply(cart.subtotal()).
Task 6 — Risk score on a domain service¶
Starting code.
public class CreditRiskAssessor {
public int score(LoanApplication app) {
int score = 0;
if (app.getApplicant().getCreditScore() < 600) score += 30;
if (app.getApplicant().getDebtToIncome() > 0.4) score += 25;
if (app.getAmount().compareTo(BigDecimal.valueOf(100_000)) > 0) score += 20;
if (app.getApplicant().getEmploymentMonths() < 12) score += 15;
if (app.getCollateral() == null) score += 10;
return score;
}
}
Goal. This is a legitimate domain service — it composes two aggregates (Applicant, LoanApplication). Push the sub-predicates to the owner classes (e.g. Applicant.hasLowCreditScore()), but keep the scoring composition here. Document the choice.
Task 7 — Mapper hiding in a controller¶
Starting code.
@RestController
public class ProductController {
@GetMapping("/products/{id}")
public ProductDto get(@PathVariable Long id) {
Product p = repo.findById(id).orElseThrow();
ProductDto dto = new ProductDto();
dto.setId(p.getId());
dto.setName(p.getName());
dto.setPrice(p.getPrice().toString());
dto.setInStock(p.getStockQuantity() > 0);
dto.setCategory(p.getCategory().getName());
return dto;
}
}
Goal. Extract a ProductMapper (or MapStruct interface). Controller becomes two lines.
Task 8 — Train wreck in validator¶
Starting code.
public class OrderValidator {
public boolean isValid(Order order) {
return order.getCustomer().getAddress().getZip() != null
&& !order.getCustomer().getAddress().getZip().isBlank()
&& order.getCustomer().getPaymentMethod().getCard().getExpiryDate().isAfter(LocalDate.now())
&& order.getItems().stream().allMatch(i -> i.getProduct().isAvailable());
}
}
Goal. Eliminate the train wrecks. Push predicates: Address.hasZip(), PaymentMethod.isUsable(), LineItem.isAvailable(), Order.isValid(). Validator delegates.
Validation table¶
After each task verify:
| Check | Expected after fix |
|---|---|
| ATFD of envious method | ≤ 5 |
| LAA of envious method | ≥ 1/3 |
| FDP of envious method | ≤ 5 |
| SonarJava S3398 warnings | 0 on touched methods |
| IntelliJ "Feature envy" | Not flagged |
| Original tests pass | Yes (no behaviour change) |
| New methods on entity have unit tests | Yes |
| No new circular dependencies | Verified by mvn dependency:analyze or jdeps |
Worked solution sketch — Task 1¶
Starting point: AccountReportService.netBalance has ATFD = 4 (deposits, withdrawals, feeSchedule, monthsOpen) and LAA = 0.
Step 1 — Extract Method on the gross sum into a local method, then move to Account.totalDeposits().
public class Account {
public BigDecimal totalDeposits() {
return deposits.stream().map(Deposit::getAmount)
.reduce(BigDecimal.ZERO, BigDecimal::add);
}
public BigDecimal totalWithdrawals() {
return withdrawals.stream().map(Withdrawal::getAmount)
.reduce(BigDecimal.ZERO, BigDecimal::add);
}
public BigDecimal accumulatedFees() {
return feeSchedule.monthlyFee()
.multiply(BigDecimal.valueOf(monthsOpen));
}
public BigDecimal netBalance() {
return totalDeposits().subtract(totalWithdrawals()).subtract(accumulatedFees());
}
}
Step 2 — Service collapses:
public class AccountReportService {
public BigDecimal netBalance(Account a) {
return a.netBalance();
}
}
At this point you may even delete AccountReportService and have callers go directly to Account. The service was a pure envy host with no other responsibilities.
Step 3 — Verify. ATFD on Account.netBalance is 0 (own state only). LAA = 1.0. Tests still green. SonarJava silent.
Apply the same three-step pattern to every other task: extract the envious chunks, move them to the owner, simplify the caller, verify metrics and tests.