Pratik kodlama

Park yeri (low-level design): sınıf tasarımı, araç tipleri, ücret stratejisi ve eşzamanlı giriş-çıkış

Orta~60 dkAmazonUber

Görev

Çok katlı bir otoparkın yazılımını tasarlayıp kodlayacaksın: araç girişte bilet alır, uygun bir yere yönlendirilir, çıkışta süresine göre ücret öder. Bu bir low-level design (LLD) ya da object-oriented design (OOD) sorusu: sistem tasarımındaki gibi kutular ve ok'lar değil, sınıflar, sorumluluklar ve çalışan kod bekleniyor. Aday raporlarına göre Amazon ve Uber gibi şirketler bu türü ayrı bir tur olarak soruyor; park yeri, asansör, kütüphane, satranç ve LRU cache en sık anılan örnekler.

LLD turunda mülakatçı genelde tek cümleyle başlar ("bir otopark tasarla") ve gereksinimleri senin çıkarmanı bekler. Aşağıdaki dört seviye, iyi bir adayın kendiliğinden izleyeceği sırayı taklit ediyor: önce çekirdek modeli kur, sonra çeşitliliği ekle, sonra değişme ihtimali yüksek kısmı (ücretlendirme) soyutla, en son eşzamanlılığı ele al.

Netleştirme soruları

  • Araç tipleri ve yer boyutları? (Motosiklet, otomobil, van; küçük, kompakt, büyük yer. Küçük araç büyük yere sığar, tersi olmaz.)
  • Yer seçimi kuralı? (En küçük uygun boyut, aynı boyutta girişe en yakın: düşük kat, düşük numara.)
  • Ücret? (İlk 15 dakika ücretsiz, sonra başlayan her saat, araç tipine göre; 24 saatlik dilim başına tavan. Etkinlik günlerinde sabit ücret.)
  • Para birimi ve tip? (Kuruş, long.)
  • Kaç giriş/çıkış kapısı? (Birden çok; aynı anda çalışırlar: Seviye 4.)
  • Kapsam dışı: ödeme entegrasyonu, rezervasyon, plaka tanıma kamerası. (Arayüz olarak anılır, kodlanmaz.)

Seviye 1

Çekirdek model ve iki işlem: park ve leave.

java
Optional<Ticket> park(Vehicle vehicle);     // yer yoksa ya da plaka zaten içerideyse boş
Optional<Receipt> leave(String ticketId);   // bilinmeyen ya da kullanılmış bilet için boş
text
park(Vehicle["34 ABC 01", CAR])  -> Ticket[id=T1, spot=ParkingSpot[floor=0, number=1, size=COMPACT], entryTime=08:00]
(90 dakika sonra)
leave("T1")                      -> Receipt[fee=10000]   // 2 başlayan saat x 50 TL
leave("T1")                      -> Optional.empty       // bilet tekrar kullanılamaz

Sınıflar ve sorumluluklar:

diyagram yükleniyor…
classDiagram
    class ParkingLot {
        +park(Vehicle) Optional~Ticket~
        +leave(String) Optional~Receipt~
        +availableSpots(SpotSize) int
        +changePricing(PricingStrategy)
    }
    class PricingStrategy {
        <<interface>>
        +feeFor(VehicleType, Duration) long
    }
    class HourlyPricing
    class FlatPricing
    class Vehicle {
        <<record>>
        plate
        type
    }
    class ParkingSpot {
        <<record>>
        floor
        number
        size
    }
    class Ticket {
        <<record>>
    }
    class Receipt {
        <<record>>
    }
    class VehicleType {
        <<enum>>
        +fitsIn(SpotSize) boolean
    }
    ParkingLot --> PricingStrategy
    ParkingLot --> Ticket
    PricingStrategy <|.. HourlyPricing
    PricingStrategy <|.. FlatPricing
    Ticket --> Vehicle
    Ticket --> ParkingSpot
    Vehicle --> VehicleType

