← Back to list

Aggregate Tasarım Kararları: Somut Senaryolar — Ek B.K9

Part B — Implementation Pack (çalışır kod + kampanya bazlı detaylı uygulama ekleri)

Sahin Yelkenci · 2026-06-14 07:40 · 0 claps · 13.3 min read
#domain-driven-design #aggregate-design #implementation-guide #sequence-diagrams #class-diagram
Open on Medium ↗

Aggregate Tasarım Kararları: Somut Senaryolar — Ek B.K9 — Coupon: Implementation — YENİ aggregate Coupon + CouponBucket child, ayrı CouponUsage aggregate, two-phase counter (reserve/commit/release), 10 bucket → 10x throughput

Part B — Implementation Pack (çalışır kod + kampanya bazlı detaylı uygulama ekleri)

Bu ek hangi senaryoyu çözüyor? “Yaza özel %15 indirim, kupon kodu YAZ2026, en fazla 100.000 kullanım.” Kullanıcı sepete kuponu girdiğinde global bir sayaç tüketiliyor. Black Friday’in 1/10'u kadar yoğun bir gün, dakikada 5.000 kupon talebi geliyor. Tek bir satır üzerinde counter güncellemesi yaparsa hot row, kilit kuyruğu, throughput çöker. Aynı kullanıcı kuponu iki kez kullanmak isteyebilir — single-use disiplini ayrı bir aggregate ister. Sipariş tamamlanmadan kupon “harcandı” sayılırsa, vazgeçilen sepetler stoğu boşa tüketir; tamamlandıktan sonra harcanırsa, paralel talepler limit’i aşar. Hibrit cevap: two-phase counter — önce rezerve et (sepet kilidinde), sonra commit veya release et (sipariş tamam veya iptal).

Bu ekte Coupon ayrı aggregate’tir. Cart, kupon ile sepet hesabını yapan tarafta; Coupon ise sayacı, dağıtım kovasını ve süre/limit kurallarını yöneten tarafta. CouponUsage ayrı bir aggregate’tir; “bu kullanıcı bu kuponu kullandı mı?” sorusunu tek-yazılık bir disiplinle cevaplar. K5'te kurulan StackingPolicy compatibility matrisine Coupon yeni bir aktör olarak girer.

Bu dosya, Bölüm 4.3 — Coupon: Çoklu Kullanım’nın görsel implementasyon eki.

1. Bağlam Tek Bakışta

Defne, yaza özel bir koleksiyon hazırladığı butiğin müşterisi. Marka, sezon başında basın bültenine “YAZ2026 kupon koduyla sepetinizde %15 indirim, ilk 100.000 müşteriye” diye duyuru yapıyor. Defne 14:30'da kupon kodunu sepete giriyor; aynı anda binlerce müşteri de aynı şeyi yapıyor. Sistemin verdiği söz ikilidir: (1) kuponun kullanım sayısı 100.000'i hiçbir koşulda aşmayacak, (2) Defne kuponu birden fazla kez kullanamayacak. Bu iki sözün ardındaki teknik disiplin tamamen farklıdır.

İlk söz “kaç kez kullanıldı” sorusunun cevabıdır; tek bir sayacı korumak gerekir. Naif yaklaşım: Coupon aggregate'inde bir usageCount field'ı, her sipariş sonunda usageCount++. Bu yaklaşım bir grup koşula kadar çalışır — saniyede 50 talep gibi düşük yoğunluk. Ama Defne'nin kupon kampanyası başlatıldığında, sayaca yapılan istekler bir kuyruğa giriyor; her transaction önceki transaction'ı bekliyor. PostgreSQL'de tek satıra exclusive lock alınmış, sıra sırasını bekleyen 800 connection birikmiş. Bu hot row sorunudur. Çözüm: tek satırı 10 satıra bölmek. Coupon aggregate'i içinde 10 CouponBucket child entity tutuluyor; her bucket 100_000 / 10 = 10.000 kapasite taşıyor. Talep geldiğinde rastgele bir bucket seçilir, oradan rezerve edilir. 10 ayrı satıra paralel artırım yapılabildiği için throughput 10x artar.

İkinci söz “bu kullanıcı bu kuponu daha önce kullandı mı” sorusunun cevabıdır; bu Coupon aggregate’inin sorumluluğu değildir. Coupon, kuponun kendi limitini tutar; kullanıcı-bazlı geçmişi tutmaz. Bu sorumluluk CouponUsage aggregate’inindir: (couponCode, customerId) ikilisi unique constraint ile bir tablo oluşturur, her satır tek bir kullanıma karşılık gelir. Defne aynı kuponu iki kez denerse, ikinci insert'te database-level UNIQUE violation gelir; biz bu hatayı domain exception'a çeviririz. Ayrıştırma sebebi şu: Coupon aggregate'i milyonlarca okuyucuya açık (sayacı kontrol etmek için), CouponUsage tablosu ise sadece sipariş onay anında insert/upsert görür. Birbirinden farklı erişim örüntülerini farklı transaction sınırlarına ayırmak — bu bölümün sessiz disiplinidir.

