Derinlemesine yazılım eğitimleri için kanalımı takip edebilirsiniz...

C# İle Specification Pattern’ı İnceleyelim

Merhaba,

Bu içeriğimizde, domain içerisinde iş kurallarını kapsülleyerek kod tabanında farklı noktalarda yeniden kullanılabilir kılmamızı ve böylece bu kuralları tek bir yerden merkezi olarak yönetmemizi sağlayan Specification modelini ele alacak ve detaylı bir şekilde nasıl uygulandığını irdeliyor olacağız.

Specification Pattern Nedir?

Specification Pattern, genellikle Domain Driven Design (DDD)‘da iş kurallarını bağımsız mantıksal birimler halinde merkezi bir noktada özetleyerek, kodun yeniden kullanılabilir olmasını amaçlayan bir yaklaşımdır.

Burada iş kurallarından kastedileni net bir şekilde izah edebilmek için şöyle bir metafor üzerinden misalde bulunmamız yerinde olacaktır; Diyelim ki uygulamamız ile ilgili belirli gereksinimlerimiz söz konusu ve bu gereksinimlerden sadece birinin aşağıdaki gibi olduğunu varsayalım.

uygulamanızın kullanıcı hedef kitlesi 18 yaş üzerinde olsun…

Bu gereksinimi karşılayabilmek için aşağıdaki gibi bir davranış sergileyebilirsiniz.

void Register(UserInfo userInfo)
{
    if (userInfo.Age < 18)
        throw new Exception("Too young...");
}

Öyle değil mi? İşte burada yazmış olduğumuz if kalıbı bir iş kuralının somutlaşmış örneğidir. Bunun basit bir tanımlama olduğunun farkındayım. Burada mühim olan nokta şudur ki, bir iş kuralı kod içerisinde geliştirici açısından yaptığı fiiliyatın dışında bir anlam ifade etmeyebilir. Tıpkı burada olduğu gibi. Yazılan bu kodu herhangi bir geliştirici incelediğinde ‘Age’ değeri 18’den küçük olduğu durumda bir exception fırlatılacağını bilmekte ama bu bilginin uygulamanın geneline dair kabul edilmiş bir davranış mı yoksa sadece burada uygulanan fiili bir hamle mi olup olmadığını bilememektedir. Ya da bu iş kuralından yola çıkıp bu uygulamayı 18 yaşından küçüklerin kullanıp kullanamayacağına dair bir fikir de edinemeyecektir. Yani anlayacağınız iş kurallarının esas mahiyetleri açısından anlam kazanabilmeleri için ya analiz ekiplerindeki personeller tarafından izah edilmeleri ya da dokümante edilmeleri gerekmektedir.

Şimdi gelin bu örnek üzerinden konumuz olan Specification Pattern’ı temelde örneklendirerek hafiften içeriğe de girizgah eyleyelim…

Bizler bu iş kuralını if kalıbından ziyade bir kod konseptine dönüştürürsek eğer aşağıdaki gibi bir kullanım söz konusu olacaktır.

void Register(UserInfo userInfo)
{
    UserSpecification specification = new();
    if (specification.IsSatisfiedBy(userInfo))
        throw new Exception("Too young...");
}

class UserSpecification
{
    private int _age;

    public UserSpecification(int age = 18)
        => _age = age;

    public bool IsSatisfiedBy(UserInfo userInfo)
        => userInfo.Age > _age;
}