Değer nesneleri (Vehicle, ParkingSpot, Ticket, Receipt) değişmez record; durumu değişen tek nesne ParkingLot. Sık yapılan hata: ParkingSpot'a boolean occupied ve Vehicle vehicle alanları koyup her şeyi ona yüklemek. O zaman "boş yer bul" her seferinde tüm yerleri taramak olur ve eşzamanlılıkta her yer nesnesinin ayrı ayrı kilitlenmesi gerekir. Boş yerleri bir koleksiyon olarak tutmak hem aramayı hem kilitlemeyi basitleştirir.

Seviye 2

Araç tipleri ve yer boyutları; en küçük uygun boyut, aynı boyutta en yakın yer.

text
Yerler: kat 2 SMALL, kat 1 COMPACT (2 ve 5), kat 0 LARGE
park(motosiklet)  -> kat 2 SMALL        (uzak ama en küçük uygun boyut)
park(motosiklet)  -> kat 1 no 2 COMPACT (küçük kalmadı, sıradaki boyutta en yakın)
park(otomobil)    -> kat 1 no 5 COMPACT
park(otomobil)    -> kat 0 LARGE        (kompakt kalmadı)
park(van)         -> Optional.empty     (büyük yer dolu)

"Hangi araç hangi yere sığar" bilgisi if/else zinciri olarak ParkingLot'a değil, VehicleType enum'una aittir: her tip en küçük uygun boyutu bilir, fitsIn boyutların sırasını karşılaştırır. Yeni bir araç tipi (ör. elektrikli otomobil) eklemek tek bir enum sabiti eklemektir; ParkingLot değişmez (Open/Closed, bkz. Bölüm 17). Boyut başına boş yerler, girişe yakınlığa göre sıralı bir kümede tutulur; "en yakın boş yer" kümenin ilk elemanıdır.

Seviye 3

Ücretlendirme değişebilir olmalı: saatlik tarife (ücretsiz süre, başlayan her saat, günlük tavan) ve etkinlik günleri için sabit ücret; tarife çalışma anında değiştirilebilmeli. Süre ölçümü test edilebilir olmalı.

text
15 dk         -> 0            (ücretsiz süre dahil)
16 dk         -> 50 TL        (ilk başlayan saat)
61 dk         -> 100 TL       (61 dakika = 2 başlayan saat)
10 saat       -> 400 TL       (günlük tavan)
26 saat       -> 400 + 100 TL (bir tam gün tavanı + 2 saat)

Bu, Strategy deseninin ders kitabı örneği: PricingStrategy tek metotlu bir arayüz (@FunctionalInterface), ParkingLot sadece onu çağırır. Tarife değişikliği volatile bir alanı değiştirmektir; çıkış anındaki tarife uygulanır (bu bir iş kuralı; "girişteki tarife uygulansın" denirse strateji bilete yazılır).

Zaman için Instant.now() doğrudan çağrılmaz; java.time.Clock constructor'dan enjekte edilir. Testte saati elle ilerleten bir Clock alt sınıfı "90 dakika park etti" senaryosunu sleep olmadan, kesin olarak test eder. Spring'de aynı şey bir Clock bean'i ile yapılır.

HourlyPricing constructor'ında her araç tipi için tarife olup olmadığını kontrol etmek küçük ama önemli bir karar: eksik tarife uygulama açılırken patlar, gece 3'te çıkış bariyerinde değil.

Seviye 4

Otoparkın birden çok giriş ve çıkış kapısı var; aynı anda çalışırlar. Gereksinimler:

  • İki araç asla aynı yere yönlendirilmez.
  • Kapasite kadar araç girer, fazlası reddedilir.
  • Aynı bilet iki çıkış kapısında aynı anda okutulursa sadece bir kez ücret alınır ve yer bir kez boşaltılır.
  • Aynı plaka iki girişte aynı anda görünürse sadece biri bilet alır.

Çözüm, her kritik adımı tek bir atomik işleme indirmek:

YarışAtomik işlem
İki araç aynı yeri alırBoyut başına ConcurrentSkipListSet<ParkingSpot>; pollFirst() en yakın yeri atomik olarak çıkarır, iki thread aynı elemanı alamaz
Aynı bilet iki kez okutulurConcurrentHashMap.remove(ticketId); sadece bir thread null olmayan bilet alır
Aynı plaka iki kez girerConcurrentHashMap.newKeySet().add(plate); sadece biri true alır, yer bulunamazsa geri alınır
Tarife değişikliğivolatile referans; okuyan thread ya eskisini ya yenisini görür

