Kargo ücreti servisi: if/else'ten Strategy'ye
Spring tabanlı live coding görevlerine benzeyen kurgu
Sana küçük bir Spring Boot projesi verildi. Ürün detay sayfası kargo ücretini gösterecek. Servis çalışıyor ama kod incelemesinde birçok yorum var. Mülakatçı: "Bu kodu production'a çıkacak hale getir. Yeni kargo firmaları sık ekleniyor."
Elinde şunlar var: Product entity'si (id, name, price: BigDecimal, weightKg: BigDecimal) ve ProductRepository extends JpaRepository<Product, Long>.
Görevler
- Kargo hesaplamasını, yeni firma eklemek mevcut kodu değiştirmeyi gerektirmeyecek şekilde (Open/Closed) yeniden tasarla.
- Controller metodunu doldur: ürün yoksa 404, desteklenmeyen kargo firması için 400 dönsün.
- Sınıf ve endpoint isimlendirmesini Java ve REST kurallarına uygun hale getir.
- Para hesaplarındaki tip ve magic value problemlerini düzelt.
- En az bir anlamlı unit test yaz.
// ===== ShippingService.java =====
@Service
public class ShippingService {
public double calculate(String carrier, double weight, double cartTotal) {
if (carrier.equals("ARAS")) {
if (weight > 5) return 89.90;
return 49.90;
} else if (carrier.equals("YURTICI")) {
if (cartTotal >= 500) return 0;
return 54.90;
} else if (carrier.equals("MNG")) {
return 39.90 + weight * 4.5;
}
return 0;
}
}
// ===== product_controller.java =====
@RestController
public class product_controller {
@Autowired
private ShippingService shippingService;
@Autowired
private ProductRepository productRepository;
@GetMapping("/getShippingFee")
public double getShippingFee(@RequestParam Long productId, @RequestParam String carrier) {
// TODO: ürünü bul, kargo ücretini hesapla ve dön
return 0;
}
}
İpucu 1
Her firmanın kuralını ayrı bir sınıfa taşı. Ortak bir interface tanımla: hangi firmayı desteklediği ve ücreti nasıl hesapladığı.
İpucu 2
Spring, aynı interface'i implemente eden tüm bean'leri List olarak enjekte edebilir. Constructor'da bunu Map<Carrier, ...>'e çevir. Bilinmeyen firma için domain exception fırlat, @RestControllerAdvice ile 400'e çevir.
İpucu 3
Endpoint: GET /api/v1/products/{productId}/shipping-fee?carrier=ARAS. Sınıf: ProductController. Field injection yerine constructor injection. Para: BigDecimal, eşik ve ücretler sabit veya konfigürasyon. 'Bilinmeyen firma → 0 TL' sessiz bir bug; exception olmalı.
Referans çözümü göster
// ===== Carrier.java =====
public enum Carrier {
ARAS, YURTICI, MNG;
public static Carrier from(String value) {
try {
return valueOf(value.trim().toUpperCase(Locale.ROOT)); // Türk locale'inde "i" tuzağına dikkat
} catch (IllegalArgumentException | NullPointerException e) {
throw new UnsupportedCarrierException(value);
}
}
}
// ===== ShippingFeePolicy.java =====
public interface ShippingFeePolicy {
Carrier carrier();
BigDecimal feeFor(ShipmentQuote quote);
}
// ===== ShipmentQuote.java =====
public record ShipmentQuote(BigDecimal weightKg, BigDecimal cartTotal) {}
// ===== ArasFeePolicy.java =====
@Component
class ArasFeePolicy implements ShippingFeePolicy {
private static final BigDecimal HEAVY_THRESHOLD_KG = new BigDecimal("5");
private static final BigDecimal HEAVY_FEE = new BigDecimal("89.90");
private static final BigDecimal STANDARD_FEE = new BigDecimal("49.90");
@Override public Carrier carrier() { return Carrier.ARAS; }
@Override
public BigDecimal feeFor(ShipmentQuote quote) {
return quote.weightKg().compareTo(HEAVY_THRESHOLD_KG) > 0 ? HEAVY_FEE : STANDARD_FEE;
}
}
// ===== YurticiFeePolicy.java =====
@Component
class YurticiFeePolicy implements ShippingFeePolicy {
private static final BigDecimal FREE_SHIPPING_THRESHOLD = new BigDecimal("500");
private static final BigDecimal STANDARD_FEE = new BigDecimal("54.90");
@Override public Carrier carrier() { return Carrier.YURTICI; }
@Override
public BigDecimal feeFor(ShipmentQuote quote) {
return quote.cartTotal().compareTo(FREE_SHIPPING_THRESHOLD) >= 0 ? BigDecimal.ZERO : STANDARD_FEE;
}
}
// ===== MngFeePolicy.java =====
@Component
class MngFeePolicy implements ShippingFeePolicy {
private static final BigDecimal BASE_FEE = new BigDecimal("39.90");
private static final BigDecimal PER_KG = new BigDecimal("4.50");
@Override public Carrier carrier() { return Carrier.MNG; }
@Override
public BigDecimal feeFor(ShipmentQuote quote) {
return BASE_FEE.add(PER_KG.multiply(quote.weightKg())).setScale(2, RoundingMode.HALF_UP);
}
}
// ===== ShippingFeeCalculator.java =====
@Service
public class ShippingFeeCalculator {
private final Map<Carrier, ShippingFeePolicy> policies;
public ShippingFeeCalculator(List<ShippingFeePolicy> policies) {
// Aynı carrier iki kez tanımlanırsa uygulama açılırken hata verir (fail fast)
this.policies = policies.stream()
.collect(Collectors.toUnmodifiableMap(ShippingFeePolicy::carrier, Function.identity()));
}
public BigDecimal calculate(Carrier carrier, ShipmentQuote quote) {
ShippingFeePolicy policy = policies.get(carrier);
if (policy == null) {
throw new UnsupportedCarrierException(carrier.name());
}
return policy.feeFor(quote);
}
}
// ===== ProductController.java =====
@RestController
@RequestMapping("/api/v1/products")
public class ProductController {
private final ProductRepository productRepository;
private final ShippingFeeCalculator shippingFeeCalculator;
public ProductController(ProductRepository productRepository, ShippingFeeCalculator shippingFeeCalculator) {
this.productRepository = productRepository;
this.shippingFeeCalculator = shippingFeeCalculator;
}
@GetMapping("/{productId}/shipping-fee")
public ShippingFeeResponse shippingFee(@PathVariable long productId, @RequestParam String carrier) {
Product product = productRepository.findById(productId)
.orElseThrow(() -> new ProductNotFoundException(productId));
Carrier parsed = Carrier.from(carrier); // bilinmeyense UnsupportedCarrierException
BigDecimal fee = shippingFeeCalculator.calculate(parsed, new ShipmentQuote(product.getWeightKg(), product.getPrice()));
return new ShippingFeeResponse(productId, parsed, fee);
}
}
// Not: İş mantığı büyürse controller yerine bir ProductShippingService'e taşınır.
// ===== GlobalExceptionHandler.java =====
@RestControllerAdvice
class GlobalExceptionHandler {
@ExceptionHandler(ProductNotFoundException.class)
ProblemDetail notFound(ProductNotFoundException e) {
return ProblemDetail.forStatusAndDetail(HttpStatus.NOT_FOUND, e.getMessage());
}
@ExceptionHandler(UnsupportedCarrierException.class)
ProblemDetail unsupported(UnsupportedCarrierException e) {
return ProblemDetail.forStatusAndDetail(HttpStatus.BAD_REQUEST, e.getMessage());
}
}
// ===== ShippingFeeCalculatorTest.java =====
class ShippingFeeCalculatorTest {
private final ShippingFeeCalculator calculator =
new ShippingFeeCalculator(List.of(new ArasFeePolicy(), new YurticiFeePolicy()));
@Test
void yurticiIsFreeAboveThreshold() {
BigDecimal fee = calculator.calculate(Carrier.YURTICI, new ShipmentQuote(BigDecimal.ONE, new BigDecimal("500")));
assertThat(fee).isEqualByComparingTo("0");
}
@Test
void arasChargesHeavyFeeAboveFiveKg() {
BigDecimal fee = calculator.calculate(Carrier.ARAS, new ShipmentQuote(new BigDecimal("5.01"), BigDecimal.TEN));
assertThat(fee).isEqualByComparingTo("89.90");
}
@Test
void rejectsCarrierWithoutPolicy() {
assertThatThrownBy(() -> calculator.calculate(Carrier.MNG, new ShipmentQuote(BigDecimal.ONE, BigDecimal.TEN)))
.isInstanceOf(UnsupportedCarrierException.class);
}
}Açıklama: neden böyle?
Neyi gösterdin?
- Open/Closed: Yeni firma (ör. "SURAT") eklemek için tek bir
@Componentyazıp enum'a değer eklersin. Hesaplayıcıya ve controller'a dokunmazsın. - Strategy + Spring DI:
List<ShippingFeePolicy>enjeksiyonu.toUnmodifiableMapduplicate'te açılışta patlar (fail fast). - Sessiz bug'ı yakalamak: Eski kodda bilinmeyen firma 0 TL dönüyordu, yani bedava kargo! Mülakatçılar bunu görüp görmediğine bakar. Ayrıca
carrier.equals(...)carrier null ise NPE verirdi. - Para:
double→BigDecimal; karşılaştırmadacompareTo; testteisEqualByComparingTo(çünkü0.00ve0equals ile eşit değil). - REST: Kaynak odaklı URL (
/api/v1/products/{id}/shipping-fee), versiyon, fiil yok, çoğul isim. Hata formatı ProblemDetail.
Önceliklendirme: Bu kadar görevi 35 dakikada bitiremeyebilirsin. Başta söyle: "Önce Strategy refactor'ını ve controller'ı yapıp testini yazacağım; isimlendirme ve konfigürasyona sonra geçerim." Mülakatçı planına katılır ya da yönlendirir; ikisi de iyidir.
- Strategy: ortak interface + firma başına ayrı implementasyon
- Seçim: Spring'in enjekte ettiği List'ten Map oluşturmak (if/else'siz seçim)
- Bilinmeyen firma sessizce 0 dönmüyor; exception → 400 (ControllerAdvice)
- Ürün bulunamazsa 404
- İsimlendirme ve REST: ProductController, /api/v1/products/{id}/shipping-fee
- Constructor injection (field @Autowired yerine)
- double yerine BigDecimal; magic number'lar sabit/konfigürasyon/enum
- Unit test (JUnit/Mockito): en az bir strategy veya bilinmeyen firma senaryosu
- Yeni bir kargo firması ekleyelim. Hangi dosyalara dokunuyorsun?
- Ücretleri deploy etmeden değiştirmek istersek ne yaparsın? (konfigürasyon, DB, feature flag)
- Kampanya: belirli bir tarihte tüm firmalarda %50 indirim. Mevcut tasarımı bozmadan nasıl eklersin? (Decorator)
- Bu endpoint saniyede 10.000 istek alırsa nerede cache kullanırsın?