Ve bu kullanımda bir iş kuralı uygulamaktan ziyade IsSatisfiedBy metodu ile bir doğrulamanın yaptırıldığı görüldüğü için artık diğer geliştiriciler açısından buradaki yapılan işlem salt bir fiiliyata nazaran daha fazla bir anlam ifade edebilecektir. Çünkü artık bu metot bir iş kuralının dışında, o kuralı uygulayan bir şartname(specification) görevi görmektedir. Bu şartnamenin kullanıldığı her noktada içerisindeki şart/kural uygulama sürecinde icra edilecek ve işin güzel yanı bu yapı sayesinde bu iş mantığı merkezi bir noktadan yönetilebilir bir nitelik kazanmış olacaktır. Yani bir gün bu iş kuralını değiştirme kararı verildiği taktirde uygulandığı her noktada hususi değişiklik yapmak yerine bu şartname üzerinden tek seferlik değişiklikle yeni davranış tüm noktalara merkezi olarak yansıtılmış olunacaktır.

İşte Specification Pattern’ın gayesi bu mantıkta iş kurallarını temellendirmek ve merkezi hale getirmek üzerine kuruludur…

Specification Pattern’ın İhtiyaç Duyulduğu Noktaları İnceleyelim

Specification pattern’ın ihtiyacını daha net gösterebilmek için aşağıdaki sınıf üzerinden değerlendirmede bulunabiliriz;

abstract class Entity
{
    public int Id { get; set; }
}

class Person : Entity
{
    public string Name { get; set; }
    public int Age { get; set; }
    public Gender Gender { get; set; }
    public DateTime CreatedDate { get; set; }
}

enum Gender
{
    Woman,
    Man
}

Şimdi diyelim ki, yeni kayıt olan personelleri elde etmek istiyoruz. Bunun için bir repository sınıfında aşağıdaki gibi bir çalışma gerçekleştirilebilir.

class PersonRepository
{
    public IReadOnlyList<Person> GetByCreatedDate(DateTime minCreatedDate)
    {
        //...
    }
}

Yahut cinsiyete, yaşa vs. gibi aramalar yapmak istersek de aşağıdaki gibi metotlar eşliğinde çalışmamızı gerçekleştirebiliriz.

class PersonRepository
{
    public IReadOnlyList<Person> GetByCreatedDate(DateTime minCreatedDate)
    {
        //...
    }
    public IReadOnlyList<Person> GetByAge(int age)
    {
        //...
    }
    public IReadOnlyList<Person> GetByGender(Gender gender)
    {
        //...
    }
}

Arama kriterleri bu şekilde tekil olarak değerlendirildiğinde rahatlıkla ayrı metotlar üzerinde bu işlemleri gerçekleştirebilmekteyiz. Amma velakin, bu kriterleri birleştirmeye karar verdiğimizde işlerimiz biraz daha karmaşıklaşacaktır.

Tabi, tüm arama kriterlerini kümülatif olarak barındıran aşağıdaki gibi bir metot geliştirerek de bu ihtiyacı rahatlıkla karşılayabiliriz.

class PersonRepository
{
    public IReadOnlyList<Person> Find(DateTime? minCreatedDate = null, int? age, Gender? gender = null)
    {
        //...
    }
}

Elbet bu metoda da daha fazla kriter ekleyerek genişletebiliriz. Evet, kriterleri bütünleşik olarak bu metotla karşılayarak ihtiyaçlarımızı böylece giderebiliriz ama bu yöntemin sizlerin de içine sinmediğini biliyorum 🙂

İçeriğimizin devamında buradaki ihtiyaçları daha ideal bir şekilde karşılayabilmemizi sağlayacak olan Specification pattern yaklaşımıyla ele alıyor olacağız. Şimdilik örnek senaryoyu biraz daha açmaya devam edelim.

Bazen yapılan çalışmalarda hedef verileri veritabanından çektikten sonra da in-memory’de doğrulamalar gerçekleştirmemiz gerekebilmektedir. Örneğin, bir person’ı herhangi bir göreve tabi tutmadan önce yaşının bu görev için uygun olup olmadığını doğrulamak isteriz.

class PersonService
{
    PersonRepository _personRepository = new();
    public bool AssignToPerson(int personId)
    {
        Person person = _personRepository.GetById(personId);
        if (person.Age < 18)
            throw new Exception("The person is not eligible for task.");

        return true;
    }
}

