Command Pattern: İsteği Object'e Çevirmek, Undo/Redo ve Kuyruğa Atmak
Command, bir isteği object olarak paketler; böylece invoker ne yapıldığını bilmeden çalıştırabilir, geri alabilir, queue'ya koyabilir ve loglayabilir. Bookstore katalog editöründe undo/redo stack'i ve MacroCommand, BCL'deki System.Windows.Input.ICommand, command'ı System.Text.Json ile serialize edip Channel üzerinden background'da çalıştırmak, GoF command'ı ile CQRS command'ının farkı, MediatR 13'ün lisansı ve kütüphanesiz bir dispatcher.
Sürüm notu: Bu yazıdaki kodlar C# 14 ve .NET 10 ile derlenip denenmiştir.
Önce Tahmin Et
Bookstore'un katalog editöründe ürün ekibi fiyat değiştiriyor, kitap adlarını düzeltiyor, kitapları katalogdan kaldırıyor ve her adımı Ctrl+Z ile geri alabilmek istiyor. Geri alınan bir adım Ctrl+Y ile yeniden uygulanabiliyor. Editörün geçmişini tutan class şu:
namespace Bookstore.Catalog;
// Invoker: command'ı çalıştırır ve geçmişi tutar; ne yaptığını bilmez
public sealed class CommandHistory
{
private readonly Stack<ICatalogCommand> _undo = new();
private readonly Stack<ICatalogCommand> _redo = new();
public void Run(ICatalogCommand command)
{
command.Execute();
_undo.Push(command);
_redo.Clear();
}
public string? Undo()
{
if (!_undo.TryPop(out ICatalogCommand? command))
return null;
command.Undo();
_redo.Push(command);
return command.Description;
}
public string? Redo()
{
if (!_redo.TryPop(out ICatalogCommand? command))
return null;
command.Execute();
_undo.Push(command);
return command.Description;
}
public override string ToString() => $"undo: {_undo.Count}, redo: {_redo.Count}";
}
Üç düzenleme yapılıyor, ikisi geri alınıyor, biri yeniden uygulanıyor, sonra başka bir kitabın fiyatı değiştiriliyor ve son olarak bir kez daha redo isteniyor. Son Redo neyi yeniden uygular: kaldırılan Emma'yı mı, hiçbir şeyi mi?
public static class HistoryDemo
{
public static void Run()
{
BookCatalog catalog = SampleCatalog.Create();
CommandHistory history = new();
history.Run(new ChangePrice(catalog, "978-0441013593", 14.99m));
history.Run(new RenameBook(catalog, "978-0547928227", "The Hobbit (Illustrated)"));
history.Run(new RemoveBook(catalog, "978-0141439518"));
Console.WriteLine($"after 3 edits: {catalog}");
Console.WriteLine($"undo: {history.Undo()}");
Console.WriteLine($"undo: {history.Undo()}");
Console.WriteLine($"after 2 undos: {catalog} [{history}]");
Console.WriteLine($"redo: {history.Redo()}");
history.Run(new ChangePrice(catalog, "978-0765326355", 16.00m));
Console.WriteLine($"after a new edit: [{history}], redo: {history.Redo() ?? "nothing to redo"}");
Console.WriteLine($"final: {catalog}");
}
}
Redo Stack'i Neden Boşaldı?
after 3 edits: Dune 14.99 | The Hobbit (Illustrated) 15.00 | The Way of Kings 18.00
undo: remove 978-0141439518
undo: rename 978-0547928227 -> The Hobbit (Illustrated)
after 2 undos: Emma 8.50 | Dune 14.99 | The Hobbit 15.00 | The Way of Kings 18.00 [undo: 1, redo: 2]
redo: rename 978-0547928227 -> The Hobbit (Illustrated)
after a new edit: [undo: 3, redo: 0], redo: nothing to redo
final: Emma 8.50 | Dune 14.99 | The Hobbit (Illustrated) 15.00 | The Way of Kings 16.00
Hiçbir şeyi. Yeni bir düzenleme geldiği anda Run redo stack'ini temizliyor ve Emma'nın kaldırılması geçmişten siliniyor. Bu bir hata değil, kullandığın her metin editörünün davranışı: geri alınan adımlar, "geçmişin alternatif bir dalı" gibidir; yeni bir adım atınca o dal kaybolur. Temizlemeseydin, redo eski bir durumu varsayan bir command'ı (artık var olmayan bir kitabı kaldırmak gibi) değişmiş bir katalog üzerinde çalıştırırdı.
Asıl ilginç olan, CommandHistory'nin bu kadar az şey bilmesi. Fiyat mı değişiyor, kitap mı siliniyor, hangi alan hangi değere dönecek; hiçbirinden haberi yok. Elinde yalnızca Execute ve Undo diyebildiği object'ler var. Bunu mümkün kılan, her düzenlemeyi bir method çağrısı olarak değil, bir object olarak temsil etmek. Bu yazının konusu bu fikir: Command pattern. GoF'taki rollerini, undo/redo'yu ve birden fazla adımı tek adım gibi geri alan macro command'ı, .NET'in kendi ICommand'ını, command'ı JSON'a (JavaScript Object Notation) serialize edip bir queue üzerinden background'da çalıştırmayı ve CQRS'teki (Command Query Responsibility Segregation) "command" ile ilişkisini konuşacağız.
GoF'un Tanımı: İsteği Object'e Çevirmek
Command, GoF (Gang of Four: Erich Gamma, Richard Helm, Ralph Johnson ve John Vlissides'in 1994 tarihli Design Patterns: Elements of Reusable Object-Oriented Software kitabı) kataloğunun behavioral pattern'lerinden biri. Amacı, kendi cümlelerimle: bir isteği bir object olarak paketlemek; böylece client'ları farklı isteklerle parametrize edebilmek, istekleri queue'ya koyup loglayabilmek ve geri alınabilir işlemleri desteklemek. Tek cümlede dört kazanç var ve bu yazının bölümleri onları sırayla izliyor.
Roller beş tane. Command, işlemi çalıştırmak için bir interface tanımlar (ICatalogCommand). ConcreteCommand, bir receiver ile bir eylem arasındaki bağı kurar ve Execute'u receiver üzerinde ilgili işlemi çağırarak implement eder (ChangePrice, RenameBook, RemoveBook). Receiver, işi gerçekten yapmayı bilen object'tir (BookCatalog). Invoker, command'dan isteği yerine getirmesini ister ve ne yaptığını bilmez (CommandHistory). Client ise ConcreteCommand'ı oluşturup receiver'ını belirler (örnekteki demo kodu). Kitabın örneği bir metin editörünün menüleri: her MenuItem bir command tutar ve tıklandığında yalnızca onun Execute'unu çağırır; menü "yapıştır" ile "belge aç"ın farkını bilmez.
Command'lar ve Undo
Receiver olarak katalog, command'lardan tamamen habersiz; sıradan bir domain class'ı:
namespace Bookstore.Catalog;
public sealed class Book(string isbn, string title, string category, decimal price)
{
public string Isbn { get; } = isbn;
public string Title { get; set; } = title;
public string Category { get; } = category;
public decimal Price { get; set; } = price;
}
// Receiver: işi gerçekten yapan taraf; command'lardan haberi yok
public sealed class BookCatalog
{
private readonly Dictionary<string, Book> _books = [];
public Book this[string isbn] => _books[isbn];
public IEnumerable<Book> InCategory(string category) => _books.Values.Where(book => book.Category == category);
public void Add(Book book) => _books.Add(book.Isbn, book);
public Book Remove(string isbn) => _books.Remove(isbn, out Book? book) ? book : throw new KeyNotFoundException(isbn);
public override string ToString() =>
string.Join(" | ", _books.Values.OrderBy(book => book.Isbn).Select(book => $"{book.Title} {book.Price}"));
}
namespace Bookstore.Catalog;
public interface ICatalogCommand
{
string Description { get; }
void Execute();
void Undo();
}
public sealed class ChangePrice(BookCatalog catalog, string isbn, decimal newPrice) : ICatalogCommand
{
private decimal _oldPrice;
public string Description => $"price {isbn} -> {newPrice}";
public void Execute()
{
// Undo için gereken state'i Execute anında yakala
_oldPrice = catalog[isbn].Price;
catalog[isbn].Price = newPrice;
}
public void Undo() => catalog[isbn].Price = _oldPrice;
}
public sealed class RenameBook(BookCatalog catalog, string isbn, string newTitle) : ICatalogCommand
{
private string _oldTitle = "";
public string Description => $"rename {isbn} -> {newTitle}";
public void Execute()
{
_oldTitle = catalog[isbn].Title;
catalog[isbn].Title = newTitle;
}
public void Undo() => catalog[isbn].Title = _oldTitle;
}
public sealed class RemoveBook(BookCatalog catalog, string isbn) : ICatalogCommand
{
private Book? _removed;
public string Description => $"remove {isbn}";
public void Execute() => _removed = catalog.Remove(isbn);
public void Undo() => catalog.Add(_removed!);
}
Her command, geri almak için gereken bilgiyi kendisi taşıyor: ChangePrice eski fiyatı, RenameBook eski adı, RemoveBook kaldırılan Book object'inin kendisini. Bu bilgiyi constructor'da değil Execute anında yakalamak önemli: command oluşturulduğu an ile çalıştırıldığı an arasında katalog değişmiş olabilir, redo ile aynı command ikinci kez çalıştırılabilir. "Önceki değer", çalıştırma anındaki değerdir.
Undo'nun bu biçimi her işlem için tersini yazabildiğin sürece çalışır. Bazı işlemlerin tersini yazmak zor ya da imkânsızdır (bir resmi filtreden geçirmek, karmaşık bir hesaplamanın sonucunu üzerine yazmak); o zaman command, değişiklikten önce object'in bir snapshot'ını alır ve undo'da onu geri yükler. Bu, serinin ilerideki Memento yazısının konusu; Command ve Memento sık sık birlikte kullanılır.
MacroCommand: Birden Fazla Adımı Tek Adım Gibi Geri Almak
Kampanya döneminde ürün ekibi Fantasy kategorisindeki bütün kitaplara yüzde 20 indirim uyguluyor. Bu, kullanıcının gözünde tek bir işlem; Ctrl+Z'ye bir kez basınca bütün fiyatlar geri gelmeli. GoF'un MacroCommand'ı, command'ları bir command'ın içine koyarak bunu çözüyor; serinin Composite yazısındaki yapının command'lara uygulanmış hâli:
// Composite command: birden fazla command tek bir adım gibi geri alınır
public sealed class MacroCommand(string description, IReadOnlyList<ICatalogCommand> steps) : ICatalogCommand
{
public string Description => $"{description} ({steps.Count} steps)";
public void Execute()
{
foreach (ICatalogCommand step in steps)
step.Execute();
}
public void Undo()
{
// Ters sırada geri al
for (int i = steps.Count - 1; i >= 0; i--)
steps[i].Undo();
}
}
public static class MacroDemo
{
public static void Run()
{
BookCatalog catalog = SampleCatalog.Create();
CommandHistory history = new();
// Kampanya: Fantasy kategorisinde yüzde 20 indirim, tek adım olarak
ICatalogCommand[] steps =
[
.. catalog.InCategory("Fantasy").Select(book => new ChangePrice(catalog, book.Isbn, Math.Round(book.Price * 0.8m, 2)))
];
history.Run(new MacroCommand("fantasy -20%", steps));
Console.WriteLine($"campaign: {catalog}");
Console.WriteLine($"undo: {history.Undo()}");
Console.WriteLine($"restored: {catalog}");
}
}
campaign: Emma 8.50 | Dune 12.99 | The Hobbit 12.00 | The Way of Kings 14.40
undo: fantasy -20% (2 steps)
restored: Emma 8.50 | Dune 12.99 | The Hobbit 15.00 | The Way of Kings 18.00
CommandHistory değişmedi; macro'yu sıradan bir command gibi çalıştırıp geri aldı. Undo'nun ters sırada yapılması önemli: adımlar birbirine bağımlıysa (önce fiyatı değiştir, sonra o fiyata göre bir kampanya etiketi ekle), geri alırken de sondan başa gitmek gerekir.
.NET'teki Command: ICommand ve Delegate'ler
.NET'in BCL'inde (Base Class Library) GoF'un command'ının birebir karşılığı olan bir interface var: System.Windows.Input.ICommand. Adındaki "Windows"a rağmen System.ObjectModel assembly'sinde duruyor ve her .NET uygulamasında kullanılabiliyor. XAML (Extensible Application Markup Language) tabanlı UI (User Interface) framework'lerinde butonlar bu interface'e bağlanır; WPF (Windows Presentation Foundation), .NET MAUI (Multi-platform App UI) ve Avalonia bunların başlıcaları. Interface, MVVM (Model-View-ViewModel) yaklaşımının temel taşıdır. GoF'un menü örneğinin bugünkü hâli:
using System.Windows.Input;
namespace Bookstore.Ui;
// BCL'deki command contract'ı: XAML tabanlı UI framework'lerinin butonları buna bağlanır
public sealed class RelayCommand(Action execute, Func<bool> canExecute) : ICommand
{
public event EventHandler? CanExecuteChanged;
public bool CanExecute(object? parameter) => canExecute();
public void Execute(object? parameter)
{
if (CanExecute(parameter))
execute();
}
public void RaiseCanExecuteChanged() => CanExecuteChanged?.Invoke(this, EventArgs.Empty);
}
public static class RelayDemo
{
public static void Run()
{
bool hasChanges = false;
RelayCommand save = new(() => Console.WriteLine(" saved"), () => hasChanges);
save.CanExecuteChanged += (_, _) => Console.WriteLine($" button enabled: {save.CanExecute(null)}");
Console.WriteLine($"button enabled: {save.CanExecute(null)}");
save.Execute(null);
hasChanges = true;
save.RaiseCanExecuteChanged();
save.Execute(null);
}
}
button enabled: False
button enabled: True
saved
Buton invoker; CanExecute false olduğu sürece kendini pasif gösteriyor ve CanExecuteChanged event'i geldiğinde durumu yeniden soruyor. RelayCommand'ın kendisi ise ilginç bir şey yapıyor: ConcreteCommand'ı bir class olarak yazmak yerine, davranışı iki delegate olarak alıyor. Peter Norvig'in (Google'ın eski araştırma direktörü) 1996 tarihli "Design Patterns in Dynamic Languages" sunumundaki tespit tam olarak bu: first-class function'ı olan dillerde Command, Strategy, Template Method ve Visitor ya görünmez olur ya da çok sadeleşir; Norvig'e göre 23 GoF pattern'inden 16'sı Lisp ya da Dylan gibi dillerde daha basit ifade ediliyor. C#'ta Action ve Func bu yüzden çoğu zaman en kısa command'dır. Delegate'lerin kendisi için C# İleri serisindeki "Delegate, Func ve Action: Method'u Değer Gibi Taşımak" yazısına bakabilirsin.
Ama delegate'in bir sınırı var: undo için gereken state'i ve işlemin açıklamasını taşımak, ve bir sonraki bölümde göreceğimiz gibi serialize edilmek. Bir lambda'yı JSON'a çeviremezsin. Command'ı class olarak yazmanın en güçlü gerekçesi de bu.
Command'ı Veri Yapmak: Serialize, Queue ve Background
Sipariş tamamlandığında kargo çağrılmalı ve fatura e-postası gitmeli. Bu işler yavaş ve dış servislere bağlı; HTTP (Hypertext Transfer Protocol) isteğini bekletmek istemiyoruz. Command'ı yalnızca veri taşıyan bir record olarak tasarlarsak, onu serialize edip bir queue'ya bırakabilir, başka bir process'te (ya da aynı process'in background'unda) çalıştırabiliriz. Receiver ve çalıştırma mantığı artık command'ın içinde değil, ayrı bir handler'da:
using System.Text.Json.Serialization;
namespace Bookstore.Jobs;
// Command artık bir veri: serialize edilip queue'ya atılabilir, loglanabilir, tekrar denenebilir
[JsonPolymorphic(TypeDiscriminatorPropertyName = "type")]
[JsonDerivedType(typeof(ShipOrder), "ship-order")]
[JsonDerivedType(typeof(SendInvoiceEmail), "send-invoice-email")]
public abstract record BackgroundCommand(Guid CommandId);
public sealed record ShipOrder(Guid CommandId, Guid OrderId, string Carrier) : BackgroundCommand(CommandId);
public sealed record SendInvoiceEmail(Guid CommandId, string InvoiceNo, string Email) : BackgroundCommand(CommandId);
public interface ICommandHandler<in TCommand> where TCommand : BackgroundCommand
{
Task HandleAsync(TCommand command, CancellationToken ct);
}
public sealed class ShipOrderHandler : ICommandHandler<ShipOrder>
{
public Task HandleAsync(ShipOrder command, CancellationToken ct)
{
Console.WriteLine($" shipping order {command.OrderId.ToString()[..8]} with {command.Carrier}");
return Task.CompletedTask;
}
}
public sealed class SendInvoiceEmailHandler : ICommandHandler<SendInvoiceEmail>
{
public Task HandleAsync(SendInvoiceEmail command, CancellationToken ct)
{
Console.WriteLine($" emailing {command.InvoiceNo} to {command.Email}");
return Task.CompletedTask;
}
}
using Microsoft.Extensions.DependencyInjection;
namespace Bookstore.Jobs;
// Kütüphanesiz dispatcher: her command için bir satır, reflection yok, go-to-definition çalışır
public sealed class CommandDispatcher(IServiceScopeFactory scopes)
{
public async Task DispatchAsync(BackgroundCommand command, CancellationToken ct)
{
using IServiceScope scope = scopes.CreateScope();
Task handling = command switch
{
ShipOrder ship => Handle(scope, ship, ct),
SendInvoiceEmail email => Handle(scope, email, ct),
_ => throw new NotSupportedException($"No handler for {command.GetType().Name}")
};
await handling;
}
private static Task Handle<TCommand>(IServiceScope scope, TCommand command, CancellationToken ct)
where TCommand : BackgroundCommand =>
scope.ServiceProvider.GetRequiredService<ICommandHandler<TCommand>>().HandleAsync(command, ct);
}
using System.Text.Json;
using System.Threading.Channels;
using Bookstore.Jobs;
using Microsoft.Extensions.DependencyInjection;
ServiceCollection services = new();
services.AddScoped<ICommandHandler<ShipOrder>, ShipOrderHandler>();
services.AddScoped<ICommandHandler<SendInvoiceEmail>, SendInvoiceEmailHandler>();
services.AddSingleton<CommandDispatcher>();
using ServiceProvider provider = services.BuildServiceProvider();
// Gerçek hayatta bir message queue ya da outbox tablosu; burada bellek içi bir Channel
Channel<string> queue = Channel.CreateUnbounded<string>();
// Consumer: JSON'u okur, command'a çevirir, handler'a dağıtır
Task consumer = Task.Run(async () =>
{
CommandDispatcher dispatcher = provider.GetRequiredService<CommandDispatcher>();
await foreach (string json in queue.Reader.ReadAllAsync())
{
Console.WriteLine($"<- {json}");
try
{
BackgroundCommand command = JsonSerializer.Deserialize<BackgroundCommand>(json, JsonSerializerOptions.Web)!;
await dispatcher.DispatchAsync(command, CancellationToken.None);
}
catch (JsonException ex)
{
Console.WriteLine($" dead-letter: {ex.Message}");
}
}
});
// Producer: sipariş endpoint'i işi kendisi yapmaz, command'ı queue'ya bırakır
Guid orderId = Guid.Parse("0199a3b2-7c1e-7d40-9a51-3f2e8b6c1d24");
BackgroundCommand[] commands =
[
new ShipOrder(Guid.Parse("0199a3b2-7c1e-7d40-9a51-000000000001"), orderId, "DHL"),
new SendInvoiceEmail(Guid.Parse("0199a3b2-7c1e-7d40-9a51-000000000002"), "INV-2026-00042", "ada@example.com")
];
foreach (BackgroundCommand command in commands)
await queue.Writer.WriteAsync(JsonSerializer.Serialize(command, JsonSerializerOptions.Web));
// Yeni sürümün producer'ı, eski sürümün consumer'ının tanımadığı bir command gönderiyor
await queue.Writer.WriteAsync("""{"type":"cancel-order","commandId":"0199a3b2-7c1e-7d40-9a51-000000000003","orderId":"0199a3b2-7c1e-7d40-9a51-3f2e8b6c1d24"}""");
queue.Writer.Complete();
await consumer;
<- {"type":"ship-order","orderId":"0199a3b2-7c1e-7d40-9a51-3f2e8b6c1d24","carrier":"DHL","commandId":"0199a3b2-7c1e-7d40-9a51-000000000001"}
shipping order 0199a3b2 with DHL
<- {"type":"send-invoice-email","invoiceNo":"INV-2026-00042","email":"ada@example.com","commandId":"0199a3b2-7c1e-7d40-9a51-000000000002"}
emailing INV-2026-00042 to ada@example.com
<- {"type":"cancel-order","commandId":"0199a3b2-7c1e-7d40-9a51-000000000003","orderId":"0199a3b2-7c1e-7d40-9a51-3f2e8b6c1d24"}
dead-letter: Read unrecognized type discriminator id 'cancel-order'. Path: $ | LineNumber: 0 | BytePositionInLine: 35.
Üç şeye dikkat et. Birincisi, [JsonPolymorphic] ve [JsonDerivedType] ile System.Text.Json her command'ın JSON'una bir type alanı yazıyor; consumer bu alana bakarak doğru tipe geri çeviriyor. İkincisi, son mesaj: producer'ın yeni sürümü cancel-order gönderiyor ama consumer'ın eski sürümü bu tipi tanımıyor. Command'ı veri yaptığın an, o veri bir contract olur ve sürümlenmesi gerekir; consumer'ı producer'dan önce deploy et, tanınmayan mesajı kaybetme (dead-letter queue'ya taşı) ve alan ekleyip çıkarırken eski mesajların hâlâ queue'da beklediğini unutma. Üçüncüsü, her command bir CommandId taşıyor. Queue'lar mesajı en az bir kez teslim eder, yani aynı command iki kez gelebilir; handler bu id ile "bunu zaten işledim mi?" diye bakabilmeli. Bunun nasıl yapılacağını Çözüm Desenleri serisindeki "Idempotency Key" yazısında ayrıntısıyla anlattık. Channel<T> ile producer/consumer kurmanın ayrıntıları için de C# Async serisindeki "Channels ile Producer/Consumer" yazısına bakabilirsin.
CQRS Command'ı, MediatR ve Kütüphanesiz Dispatcher
Bu son biçim, GoF'un command'ından bir adım uzaklaştı. GoF'ta command receiver'ını tanır ve Execute'u kendisi implement eder; burada command yalnızca niyeti taşıyan bir veri, işi yapan kod ise handler'da. CQRS ve MediatR gibi kütüphanelerdeki "command" bu ikinci anlamdadır: sistemin durumunu değiştirme isteği, bir veri object'i olarak, tam olarak bir handler'a gider. Ortak noktaları isteği bir object'e çevirmek; fark, davranışın nerede durduğu. GoF command'ı undo ve macro için idealdir, çünkü davranış ve geri alma bilgisi bir aradadır; CQRS command'ı ise serialize, queue, log ve retry için idealdir, çünkü saf veridir. CQRS'in mimari tarafı bu serinin değil, ilerideki Yazılım Mimarisi serilerinin konusu.
Yukarıdaki CommandDispatcher bilinçli olarak kütüphanesiz: her command tipi için switch'te bir satır, reflection yok, go-to-definition handler'a kadar götürüyor ve yeni bir command eklenip dispatcher'a eklenmezse ilk mesajda NotSupportedException ile hemen fark ediliyor. Bedeli, her yeni command için bir satır eklemek. MediatR bu satırı reflection ve assembly taramasıyla ortadan kaldırır ve üzerine pipeline behavior'lar ekler. Ama zaman içinde değişen bir bilgi var: Jimmy Bogard'ın 2 Temmuz 2025 tarihli duyurusuna göre MediatR 13.0 ve sonrası dual license ile yayımlanıyor (RPL 1.5, yani Reciprocal Public License ya da Lucky Penny Software'in ticari lisansı); yıllık geliri 5 milyon USD (United States Dollar) altındaki şirketler, kâr amacı gütmeyen kuruluşlar, eğitim ve production dışı ortamlar için ücretsiz bir Community sürümü var, eski sürümler ise yayımlandıkları lisansla kalıyor. Kütüphaneye gerçekten ihtiyacın olup olmadığını serinin Mediator yazısında ayrıca konuşacağız; çoğu uygulama için yukarıdaki gibi otuz satır yeterli.
Sık Düşülen Tuzaklar
Her method çağrısını command'a çevirmek. orderService.Cancel(id) yerine new CancelOrderCommand(id) oluşturup bir dispatcher'a vermek, ancak command'ın kendisi bir şey kazandırıyorsa (queue, undo, log, retry, ortak pipeline) anlamlı. Kazandırmıyorsa kodu okuyan kişi, bir method çağrısının nereye gittiğini bulmak için bir class hiyerarşisini ve bir dispatcher'ı takip etmek zorunda kalır.
Geri alınamayanı geri almak. Undo, gönderilmiş bir e-postayı geri çağıramaz, tahsil edilmiş parayı sessizce geri koyamaz. Dış dünyaya etki eden command'lar için undo değil, compensation (telafi) command'ı tasarla: "iade et", "düzeltme e-postası gönder". Undo stack'ini yalnızca uygulamanın kendi state'i için kullan.
Undo state'ini yanlış anda yakalamak. Eski değeri constructor'da okumak, command oluşturulduktan sonra yapılan değişiklikleri ezer. Execute anında yakala.
Command'ın içinde canlı referans. RemoveBook kaldırdığı Book object'ini tutuyor; arada biri o object'i değiştirirse undo değişmiş hâlini geri koyar. Serialize edilecek command'larda ise canlı referans hiç olmamalı: DbContext, HttpContext ya da bir entity yerine id'ler ve değerler taşı.
Sınırsız geçmiş. Uzun bir oturumda undo stack'i her command'ı ve tuttuğu state'i bellekte biriktirir. Geçmişi bir üst sınırla tut (son 100 adım gibi) ya da büyük state'ler için snapshot yerine fark sakla.
Ne Zaman Kullanma
İşlem hemen, aynı yerde çalışıyor ve geri alma, queue'ya koyma, loglama ya da yeniden deneme ihtiyacı yoksa, method çağrısı yeterli. Tek bir yerde kullanılan bir callback için bir command class'ı yazma; bir Action verir geçersin. Ve "ileride belki queue'ya atarız" diye bugün dispatcher kurma; sipariş endpoint'inin gerçekten yavaşladığı ya da bir dış servisin gerçekten güvenilmez olduğu gün command'ı veri yapmak birkaç saatlik bir iş, gereksiz dispatcher ise her gün ödenen bir navigasyon bedeli.
Küçük Bir Deneme
★☆☆ CommandHistory'ye bir üst sınır ekle: geçmiş 3 adımı geçince en eski command düşsün. Stack<T> bu iş için uygun mu, yoksa LinkedList<T> mi gerekir?
★★☆ ChangePrice'a, aynı kitabın fiyatını art arda değiştiren iki command'ı tek bir command'a birleştiren bir TryMerge method'u ekle (metin editörlerinin yazılan karakterleri tek bir undo adımında toplaması gibi). Undo sonrası fiyat neye dönmeli?
★★★ Consumer'ı, aynı CommandId'li bir mesaj ikinci kez geldiğinde handler'ı çalıştırmayacak şekilde değiştir. İşlenmiş id'leri bellekte tutmak neden yetmez, bir veritabanı tablosunda tutarken hangi sıra (önce id'yi yaz mı, önce işi yap mı) hangi hatayı doğurur?
Bir sonraki yazıda C#'ın kendisine gömülmüş iki behavioral pattern'e geçiyoruz: Iterator ve Interpreter. yield return'ün arkasındaki iterator'ı, IAsyncEnumerable<T> ile sayfalı bir API'yi (Application Programming Interface) okumayı ve küçük bir kural dilini expression tree'ye derleyen bir interpreter'ı konuşacağız.