Global bir synchronized ile de doğru olurdu; ama tüm kapılar tek kilitte sıraya girer. Burada global bir kilit yok: farklı boyuttaki yerler farklı kümelerde, farklı biletler farklı anahtarlarda. availableSpots ise eşzamanlı trafikte bir anlık tahmindir (ConcurrentSkipListSet.size() elemanları sayar ve eşzamanlı değişikliklerde kesin değildir); gösterge panosu için yeterli, ama "kalan yer 1 ise gir" gibi bir karar için kullanılmaz; karar pollFirst'ün sonucuyla verilir.

"Önce boş yer var mı bak, sonra al" (check-then-act) eşzamanlılıkta her zaman yanlıştır: iki kapı aynı anda "1 yer var" görür ve ikisi de araç alır. Kontrol ile alma aynı atomik işlem olmalı. Bu, Bölüm 22'deki "bakiyeyi oku, yeterliyse düş" hatasının otopark versiyonudur.

İpuçları

İpucu 1

"Hangi araç hangi yere sığar" bilgisini VehicleType enum'una koy (fitsIn), boş yerleri de boyut başına, girişe yakınlığa göre sıralı bir kümede tut. Böylece en küçük uygun ve en yakın yer, boyutlar sırayla denenip kümenin ilk elemanı alınarak bulunur.

İpucu 2

Ücretlendirmeyi tek metotlu bir PricingStrategy arayüzüne çıkar ve zamanı Clock olarak enjekte et. "Başlayan her saat" için Math.ceilDiv kullan; günlük tavanı tam günler ve kalan kısım için ayrı uygula.

İpucu 3

Eşzamanlılıkta "kontrol et, sonra al" yerine "al ve sonuca bak": ConcurrentSkipListSet.pollFirst(), ConcurrentHashMap.remove(key) ve Set.add(plate) zaten atomik. Başarısız adımda öncekini geri al (plaka rezervasyonunu kaldır).

Java çözümü

Değer tipleri, ücret stratejileri ve ParkingLot. Java 21 ile derlenip testlerle (eşzamanlılık testleri birden çok kez) çalıştırıldı.

Dosya: SpotSize.java

java
/** Declared from smallest to largest; ordinal order is used for best-fit search. */
public enum SpotSize {
    SMALL, COMPACT, LARGE
}

Dosya: VehicleType.java

java
/** Each vehicle type knows the smallest spot it fits in; it fits every larger spot too. */
public enum VehicleType {
    MOTORCYCLE(SpotSize.SMALL),
    CAR(SpotSize.COMPACT),
    VAN(SpotSize.LARGE);

    private final SpotSize smallestFit;

    VehicleType(SpotSize smallestFit) {
        this.smallestFit = smallestFit;
    }

    public boolean fitsIn(SpotSize size) {
        return size.compareTo(smallestFit) >= 0;
    }
}

Dosya: Vehicle.java

java
import java.util.Objects;

public record Vehicle(String plate, VehicleType type) {
    public Vehicle {
        Objects.requireNonNull(plate, "plate");
        Objects.requireNonNull(type, "type");
    }
}

Dosya: ParkingSpot.java

java
import java.util.Comparator;

/** Natural order = closest to the entrance first (lower floor, then lower number). */
public record ParkingSpot(int floor, int number, SpotSize size) implements Comparable<ParkingSpot> {

    private static final Comparator<ParkingSpot> NEAREST_FIRST =
            Comparator.comparingInt(ParkingSpot::floor).thenComparingInt(ParkingSpot::number);

    @Override
    public int compareTo(ParkingSpot other) {
        return NEAREST_FIRST.compare(this, other);
    }
}

Dosya: Ticket.java

java
import java.time.Instant;

public record Ticket(String id, Vehicle vehicle, ParkingSpot spot, Instant entryTime) {}

Dosya: Receipt.java

java
import java.time.Duration;
import java.time.Instant;