Two-phase counter olayın derinlikli kısmıdır. Naif tasarım “siparişi al, sonra kuponu artır” der. Ama Defne sepeti kilitledikten sonra kart bilgisi girip 2 dakika bekliyor. O 2 dakikada, ona ayırılmış kupon kullanımı dünyanın geri kalanına da açık görünüyor. Sayaç 99.998'de iken 5 paralel müşteri aynı anda sepet kilitlerse, beşi de “kupon hâlâ var” cevabını alır; sipariş onay anında dördü başarısız olur (counter dolmuştur). Bu hayal kırıklığı sistemini sallayan bir UX sorunudur. Çözüm: kuponu sepete uygulama anında rezerve et (counter +1), sipariş onayında commit et (rezerve kalıcı sayar), sepet iptal edilirse release et (counter -1, başkasına döner). Counter “rezerve edildi” ve “tamamlandı” olarak iki farklı değer tutar; rezerve TTL’i ile (15 dakika) zombi rezervasyonlar otomatik temizlenir. Sepet kilidi süresi ile kupon rezervasyonu aynı yaşam döngüsünü paylaşır.

K5'te kurulan StackingPolicy compatibility matrisi şimdi Coupon kampanyasını da kapsar. Coupon, Tier ile compatible (toplama uygulanır), Bundle ile mutually exclusive (kullanıcı tercih eder), B2G1 ile compatible. Cart aggregate’inde applyCoupon use case'i StackingPolicy.check'i çağırır; çatışma varsa ResolutionStrategy'e göre karar alır. Coupon nesnesi cart'a CouponDiscount (K5'te tanımlı AppliedDiscount.permits listesindeki tip) olarak yansır.

2. User Journey Sequence Diagram

Sıra diyagramı önemli bir gerçeği görselleştiriyor: rezervasyon ve commit iki farklı transaction’da. Rezervasyon, sepete kuponun uygulandığı anda; commit, siparişin onaylandığı anda. Aralarında kullanıcı kart bilgisi girer, taksit seçer, vazgeçer. Vazgeçme durumunda (sepet kilidi expire olursa) bir scheduled job rezervasyonu otomatik release eder.

3. DDD Katmanları Diyagramı

İki ayrı aggregate’in (Coupon, CouponUsage) ayrı repository’lere bağlandığına dikkat. ApplyCouponUseCase her ikisine de erişir; ama ApplyCouponUseCase tek transaction içinde sadece Coupon (rezerve) ve Cart’ı (kupon yansıması) günceller. CouponUsage insert’i ConfirmOrderUseCase’e ait — sipariş onayında bir başka transaction’da yapılır.

4. Aggregate Anatomi

