Mert Özen Her Satırda Daha İleri
Yazılım Tasarımı Design Patterns — #18

Memento Pattern: Snapshot Almak, Geri Yüklemek ve Encapsulation'ı Bozmamak

Mert Özen 7 Kas 2026 12 dk 5 görüntülenme
Memento Pattern: Snapshot Almak, Geri Yüklemek ve Encapsulation'ı Bozmamak

Memento, bir object'in iç durumunu encapsulation'ı bozmadan dışarı alıp sonra geri yüklemeyi sağlar; C#'ta bunun en temiz yolu private nested bir record ve caretaker'a gösterilen dar bir interface'tir. with expression'ının shallow kopya tuzağını, ödeme sihirbazında kapasiteli undo'yu, Utf8JsonReader'ın JsonReaderState'ini, Command ile undo'yu, JSON'a yazılan snapshot'ın sürümlenmesini ve immutable state ile bellek farkını gerçek çıktılarla görüyoruz.

Sürüm notu: Bu yazıdaki kodlar C# 14 ve .NET 10 ile derlenip denenmiştir.

Önce Tahmin Et

Bookstore'un sepet ekranına "geri al" düğmesi ekleniyor. İlk sürümü yazan geliştirici record'ların with expression'ının kopya ürettiğini biliyor ve her değişiklikten önce sepetin bir kopyasını bir stack'e atıyor:

public sealed record CartDraft(string ShippingMethod, List<string> Isbns, string? CouponCode);

Stack<CartDraft> history = new();
CartDraft draft = new("Standard", ["978-0441013593"], CouponCode: null);

history.Push(draft with { });
draft.Isbns.Add("978-0201633610");
draft = draft with { CouponCode = "FREESHIP" };

draft = history.Pop();
Console.WriteLine($"Coupon: {draft.CouponCode ?? "-"}, items: {draft.Isbns.Count}");

Snapshot alındıktan sonra sepete ikinci bir kitap eklendi ve kupon uygulandı. "Geri al" ile snapshot'a dönüldüğünde kupon kaybolur mu? Sepette kaç kitap kalır?

Kopya Her Zaman Snapshot Değildir

Gerçek çıktı:

Coupon: -, items: 2

Kupon geri alındı, eklenen kitap alınmadı. with expression'ı yeni bir record üretir ama field'ları tek tek kopyalar: Isbns bir List<string> referansı olduğu için snapshot ile canlı sepet aynı listeyi paylaşıyor. CouponCode değişikliği yeni bir record ürettiği için snapshot'a dokunmadı; Isbns.Add ise ikisinin ortak listesini değiştirdi. Shallow ve deep copy farkının ayrıntısı serinin "Prototype Pattern: record ile with, Shallow ve Deep Copy ve ICloneable'ın Neden Önerilmediği" yazısında. Bu yazının sorusu farklı: bir object'in geçmiş bir anını güvenle saklamak için ne gerekir ve bunu yaparken o object'in içini dünyaya açmak zorunda mıyız?

Yukarıdaki koda bir sorun daha var: CartDraft'ın bütün state'i public. Herkes Isbns'e ekleme yapabiliyor, stack'teki snapshot'ları değiştirebiliyor. Geri alma işini yapan kod, sepetin iç yapısını da bilmek zorunda.

GoF'un Tanımı: Encapsulation'ı Bozmadan Durumu Dışarı Almak