/** Fee is in minor units (kuruş). */
public record Receipt(Ticket ticket, Instant exitTime, long fee) {
    public Duration parkedFor() {
        return Duration.between(ticket.entryTime(), exitTime);
    }
}

Dosya: PricingStrategy.java

java
import java.time.Duration;

/** Level 3: pricing varies (hourly, flat event price, weekend...) while ParkingLot stays the same. */
@FunctionalInterface
public interface PricingStrategy {
    long feeFor(VehicleType type, Duration parkedFor);
}

Dosya: HourlyPricing.java

java
import java.time.Duration;
import java.util.EnumMap;
import java.util.Map;

/** Free grace period, then every started hour is charged, capped per 24 hours. */
public final class HourlyPricing implements PricingStrategy {

    private static final long HOUR_NANOS = Duration.ofHours(1).toNanos();

    private final Map<VehicleType, Long> hourlyRate;
    private final Duration gracePeriod;
    private final long dailyCap;

    public HourlyPricing(Map<VehicleType, Long> hourlyRate, Duration gracePeriod, long dailyCap) {
        this.hourlyRate = new EnumMap<>(hourlyRate);
        for (VehicleType type : VehicleType.values()) {
            if (!this.hourlyRate.containsKey(type)) {
                throw new IllegalArgumentException("missing rate for " + type);   // fail at startup, not at the exit gate
            }
        }
        this.gracePeriod = gracePeriod;
        this.dailyCap = dailyCap;
    }

    @Override
    public long feeFor(VehicleType type, Duration parkedFor) {
        if (parkedFor.isNegative()) {
            throw new IllegalArgumentException("negative duration");
        }
        if (parkedFor.compareTo(gracePeriod) <= 0) {
            return 0;
        }
        long fullDays = parkedFor.toDays();
        Duration rest = parkedFor.minusDays(fullDays);
        long startedHours = Math.ceilDiv(rest.toNanos(), HOUR_NANOS);     // 61 minutes = 2 hours
        long restFee = Math.min(startedHours * hourlyRate.get(type), dailyCap);
        return Math.addExact(Math.multiplyExact(fullDays, dailyCap), restFee);
    }
}

Dosya: FlatPricing.java

java
import java.time.Duration;
import java.util.EnumMap;
import java.util.Map;

/** Event day: one price per vehicle type regardless of duration. */
public final class FlatPricing implements PricingStrategy {

    private final Map<VehicleType, Long> price;

    public FlatPricing(Map<VehicleType, Long> price) {
        this.price = new EnumMap<>(price);
        if (this.price.size() != VehicleType.values().length) {
            throw new IllegalArgumentException("a price is required for every vehicle type");
        }
    }

    @Override
    public long feeFor(VehicleType type, Duration parkedFor) {
        return price.get(type);
    }
}

Dosya: ParkingLot.java

java
import java.time.Clock;
import java.time.Duration;
import java.time.Instant;
import java.util.Collection;
import java.util.EnumMap;
import java.util.Map;
import java.util.Objects;
import java.util.Optional;
import java.util.Set;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ConcurrentSkipListSet;
import java.util.concurrent.atomic.AtomicLong;

/**
 * Level 1: park/leave with tickets. Level 2: vehicle types and best-fit spot choice.
 * Level 3: pluggable pricing and an injected clock. Level 4: safe for many gates at once.
 */
public final class ParkingLot {

    // One sorted, thread-safe free list per size; pollFirst() removes atomically, so a spot is never given twice.
    private final Map<SpotSize, ConcurrentSkipListSet<ParkingSpot>> freeSpots = new EnumMap<>(SpotSize.class);
    private final Map<String, Ticket> activeTickets = new ConcurrentHashMap<>();
    private final Set<String> platesInside = ConcurrentHashMap.newKeySet();
    private final AtomicLong ticketSequence = new AtomicLong();
    private final Clock clock;
    private volatile PricingStrategy pricing;

    public ParkingLot(Collection<ParkingSpot> spots, PricingStrategy pricing, Clock clock) {
        for (SpotSize size : SpotSize.values()) {
            freeSpots.put(size, new ConcurrentSkipListSet<>());   // map structure is fixed after construction
        }
        for (ParkingSpot spot : spots) {
            if (!freeSpots.get(spot.size()).add(spot)) {
                throw new IllegalArgumentException("duplicate spot " + spot);
            }
        }
        this.pricing = Objects.requireNonNull(pricing);
        this.clock = Objects.requireNonNull(clock);
    }