Anatomide görünmeyen bir karar: Coupon aggregate'i bucket'larını contains ile tutuyor (kompozisyon), CouponUsage ise sadece CouponCode referansı ile (ID by reference). Bu DDD'nin temel kuralı: bir aggregate başka bir aggregate'i ID ile referans eder, embed etmez. Coupon ile Bucket yaşam döngüsünü paylaşır (kupon silinirse bucket'lar da silinir); ama CouponUsage Coupon'dan bağımsızdır — kupon iptal edilse de geçmiş kullanım kayıtları durur.

5. Decision Tree — Bucket Selection ve Reservation

Karar ağacının kritik dalı Failover: rastgele seçilen bucket dolu çıkarsa, sistem başka bir bucket’a yönelir. MAX_RETRIES = 5 — 5 deneme sonrası hâlâ boş bucket bulunamadıysa kupon gerçekten tükenmiştir; CouponExhausted hatası dürüsttür. K6 Flash Sale'deki bucket failover ile aynı mantık.

6. Event Flow

Olay akışı üç farklı transaction’a yayılıyor: rezerve, commit ve TTL reaper. Üçü de outbox üzerinden Kafka’ya akar; downstream handler’lar olayı sıralı işler. CouponUsageRecorded olayı CRM ve sadakat sistemleri için kritik — müşterinin hangi kuponu, ne zaman, hangi siparişte kullandığı bilgisi başka bağlamlarda işlenir.

9. Bağlantılar

  • **Bölüm 2.4 (Kapasite ve Bucketing):** Bucket pattern’in ilk introduce edildiği yer; aynı disiplin burada Coupon counter’a uygulanıyor.
  • **Bölüm 1.4 (Outbox Pattern):** CouponReserved/Committed/Released eventleri için fundament.
  • **Bölüm 2.7 (Saga ve Telafi):** Two-phase counter Saga’nın özel durumu; reserve+commit+compensate.
  • **Ek B.K5 (Stack):** Coupon, StackingPolicy compatibility matrisinde diğer kampanyalarla birlikte değerlendirilir.
  • **Ek B.K6 (Flash Sale):** Bucketing + failover + reservation token aynı pattern.
  • **Ek B.K8 (Voucher):** Voucher tek-kullanımlık; Coupon birden çok kullanımlık. Karşılaştırma için.

10. Bu Kampanya İçin Değişen Kod

Bu bölümden itibaren commerce-platform starter projesi üzerine Coupon kampanyasını ekliyoruz. Toplam değişen dosya: 12 yeni + 2 değişen + 1 migration = 15 dosya.

10.1. Yeni Migration: V5__create_coupon.sql

src/main/resources/db/migration/V5__create_coupon.sql:

CREATE TABLE coupon (
    id              UUID PRIMARY KEY,
    code            VARCHAR(50) NOT NULL UNIQUE,
    formula         JSONB NOT NULL,
    starts_at       TIMESTAMPTZ NOT NULL,
    ends_at         TIMESTAMPTZ NOT NULL,
    total_capacity  INT NOT NULL CHECK (total_capacity > 0),
    per_customer    INT NOT NULL DEFAULT 1,
    strategy        JSONB NOT NULL,
    status          JSONB NOT NULL,
    version         BIGINT NOT NULL DEFAULT 0,
    created_at      TIMESTAMPTZ NOT NULL,
    updated_at      TIMESTAMPTZ NOT NULL
);

CREATE INDEX idx_coupon_code ON coupon (code);
CREATE INDEX idx_coupon_active
    ON coupon (code)
    WHERE (status->>'type' = 'Active');
CREATE TABLE coupon_bucket (
    id              UUID PRIMARY KEY,
    coupon_id       UUID NOT NULL REFERENCES coupon(id) ON DELETE CASCADE,
    bucket_index    INT NOT NULL,
    capacity        INT NOT NULL CHECK (capacity > 0),
    reserved        INT NOT NULL DEFAULT 0 CHECK (reserved >= 0),
    committed       INT NOT NULL DEFAULT 0 CHECK (committed >= 0),
    active_reservations JSONB NOT NULL DEFAULT '{}'::jsonb,
    created_at      TIMESTAMPTZ NOT NULL,
    UNIQUE (coupon_id, bucket_index),
    CHECK (reserved + committed <= capacity)
);
CREATE INDEX idx_coupon_bucket_coupon ON coupon_bucket (coupon_id);
CREATE TABLE coupon_usage (
    id              UUID PRIMARY KEY,
    coupon_code     VARCHAR(50) NOT NULL,
    customer_id     UUID NOT NULL,
    order_id        UUID NOT NULL,
    confirmed_at    TIMESTAMPTZ NOT NULL,
    UNIQUE (coupon_code, customer_id)
);
CREATE INDEX idx_coupon_usage_customer ON coupon_usage (customer_id);

UNIQUE (coupon_code, customer_id) ikilisi tek-kullanım disiplinini database düzeyinde garantiler. CHECK reserved + committed <= capacity ise bucket sayacının taşmasını engeller.

10.2. Yeni Value Objects

CouponCode

src/main/java/com/acme/commerce/coupon/domain/CouponCode.java:

package com.acme.commerce.coupon.domain;

import jakarta.persistence.Embeddable;
import java.util.Objects;
@Embeddable
public record CouponCode(String value) {
    public CouponCode {
        Objects.requireNonNull(value);
        if (value.isBlank() || value.length() > 50) {
            throw new IllegalArgumentException("Invalid coupon code");
        }
        value = value.trim().toUpperCase();
    }
    public static CouponCode of(String s) { return new CouponCode(s); }
}

TimeBound

src/main/java/com/acme/commerce/coupon/domain/TimeBound.java:

package com.acme.commerce.coupon.domain;

import jakarta.persistence.Embeddable;
import java.time.Instant;
import java.util.Objects;
@Embeddable
public record TimeBound(Instant startsAt, Instant endsAt) {
    public TimeBound {
        Objects.requireNonNull(startsAt);
        Objects.requireNonNull(endsAt);
        if (!endsAt.isAfter(startsAt)) {
            throw new IllegalArgumentException("endsAt must be after startsAt");
        }
    }
    public boolean contains(Instant moment) {
        return !moment.isBefore(startsAt) && moment.isBefore(endsAt);
    }
}

ReservationToken

src/main/java/com/acme/commerce/coupon/domain/ReservationToken.java:

package com.acme.commerce.coupon.domain;

import jakarta.persistence.Embeddable;
import java.util.UUID;
@Embeddable
public record ReservationToken(UUID value) {
    public static ReservationToken generate() {
        return new ReservationToken(UUID.randomUUID());
    }
    public static ReservationToken of(String s) {
        return new ReservationToken(UUID.fromString(s));
    }
}

10.3. Sealed Types: DiscountFormula, CouponStatus, BucketSelectionStrategy

DiscountFormula

src/main/java/com/acme/commerce/coupon/domain/DiscountFormula.java:

package com.acme.commerce.coupon.domain;

import com.acme.commerce.shared.valueobjects.Money;
import com.fasterxml.jackson.annotation.JsonSubTypes;
import com.fasterxml.jackson.annotation.JsonTypeInfo;
import java.math.BigDecimal;
@JsonTypeInfo(use = JsonTypeInfo.Id.NAME, property = "type")
@JsonSubTypes({
        @JsonSubTypes.Type(value = DiscountFormula.PercentageOff.class, name = "PercentageOff"),
        @JsonSubTypes.Type(value = DiscountFormula.FixedAmountOff.class, name = "FixedAmountOff")
})
public sealed interface DiscountFormula
        permits DiscountFormula.PercentageOff, DiscountFormula.FixedAmountOff {
    Money apply(Money subtotal);
    record PercentageOff(BigDecimal percentage, Money maxDiscount) implements DiscountFormula {
        public PercentageOff {
            if (percentage.signum() <= 0 || percentage.compareTo(BigDecimal.valueOf(100)) > 0) {
                throw new IllegalArgumentException("Percentage 0-100 arası olmalı");
            }
        }
        @Override
        public Money apply(Money subtotal) {
            Money raw = subtotal.percentageOf(percentage);
            return raw.isGreaterThan(maxDiscount) ? maxDiscount : raw;
        }
    }
    record FixedAmountOff(Money amount) implements DiscountFormula {
        @Override
        public Money apply(Money subtotal) {
            return subtotal.isGreaterThanOrEqualTo(amount) ? amount : subtotal;
        }
    }
}

CouponStatus

src/main/java/com/acme/commerce/coupon/domain/CouponStatus.java:

package com.acme.commerce.coupon.domain;

import com.fasterxml.jackson.annotation.JsonSubTypes;
import com.fasterxml.jackson.annotation.JsonTypeInfo;
import java.time.Instant;
@JsonTypeInfo(use = JsonTypeInfo.Id.NAME, property = "type")
@JsonSubTypes({
        @JsonSubTypes.Type(value = CouponStatus.Active.class, name = "Active"),
        @JsonSubTypes.Type(value = CouponStatus.Paused.class, name = "Paused"),
        @JsonSubTypes.Type(value = CouponStatus.Exhausted.class, name = "Exhausted")
})
public sealed interface CouponStatus
        permits CouponStatus.Active, CouponStatus.Paused, CouponStatus.Exhausted {
    Instant transitionedAt();
    record Active(Instant transitionedAt) implements CouponStatus {}
    record Paused(Instant transitionedAt, String reason) implements CouponStatus {}
    record Exhausted(Instant transitionedAt) implements CouponStatus {}
}

BucketSelectionStrategy

src/main/java/com/acme/commerce/coupon/domain/BucketSelectionStrategy.java:

package com.acme.commerce.coupon.domain;

import com.fasterxml.jackson.annotation.JsonSubTypes;
import com.fasterxml.jackson.annotation.JsonTypeInfo;
import java.util.List;
import java.util.Random;
import java.util.UUID;
@JsonTypeInfo(use = JsonTypeInfo.Id.NAME, property = "type")
@JsonSubTypes({
        @JsonSubTypes.Type(value = BucketSelectionStrategy.RandomPick.class, name = "Random"),
        @JsonSubTypes.Type(value = BucketSelectionStrategy.ConsistentHash.class, name = "ConsistentHash")
})
public sealed interface BucketSelectionStrategy
        permits BucketSelectionStrategy.RandomPick, BucketSelectionStrategy.ConsistentHash {
    int pick(int bucketCount, UUID seed);
    record RandomPick() implements BucketSelectionStrategy {
        private static final Random RNG = new Random();
        @Override public int pick(int bucketCount, UUID seed) {
            return RNG.nextInt(bucketCount);
        }
    }
    record ConsistentHash() implements BucketSelectionStrategy {
        @Override public int pick(int bucketCount, UUID seed) {
            return Math.floorMod(seed.hashCode(), bucketCount);
        }
    }
}

10.4. Coupon Aggregate Root

src/main/java/com/acme/commerce/coupon/domain/Coupon.java:

package com.acme.commerce.coupon.domain;

import com.acme.commerce.customer.domain.CustomerId;
import com.acme.commerce.shared.domain.AggregateRoot;
import com.acme.commerce.coupon.domain.events.CouponReserved;
import com.acme.commerce.coupon.domain.events.CouponCommitted;
import com.acme.commerce.coupon.domain.events.CouponReleased;
import java.time.Clock;
import java.time.Duration;
import java.time.Instant;
import java.util.*;
public class Coupon extends AggregateRoot {
    private static final int MAX_BUCKET_FAILOVER = 5;
    private static final Duration RESERVATION_TTL = Duration.ofMinutes(15);
    private CouponId id;
    private CouponCode code;
    private DiscountFormula formula;
    private TimeBound timeBound;
    private CouponLimit limit;
    private final Map<BucketId, CouponBucket> buckets = new LinkedHashMap<>();
    private BucketSelectionStrategy strategy;
    private CouponStatus status;
    private long version;
    private Instant createdAt;
    private Instant updatedAt;
    public static Coupon create(CouponCode code, DiscountFormula formula,
                                 TimeBound timeBound, CouponLimit limit,
                                 int bucketCount, BucketSelectionStrategy strategy,
                                 Clock clock) {
        Objects.requireNonNull(code);
        if (bucketCount <= 0) throw new IllegalArgumentException("bucketCount > 0");
        if (limit.totalCapacity() < bucketCount)
            throw new IllegalArgumentException("totalCapacity en az bucketCount kadar olmalı");
        Coupon c = new Coupon();
        c.id = CouponId.generate();
        c.code = code;
        c.formula = formula;
        c.timeBound = timeBound;
        c.limit = limit;
        c.strategy = strategy;
        Instant now = clock.instant();
        c.status = new CouponStatus.Active(now);
        c.createdAt = now;
        c.updatedAt = now;
        int perBucket = limit.totalCapacity() / bucketCount;
        int remainder = limit.totalCapacity() % bucketCount;
        for (int i = 0; i < bucketCount; i++) {
            int cap = perBucket + (i < remainder ? 1 : 0);
            CouponBucket b = CouponBucket.create(i, cap, now);
            c.buckets.put(b.id(), b);
        }
        return c;
    }
    public ReservationResult tryReserve(CustomerId customerId, Clock clock) {
        if (!(status instanceof CouponStatus.Active)) {
            return new ReservationResult.Rejected("Coupon not active");
        }
        Instant now = clock.instant();
        if (!timeBound.contains(now)) {
            return new ReservationResult.Rejected("Coupon time window closed");
        }
        List<BucketId> tried = new ArrayList<>();
        UUID seed = customerId.value();
        BucketId[] order = buckets.keySet().toArray(new BucketId[0]);
        for (int attempt = 0; attempt < Math.min(MAX_BUCKET_FAILOVER, order.length); attempt++) {
            int idx = strategy.pick(order.length, seed);
            BucketId picked = order[idx];
            if (tried.contains(picked)) {
                seed = new UUID(seed.getMostSignificantBits() ^ attempt, seed.getLeastSignificantBits());
                continue;
            }
            tried.add(picked);
            CouponBucket bucket = buckets.get(picked);
            ReservationToken token = ReservationToken.generate();
            if (bucket.tryReserve(token, RESERVATION_TTL, clock)) {
                this.updatedAt = now;
                register(new CouponReserved(id, code, customerId, picked, token, now));
                if (totalReserved() + totalCommitted() >= limit.totalCapacity()) {
                    this.status = new CouponStatus.Exhausted(now);
                }
                return new ReservationResult.Success(token, picked);
            }
        }
        return new ReservationResult.Rejected("All buckets exhausted");
    }
    public void commitReservation(ReservationToken token, Clock clock) {
        for (CouponBucket b : buckets.values()) {
            if (b.commit(token)) {
                Instant now = clock.instant();
                this.updatedAt = now;
                register(new CouponCommitted(id, code, b.id(), token, now));
                return;
            }
        }
        throw new IllegalStateException("Reservation token not found: " + token);
    }
    public void releaseReservation(ReservationToken token, Clock clock) {
        for (CouponBucket b : buckets.values()) {
            if (b.release(token)) {
                Instant now = clock.instant();
                this.updatedAt = now;
                register(new CouponReleased(id, code, b.id(), token, now));
                return;
            }
        }
    }
    public int releaseExpiredReservations(Clock clock) {
        int total = 0;
        Instant now = clock.instant();
        for (CouponBucket b : buckets.values()) {
            List<ReservationToken> expired = b.expiredTokens(now);
            for (ReservationToken t : expired) {
                if (b.release(t)) {
                    register(new CouponReleased(id, code, b.id(), t, now));
                    total++;
                }
            }
        }
        if (total > 0) this.updatedAt = now;
        return total;
    }
    public int totalReserved() {
        return buckets.values().stream().mapToInt(CouponBucket::reserved).sum();
    }
    public int totalCommitted() {
        return buckets.values().stream().mapToInt(CouponBucket::committed).sum();
    }
    public int remainingCapacity() {
        return limit.totalCapacity() - totalReserved() - totalCommitted();
    }
    public CouponId id() { return id; }
    public CouponCode code() { return code; }
    public DiscountFormula formula() { return formula; }
    public TimeBound timeBound() { return timeBound; }
    public CouponLimit limit() { return limit; }
    public CouponStatus status() { return status; }
    public Collection<CouponBucket> buckets() { return Collections.unmodifiableCollection(buckets.values()); }
}

tryReserve metodunun döngüsü dikkat ister: aynı bucket'a tekrar düşmemek için seed mutate ediyoruz; en fazla MAX_BUCKET_FAILOVER deneme yapıyoruz. Tüm bucket'lar dolu çıkarsa Rejected dönüyoruz.

10.5. CouponBucket Child Entity

src/main/java/com/acme/commerce/coupon/domain/CouponBucket.java:

package com.acme.commerce.coupon.domain;

import java.time.Clock;
import java.time.Duration;
import java.time.Instant;
import java.util.*;
public class CouponBucket {
    private BucketId id;
    private int bucketIndex;
    private int capacity;
    private int reserved;
    private int committed;
    private final Map<ReservationToken, Instant> activeReservations = new HashMap<>();
    private Instant createdAt;
    public static CouponBucket create(int index, int capacity, Instant now) {
        CouponBucket b = new CouponBucket();
        b.id = BucketId.generate();
        b.bucketIndex = index;
        b.capacity = capacity;
        b.reserved = 0;
        b.committed = 0;
        b.createdAt = now;
        return b;
    }
    public boolean tryReserve(ReservationToken token, Duration ttl, Clock clock) {
        if (reserved + committed >= capacity) return false;
        Instant expiresAt = clock.instant().plus(ttl);
        activeReservations.put(token, expiresAt);
        reserved++;
        return true;
    }
    public boolean commit(ReservationToken token) {
        if (!activeReservations.containsKey(token)) return false;
        activeReservations.remove(token);
        reserved--;
        committed++;
        return true;
    }
    public boolean release(ReservationToken token) {
        if (!activeReservations.containsKey(token)) return false;
        activeReservations.remove(token);
        reserved--;
        return true;
    }
    public List<ReservationToken> expiredTokens(Instant now) {
        return activeReservations.entrySet().stream()
                .filter(e -> e.getValue().isBefore(now))
                .map(Map.Entry::getKey)
                .toList();
    }
    public int remaining() { return capacity - reserved - committed; }
    public BucketId id() { return id; }
    public int reserved() { return reserved; }
    public int committed() { return committed; }
    public int capacity() { return capacity; }
    public int bucketIndex() { return bucketIndex; }
}

tryReserve/commit/release üçü de aritmetiği aggregate içinde tutuyor; database CHECK constraint son güvenlik.

10.6. ReservationResult Sealed

src/main/java/com/acme/commerce/coupon/domain/ReservationResult.java:

package com.acme.commerce.coupon.domain;

public sealed interface ReservationResult
        permits ReservationResult.Success, ReservationResult.Rejected {
    record Success(ReservationToken token, BucketId pickedBucket) implements ReservationResult {}
    record Rejected(String reason) implements ReservationResult {}
}

10.7. CouponLimit, CouponId, BucketId

src/main/java/com/acme/commerce/coupon/domain/CouponLimit.java:

package com.acme.commerce.coupon.domain;

import jakarta.persistence.Embeddable;
@Embeddable
public record CouponLimit(int totalCapacity, int perCustomer) {
    public CouponLimit {
        if (totalCapacity <= 0) throw new IllegalArgumentException("totalCapacity > 0");
        if (perCustomer <= 0) throw new IllegalArgumentException("perCustomer > 0");
    }
}

CouponId ve BucketId standart EntityId record’ları (önceki kampanyalar gibi).

10.8. Domain Events

src/main/java/com/acme/commerce/coupon/domain/events/CouponReserved.java

package com.acme.commerce.coupon.domain.events;

import com.acme.commerce.coupon.domain.*;
import com.acme.commerce.customer.domain.CustomerId;
import com.acme.commerce.shared.domain.DomainEvent;
import java.time.Instant;
import java.util.UUID;
public record CouponReserved(
        CouponId couponId,
        CouponCode code,
        CustomerId customerId,
        BucketId bucketId,
        ReservationToken token,
        Instant occurredAt
) implements DomainEvent {
    @Override public UUID eventId() { return token.value(); }
}

CouponCommitted ve CouponReleased benzer şekilde — customerId yerine sadece bucketId ve token taşırlar.

10.9. CouponDiscount (AppliedDiscount permits genişlemesi)

src/main/java/com/acme/commerce/cart/domain/AppliedDiscount.java dosyasına yeni permits ekleniyor — K5'te zaten CouponDiscount permits olarak eklenmişti, burada record gövdesini tanımlıyoruz:

src/main/java/com/acme/commerce/cart/domain/CouponDiscount.java:

package com.acme.commerce.cart.domain;

import com.acme.commerce.coupon.domain.CouponCode;
import com.acme.commerce.coupon.domain.ReservationToken;
import com.acme.commerce.shared.valueobjects.Money;
import java.time.Instant;
import java.util.UUID;
public record CouponDiscount(
        UUID id,
        CouponCode code,
        ReservationToken reservationToken,
        Money savings,
        Instant appliedAt
) implements AppliedDiscount {
    @Override public UUID discountId() { return id; }
    @Override public Money savingsAmount() { return savings; }
    @Override public DiscountKind kind() { return DiscountKind.COUPON; }
}

10.10. Repository Ports & JPA Impls

CouponRepository, CouponJpaRepository, CouponRepositoryImpl — diğer aggregate'ler için yazılan pattern aynen uygulanır. CouponUsage için ayrı port ve repository:

src/main/java/com/acme/commerce/coupon/domain/CouponUsageRepository.java

package com.acme.commerce.coupon.domain;

import com.acme.commerce.customer.domain.CustomerId;
public interface CouponUsageRepository {
    boolean existsByCouponAndCustomer(CouponCode code, CustomerId customerId);
    void save(CouponUsage usage);
}

existsByCouponAndCustomer Customer/Coupon ikilisi UNIQUE olduğu için tek satır kontrolü; save insert yapar, race durumunda DB UNIQUE violation atar — application katmanı bunu CouponAlreadyUsedException'a çevirir.

10.11. ApplyCouponUseCase

src/main/java/com/acme/commerce/coupon/application/ApplyCouponUseCase.java:

package com.acme.commerce.coupon.application;

import com.acme.commerce.cart.domain.*;
import com.acme.commerce.coupon.domain.*;
import com.acme.commerce.customer.domain.CustomerId;
import com.acme.commerce.shared.valueobjects.Money;
import org.springframework.dao.DataIntegrityViolationException;
import org.springframework.retry.annotation.Backoff;
import org.springframework.retry.annotation.Retryable;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.time.Clock;
import java.util.UUID;
@Service
public class ApplyCouponUseCase {
    private final CartRepository cartRepository;
    private final CouponRepository couponRepository;
    private final CouponUsageRepository usageRepository;
    private final StackingPolicy stackingPolicy;
    private final Clock clock;
    public ApplyCouponUseCase(CartRepository cartRepository,
                               CouponRepository couponRepository,
                               CouponUsageRepository usageRepository,
                               StackingPolicy stackingPolicy,
                               Clock clock) {
        this.cartRepository = cartRepository;
        this.couponRepository = couponRepository;
        this.usageRepository = usageRepository;
        this.stackingPolicy = stackingPolicy;
        this.clock = clock;
    }
    @Retryable(maxAttempts = 4,
            backoff = @Backoff(delay = 100, multiplier = 2.0, random = true))
    @Transactional
    public ApplyResult execute(CartId cartId, String couponCodeStr) {
        CouponCode code = CouponCode.of(couponCodeStr);
        Cart cart = cartRepository.findById(cartId)
                .orElseThrow(() -> new CartNotFoundException(cartId));
        Coupon coupon = couponRepository.findByCode(code)
                .orElseThrow(() -> new CouponNotFoundException(code));
        CustomerId customerId = cart.customerId();
        if (usageRepository.existsByCouponAndCustomer(code, customerId)) {
            return new ApplyResult.Rejected("Bu kuponu daha önce kullandınız");
        }
        ReservationResult res = coupon.tryReserve(customerId, clock);
        if (res instanceof ReservationResult.Rejected r) {
            couponRepository.save(coupon);
            return new ApplyResult.Rejected(r.reason());
        }
        ReservationResult.Success success = (ReservationResult.Success) res;
        Money savings = coupon.formula().apply(cart.subtotal());
        CouponDiscount discount = new CouponDiscount(
                UUID.randomUUID(),
                code,
                success.token(),
                savings,
                clock.instant());
        StackResult stackResult = cart.applyCoupon(discount, stackingPolicy, clock);
        if (stackResult instanceof StackResult.Rejected sr) {
            coupon.releaseReservation(success.token(), clock);
            couponRepository.save(coupon);
            return new ApplyResult.Rejected(sr.reason());
        }
        cartRepository.save(cart);
        couponRepository.save(coupon);
        return new ApplyResult.Success(success.token(), savings);
    }
    public sealed interface ApplyResult permits ApplyResult.Success, ApplyResult.Rejected {
        record Success(ReservationToken token, Money savings) implements ApplyResult {}
        record Rejected(String reason) implements ApplyResult {}
    }
}

Use case akışı: kupon bul → kullanım kontrolü → bucket rezerve → stacking kontrolü → cart’a yansıt → kayıt. Stacking reddederse rezervasyonu geri al — bu “kompansasyon” disiplini Saga pattern’e uyumlu.

10.12. CouponReservationReaperJob

src/main/java/com/acme/commerce/coupon/application/CouponReservationReaperJob.java:

package com.acme.commerce.coupon.application;

import com.acme.commerce.coupon.domain.Coupon;
import com.acme.commerce.coupon.domain.CouponRepository;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;
import org.springframework.transaction.annotation.Transactional;
import java.time.Clock;
@Component
public class CouponReservationReaperJob {
    private final CouponRepository couponRepository;
    private final Clock clock;
    public CouponReservationReaperJob(CouponRepository couponRepository, Clock clock) {
        this.couponRepository = couponRepository;
        this.clock = clock;
    }
    @Scheduled(fixedDelay = 60_000)
    @Transactional
    public void reapExpiredReservations() {
        for (Coupon coupon : couponRepository.findAllActiveWithExpiringReservations(clock.instant())) {
            int released = coupon.releaseExpiredReservations(clock);
            if (released > 0) {
                couponRepository.save(coupon);
            }
        }
    }
}

Her dakika çalışır, TTL’i geçen rezervasyonları temizler. Production’da bu job ayrı bir leader-elected instance’da çalıştırılır — duplicate çalışma yarış koşulları yaratır.

10.13. Cart.applyCoupon (değişen kod)

K5'te tanımlanan Cart.applyDiscount metoduna paralel, yeni kupon-spesifik bir entry point. K5 stack mantığını yeniden kullanır:

src/main/java/com/acme/commerce/cart/domain/Cart.java içine eklenen method:

public StackResult applyCoupon(CouponDiscount coupon,
                                StackingPolicy stackingPolicy,
                                Clock clock) {
    return applyDiscount(coupon, stackingPolicy, clock);
}

İnce bir farkla: applyCoupon sadece tip-spesifik bir convenience metodu; underlying applyDiscount çalışır. Bu, K5 disiplinini bozmadan Coupon kampanyasının cart API'sini doğal yapar.

10.14. Compatibility Matrix güncellemesi

K5'te tanımlanan CompatibilityMatrix.defaultMatrix() metoduna Coupon kuralları eklenir:

// CompatibilityMatrix.java - defaultMatrix() içine ek
addRule(DiscountKind.COUPON, DiscountKind.TIERED, Rule.Compatible.INSTANCE);
addRule(DiscountKind.COUPON, DiscountKind.B2G1, Rule.Compatible.INSTANCE);
addRule(DiscountKind.COUPON, DiscountKind.BUNDLE,
        new Rule.MutuallyExclusive(ResolutionStrategy.HIGHER_SAVINGS));
addRule(DiscountKind.COUPON, DiscountKind.CROSS_CATEGORY, Rule.Compatible.INSTANCE);

Coupon, Bundle ile mutually exclusive (kullanıcı tercih eder, daha yüksek tasarruf kazanır); diğerleri ile uyumlu.

10.15. Test Senaryoları

src/test/java/com/acme/commerce/coupon/domain/CouponTest.java aggregate-level testler içerir; reserve/commit/release/expire/failover tüm path'leri kapsar. Fixed clock disiplini önceki test'lerde olduğu gibi uygulanır.

@Test
void reserveSuccessThenCommitDecreasesReservedIncreasesCommitted() {
    Coupon coupon = Coupon.create(
            CouponCode.of("YAZ2026"),
            new DiscountFormula.PercentageOff(BigDecimal.valueOf(15), Money.tl(500)),
            new TimeBound(NOW.minusSeconds(1), NOW.plusSeconds(86400)),
            new CouponLimit(100, 1),
            10,
            new BucketSelectionStrategy.RandomPick(),
            FIXED_CLOCK);

ReservationResult.Success res = (ReservationResult.Success)
            coupon.tryReserve(CustomerId.generate(), FIXED_CLOCK);
    assertThat(coupon.totalReserved()).isEqualTo(1);
    assertThat(coupon.totalCommitted()).isZero();
    coupon.commitReservation(res.token(), FIXED_CLOCK);
    assertThat(coupon.totalReserved()).isZero();
    assertThat(coupon.totalCommitted()).isEqualTo(1);
}

Toplam değişiklik özeti: 12 yeni dosya (CouponCode, TimeBound, ReservationToken, DiscountFormula, CouponStatus, BucketSelectionStrategy, Coupon, CouponBucket, ReservationResult, CouponLimit, CouponDiscount, ApplyCouponUseCase, CouponReservationReaperJob), 2 değişen dosya (Cart, CompatibilityMatrix), 1 yeni migration. Bucketing pattern + two-phase counter + ayrı CouponUsage aggregate üçlüsü, kupon kampanyasının ölçeklenebilir ve doğru çalışmasının üç ayağıdır.

**İçindekiler… **» Bölüm 4.3 — Coupon: Çoklu Kullanım, Two-Phase Counter, Bucketing (K9) » https://gitlab.com/sahin.yelkenci2/aggregate-design-commerce-platform


메타데이터
post_id
bd67cbd33b31
slug
aggregate-tasarım-kararları-somut-senaryolar-ek-b-k9-bd67cbd33b31
url
https://medium.com/@sahinyelkenci/aggregate-tasar%C4%B1m-kararlar%C4%B1-somut-senaryolar-ek-b-k9-bd67cbd33b31
canonical_url
https://medium.com/@sahinyelkenci/aggregate-tasar%C4%B1m-kararlar%C4%B1-somut-senaryolar-ek-b-k9-bd67cbd33b31
author_url
https://medium.com/@sahinyelkenci
status
ok
fetched_at
2026-08-10 13:32:26