← Back to list

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

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

Sahin Yelkenci · 2026-06-13 13:08 · 0 claps · 6.9 min read
#domain-driven-design #aggregate-design #implementation-guide #sequence-diagrams #class-diagram
Open on Medium ↗

Aggregate Tasarım Kararları: Somut Senaryolar — Ek B.K8 — Voucher: Implementation — YENİ aggregate Voucher, VoucherStatus sealed (5 dal), atomic claim + idempotency key + single-use disiplini

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

Bu dosya, Bölüm 4.2 — Tek Kullanımlık Voucher Kampanyaları’nın görsel implementasyon eki.

1. Bağlam Tek Bakışta

Voucher, müşteriye kişiye özel, tek kullanımlık bir indirim “biletidir”. Coupon (K9) ile karıştırılmamalı: Coupon paylaşılır kod (WELCOME10), milyonlarca kişi kullanır, kullanım sayacı tutulur. Voucher bireysel: her vouchera bir kişi atanmış, bir kez claim edilir, bitti.

Somut senaryo: Mira’nın doğum gününde sistem ona özel %15 voucher hediye etti. Voucher kodu BD-MIRA-2026-A4F2. Bu kodu sadece Mira kullanabilir; bir kez kullandığında diğer kim olursa hata. Aynı zamanda 30 gün geçerli; 30 gün sonra otomatik expire olur.

K8'in ana zorluğu: claim atomicity. Eğer Mira aynı anda iki tarayıcıdan voucher’ı claim etse, biri “Claimed” göstermeli, diğeri “Already claimed” hatası vermeli. Optimistic locking + state machine + idempotency key bunu garanti eder.

K9 (Coupon) ile farkları:

  • Voucher bireyseldir; assignedCustomerId field'ı zorunludur. Coupon paylaşılır.
  • Voucher tek kullanımlık ve bütünseldir; bucketing yok. Coupon yüksek volüm için bucket’lı.
  • Voucher state machine = Issued → Claimed → Used | Expired | Revoked.

2. User Journey: Sequence Diagram

Kritik: Claim ve Use ayrı state'ler. Mira voucher'ı claim ettiğinde sepete uygulanır (Claimed). Ödeme tamamlandığında Used'e geçer. Eğer ödeme yapmadan vazgeçerse releaseClaim ile geri Issued olabilir (eğer henüz expire olmadıysa).

3. DDD Katmanları

4. Voucher Aggregate Anatomisi

5. State Machine Decision Tree

6. Event Flow

Event listesi:

  1. VoucherIssued — admin yarattı / kampanyadan çıktı
  2. VoucherClaimed — kullanıcı sepete attığında
  3. VoucherUsed — ödeme tamamlanınca terminal
  4. VoucherReleased — sepetten çıkarıldı (Issued'a geri)
  5. VoucherExpired — süresi doldu
  6. VoucherRevoked — admin iptal etti

9. Bağlantılar

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

10.1 Yeni: VoucherId.java ve VoucherCode.java

package com.acme.commerce.voucher.domain;

import com.acme.commerce.shared.domain.EntityId;
import jakarta.persistence.Embeddable;
import java.util.UUID;
@Embeddable
public record VoucherId(UUID value) implements EntityId {
    public static VoucherId generate() { return new VoucherId(UUID.randomUUID()); }
}
package com.acme.commerce.voucher.domain;

import jakarta.persistence.Embeddable;
import java.util.regex.Pattern;
@Embeddable
public record VoucherCode(String value) {
    private static final Pattern PATTERN = Pattern.compile("^[A-Z0-9-]{6,32}$");
    public VoucherCode {
        if (value == null || !PATTERN.matcher(value).matches()) {
            throw new IllegalArgumentException("Invalid voucher code format");
        }
    }
    public static VoucherCode of(String value) {
        return new VoucherCode(value.toUpperCase());
    }
}

10.2 Yeni: VoucherType.java (sealed)

package com.acme.commerce.voucher.domain;

import com.acme.commerce.shared.valueobjects.Currency;
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 = VoucherType.FixedAmount.class, name = "FixedAmount"),
        @JsonSubTypes.Type(value = VoucherType.Percentage.class, name = "Percentage"),
        @JsonSubTypes.Type(value = VoucherType.FreeShipping.class, name = "FreeShipping")
})
public sealed interface VoucherType 
        permits VoucherType.FixedAmount, VoucherType.Percentage, VoucherType.FreeShipping {
    Money calculateDiscount(Money subtotal);
    record FixedAmount(Money amount) implements VoucherType {
        @Override
        public Money calculateDiscount(Money subtotal) {
            // Subtotal'dan büyük olamaz
            if (amount.isGreaterThan(subtotal)) return subtotal;
            return amount;
        }
    }
    record Percentage(BigDecimal percentage, Money maxDiscount) implements VoucherType {
        public Percentage {
            if (percentage.signum() <= 0 || percentage.compareTo(BigDecimal.valueOf(100)) > 0) {
                throw new IllegalArgumentException("Percentage in (0, 100]");
            }
        }
        @Override
        public Money calculateDiscount(Money subtotal) {
            Money raw = subtotal.percentageOf(percentage);
            if (maxDiscount != null && raw.isGreaterThan(maxDiscount)) {
                return maxDiscount;
            }
            return raw;
        }
    }
    record FreeShipping() implements VoucherType {
        @Override
        public Money calculateDiscount(Money subtotal) {
            // Shipping ayrı module, burada placeholder
            return Money.zero(Currency.TL);
        }
    }
}