    /** Empty if the lot has no fitting spot or the same plate is already inside. */
    public Optional<Ticket> park(Vehicle vehicle) {
        if (!platesInside.add(vehicle.plate())) {
            return Optional.empty();                   // atomic check-and-add: two gates, same plate
        }
        Optional<ParkingSpot> spot = takeBestFit(vehicle.type());
        if (spot.isEmpty()) {
            platesInside.remove(vehicle.plate());       // undo the reservation
            return Optional.empty();
        }
        var ticket = new Ticket("T" + ticketSequence.incrementAndGet(), vehicle, spot.get(), clock.instant());
        activeTickets.put(ticket.id(), ticket);
        return Optional.of(ticket);
    }

    /** Empty for an unknown or already used ticket, so a double scan never charges twice. */
    public Optional<Receipt> leave(String ticketId) {
        Ticket ticket = activeTickets.remove(ticketId);   // atomic: only one gate wins
        if (ticket == null) {
            return Optional.empty();
        }
        Instant exit = clock.instant();
        var receipt = new Receipt(ticket, exit,
                pricing.feeFor(ticket.vehicle().type(), Duration.between(ticket.entryTime(), exit)));
        freeSpots.get(ticket.spot().size()).add(ticket.spot());
        platesInside.remove(ticket.vehicle().plate());
        return Optional.of(receipt);
    }

    /** Applies to vehicles leaving from now on. */
    public void changePricing(PricingStrategy newPricing) {
        this.pricing = Objects.requireNonNull(newPricing);
    }

    /** For the display board. Exact when idle; a moment-in-time estimate under concurrent traffic. */
    public int availableSpots(SpotSize size) {
        return freeSpots.get(size).size();
    }

    public int occupiedSpots() {
        return activeTickets.size();
    }

    /** Smallest fitting size first (keeps large spots for vans), nearest spot within a size. */
    private Optional<ParkingSpot> takeBestFit(VehicleType type) {
        for (SpotSize size : SpotSize.values()) {
            if (type.fitsIn(size)) {
                ParkingSpot spot = freeSpots.get(size).pollFirst();
                if (spot != null) {
                    return Optional.of(spot);
                }
            }
        }
        return Optional.empty();
    }
}

Karmaşıklık (S: yer sayısı, B: boyut sayısı, sabit 3): park O(B log S), leave O(log S), availableSpots O(S) (küme sayımı; sık çağrılacaksa boyut başına AtomicInteger sayaç tutulur).

Testler

javac -d out *.java && java -cp out ParkingLotTest ile çalışır; çıktı 8/8 tests passed. Eşzamanlılık testleri bir CountDownLatch ile tüm görevleri aynı anda başlatır: 50 kompakt yere 200 otomobil (tam 50 bilet, hepsi farklı yer), 16 kapıda aynı bilet (tek fiş).

Dosya: ParkingLotTest.java

java
import java.time.Clock;
import java.time.Duration;
import java.time.Instant;
import java.time.ZoneId;
import java.time.ZoneOffset;
import java.util.ArrayList;
import java.util.HashSet;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.concurrent.Callable;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.Future;

public final class ParkingLotTest {

    /** Clock the test can move; tests never sleep. */
    static final class MutableClock extends Clock {
        private volatile Instant now = Instant.parse("2026-10-22T08:00:00Z");
        void advance(Duration d) { now = now.plus(d); }
        @Override public Instant instant() { return now; }
        @Override public ZoneId getZone() { return ZoneOffset.UTC; }
        @Override public Clock withZone(ZoneId zone) { throw new UnsupportedOperationException(); }
    }

    static final PricingStrategy HOURLY = new HourlyPricing(
            Map.of(VehicleType.MOTORCYCLE, 2_000L, VehicleType.CAR, 5_000L, VehicleType.VAN, 8_000L),
            Duration.ofMinutes(15), 40_000L);