Benzer şekilde aynı kriteri karşılayan diğer person’ları da veritabanından sorgulamakta isteyebiliriz. Bunun için de aşağıdaki gibi repository sınıfında bir işlem uygulayabiliriz.

class PersonRepository
{
    public IReadOnlyList<Person> FindPeopleFitForTheJob()
    {
        return dbContext.Where(person => person.Age > 18).ToList();
    }
}

Şimdi burada durup, yapmış olduğumuz işlemleri değerlendirirsek eğer bir person ile ilgili iş kuralları ve domain bilgisi gereği farklı davranışları farklı sınıflar ve metotlar üzerinde tanımlamak zorunda kaldığımızı görüyorsunuz. Ve hatta bu sınıflar da Don’t Repeat Yourself(DRY) ilkesini ihlal ederek ister istemez tekrarlı işlemler gerçekleştirebiliyoruz. İşte Specification pattern’ın bizlere yardımcı olabileceği yer tam da burasıdır.

Specification pattern, farklı şartlara bağlı olacak şekilde değerlendirmelerde bulunabilecek ya da bir başka deyişle değerlendirmeleri tam olarak nasıl ayırt edebileceğini bilecek sınıflar tanımlamamızı ve domain bilgisi gereği türlü iş kurallarını bu sınıflar üzerinden uygulamamızı savunan bir tasarım desenidir.

Misal olarak aşağıdaki gibi işe uygun olan person’ları ifade eden bir şartname olarak ‘PeopleFitForTheJobSpecification’ sınıfını tanımlayabiliriz.

class PeopleFitForTheJobSpecification : Specification<Person>
{
    public override Expression<Func<Person, bool>> ToExpression()
    {
        return p => p.Age > 18;
    }
}

Biliyorum, base class olarak kullanılan Specification<T> sınıfının nereden geldiğini merak ediyorsunuz. Zaten işin tüm detayı orada 🙂 Onu ilerideki satırlarımızda ayrıca konuşuyor olacağız. Şimdilik bu specification ile farklı sınıflarda tekrar eden iş kurallarının nasıl merkezi hale getirildiğini görerek devam edelim.

class PersonService
{
    PersonRepository _personRepository = new();
    public bool AssignToPerson(int personId)
    {
        Person person = _personRepository.GetById(personId);

        PeopleFitForTheJobSpecification specification = new();

        if (!specification.IsSatisfiedBy(person))
            throw new Exception("The person is not eligible for task.");

        return true;
    }
}
class PersonRepository
{
    public IReadOnlyList<Person> FindPeopleFitForTheJob()
    {
        PeopleFitForTheJobSpecification specification = new();
        return dbContext.Where(person => specification.IsSatisfiedBy(person)).ToList();
    }
}

Dikkat ederseniz bu yaklaşım yalnızca iş kurallarının ve domain bilgisi gereği farklı davranışların tekrarlarını ortadan kaldırmakla kalmamakta, aynı zamanda birden çok özelliğin birleştirilmesine de olanak sağlayabilecek bir altyapı sunmaktadır. Böylece oldukça karmaşık arama ve doğrulama kriterlerini kolayca oluşturmamıza yardımcı olmaktadır.

Buradan yola çıkarak şöyle bir genellemeye varabiliriz ki, Specification pattern iki ana noktada kullanıma sunulabilmektedir;

  • Veritabanından elimizdeki specification’a uyan hedef verileri sorgularken.
  • Veritabanından sorgu neticesinde gelen veya dış bir servis tarafından gönderilen yahut kendimizce create edilen herhangi bir nesnenin ya da nesnelerin iş kurallarına uyup uymadığını in-memory’de kontrol ederken.

Specification Pattern Nasıl Uygulanır?