10.3 Yeni: VoucherStatus.java (sealed)

package com.acme.commerce.voucher.domain;

import com.acme.commerce.cart.domain.CartId;
import com.fasterxml.jackson.annotation.JsonSubTypes;
import com.fasterxml.jackson.annotation.JsonTypeInfo;
import java.time.Instant;
import java.util.UUID;
@JsonTypeInfo(use = JsonTypeInfo.Id.NAME, property = "type")
@JsonSubTypes({
        @JsonSubTypes.Type(value = VoucherStatus.Issued.class, name = "Issued"),
        @JsonSubTypes.Type(value = VoucherStatus.Claimed.class, name = "Claimed"),
        @JsonSubTypes.Type(value = VoucherStatus.Used.class, name = "Used"),
        @JsonSubTypes.Type(value = VoucherStatus.Expired.class, name = "Expired"),
        @JsonSubTypes.Type(value = VoucherStatus.Revoked.class, name = "Revoked")
})
public sealed interface VoucherStatus 
        permits VoucherStatus.Issued, VoucherStatus.Claimed,
                VoucherStatus.Used, VoucherStatus.Expired, VoucherStatus.Revoked {
    Instant transitionedAt();
    record Issued(Instant transitionedAt) implements VoucherStatus {}
    record Claimed(Instant transitionedAt, CartId claimedForCartId, UUID idempotencyKey) implements VoucherStatus {}
    record Used(Instant transitionedAt, UUID orderId) implements VoucherStatus {}
    record Expired(Instant transitionedAt) implements VoucherStatus {}
    record Revoked(Instant transitionedAt, String reason) implements VoucherStatus {}
}

10.4 Yeni: Voucher.java (aggregate root)

package com.acme.commerce.voucher.domain;