    interface TestCase { void run() throws Exception; }

    public static void main(String[] args) throws Exception {
        Map<String, TestCase> tests = new LinkedHashMap<>();
        tests.put("level1_parkAndLeave", ParkingLotTest::level1_parkAndLeave);
        tests.put("level1_fullLotAndDuplicatePlate", ParkingLotTest::level1_fullLotAndDuplicatePlate);
        tests.put("level2_bestFitAndNearestSpot", ParkingLotTest::level2_bestFitAndNearestSpot);
        tests.put("level2_vanNeedsLargeSpot", ParkingLotTest::level2_vanNeedsLargeSpot);
        tests.put("level3_hourlyPricingRules", ParkingLotTest::level3_hourlyPricingRules);
        tests.put("level3_pricingCanBeSwapped", ParkingLotTest::level3_pricingCanBeSwapped);
        tests.put("level4_concurrentEntriesNeverShareASpot", ParkingLotTest::level4_concurrentEntriesNeverShareASpot);
        tests.put("level4_doubleExitChargesOnce", ParkingLotTest::level4_doubleExitChargesOnce);

        int passed = 0;
        for (var test : tests.entrySet()) {
            test.getValue().run();
            System.out.println("PASS " + test.getKey());
            passed++;
        }
        System.out.println(passed + "/" + tests.size() + " tests passed");
    }

    static void level1_parkAndLeave() {
        var clock = new MutableClock();
        var lot = new ParkingLot(spots(1, 0, 2, 0), HOURLY, clock);
        Ticket ticket = lot.park(car("34 ABC 01")).orElseThrow();
        check(lot.occupiedSpots() == 1 && lot.availableSpots(SpotSize.COMPACT) == 1, "one taken");
        clock.advance(Duration.ofMinutes(90));
        Receipt receipt = lot.leave(ticket.id()).orElseThrow();
        check(receipt.fee() == 10_000 && receipt.parkedFor().equals(Duration.ofMinutes(90)), "2 started hours");
        check(lot.availableSpots(SpotSize.COMPACT) == 2, "spot returned");
        check(lot.leave(ticket.id()).isEmpty(), "ticket cannot be reused");
    }

    static void level1_fullLotAndDuplicatePlate() {
        var lot = new ParkingLot(spots(1, 0, 1, 0), HOURLY, new MutableClock());
        check(lot.park(car("A")).isPresent(), "first car");
        check(lot.park(car("A")).isEmpty(), "same plate twice");
        check(lot.park(car("B")).isEmpty(), "lot full");
    }

    static void level2_bestFitAndNearestSpot() {
        var lot = new ParkingLot(List.of(
                new ParkingSpot(2, 1, SpotSize.SMALL),
                new ParkingSpot(1, 5, SpotSize.COMPACT),
                new ParkingSpot(1, 2, SpotSize.COMPACT),
                new ParkingSpot(0, 1, SpotSize.LARGE)), HOURLY, new MutableClock());
        Ticket moto = lot.park(new Vehicle("M1", VehicleType.MOTORCYCLE)).orElseThrow();
        check(moto.spot().size() == SpotSize.SMALL, "motorcycle takes small even if it is farther");
        Ticket moto2 = lot.park(new Vehicle("M2", VehicleType.MOTORCYCLE)).orElseThrow();
        check(moto2.spot().equals(new ParkingSpot(1, 2, SpotSize.COMPACT)), "then nearest compact: " + moto2.spot());
        Ticket car = lot.park(car("C1")).orElseThrow();
        check(car.spot().equals(new ParkingSpot(1, 5, SpotSize.COMPACT)), "car takes compact before large");
        Ticket car2 = lot.park(car("C2")).orElseThrow();
        check(car2.spot().size() == SpotSize.LARGE, "large only when nothing smaller fits");
    }

    static void level2_vanNeedsLargeSpot() {
        var lot = new ParkingLot(spots(1, 3, 3, 0), HOURLY, new MutableClock());
        check(lot.park(new Vehicle("V", VehicleType.VAN)).isEmpty(), "no large spot");
    }