Aslında specification pattern’ın iş kurallarını barındıran sınıflar ile uygulanacağını yukarıdaki satırlarımızda yaptığımız izahatlerden anlamışsınızdır diye düşünüyorum. Biz yine de olayı daha da derinleştirecek ve pratiksel olarak işin usulünü tam teferruatlı masaya yatıracağız. Şimdi öncelikle iş kurallarını barındıracak sınıfları oluşturabilmek için ‘PeopleFitForTheJobSpecification’ sınıfında kullandığımız gibi generic olan base Specification<T> sınıfını oluşturmamız gerekmektedir.

abstract class Specification<T>
{
    public abstract Expression<Func<T, bool>> ToExpression();
    public bool IsSatisfiedBy(T entity)
    {
        Func<T, bool> predicate = ToExpression().Compile();
        return predicate(entity);
    }
}

Bu sınıf domain bilgisi gereği tanımlanacak iş kuralının uyumlu olup olmadığını kontrol edebilmek için ‘IsSatisfiedBy’ metodunu barındırmaktadır. Bu metot içerisinde ‘ToExpression’ isimli abstract olarak tanımlanmış metot imzası compile edilmekte ve gerekli kontrol bunun üzerinden gerçekleştirilerek uygulamadan encapsulate edilmektedir.

Bu tanımlamadan sonra uygulama sürecinde kullanacağımız tüm iş kurallarını yukarıdaki ‘PeopleFitForTheJobSpecification’ sınıfında olduğu gibi özel specification sınıfları ile temsil edebiliriz.

Bir kaç örnek yaparsak eğer;

Sadece erkek kişileri temsil eden specification;

class OnlyMenSpecification : Specification<Person>
{
    public override Expression<Func<Person, bool>> ToExpression()
    {
        return p => p.Gender == Gender.Man;
    }
}

Sadece kadın olan ve 18 yaşından büyük olan kişileri temsil eden specification;

class WomenAndOver18YearsOldOnlySpecification : Specification<Person>
{
    public override Expression<Func<Person, bool>> ToExpression()
    {
        return p => p.Gender == Gender.Woman && p.Age > 18;
    }
}

2020’den önce üretilmiş ve 18 yaşın üzerindeki kişileri temsil eden specification;

class PersonsOver18YearsOfAgeManufacturedBefore2020 : Specification<Person>
{
    public override Expression<Func<Person, bool>> ToExpression()
    {
        return p => p.CreatedDate.Year <= 2020 && p.Age > 18;
    }
}

İşte bu kadar… Oluşturulan bu specification’ları isterseniz query yahut validation işlemleri sırasında kullanabilir ve böylece iş kurallarını uygulama çapında hem anlamsal olarak daha verimli kullanabilir hem de merkezi bir noktadan yönetilebilir kılabilirsiniz.

Composite Specification

Oluşturulan specification’ları AND, OR ve NOT gibi mantıksal operatörler eşliğinde birleştirerek kullanmaya Composite Specification denmektedir. Bunun için aşağıdaki gibi bu mantıksal operatörlere karşılık özel specification sınıfları tanımlanabilir.

Tüm sınıflarda ortak kullanılacağı için öncelikle aşağıdaki ‘ParameterReplacer’ sınıfı tanımlanmalıdır;

class ParameterReplacer : ExpressionVisitor
{
    private readonly ParameterExpression _parameter;
    public ParameterReplacer(ParameterExpression parameter)
        => _parameter = parameter;
    protected override Expression VisitParameter(ParameterExpression node)
        => base.VisitParameter(_parameter);
}

Burada ParameterExpression ile constructor’dan gelen parametre temsil edilmekte ve ‘VisitParameter’ metodu ile bu parametrenin işlevi devreye sokulmaktadır. Haklı olarak neye yarayacak bu hoca la? diye sorduğunuzu duyar gibiyim… Bizler bu parametre ile oluşturacağımız AND, OR ve NOT mantıksal çalışmalarını temsil edecek ve öncül olan specification’larımız ile birleştireceğiz.