import com.acme.commerce.cart.domain.CartId;
import com.acme.commerce.customer.domain.CustomerId;
import com.acme.commerce.shared.domain.AggregateRoot;
import com.acme.commerce.shared.domain.DomainException;
import com.acme.commerce.voucher.domain.events.*;
import io.hypersistence.utils.hibernate.type.json.JsonType;
import jakarta.persistence.*;
import org.hibernate.annotations.Type;
import java.time.Clock;
import java.time.Instant;
import java.util.UUID;
@Entity
@Table(name = "voucher")
public class Voucher extends AggregateRoot {
    @Id
    @Column(name = "id")
    private UUID id;
    @Column(name = "code", nullable = false, unique = true, length = 32)
    private String code;
    @Column(name = "assigned_customer_id", nullable = false)
    private UUID assignedCustomerId;
    @Column(name = "type", nullable = false, columnDefinition = "jsonb")
    @Type(JsonType.class)
    private VoucherType type;
    @Column(name = "status", nullable = false, columnDefinition = "jsonb")
    @Type(JsonType.class)
    private VoucherStatus status;
    @Column(name = "issued_at", nullable = false)
    private Instant issuedAt;
    @Column(name = "expires_at", nullable = false)
    private Instant expiresAt;
    @Version
    @Column(name = "version")
    private long version;
    protected Voucher() {}
    public static Voucher issue(VoucherCode code, CustomerId assignedTo,
                                 VoucherType type, Instant expiresAt, Clock clock) {
        Instant now = clock.instant();
        if (!expiresAt.isAfter(now)) {
            throw new IllegalArgumentException("expiresAt must be in future");
        }
        Voucher v = new Voucher();
        v.id = UUID.randomUUID();
        v.code = code.value();
        v.assignedCustomerId = assignedTo.value();
        v.type = type;
        v.status = new VoucherStatus.Issued(now);
        v.issuedAt = now;
        v.expiresAt = expiresAt;
        v.register(new VoucherIssued(new VoucherId(v.id), code, assignedTo, type, expiresAt, now));
        return v;
    }
    public sealed interface ClaimResult permits ClaimResult.Success, ClaimResult.AlreadyClaimedSameRequest {
        record Success() implements ClaimResult {}
        record AlreadyClaimedSameRequest() implements ClaimResult {}
    }
    public ClaimResult claim(CustomerId requestingCustomerId, CartId cartId,
                             UUID idempotencyKey, Clock clock) {
        Instant now = clock.instant();
        // Idempotency: zaten claim edilmiş, aynı request mi?
        if (status instanceof VoucherStatus.Claimed claimed) {
            if (idempotencyKey.equals(claimed.idempotencyKey())) {
                return new ClaimResult.AlreadyClaimedSameRequest();
            }
            throw new DomainException("Voucher already claimed");
        }
        if (status instanceof VoucherStatus.Used) {
            throw new DomainException("Voucher already used");
        }
        if (status instanceof VoucherStatus.Expired || now.isAfter(expiresAt)) {
            throw new DomainException("Voucher expired");
        }
        if (status instanceof VoucherStatus.Revoked rev) {
            throw new DomainException("Voucher revoked: " + rev.reason());
        }
        if (!assignedCustomerId.equals(requestingCustomerId.value())) {
            throw new DomainException("Voucher not assigned to this customer");
        }
        this.status = new VoucherStatus.Claimed(now, cartId, idempotencyKey);
        register(new VoucherClaimed(new VoucherId(id), requestingCustomerId, cartId, now));
        return new ClaimResult.Success();
    }
    public void use(UUID orderId, Clock clock) {
        if (!(status instanceof VoucherStatus.Claimed claimed)) {
            throw new DomainException("Voucher not in Claimed state");
        }
        Instant now = clock.instant();
        this.status = new VoucherStatus.Used(now, orderId);
        register(new VoucherUsed(new VoucherId(id), orderId, claimed.claimedForCartId(), now));
    }
    public void releaseClaim(Clock clock) {
        if (!(status instanceof VoucherStatus.Claimed claimed)) {
            throw new DomainException("Voucher not in Claimed state");
        }
        Instant now = clock.instant();
        if (now.isAfter(expiresAt)) {
            this.status = new VoucherStatus.Expired(now);
            register(new VoucherExpired(new VoucherId(id), now));
        } else {
            this.status = new VoucherStatus.Issued(now);
            register(new VoucherReleased(new VoucherId(id), claimed.claimedForCartId(), now));
        }
    }
    public void revoke(String reason, Clock clock) {
        if (status instanceof VoucherStatus.Used) {
            throw new DomainException("Cannot revoke used voucher");
        }
        Instant now = clock.instant();
        this.status = new VoucherStatus.Revoked(now, reason);
        register(new VoucherRevoked(new VoucherId(id), reason, now));
    }
    public void checkExpiry(Clock clock) {
        Instant now = clock.instant();
        if ((status instanceof VoucherStatus.Issued || status instanceof VoucherStatus.Claimed) 
                && now.isAfter(expiresAt)) {
            this.status = new VoucherStatus.Expired(now);
            register(new VoucherExpired(new VoucherId(id), now));
        }
    }
    // Getter'lar
    public VoucherId id() { return new VoucherId(id); }
    public VoucherCode code() { return VoucherCode.of(code); }
    public CustomerId assignedCustomerId() { return CustomerId.of(assignedCustomerId.toString()); }
    public VoucherType type() { return type; }
    public VoucherStatus status() { return status; }
    public Instant expiresAt() { return expiresAt; }
}

10.5 Yeni: ClaimVoucherUseCase.java

package com.acme.commerce.voucher.application;