    static void level3_hourlyPricingRules() {
        check(HOURLY.feeFor(VehicleType.CAR, Duration.ofMinutes(15)) == 0, "grace period inclusive");
        check(HOURLY.feeFor(VehicleType.CAR, Duration.ofMinutes(16)) == 5_000, "first started hour");
        check(HOURLY.feeFor(VehicleType.CAR, Duration.ofMinutes(61)) == 10_000, "61 min = 2 hours");
        check(HOURLY.feeFor(VehicleType.CAR, Duration.ofHours(10)) == 40_000, "daily cap");
        check(HOURLY.feeFor(VehicleType.CAR, Duration.ofHours(26)) == 50_000, "1 day cap + 2 hours");
        check(HOURLY.feeFor(VehicleType.VAN, Duration.ofHours(2)) == 16_000, "rate per type");
        expectThrows(IllegalArgumentException.class,
                () -> new HourlyPricing(Map.of(VehicleType.CAR, 1L), Duration.ZERO, 1));
    }

    static void level3_pricingCanBeSwapped() {
        var clock = new MutableClock();
        var lot = new ParkingLot(spots(1, 0, 2, 0), HOURLY, clock);
        Ticket ticket = lot.park(car("E")).orElseThrow();
        lot.changePricing(new FlatPricing(Map.of(
                VehicleType.MOTORCYCLE, 10_000L, VehicleType.CAR, 25_000L, VehicleType.VAN, 40_000L)));
        clock.advance(Duration.ofHours(5));
        check(lot.leave(ticket.id()).orElseThrow().fee() == 25_000, "event price at exit");
    }

    static void level4_concurrentEntriesNeverShareASpot() throws Exception {
        var lot = new ParkingLot(spots(1, 0, 50, 0), HOURLY, new MutableClock());
        List<Optional<Ticket>> results = runConcurrently(200, i -> lot.park(car("P" + i)));
        List<Ticket> tickets = results.stream().flatMap(Optional::stream).toList();
        check(tickets.size() == 50, "exactly capacity: " + tickets.size());
        check(new HashSet<>(tickets.stream().map(Ticket::spot).toList()).size() == 50, "all spots distinct");
        check(lot.availableSpots(SpotSize.COMPACT) == 0, "full");

        List<Optional<Receipt>> exits = runConcurrently(50, i -> lot.leave(tickets.get(i).id()));
        check(exits.stream().allMatch(Optional::isPresent) && lot.availableSpots(SpotSize.COMPACT) == 50, "all back");
    }

    static void level4_doubleExitChargesOnce() throws Exception {
        var lot = new ParkingLot(spots(1, 0, 1, 0), HOURLY, new MutableClock());
        String id = lot.park(car("X")).orElseThrow().id();
        List<Optional<Receipt>> exits = runConcurrently(16, i -> lot.leave(id));   // 16 gates scan the same ticket
        check(exits.stream().filter(Optional::isPresent).count() == 1, "one receipt");
        check(lot.availableSpots(SpotSize.COMPACT) == 1, "spot added back once");
    }

    // ---------- helpers ----------

    /** floors x (small, compact, large) spots on every floor. */
    static List<ParkingSpot> spots(int floors, int small, int compact, int large) {
        List<ParkingSpot> spots = new ArrayList<>();
        for (int floor = 0; floor < floors; floor++) {
            int number = 1;
            for (int i = 0; i < small; i++) spots.add(new ParkingSpot(floor, number++, SpotSize.SMALL));
            for (int i = 0; i < compact; i++) spots.add(new ParkingSpot(floor, number++, SpotSize.COMPACT));
            for (int i = 0; i < large; i++) spots.add(new ParkingSpot(floor, number++, SpotSize.LARGE));
        }
        return spots;
    }

    static Vehicle car(String plate) {
        return new Vehicle(plate, VehicleType.CAR);
    }

    interface IndexedTask<T> { T run(int index); }

    /** Starts all tasks at the same moment to maximize contention. */
    static <T> List<T> runConcurrently(int count, IndexedTask<T> task) throws Exception {
        ExecutorService pool = Executors.newFixedThreadPool(16);
        try {
            var start = new CountDownLatch(1);
            List<Future<T>> futures = new ArrayList<>();
            for (int i = 0; i < count; i++) {
                int index = i;
                futures.add(pool.submit((Callable<T>) () -> {
                    start.await();
                    return task.run(index);
                }));
            }
            start.countDown();
            List<T> results = new ArrayList<>();
            for (Future<T> f : futures) results.add(f.get());
            return results;
        } finally {
            pool.shutdownNow();
        }
    }