GoF (Gang of Four: Gamma, Helm, Johnson ve Vlissides'in 1994 tarihli Design Patterns kitabı) Memento'yu behavioral pattern'ler arasında sayar. Niyeti, encapsulation'ı bozmadan bir object'in iç durumunu yakalayıp dışarıda saklamak, böylece object'i daha sonra o duruma geri döndürebilmek. Kitapta üç rol var. Originator, durumu kaydedilecek object'tir; memento'yu o üretir ve yalnız o geri yükler. Memento, originator'ın durumunu taşıyan object'tir. Caretaker ise memento'ları saklayan ama içlerine hiç bakmayan taraftır: undo mekanizması, taslak yöneticisi ya da oyunun kayıt sistemi.

Pattern'in püf noktası GoF'un "iki interface" dediği şey. Caretaker memento'nun narrow interface'ini görür: elinde bir object var ama içinde ne olduğunu bilmez, yalnız saklayıp geri verebilir. Originator ise wide interface'i görür ve bütün durumu okuyabilir. C++'ta bu, memento class'ında originator'ı friend ilan ederek yapılıyordu. C#'ta friend yok, ama daha temiz bir aracımız var: private nested type.

C#'ta Memento: Private Nested Record ve Narrow Interface

Bookstore'un çok adımlı ödeme sihirbazı (sepet, adres, kargo, kupon) originator olsun:

public sealed record OrderLine(string Isbn, int Quantity);

// Caretaker'ın gördüğü narrow interface: içerik yok, yalnız kimlik bilgisi
public interface ICheckoutMemento
{
    int Step { get; }
}

// Originator
public sealed class CheckoutWizard
{
    private readonly List<OrderLine> _lines = [];
    private string? _address;
    private string _shipping = "Standard";
    private string? _coupon;
    private int _step = 1;

    public void AddBook(string isbn, int quantity)
    {
        _lines.Add(new OrderLine(isbn, quantity));
        _step = 2;
    }

    public void SetAddress(string address)
    {
        _address = address;
        _step = 3;
    }

    public void ChooseShipping(string method) => _shipping = method;
    public void ApplyCoupon(string code) => _coupon = code;

    public ICheckoutMemento Save() => new Snapshot([.. _lines], _address, _shipping, _coupon, _step);

    public void Restore(ICheckoutMemento memento)
    {
        if (memento is not Snapshot s)
            throw new ArgumentException("Memento does not belong to CheckoutWizard.", nameof(memento));
        _lines.Clear();
        _lines.AddRange(s.Lines);
        (_address, _shipping, _coupon, _step) = (s.Address, s.Shipping, s.Coupon, s.Step);
    }

    public override string ToString() =>
        $"step {_step}: {_lines.Sum(l => l.Quantity)} books, {_shipping}, address={_address ?? "-"}, coupon={_coupon ?? "-"}";

    // Wide interface: yalnız CheckoutWizard içeriği okuyabilir
    private sealed record Snapshot(
        ImmutableArray<OrderLine> Lines, string? Address, string Shipping, string? Coupon, int Step) : ICheckoutMemento;
}

// Caretaker: memento'ları saklar, içlerine bakmaz
public sealed class CheckoutHistory(CheckoutWizard wizard, int capacity)
{
    private readonly LinkedList<ICheckoutMemento> _undo = new();

    public bool CanUndo => _undo.Count > 0;

    public void Backup()
    {
        _undo.AddLast(wizard.Save());
        if (_undo.Count > capacity)
            _undo.RemoveFirst();
    }

    public void Undo()
    {
        if (_undo.Last is not { } last)
            return;
        _undo.RemoveLast();
        wizard.Restore(last.Value);
    }
}

Üç karar var. Birincisi Snapshot'ın private nested record olması: CheckoutWizard dışındaki hiçbir kod onun adını bile yazamaz. İkincisi satırların ImmutableArray<OrderLine> olarak kopyalanması ve OrderLine'ın kendisinin de immutable bir record olması; açılıştaki ortak liste problemi böylece iki seviyede kapanıyor. Üçüncüsü caretaker'ın kapasitesi: LinkedList<T> kullandık çünkü sınır aşılınca en eski memento'yu baştan atmamız gerekiyor, Stack<T> buna izin vermez. Deneyelim:

CheckoutWizard wizard = new();
CheckoutHistory history = new(wizard, capacity: 3);

history.Backup();
wizard.AddBook("978-0441013593", 1);
history.Backup();
wizard.AddBook("978-0201633610", 2);
history.Backup();
wizard.SetAddress("221B Baker Street");
wizard.ApplyCoupon("FREESHIP");
Console.WriteLine(wizard);

history.Undo();
Console.WriteLine(wizard);
history.Undo();
Console.WriteLine(wizard);
Console.WriteLine($"Undo available: {history.CanUndo}");
step 3: 3 books, Standard, address=221B Baker Street, coupon=FREESHIP
step 2: 3 books, Standard, address=-, coupon=-
step 2: 1 books, Standard, address=-, coupon=-
Undo available: True

Caretaker'ın içeriye bakmaya çalıştığını düşünelim. Biri Bad.cs adlı bir dosyada "kuponu snapshot'tan okuyayım" diye bir cast yazarsa:

namespace Bookstore.Checkout;

public sealed class SneakyHistory(CheckoutWizard wizard)
{
    public string? PeekCoupon() => ((CheckoutWizard.Snapshot)wizard.Save()).Coupon;
}
Bad.cs(5,53): error CS0122: 'CheckoutWizard.Snapshot' is inaccessible due to its protection level

Encapsulation'ı derleyici koruyor. ICheckoutMemento'nun tek üyesi olan Step ise bilinçli bir taviz: undo menüsünde "2. adıma dön" yazabilmek için caretaker'a yalnız bu kadarını gösteriyoruz. Narrow interface'e ne koyacağın bir tasarım kararı; boş bir marker interface de olabilir.

.NET'teki Memento: Utf8JsonReader ve JsonReaderState

BCL (Base Class Library) içinde bu pattern'in en temiz örneği System.Text.Json'ın düşük seviyeli okuyucusu. Utf8JsonReader bir ref struct; bir buffer'ı okur ve o buffer'dan daha uzun yaşayamaz. Büyük bir JSON (JavaScript Object Notation) dosyası network'ten parça parça geliyorsa, bir parçayı okuyup reader'ın kaldığı yeri saklaman, sonraki parçada yeni bir reader'ı o durumdan başlatman gerekir. Saklanan şey reader.CurrentState'in döndürdüğü JsonReaderState:

byte[] json = Encoding.UTF8.GetBytes("""{"isbn":"978-0441013593","title":"Dune","price":18}""");
byte[] firstChunk = json[..30];
byte[] secondChunk = json[30..];

Utf8JsonReader reader = new(firstChunk, isFinalBlock: false, state: default);
while (reader.Read())
    Console.WriteLine($"chunk 1: {reader.TokenType} {Show(ref reader)}");

// Reader'ın iç durumu opak bir struct olarak dışarı alınıyor
JsonReaderState state = reader.CurrentState;
byte[] rest = [.. firstChunk[(int)reader.BytesConsumed..], .. secondChunk];
Console.WriteLine($"consumed {reader.BytesConsumed} of {firstChunk.Length}, carried over {firstChunk.Length - reader.BytesConsumed}");

reader = new Utf8JsonReader(rest, isFinalBlock: true, state);
while (reader.Read())
    Console.WriteLine($"chunk 2: {reader.TokenType} {Show(ref reader)}");
chunk 1: StartObject
chunk 1: PropertyName isbn
chunk 1: String 978-0441013593
consumed 24 of 30, carried over 6
chunk 2: PropertyName title
chunk 2: String Dune
chunk 2: PropertyName price
chunk 2: Number 18
chunk 2: EndObject 

İlk parça "title" property adının ortasında bitiyordu; reader tamamlanmamış token'ı okumadı, 30 byte'ın 24'ünü tüketip durdu. Kalan 6 byte'ı ikinci parçanın önüne ekleyip yeni reader'ı eski state ile kurunca okuma kaldığı yerden devam etti. JsonReaderState'in public yüzeyine reflection ile baktığımızda yalnız bir constructor ve Options property'si görünüyor; nesting derinliği, bir önceki token'ın tipi, bir property adından sonra değer beklenip beklenmediği gibi bilgiler internal. Bu tam olarak GoF'un narrow interface'i: sen state'i taşırsın, yalnız reader onu açabilir.

Command ile Undo: Ters İşlem mi, Snapshot mı?

GoF, Memento'yu Command'ın yakın akrabası olarak anar: geri alınabilir bir command, çalışmadan önce originator'dan bir memento alıp undo'da onu geri yükleyebilir.

public interface ICheckoutCommand
{
    void Execute();
    void Undo();
}

public sealed class ApplyCoupon(CheckoutWizard wizard, string code) : ICheckoutCommand
{
    private ICheckoutMemento? _before;

    public void Execute()
    {
        _before = wizard.Save();
        wizard.ApplyCoupon(code);
    }

    public void Undo() => wizard.Restore(_before ?? throw new InvalidOperationException("Not executed."));
}

CheckoutWizard wizard = new();
wizard.AddBook("978-0441013593", 1);
List<ICheckoutCommand> done = [];

ICheckoutCommand[] commands = [new ApplyCoupon(wizard, "FREESHIP"), new ChooseShipping(wizard, "Express")];
foreach (ICheckoutCommand command in commands)
{
    command.Execute();
    done.Add(command);
    Console.WriteLine(wizard);
}
for (int i = done.Count - 1; i >= 0; i--)
{
    done[i].Undo();
    Console.WriteLine(wizard);
}
step 2: 1 books, Standard, address=-, coupon=FREESHIP
step 2: 1 books, Express, address=-, coupon=FREESHIP
step 2: 1 books, Standard, address=-, coupon=FREESHIP
step 2: 1 books, Standard, address=-, coupon=-

ChooseShipping aynı kalıpta yazıldı. Undo'nun diğer yolu ters işlem: "kuponu uygula"nın tersi "kuponu kaldır"; Command'ın kendisi ve undo/redo stack'i serinin "Command Pattern: İsteği Object'e Çevirmek, Undo/Redo ve Kuyruğa Atmak" yazısında. İkisi arasındaki seçim bir trade-off. Ters işlem bellek harcamaz ama her command için doğru tersini yazmak gerekir ve bazı işlemlerin tersi yoktur (yuvarlama yapan bir fiyat hesabı, silinmiş bir satırın sırası). Memento her zaman doğru geri döner ama her command bir durum kopyası taşır. Pratik kural: state küçükse ve ters işlemi yazmak zorsa memento, state büyükse ve tersi bariz ise ters işlem.

Kalıcı Snapshot: JSON ve Şema Sürümü

Ödeme taslağını kullanıcı tarayıcıyı kapatsa da saklamak istiyoruz, yani memento veritabanına JSON olarak yazılacak. Persist edilen her snapshot bir sözleşmedir: yarın kodu değiştirdiğinde dün yazılmış JSON'ı yine okuyabilmelisin. v1'de tek bir coupon string'i vardı; v2'de birden fazla kupon desteklemek için coupons listesine geçtik:

// Kalıcı snapshot'ın v2 şeması: tek kupon yerine kupon listesi
public sealed record DraftSnapshotV2(
    int SchemaVersion, IReadOnlyList<DraftLine> Lines, string? Address, string Shipping,
    IReadOnlyList<string> Coupons, int Step)
{
    public const int CurrentVersion = 2;
}

string v1 = """{"schemaVersion":1,"lines":[{"isbn":"978-0441013593","quantity":1}],"address":"221B Baker Street","shipping":"Express","coupon":"FREESHIP","step":3}""";
JsonSerializerOptions web = new(JsonSerializerDefaults.Web);

DraftSnapshotV2 naive = JsonSerializer.Deserialize<DraftSnapshotV2>(v1, web)!;
Console.WriteLine($"naive:  coupons={(naive.Coupons is null ? "null" : "[" + string.Join(",", naive.Coupons) + "]")} step={naive.Step}");

JsonSerializerOptions required = new(web) { RespectRequiredConstructorParameters = true };
JsonSerializerOptions strict = new(web) { UnmappedMemberHandling = JsonUnmappedMemberHandling.Disallow };
foreach ((string name, JsonSerializerOptions options) in new[] { ("required", required), ("strict", strict) })
{
    try
    {
        JsonSerializer.Deserialize<DraftSnapshotV2>(v1, options);
    }
    catch (JsonException ex)
    {
        Console.WriteLine($"{name}: {ex.Message}");
    }
}
naive:  coupons=null step=3
required: JSON deserialization for type 'Bookstore.Checkout.DraftSnapshotV2' was missing required properties including: 'coupons'.
strict: The JSON property 'coupon' could not be mapped to any .NET member contained in type 'Bookstore.Checkout.DraftSnapshotV2'.

Varsayılan ayarlarla eski snapshot hatasız açıldı ve kupon sessizce kayboldu. Daha da kötüsü, nullable olmayan IReadOnlyList<string> tipindeki Coupons parametresi null geldi; JSON'da karşılığı olmayan constructor parametresine System.Text.Json varsayılan değeri veriyor. .NET 9 ile gelen RespectRequiredConstructorParameters eksik parametreyi, JsonUnmappedMemberHandling.Disallow da tanınmayan property'yi exception'a çeviriyor. İkisi de sessiz veri kaybını gürültülü bir hataya çevirir ama eski taslağı kurtarmaz. Kurtaran şey açık bir sürüm numarası ve bir upgrade adımı:

public static DraftSnapshotV2 Read(string json)
{
    JsonObject node = JsonNode.Parse(json)!.AsObject();
    int version = node["schemaVersion"]?.GetValue<int>() ?? 1;

    // v1 -> v2: "coupon" tek bir string'di, artık "coupons" listesi
    if (version == 1)
    {
        string? coupon = node["coupon"]?.GetValue<string>();
        node.Remove("coupon");
        node["coupons"] = coupon is null ? new JsonArray() : new JsonArray(coupon);
        node["schemaVersion"] = DraftSnapshotV2.CurrentVersion;
    }
    else if (version != DraftSnapshotV2.CurrentVersion)
    {
        throw new NotSupportedException($"Unknown snapshot version {version}");
    }

    return node.Deserialize<DraftSnapshotV2>(Options)!;
}
upgrade: coupons=[FREESHIP] step=3 version=2
{"schemaVersion":2,"lines":[{"isbn":"978-0441013593","quantity":1}],"address":"221B Baker Street","shipping":"Express","coupons":["FREESHIP"],"step":3}

Upgrade işini typed DTO (Data Transfer Object) yerine JsonNode üzerinde yapıyoruz, çünkü v1'in şekli artık hiçbir C# tipinde yaşamıyor. Burada bilinçli olarak encapsulation'dan bir adım geri attığımızı da söyleyelim: kalıcı snapshot public bir DTO, çünkü serializer'ın onu okuyabilmesi gerekiyor. Bu DTO'yu yalnız CheckoutWizard'ın export ve import method'larının kullanması, geri kalan kodun onu bir string olarak taşıması, pattern'in ruhunu korur.

Bellek: Her Snapshot'ı Kopyalamak mı, State'i Immutable Yapmak mı?

Memento'nun GoF'un da andığı bedeli maliyet: originator büyük bir durum taşıyorsa ve sık snapshot alınıyorsa bellek hızla dolar. Sepete 1.000 kitap tek tek eklenip her adımda snapshot alınsın. İlk yöntem bizim Save()'in yaptığı gibi her seferinde listeyi kopyalamak; ikincisi state'in kendisini ImmutableList<T> yapıp snapshot olarak yalnız referansı saklamak:

const int Steps = 1_000;

long before = GC.GetAllocatedBytesForCurrentThread();
List<OrderLine> mutable = [];
List<ImmutableArray<OrderLine>> copies = [];
for (int i = 0; i < Steps; i++)
{
    mutable.Add(new OrderLine($"isbn-{i}", 1));
    copies.Add([.. mutable]);
}
long copyBytes = GC.GetAllocatedBytesForCurrentThread() - before;

before = GC.GetAllocatedBytesForCurrentThread();
ImmutableList<OrderLine> state = [];
List<ImmutableList<OrderLine>> shared = [];
for (int i = 0; i < Steps; i++)
{
    state = state.Add(new OrderLine($"isbn-{i}", 1));
    shared.Add(state);
}
long sharedBytes = GC.GetAllocatedBytesForCurrentThread() - before;

Console.WriteLine($"copy every snapshot:  {copyBytes / 1024} KB");
Console.WriteLine($"immutable state:      {sharedBytes / 1024} KB");
Console.WriteLine($"same content: {copies[^1].SequenceEqual(shared[^1])}, first snapshot still has {shared[0].Count} line");
copy every snapshot:  4038 KB
immutable state:      577 KB
same content: True, first snapshot still has 1 line

Kopyalayan yöntem n² ile büyüyor: 1.000. snapshot 1.000 referanslık bir array. ImmutableList<T> ise bir ağaç; her Add yalnız değişen yoldaki birkaç düğümü yeniden üretiyor, eski sürümler geri kalan düğümleri paylaşıyor. Sonuç yedi kata yakın daha az allocation ve her snapshot'ın kendi içinde tutarlı olması (ilk snapshot hâlâ tek satırlı). Buradan çıkan ders şu: state immutable ise memento ayrı bir kopya değil, state'in kendisidir. Record'lar, immutable collection'lar ve with expression'ı bir araya geldiğinde Memento pattern'i neredeyse kaybolur; geriye yalnız "eski referansı sakla" kalır.

Sık Düşülen Tuzaklar

Shallow snapshot. Açılıştaki hata: snapshot ile canlı object mutable bir collection'ı paylaşıyor. Snapshot'a giren her şey ya kopyalanmalı ya da immutable olmalı; içindeki object'ler de dahil.

Sınırsız geçmiş. Her tuşa basışta snapshot alan bir editor, kapasite sınırı olmayan bir caretaker ile saatler içinde yüzlerce megabayta ulaşır. Caretaker'a kapasite ver ve snapshot'ı her değişiklikte değil, anlamlı adımlarda al (form adımı değişince, yazmaya ara verilince).

Snapshot'a girmemesi gerekenler. Bir DbContext, açık bir Stream, CancellationToken ya da kart numarası gibi hassas veri memento'ya girmemeli. Bellekteki memento yalnız süreç ömrü kadar yaşar; JSON'a yazılan memento ise log'lara, yedeklere ve destek ekibinin ekranına kadar gider. Neyin persist edilmemesi gerektiği OWASP (Open Worldwide Application Security Project) Top 10 serisindeki "OWASP A04: Cryptographic Failures (2) — Parola Saklama, Data Protection ve Secret Yönetimi" yazısında.

Sürümsüz kalıcı snapshot. JSON'a schemaVersion yazmadıysan, şema ilk değiştiğinde eski kayıtların hangi şekilde olduğunu tahmin etmek zorunda kalırsın. Sürüm numarasını ilk günden yaz, upgrade adımlarını tek bir yerde topla ve deserialization'ı varsayılan ayarlarla bırakma.

Restore'un yan etkileri. Restore durumu geri yükler ama durum değiştiğinde tetiklenmesi gereken event'ler, computed property'ler ya da başka object'lerdeki referanslar kendiliğinden düzelmez. Restore'dan sonra "durum değişti" bildirimini açıkça yap.

Ne Zaman Kullanma

State zaten immutable ise ayrı bir Memento kurma: önceki record'un referansını sakla, o zaten bir snapshot. Durum küçük ve her işlemin tersi bariz ise ters işlemli Command daha az bellek harcar. Değişikliklerin tam geçmişine, yani "kim neyi ne zaman değiştirdi" sorusuna ihtiyacın varsa snapshot değil, değişikliklerin kendisini saklamak gerekir; bu Event Sourcing'in alanı ve ilerideki Yazılım Mimarisi serilerinin konusu, veritabanı tarafındaki audit yaklaşımı ise Çözüm Desenleri serisinin "Audit Trail: Kim, Neyi, Ne Zaman, Neden Değiştirdi?" yazısında. Veritabanı transaction'ı içinde "buraya kadar geri dön" ihtiyacı ise uygulama seviyesinde memento değil, savepoint ister; Entity Framework Core (EF Core) tarafı EF Core serisindeki "EF Core'da Transaction'lar: SaveChanges, BeginTransaction ve Savepoint'ler" yazısında.

Küçük Bir Deneme

★☆☆ Açılıştaki CartDraft'ı Isbns için ImmutableList<string> kullanacak şekilde değiştir. draft.Isbns.Add satırı artık nasıl yazılmalı ve geri alma sonrası kaç kitap kalıyor?

★★☆ CheckoutHistory'ye redo ekle: undo yapılan memento'ları ikinci bir listede tut, yeni bir Backup geldiğinde redo listesini temizle. Undo, undo, yeni değişiklik, redo sırasının ne yapması gerektiğine karar ver ve test et.

★★★ DraftSnapshotSerializer'a v3 ekle: address string'i { "street", "city", "postalCode" } object'ine dönüşsün. v1 bir JSON'ın önce v2'ye, sonra v3'e zincir hâlinde yükseltildiğini gösteren bir test yaz; her upgrade adımı yalnız bir önceki sürümü bilsin.

Memento bir object'in kendi geçmişine dönmesini sağladı. Bir sonraki yazı bir object'in durumu değiştiğinde başkalarına haber vermesiyle ilgileniyor: C#'ın event'inin ötesinde IObservable<T> sözleşmesi, ASP.NET Core'daki IChangeToken ve IOptionsMonitor, ve domain event'lerle Observer.