import com.acme.commerce.cart.application.ApplyVoucherDiscountUseCase;
import com.acme.commerce.cart.domain.CartId;
import com.acme.commerce.customer.domain.CustomerId;
import com.acme.commerce.voucher.domain.*;
import org.springframework.dao.OptimisticLockingFailureException;
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 ClaimVoucherUseCase {
    private final VoucherRepository voucherRepository;
    private final ApplyVoucherDiscountUseCase applyVoucherToCart;
    private final Clock clock;
    public ClaimVoucherUseCase(VoucherRepository voucherRepository,
                                ApplyVoucherDiscountUseCase applyVoucherToCart,
                                Clock clock) {
        this.voucherRepository = voucherRepository;
        this.applyVoucherToCart = applyVoucherToCart;
        this.clock = clock;
    }
    @Retryable(value = OptimisticLockingFailureException.class, maxAttempts = 5,
               backoff = @Backoff(delay = 100, multiplier = 2.0, random = true))
    @Transactional
    public ClaimResponse execute(VoucherCode code, CustomerId customerId, CartId cartId,
                                  UUID idempotencyKey) {
        Voucher voucher = voucherRepository.findByCode(code)
                .orElseThrow(() -> new IllegalArgumentException("Voucher not found"));
        Voucher.ClaimResult result = voucher.claim(customerId, cartId, idempotencyKey, clock);
        voucherRepository.save(voucher);
        if (result instanceof Voucher.ClaimResult.Success) {
            applyVoucherToCart.execute(cartId, voucher);
        }
        // AlreadyClaimedSameRequest case'inde idempotent response
        return new ClaimResponse(voucher.id(), voucher.status());
    }
    public record ClaimResponse(VoucherId voucherId, VoucherStatus status) {}
}

10.6 Yeni: VoucherDiscount.java (Cart-side)

package com.acme.commerce.cart.domain.promotion;

import com.acme.commerce.cart.domain.AppliedDiscount;
import com.acme.commerce.shared.valueobjects.Money;
import java.time.Instant;
import java.util.UUID;
public record VoucherDiscount(
        UUID voucherId,
        String voucherCode,
        Money savedAmount,
        Instant appliedAt
) implements AppliedDiscount {
    @Override
    public String discountType() {
        return "VOUCHER_" + voucherCode;
    }
}

10.7 V7 Migration

-- V7__create_voucher.sql
CREATE TABLE voucher (
    id                       UUID PRIMARY KEY,
    code                     VARCHAR(32) NOT NULL UNIQUE,
    assigned_customer_id     UUID NOT NULL,
    type                     JSONB NOT NULL,
    status                   JSONB NOT NULL,
    issued_at                TIMESTAMPTZ NOT NULL,
    expires_at               TIMESTAMPTZ NOT NULL,
    version                  BIGINT NOT NULL DEFAULT 0
);
CREATE INDEX idx_voucher_customer ON voucher (assigned_customer_id);
CREATE INDEX idx_voucher_expiry ON voucher (expires_at) 
    WHERE (status->>'type') IN ('Issued', 'Claimed');

10.8 Test Örneği

@Test
void claimSuccessForCorrectCustomer() {
    Voucher v = Voucher.issue(
            VoucherCode.of("TEST-001"),
            customerId,
            new VoucherType.Percentage(BigDecimal.valueOf(15), Money.tl(500)),
            Instant.parse("2026-12-31T23:59:59Z"),
            FIXED_CLOCK);

UUID idemp = UUID.randomUUID();
    Voucher.ClaimResult result = v.claim(customerId, cartId, idemp, FIXED_CLOCK);
    assertThat(result).isInstanceOf(Voucher.ClaimResult.Success.class);
    assertThat(v.status()).isInstanceOf(VoucherStatus.Claimed.class);
}
@Test
void wrongCustomerThrows() {
    Voucher v = Voucher.issue(VoucherCode.of("TEST-002"), customerId,
            new VoucherType.FixedAmount(Money.tl(50)),
            Instant.parse("2026-12-31T23:59:59Z"), FIXED_CLOCK);

    CustomerId other = CustomerId.generate();
    assertThatThrownBy(() -> v.claim(other, cartId, UUID.randomUUID(), FIXED_CLOCK))
            .isInstanceOf(DomainException.class)
            .hasMessageContaining("not assigned");
}
@Test
void idempotentClaimReturnsSameResult() {
    Voucher v = Voucher.issue(/* ... */);
    UUID idemp = UUID.randomUUID();

    v.claim(customerId, cartId, idemp, FIXED_CLOCK);
    Voucher.ClaimResult second = v.claim(customerId, cartId, idemp, FIXED_CLOCK);

    assertThat(second).isInstanceOf(Voucher.ClaimResult.AlreadyClaimedSameRequest.class);
}

10.9 Özet

Toplam: 19 yeni Java + 1 değişen + 1 migration + 2 yeni Cart-side = 23 dosya.

**İçindekiler… **» Bölüm 4.2 — Tek Kullanımlık Voucher: Idempotency ve Atomic Claim (K8) » https://gitlab.com/sahin.yelkenci2/aggregate-design-commerce-platform


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