    static void check(boolean condition, String message) {
        if (!condition) {
            throw new AssertionError(message);
        }
    }

    static void expectThrows(Class<? extends Throwable> expected, Runnable action) {
        try {
            action.run();
        } catch (Throwable t) {
            if (expected.isInstance(t)) {
                return;
            }
            throw new AssertionError("expected " + expected.getSimpleName() + " but got " + t);
        }
        throw new AssertionError("expected " + expected.getSimpleName() + " but nothing was thrown");
    }
}

Mülakatçı için

  • Gereksinimi netleştirme: İlk 5 dakikada araç tiplerini, sığma kuralını, yer seçme önceliğini, ücret kurallarını, kapı sayısını ve kapsam dışını soruyor ve bunları yazılı bir listeye döküyor.
  • Seviyeler arası refactor: Araç tipi eklendiğinde ParkingLot'u değiştirmiyor (bilgi enum'da); ücret kuralı değiştiğinde yeni bir strateji yazıyor; eşzamanlılık eklendiğinde public API değişmiyor, sadece iç koleksiyonlar değişiyor.
  • Test yazma: Ücret sınırlarını (15/16 dk, 60/61 dk, günlük tavan, çok günlü) enjekte edilen saatle test ediyor; eşzamanlılık için kesin beklenen sonuçlu (tam kapasite, tek fiş) testler yazıyor.
  • Kod kalitesi: Değer nesnelerini değişmez record olarak, değişen durumu tek bir sınıfta tutuyor; double para ve if (type == CAR) zinciri kullanmıyor; geçersiz konfigürasyonu başlangıçta reddediyor; check-then-act yarışını adıyla açıklıyor.
  • Zaman yönetimi: Model ve diyagramı 10 dakikada bitirip çalışan koda geçiyor; ödeme entegrasyonu, rezervasyon gibi kapsam dışı konulara kod yazmadan "arayüz olarak şöyle eklerdim" diyerek değiniyor.

Takip soruları:

  1. Elektrikli araç şarj yerleri eklenecek: elektrikli araç önce şarjlı yere gitsin, yoksa normal yere. Hangi sınıflar değişir? Yer boyutu ile yer özelliği (şarj, engelli) ayrı boyutlar mı olmalı?
  2. Otoparkın 10 şubesi var ve sistem tek bir serviste çalışacak; ParkingLot durumu bellekte kalamaz. Yer atamasını veritabanında nasıl atomik yaparsın? (SELECT ... FOR UPDATE SKIP LOCKED ya da koşullu UPDATE.)
  3. Ödeme çıkışta başarısız olursa bilet ne olur? Bilet durumunu (ACTIVE, PAYMENT_PENDING, CLOSED) nasıl modellersin, leave iki adıma nasıl bölünür?

Hızlı tekrar

S: Otoparkta boş yerleri neden yer nesnesindeki bir occupied bayrağı yerine bir koleksiyonda tutarız?

C: "En yakın boş yer" kümenin ilk elemanı olur (tarama yok) ve eşzamanlılıkta tek bir atomik pollFirst yer almayı güvenli yapar.

S: Park ücretlendirmesinde Strategy deseni neyi çözer?

C: Tarife kuralları değiştiğinde ya da çoğaldığında ParkingLot değişmez; yeni bir PricingStrategy yazılır ve çalışma anında değiştirilebilir.

S: Aynı bilet iki çıkış kapısında aynı anda okutulursa çift ücret nasıl önlenir?

C: Aktif biletler ConcurrentHashMap'te; remove(ticketId) atomiktir ve sadece bir thread bileti alır, diğeri boş döner.

S: Zamana bağlı iş kuralları nasıl test edilebilir yapılır?

C: java.time.Clock enjekte edilir; testte elle ilerletilen bir saat kullanılır, sleep gerekmez.