Şimdi devam edince daha da net anlaşılacaktır kanaatindeyim. Hiç vakit kaybetmeden sırasıyla operatörlere karşılık specification’ları inşa edelim.

And için;

class AndSpecification<T> : Specification<T>
{
    private readonly Specification<T> _left;
    private readonly Specification<T> _right;

    public AndSpecification(Specification<T> left, Specification<T> right)
    {
        _left = left;
        _right = right;
    }

    public override Expression<Func<T, bool>> ToExpression()
    {
        Expression<Func<T, bool>> leftExpression = _left.ToExpression();
        Expression<Func<T, bool>> rightExpression = _right.ToExpression();

        ParameterExpression parameterExpression = Expression.Parameter(typeof(T));
        BinaryExpression andExpression = Expression.AndAlso(leftExpression.Body, rightExpression.Body);
        andExpression = (BinaryExpression)new ParameterReplacer(parameterExpression).Visit(andExpression);

        return Expression.Lambda<Func<T, bool>>(andExpression, parameterExpression);
    }
}

Or için;

class OrSpecification<T> : Specification<T>
{
    private readonly Specification<T> _left;
    private readonly Specification<T> _right;

    public OrSpecification(Specification<T> left, Specification<T> right)
    {
        _left = left;
        _right = right;
    }

    public override Expression<Func<T, bool>> ToExpression()
    {
        Expression<Func<T, bool>> leftExpression = _left.ToExpression();
        Expression<Func<T, bool>> rightExpression = _right.ToExpression();

        ParameterExpression parameterExpression = Expression.Parameter(typeof(T));
        BinaryExpression orExpression = Expression.OrElse(leftExpression.Body, rightExpression.Body);
        orExpression = (BinaryExpression)new ParameterReplacer(parameterExpression).Visit(orExpression);

        return Expression.Lambda<Func<T, bool>>(orExpression, parameterExpression);
    }
}

NotEqual için;

class NotEqualSpecification<T> : Specification<T>
{
    private readonly Specification<T> _left;
    private readonly Specification<T> _right;

    public NotEqualSpecification(Specification<T> left, Specification<T> right)
    {
        _left = left;
        _right = right;
    }

    public override Expression<Func<T, bool>> ToExpression()
    {
        Expression<Func<T, bool>> leftExpression = _left.ToExpression();
        Expression<Func<T, bool>> rightExpression = _right.ToExpression();

        ParameterExpression parameterExpression = Expression.Parameter(typeof(T));

        BinaryExpression notEqualExpression = Expression.NotEqual(leftExpression.Body, rightExpression.Body);
        notEqualExpression = (BinaryExpression)new ParameterReplacer(parameterExpression).Visit(notEqualExpression);

        return Expression.Lambda<Func<T, bool>>(notEqualExpression, parameterExpression);
    }
}

Equal için;

class EqualSpecification<T> : Specification<T>
{
    private readonly Specification<T> _left;
    private readonly Specification<T> _right;

    public EqualSpecification(Specification<T> left, Specification<T> right)
    {
        _left = left;
        _right = right;
    }

    public override Expression<Func<T, bool>> ToExpression()
    {
        Expression<Func<T, bool>> leftExpression = _left.ToExpression();
        Expression<Func<T, bool>> rightExpression = _right.ToExpression();

        ParameterExpression parameterExpression = Expression.Parameter(typeof(T));

        BinaryExpression equalExpression = Expression.Equal(leftExpression.Body, rightExpression.Body);
        equalExpression = (BinaryExpression)new ParameterReplacer(parameterExpression).Visit(equalExpression);

        return Expression.Lambda<Func<T, bool>>(equalExpression, parameterExpression);
    }
}

