Aggregate Tasarım Kararları: Somut Senaryolar — Ek B.K8
Part B — Implementation Pack (çalışır kod + kampanya bazlı detaylı uygulama ekleri)

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;
assignedCustomerIdfield'ı 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:
VoucherIssued— admin yarattı / kampanyadan çıktıVoucherClaimed— kullanıcı sepete attığındaVoucherUsed— ödeme tamamlanınca terminalVoucherReleased— sepetten çıkarıldı (Issued'a geri)VoucherExpired— süresi dolduVoucherRevoked— 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