Mantıksal operatörler için oluşturduğumuz specification’lara göz atarsanız eğer hepsinde ortak iş olarak, composite bir şekilde ilgili operatör ile birleştirilecek specification’lar sol(_left) ve sağ(_right) olmak üzere temsil edilmekte ve ‘ToExpression’ metodu içerisinde de bu specification’lar expression’a dönüştürülmektedirler. Akabinde her birinde Expression.Parameter ile bir parametre oluşturulmakta ve mantıksal operatöre göre specification’lar davranışa tabi tutulmakta ve bu davranış BinaryExpression türünden elde edilmektedir. Ve son olarak ilk başta oluşturduğumuz ParameterReplacer sınıfı ile, Expression.Parameter komutu eşliğinde üretilen parametreye bu davranış ‘Visit’ metodu ile geçirilmekte ve ‘Lambda’ fonksiyonu ile davranış işlenmektedir.

Tabi bu operatörlere özel çalışmalardan sonra aşağıdaki gibi Specification<T> sınıfında da ilgili operatörlere karşılık fonksiyonlar tanımlanmalıdır ki her bir specification üzerinden bir sonraki istenilen mantıkta çağrılabilsin.

abstract class Specification<T>
{
    public abstract Expression<Func<T, bool>> ToExpression();
    public bool IsSatisfiedBy(T entity)
    {
        Func<T, bool> predicate = ToExpression().Compile();
        return predicate(entity);
    }

    public Specification<T> And(Specification<T> specification)
        => new AndSpecification<T>(this, specification);
    public Specification<T> Or(Specification<T> specification)
        => new OrSpecification<T>(this, specification);
    public Specification<T> NotEqual(Specification<T> specification)
        => new NotEqualSpecification<T>(this, specification);
    public Specification<T> Equal(Specification<T> specification)
        => new EqualSpecification<T>(this, specification);
}

Ardından ister in-memory’de isterseniz de aşağıdaki gibi IQueryable sorgularında rahatlıkla kullanılabilmektedir.

ApplicationDbContext context = new();

WomenAndOver18YearsOldOnlySpecification s1 = new();
PersonsOver18YearsOfAgeManufacturedBefore2020 s2 = new();
PeopleFitForTheJobSpecification s3 = new();
OnlyMenSpecification s4 = new();

var query = context.Persons
    .Where(s1.And(s2)
             .Or(s3)
             .And(s4)
             .ToExpression()
          )
    .ToQueryString();
Console.WriteLine(query);

Özellikle IQueryable sorgularında üretilecek query’leri merak edebilme ihtimalinizden dolayı yukarıdaki örnek çalışmanın çıktısına göz atmakta fayda vardır.
C# İle Specification Pattern'ı İnceleyelim

Nihai olarak;
Uygulama bazında domain bilgileri doğrultusunda genel kabul görmüş iş kurallarını specification pattern ile merkezi bir noktaya taşıyarak daha yönetilebilir bir hale getirmenin avantajlarını mümkün mertebe gözlemlemeye çalışmış bulunuyoruz. Özellikle bu yapılanmanın, IQueryable sorgularını repository sınıfları üzerinden daha esnek bir şekilde üretebilmemizi sağlayacak bir olgunluk getirdiğine dair sizlerin de kanaati vardır diye düşünüyorum.

İlgilenenlerin faydalanması dileğiyle…
Sonraki yazılarımda görüşmek üzere…
İyi çalışmalar…

Not : Specification Pattern’a dair ve özellikle Composite Specification için yapmış olduğumuz örnek çalışmayı aşağıdaki github adresinden inceleyebilirsiniz.
https://github.com/gncyyldz/Specification_Pattern_Example

Bunlar da hoşunuza gidebilir...

2 Cevaplar

  1. orhan ekren dedi ki:

    çok faydalandım, teşekkürler. devamını dilerim.

  2. Mehmet dedi ki:

    railway oriented ile ilgili yazı yazabilir misiniz

Bir cevap yazın

E-posta hesabınız yayımlanmayacak. Gerekli alanlar * ile işaretlenmişlerdir