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

Asp.NET Core – Multitenancy Uygulama Nasıl Oluşturulur?

Merhaba,

Yakın zamana kadar ticari amaçlı geliştirdiğimiz ve belli bir işlevselliğe özel hizmet sağlayan yazılımları piyasaya sunarken her bir müşteriye hususi olarak ilgili yazılımın bir instance’ını/replika’sını üretir ve o şekilde ticari faaliyetlerimizi gerçekleştirirdik. Lakin günümüzde, ticaretinde bulunulan pazarın müşteri portföyü bulunduğumuz sokakların, şehrin yahut ülkenin sınırlarından ibaret olmamakta, global dünyanın herhangi bir noktasında ürettiğimiz yazılımın hizmetine talip olan onlarca potansiyele sahip müşteri bulunmaktadır. Hal böyleyken, edindiğimiz her bir müşteriye yazılımımızın instance’ını ayrı ayrı üretip göndermek bir yerden sonra lisanslamada kontrolsüzlük, yeni güncellemelerin tüm müşterilere eriştirilme zahmeti vs. gibi türlü türlü yönetilebilirliği negatif etkileyen problemlere mahal vermektedir. İşte böyle bir durumda ticari faaliyetleri daha efektif bir şekilde yönetmemizi sağlayacak olan farklı bir yaklaşım sergilememiz gerekmektedir. Hayır, bu efektif yaklaşımı biz değil, ticaretini yaptığımız yazılım sergilemeli ve binlerce müşteriye ve bu müşterilerin bulunduğu kullanıcı guruplarına tekbir noktadan hizmet sunabilecek şekilde davranışta bulunabilmelidir. Bize düşen ise bu davranışı uygun şekilde modelleyebilmektir. İşte bu modele, içeriğimiz boyunca değerlendireceğimiz üzere Multitenancy ismi verilmektedir. Bizler bu içeriğimizde, .NET Core geliştiricileri olarak bu modeli Asp.NET Core uygulamalarında nasıl tasarlayabileceğimizi istişare edecek ve pratik olarak gerekli gördüğümüz örneklendirme üzerinden konuyu değerlendiriyor olacağız. Buyrun başlayalım.

Multitenancy Uygulama Nedir?

Herşeyden önce kurgusal olarak üzerinde yörüngemizi belirleyeceğimiz Multitenancy kavramını anlamaya çalışarak girizgâh yapalım ve Multitenancy Uygulama Nedir? sorusunu cevaplandırmaya çalışalım.

Multitenancy; tekbir altyapıya sahip olan bir yazılımın, farklı müşteri grupları tarafından kullanılması demektir.

Multitenancy kelimesini anlamak için en doğru tanımlamanın yukarıdaki cümle olduğu kanaatindeyim. Tabi bazen, bazı terimleri veya kavramları daha iyi anlayabilmek için farklı tanımları da masaya yatırmak ve bütünsel olarak değerlendirmek gerekebilmektedir. Bizlerde bu hesap yukarıdaki cümleye alternatif olarak aşağıdaki tanımları da gözden geçirebiliriz…
(Not : Bu makaledeki konuya dair tüm tanımlamalar şahsıma aittir.)

  • Bir yazılımın tekbir instance olarak birden fazla müşteriye ve müşteri gurubuna hizmet verdiği mimaridir.
  • Aynı kodlama tabanına/altyapısına sahip bir uygulamanın tekbir deploy ile birden fazla kullanıcı grubunu barındırabilecek şekilde tasarlanmasıdır.
  • Aynı sistem veya program bileşenlerinin, aralarında veri izolasyonu sağlanmış çok kullanıcılı bir ortamda paylaşıldığı yazılım mimarisidir.

Bu tanımları harmanladığımızda Multitenancy’nin bir yazılım mimarisi olduğunu görüyoruz. Bu mimari ile nihai olarak üretilen yazılımlarda, tasarımsal açıdan kullanıcılara ve kullanıcı gruplarına görsel arayüzün(user interface) özelleştirilmesi yeteneği verilmekte lakin uygulama koduna müdahale ettirilmemektedir.

Multitenancy mimari sayesinde yazılım uygulamaları, birden çok kullanıcı grubu için sistemin ortak parçalarını paylaşabilmekte ve operasyon yönetimi ile birlikte bakım maliyetini etkin bir şekilde tasarruflu hale getirmektedir. Aynı zamanda, uygulamanın tek bir instance’ının tüm kullanıcı grupları tarafından paylaşılması, upgrade süreçlerinde yazılımın yeni versiyonunun tüm kullanıcılara tek elden yansımasını sağlayacaktır. Zaten bu özelliğiyle müşteri başı üretilen instance yaklaşımını oldukça gölgelemektedir.

Multitenancy Uygulamalara Örnekler Nelerdir?

Günlük hayatta kullandığımız birçok uygulamayı Multitenancy mimarisi açısından örneklendirebiliriz. Misal olarak; Azure ve AWS gibi cloud ortamlar, Hotmail veya Gmail gibi mail sistemleri ya da Hepsiburada, Trendyol gibi e-ticaret siteleri tam olarak Multitenancy’e örnek teşkil edecek mimarilere sahiptir diyebiliriz. Nihayetinde tüm bu uygulamalar aynı altyapı üzerinden farklı kullanıcı gruplarına hizmetler sağlanmakta ve hatta bireysel ve kurumsal kimlikler şeklinde kullanıcıları ayırarak ticari boyutta da ilgili yaklaşımın teknik yanını zenginleştirmektedirler.

Multitenancy Uygulamaların Tarihçesi

Multitenancy yaklaşımının kökeni 1960’lara kadar dayanmaktadır. Zamanında birçok şirket tarafından büyük bir ticari sunucu olarak tasarlanan anabilgisayarlara(Mainframe) sahip firmalardan bilgi işlem kaynaklarının kiralanması ve ortak altyapıya sahip uygulamaların kullanılması sonucunda tecrübe edinilmiştir. Eski sistemlerde de, sistemi açan kullanıcı tarafından girilen verilere göre şirketlerin kimlikleri belirlenmekteymiş ve bu kimliklere dayanılarak ilgili hesaba dair ayrılan kaynaklar kullanılabilirlik kazanmaktaymış. Tıpkı günümüzdeki gibi…

1990’lara gelindiğinde ise artık kiralanan kaynaklar işletim sistemi de dahil yazılım seviyesindeymiş. ERP, CRM vs. gibi uygulamalar tarafından benimsenen bu yaklaşım günümüze tüketici odaklı web uygulamaları(Hotmail, Gmail vb. gibi.) şeklinde tüm kullanıcıları tek bir platform üzerinden destekleyecek şekilde evrilmiş.

Günümüzde ise özellikle sanallaştırma teknolojisi sayesinde uygulamalar yatay ölçeklendirme(horizontal scaling) ile genişlenebilmekte ve böylece Multitenancy uygulamaları rahatlıkla kontrol edilebilmekte, kullanıcı ile veriler arasında yalıtımı güçlendirilebilmekte ve bu yaklaşımın tüm nimetlerinden tam anlamıyla istifade edilebilmektedir.

Multitenancy mimari, tüm kullanıcı gruplarının verilerini merkezi bir noktada tutmak ister. Dolayısıyla, -benim verim, ben de olsun- mantığına aykırı olduğundan dolayı bu düşünceye sahip olanlar bu modeli benimsemiş olan yazılımlara karşı ticari açıdan direnç göstermektedirler.

Multitenancy Konsept ve Teknolojisi Nasıldır?

SaaS(Software as a Service/Servis olarak yazılım) mantığında çalışmalar Multitenancy için örnek olarak gösterilebilmektedir.

Asp.NET Core - Multitenancy Uygulama Nasıl OluşturulurYandaki görseli incelerseniz eğer, Single-Tenant Application(Tek Kiracılı Uygulama) örneği temsil edilmektedir ve her bir müşteri/kullanıcı grubu için ayrı bir instance yaratıldığını görmekteyiz.

Burada bir ‘Bina Analojisi’ üzerinden durumu örneklendirirsek eğer, single-tenant application’ı kullanan bir uygulamada her bir kullanıcı grubu başlı başına asansörü, park alanını, tesisleri olan bir binaya sahiptir diyebiliriz.

Böylece her kullanıcı grubuna karşılık önceki satırlarımızda da bahsedildiği üzere ciddi bir maliyetin ortaya koyulduğunu ve yönetilebilirlik açısından etkinliğin oldukça düştüğünü söyleyebiliriz.

Asp.NET Core - Multitenancy Uygulama Nasıl OluşturulurGel gelelim yandaki görsele göz atarsanız eğer Multitenant application resmedilmekte ve her bir müşteri/kullanıcı grubunu karşılayacak ortak altyapıya sahip tekil bir uygulama üzerinden mimari temsil edilmektedir.

‘Bina Analojisi’ne devam edersek eğer multitenant application’da ise tekbir bina söz konusudur ve her bir müşteri bu bina içerisinde kiracı konumundadır. Bu kiracılar, yalnızca kendilerine ait olan dairelerine erişebilmekte ve bu daireleri istedikleri şekilde özelleştirebilmektedirler. Tüm bunların dışında ortak alanları ve hizmetleri diğer kiracılarla birlikte paylaşmaktadırlar.

Tabi burada tüm müşteriler/kullanıcı grupları, verileri tamamen birbirlerinden yalıtılmış ve özelleştirilmiş ortak bir veritabanına sahip olabileceği gibi kendilerine ait hususi bir veritabanına da sahip olabilmektedirler. Şimdi gelin Multitenancy mimarisinde benimsenebilecek veritabanı stratejilerini değerlendirerek konuya devam edelim.

Multitenancy Mimarisinde Benimsenebilecek Veritabanı Stratejileri

Multitenancy mimarisinde veritabanı ile iletişim kurulabilmesi için Şema Ayrımı(Schema Seperator), Kiracı Sütun Ayrımı(Single Database – Tenant Column Seperation), Eksiksiz Veri Yalıtımı(Multiple Database – Complete Data Isolation) ve Hibrit Yaklaşım(Hybrid Approach) olmak üzere dört temel yaklaşım benimsenebilmektedir. Sırasıyla bu yaklaşımları tek tek incelersek eğer;

  • Şema Ayrımı(Schema Seperator)
    Her kullanıcı grubunun kendisine özel bir şema adıyla ayrılmış veritabanı tablosunu kullanması durumunu ifade eden yaklaşımdır. Misal olarak, bir uygulamada Bronz, Silver ve Gold olmak üzere üç müşteri/kullanıcı grubu/kiracı olduğunu varsayarsak eğer bu yapının ilgili yaklaşımla modellenmesi aşağıdaki gibi olacaktır;Asp.NET Core - Multitenancy Uygulama Nasıl OluşturulurBu yaklaşım neticesinde tüm kullanıcı grupları net bir şekilde veritabanı seviyesinde birbirlerinden yalıtılmış olacaktırlar.

    Şema ayrımı, multitenancy mimarilerde en az tercih edilen yaklaşımdır.

    Avantajları;

    • Tam olmasa da belirli bir düzeyde mantıksal veri yalıtımı sağlar.
    • Her veritabanı daha fazla sayıda kiracıyı destekleyebilir.

    Dezavantajları;

    • Veritabanında bir kullanıcı grubuna dair herhangi bir arıza söz konusu olduğunda veri kurtarma operasyonu kısmen zordur. Çünkü diğer kullanıcı gruplarının verileri de ilgili veritabanı tarafından içerilecektir.
    • Kullanıcı grupları arasında istatistiklere ihtiyaç olduğunda şema ayrımından dolayı kısmi zorluklar yaşatabilir.
  • Kiracı Sütun Ayrımı(Single Database – Tenant Column Seperation)
    Bu yaklaşım ise tüm kullanıcı gruplarının tek bir veritabanı tablosu üzerinden hem uygulamayı hem de veritabanını paylaştığı oldukça popüler bir model sunmaktadır. Bu modelde, tabloların her biri, verileri kullanıcı grupları/müşteriler bazında filtreleyecek ‘TenantId’ kolonuna sahip olacaktır.Asp.NET Core - Multitenancy Uygulama Nasıl Oluşturulur

    Kiracı sütun ayrımı, çok fazla veri işlemesi olmadığı durumlarda oldukça ekonomik bir yaklaşımdır.

    Müşterilerin veri güvenliği konusunda endişeli olduğu ve çok fazla veri akışının söz konusu olduğu durumlarda bu model pekte ideal bir çözüm olmayabilir. Çünkü developer’lar kritik verilere sahip olan herhangi bir müşterinin datalarını istemeden de olsa alakasız bir kullanıcı grubunun erişebileceği şekilde açığa çıkarabilecek bir hata yapma toleransına sahiptir. Ee hal böyleyken müşteri açısından bu endişe gayet yerindedir.

    Ayrıca bir kullanıcı grubunun, diğer gruplara nazaran yoğun bir veri alanına ihtiyaç duyacağı durumlarda bu yaklaşım pek tavsiye edilmeyecektir. Çünkü, ortalama veri hacmi x kadar olan bir müşteri grubunun ortalama veri hacmi 15x/20x olan müşteri gruplarıyla aynı veritabanında muadil ücretlerle hizmet alması, sorgulama süreçlerinde yaşanacak performans düşüşlerinde adil olmayacak bir dağılıma mahal verecektir. Haliyle dağılımı bu şekilde uç noktalarda olabilecek müşteriler için en uygun yaklaşım bir sonraki maddede değerlendireceğimiz Eksiksiz Veri Yalıtımı(Complete Data Isolation) başlıklı yaklaşımdır.

    Avantajları;

    • Bakım ve maliyet açısından diğer yaklaşımlara nazaran en avantajlısıdır.
    • En çok kullanıcı grubuna en az sunucu ve maliyetle hizmet vermemizi sağlar.

    Dezavantajları;

    • Kullanıcı grupları arasındaki izolasyon seviyesi ve güvenlik davranışı en düşük yaklaşımdır.
    • Olası yaşanabilecek aksi durumlara istinaden yedekleme ve kurtarma operasyonları oldukça zor ve maliyetlidir.
    • Kullanıcı grupları arasında verisel yalıtımdan ödün verir.
  • Eksiksiz Veri Yalıtımı(Complete Data Isolation)
    Bu yaklaşımda kullanıcı gruplarının her biri için ayrı bir veritabanı oluşturulur. Böylece diğer kullanıcı gruplarına karşı izolasyon ve güvenlik müşteriye özel olarak tam anlamıyla gerçekleştirilmiş olur.Asp.NET Core - Multitenancy Uygulama Nasıl Oluşturulur

    Eksiksiz veri yalıtımı, multitenancy mimarilerde en çok tercih edilen yaklaşımdır.

    Avantajları;

    • Farklı kullanıcı grupları için bağımsız veritabanı sağlar.
    • Olası bir arıza durumunda verilerin geri yüklenmesi daha kolaydır.

    Dezavantajları;

    • Veritabanı sayısı artacağı için doğrudan bakım ve satın alma maliyeti artacaktır.
  • Hibrit Yaklaşım(Hybrid Approach)
    Bazen de kullanılmak istenilen veritabanı modellerinin duyulan ihtiyaçlara binaen müşteriler tarafından hususi olarak seçilmesini isteyebiliriz. Böyle durumlarda geliştirilen yazılımda hibrit yaklaşım benimseyerek veritabanı modelini belirleme olanağını müşteriye sağlatabilir ve böylece esnek bir model ortaya koyabiliriz.

    Hibrit yaklaşım sayesinde, uygulama açısından piyasaya nazaran müşteri odaklı bir davranış sergileneceğinden dolayı hem ticari fark yaratılabilir hem de müşteriler açısından tercihe bağlı izolasyon maliyeti ortaya koyularak farklı ekonomik alternatiflerde davranışlar sergilenebilir.

Evet, böylece multitenancy mimarisini uygulayan bir yazılımda benimsenebilecek tüm veritabanı yaklaşımlarını incelediğimize göre yavaş yavaş konuyu pratikte ele alabileceğimiz derinliklere doğru ilerlemekte fayda görmekteyim. Haliyle şimdi de bir yazılım açısından kullanıcı gruplarının nasıl belirlenebileceğini ele alalım istiyorum. Hayır! Burada kullanıcı gruplarının belirlenmesinden kastımız kullanıcı grubu kaydı gibi bir işlem değil, bilakis kaydı gerçekleştirilmiş kullanıcı grupları tarafından gelen isteklerin hangi kullanıcı grubundan geldiğini yazılım tarafında ne şekilde öğrendiğimiz(belirlediğimiz) kastedilmektedir. Velhasıl, buyrun istişare eyleyelim…

Kullanıcı Gruplarının/Müşterilerin/Kiracıların Belirlenmesi

Kullanıcı grupları tarafından gelen isteklerin hangi gruba ait olduğunu yazılım tarafında aşağıdaki bir kaç yöntemle tanımlayabiliriz;

  • Query String
    Kullanıcı gruplarının request url’inde query string değeri eşliğinde tanımlanması tekniğidir.

    ...?TenantId=silver

    gibi…

    Bu teknikte yapılacak isteğin kullanıcı grubuna dair bilgiyi alenen barındırması düşükte olsa güvenlik açığı riski taşımaktadır. Bu sebepten dolayı yalnızca test ve geliştirme amacıyla ilgili ortamlarda kullanılması önerilmektedir.

  • Request IP Address
    Bu yöntemde her kullanıcı grubunun belli bir IP aralığından istek göndereceği varsayılır. Yani bir kullanıcı grubu için IP aralığı ayarlandığında, gelen isteğin hangi kullanıcı grubuna ait olduğu algılanabilir.

    Tabi bu yaklaşım güvenli olsa da her senaryo için uygun değildir.

  • Request Header
    Bu strateji, kullanıcı grup kimliğini belirlemek için en etkili yöntemlerden biridir diyebiliriz. Gelen requestlerin her birinin headerın da ‘TenantId’ bilgisini barındırıyor olacaktır.

    Bu yaklaşım özellikle authentication token kullanılırken önerilmektedir. Best practices’tir.

  • Claim
    Kullanıcı grubunu belirlemenin çok daha güvenli bir yoludur. JWT kullanılan senaryolarda payload’a eklenen ‘TenantId’ verisi sayesinde fark yaratılır.

Multitenancy Uygulama Nasıl Geliştirilir?

Şimdi örnek amaçlı multitenancy mimarisini benimseyen bir uygulama geliştiriyor olacağız. Bu uygulamayı, sizler istediğiniz herhangi bir mimarisel altyapı eşliğinde geliştirebileceğiniz gibi bendeniz onion architecture altyapısında geliştirmeyi tercih ediyorum.

Asp.NET Core - Multitenancy Uygulama Nasıl Oluşturulur

Uygulama solution’ının görüntüsü…

Ayrıca veritabanı modeli olarak da Şema Ayrımı(Schema Seperator) ile birlikte Eksiksiz Veri Yalıtımı(Complete Data Isolation) yaklaşımını sergileyeceğimizden dolayı aşağıdaki görüntüde olduğu gibi dört farklı veritabanı üzerinden bir örneklendirme gerçekleştireceğiz. Böylece de hibrit bir yaklaşım sergilemiş olacağız.Asp.NET Core - Multitenancy Uygulama Nasıl OluşturulurDikkat ederseniz bu uygulamada ‘Silver’, ‘Gold’ ve ‘Bronz’ olmak üzere üç kullanıcı grubunu varsayarak yola çıkıyoruz. Gerçek uygulamalar da bu grup bilgileri nicel olarak sabit olabileceği gibi opsiyonel de olabilmektedir. Bu davranışı, ilgili yazılımın verdiği hizmet ve doğal olarak pazarın gereksinimleri belirleyecektir.

Uygulama Geliştirme Adımları

Şimdi sırasıyla aşağıdaki adımları takip ederek örnek uygulamayı geliştirelim.

  • Adım 1 (BaseEntity ve IMustHaveTenant arayüzünün oluşturulması)
    Herşeyden önce uygulamada kullanılacak temel entity yapılanmasını ve bu entity’lerde multi tenant desteğine ihtiyaç duyacak olan kuruluşlar için gerekli sözleşmeyi oluşturarak başlayalım. O halde ‘Domain’ katmanına gelip ‘Entities’ ve ‘Contracts’ isminde iki klasör ekleyelim ve sırasıyla içlerine aşağıdaki yapıları oluşturalım.

        public abstract class BaseEntity
        {
            public int Id { get; set; }
        }
    
        public interface IMustHaveTenant
        {
            public string TenantId { get; set; }
        }
    
  • Adım 2 (Product entity’sinin oluşturulması)
    Temel entity yapılanmasını oluşturduğumuza göre artık örneklendirme amaçlı bir entity oluşturabiliriz. Ben misal olarak bir ürünü temsil eden ‘Product’ isimli entity tasarlayacak ve gerekli senaryoyu onun üzerinden sergiliyor olacağım.

        public class Product : BaseEntity, IMustHaveTenant
        {
            public Product() { }
            public Product(string name, string description, int rate)
            {
                Name = name;
                Description = description;
                Rate = rate;
            }
    
            public string Name { get; private set; }
            public string Description { get; private set; }
            public int Rate { get; private set; }
            public string TenantId { get; set; }
        }
    

    Dikkat ederseniz eğer ‘Product’ entity’si ‘IMustHaveTenant’ arayüzünü implement etmekte ve böylece multi tenant desteği bildirilmektedir.

    Hazır akla gelmişken bu ‘Product’ entity’sinin basit bir DDD modeli olarak tasarlanması sizleri yanıltmasın istiyorum. Senaryoda bu şekilde bir davranış irticalen belirlenmiştir.

  • Adım 3 (Temel tenant/kiracı/müşteri/kullanıcı grubu konfigürasyonlarının ayarlanması)
    Uygulamada ‘silver’, ‘gold’ ve ‘bronz’ olmak üzere üç tür müşteri grubu varsaydığımızı önceden söylemiştik. Haliyle bu gruplara Eksiksiz Veri Yalıtımı(Complete Data Isolation) yaklaşımı gereğince izole olarak oluşturulan ve hususi olan veritabanlarına özel konfigürasyon bilgilerini belirlememiz gerekmektedir. Tabi ayrıca hibrit bir davranış hedeflediğimizden dolayı Şema Ayrımı(Schema Seperator) yaklaşımını da uygulayacağımızdan paylaşımlı(SharedTenantDB) veritabanı için de bu gruplara özel konfigürasyon bilgilerini belirlememiz gerekmektedir.

    Bunun için ‘API’ uygulamasındaki ‘appsettings.json’ dosyasına gelip aşağıdaki gibi konfigürasyon tanımlamasında bulunmamız yeterlidir;

    {
      .
      .
      .
      "TenantSettings": {
        "Defaults": {
          "DBProvider": "mssql",
          "ConnectionString": "Server=localhost, 1433;Database=SharedTenantDB;User Id=SA;Password=1q2w3e4r+!;"
        },
        "Tenants": [
          {
            "Name": "silver",
            "TenantId": "silver",
            "ConnectionString": "Server=localhost, 1433;Database=SilverTenantDB;User Id=SA;Password=1q2w3e4r+!;"
          },
          {
            "Name": "gold",
            "TenantId": "gold",
            "ConnectionString": "Server=localhost, 1433;Database=GoldTenantDB;User Id=SA;Password=1q2w3e4r+!;"
          },
          {
            "Name": "bronz",
            "TenantId": "bronz",
            "ConnectionString": "Server=localhost, 1433;Database=BronzTenantDB;User Id=SA;Password=1q2w3e4r+!;"
          },
          {
            "Name": "_silver",
            "TenantId": "_silver"
          },
          {
            "Name": "_gold",
            "TenantId": "_gold"
          },
          {
            "Name": "_bronz",
            "TenantId": "_bronz"
          }
        ]
      }
      .
      .
      .
    }
    
    

    Yukarıdaki konfigürasyonları incelersek eğer; 6 ile 9. satır aralığında her bir kullanıcı grubu için varsayılan olarak bir tanımlamada bulunulmuştur. 10 ile 38. satır aralığında ise sistemdeki tüm kullanıcı gruplarına dair bir liste tanımlanmıştır. Bu grupların her birinde ‘Name’, ‘TenantId bilgileri olacaktır lakin sadece Eksiksiz Veri Yalıtımı(Complete Data Isolation) yaklaşımını kullanacak olan gruplar için ‘ConnectionString’ bilgisi tutulacaktır. Böylece Şema Ayrımı(Schema Seperator) yaklaşımını kullanacak olan tüm kullanıcı grupları için ‘ConnectionString’ bilgisi tanımlanmayacaktır.

  • Adım 4 (Options pattern konfigürasyonlarının sağlanması)
    Bir önceki adımda belirlenen konfigürasyonel değerlere daha hızlı erişim için options pattern kullanabiliriz. Bu pattern’ın temellerini atabilmek için öncelikle ilgili konfigürasyonel değerleri karşılayabilecek sınıfları tasarlayalım. Bunun için ‘Application’ katmanına ‘Settings’ isimli bir klasör ekleyelim ve içerisine aşağıdaki sınıfları oluşturalım.

        public class TenantSettings
        {
            public DefaultSetting Defaults { get; set; }
            public List<Tenant> Tenants { get; set; }
        }
    
        public class Tenant
        {
            public string Name { get; set; }
            public string TenantId { get; set; }
            public string ConnectionString { get; set; }
        }
    
        public class DefaultSetting
        {
            public string DbProvider { get; set; }
            public string ConnectionString { get; set; }
        }
    

    Konfigürasyonel değerleri karşılayacak bu sınıfları oluşturduktan sonra ‘API’ uygulamasının ‘Program.cs’ dosyasına gelelim ve aşağıdaki eklemede bulunalım.

    builder.Services.Configure<TenantSettings>(builder.Configuration.GetSection("TenantSettings"));
    

    Bu işlemden sonra ‘appsettings.json’da ki ‘TenantSettings’ değerlerini options pattern sayesinde aşağıdaki örnekteki gibi çağırabilecek ve kullanabileceğiz.

        public class ExampleController : ControllerBase
        {
    
            readonly TenantSettings _tenantSettings;
            public ExampleController (IOptions<TenantSettings> tenantSettings)
            {
                _tenantSettings = tenantSettings.Value;
            }
            .
            .
            .
        }
    
  • Adım 5 (TenantService’in oluşturulması)
    Gelen isteklerin(request) hangi kullanıcı grubuna dair olduğunu bir şekilde tanımlamamız gerekiyor. Bizler bu içeriğimizde ‘Request Header’ yöntemiyle bu tanımlamayı gerçekleştiriyor olacağız. Tabi bu tanımlamanın sorumluluğunu bir servisin üstlenmesi gerekmektedir. Haliyle ‘TenantService’ adını vereceğimiz bu servis ile bahsi geçen operasyonu gerçekleştireceğiz. Bunun için ‘Application’ katmanında ‘Abstraction’ isminde bir klasör oluşturalım ve içerisine aşağıdaki gibi ‘ITenantService’ interface’ini ekleyelim.

        public interface ITenantService
        {
            string GetDatabaseProvider();
            string GetConnectionString();
            Tenant GetTenant();
        }
    

    Ardından bu interface’in somut halini ‘Infrastructure’ katmanında ‘Concrete’ klasörü içerisinde aşağıdaki gibi oluşturalım.

        public class TenantService : ITenantService
        {
            readonly TenantSettings _tenantSettings;
            readonly Tenant _tenant;
            readonly HttpContext _httpContext; 
            public TenantService(IOptions<TenantSettings> tenantSettings, IHttpContextAccessor httpContextAccessor)
            {
                _tenantSettings = tenantSettings.Value;
                _httpContext = httpContextAccessor.HttpContext;
                if (_httpContext != null)
                {
                    if (_httpContext.Request.Headers.TryGetValue("TenantId", out var tenantId))
                    {
                        _tenant = _tenantSettings.Tenants.FirstOrDefault(t => t.TenantId == tenantId);
                        if (_tenant == null) throw new Exception("Invalid tenant!");
    
                        //Eğer bu kullanıcı grubu/müşteri/kiracı paylaşımlı veritabanını kullanıyorsa connection string'i boş gelecektir.
                        if (string.IsNullOrEmpty(_tenant.ConnectionString))
                            _tenant.ConnectionString = _tenantSettings.Defaults.ConnectionString;
                    }
                }
            }
            public string GetConnectionString() => _tenant?.ConnectionString;
            public string GetDatabaseProvider() => _tenantSettings.Defaults?.DbProvider;
            public Tenant GetTenant() => _tenant;
        }
    

    Yukarıdaki kod bloğuna göz atarsanız eğer, gelen isteğin header’ına bakılarak ‘TenantId’ değeri aranmakta ve gelen değer karşılığında konfigürasyonel bir tanımımız bulunuyorsa bunla ilgili bilgiler toparlanmaktadır. Yok eğer herhangi bir değere karşılık gelmiyorsa bir exception fırlatılarak client uyarılmaktadır.

    Son olarak geliştirilen bu servisin mimaride dependency injection tarafından talep edilebilmesi için IoC container’a eklenmesi gerekmektedir. Bunun için ‘Infrastructure’ katmanında ‘ServiceRegistration’ isimli bir static sınıf içerisinde aşağıdaki gibi tanımlanan extension fonksiyon gerekli entegrasyon ihtiyacını giderecektir.

        public static class ServiceRegistration
        {
            public static void AddInfrastructureService(this IServiceCollection collection)
            {
                collection.AddTransient<ITenantService, TenantService>();
            }
        }
    

    Burada özellikle dikkat edilmesi gereken bir husus vardır ki; o da, ‘TenantService’in ‘AddTransient’ olarak eklenmesi mecburiyetidir. Çünkü ilgili referanstan her talep geldiğinde yeni bir instance üretilmesi gerekmektedir ki ancak bu şekilde hangi tenant’a ait bir istek gelmiş kendisini konfigüre edebilsin.

    Şimdi tanımlanan bu extension fonksiyonu ‘API’ uygulamasının ‘Program.cs’ dosyasında aşağıdaki gibi çağıralım.

    builder.Services.AddInfrastructureService();
    

    Burada ek olarak; ilgili katmana yüklenmesi gereken kütüphanelerden bahsetmemiz gerekirse eğer ‘HttpContext’ sınıfına erişim için Microsoft.AspNetCore.Http.Abstractions paketinin, ‘IOptions’ arayüzüne erişim için ise Microsoft.Extensions.Options paketinin yüklenmesi gerekmektedir.

  • Adım 6 (DbContext’in tasarlanması ve migration’ların basılıp, veritabanlarının oluşturulması)
    Şimdi ‘TenantService’ hazır olduğuna göre sırada veritabanı işlemlerinden sorumlu ‘DbContext’ sınıfını tasarlamak var. Bunun için ‘Persistence’ katmanında ‘ApplicationDbContext’ isminde bir sınıf oluşturalım ve içeriğini aşağıdaki gibi dolduralım.

        public class ApplicationDbContext : DbContext
        {
            readonly ITenantService _tenantService;
            string tenantId;
            public ApplicationDbContext(DbContextOptions options, ITenantService tenantService) : base(options)
            {
                _tenantService = tenantService;
                tenantId = _tenantService.GetTenant()?.TenantId;
            }
            public DbSet<Product> Products { get; set; }
    
            protected override void OnModelCreating(ModelBuilder modelBuilder)
            {
                modelBuilder.Entity<Product>().HasQueryFilter(p => p.TenantId == tenantId);
            }
            protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
            {
                var tenantConnectionString = _tenantService.GetConnectionString();
                if (!string.IsNullOrEmpty(tenantConnectionString))
                {
                    var dbProvider = _tenantService.GetDatabaseProvider();
                    if (dbProvider.ToLower() == "mssql")
                        optionsBuilder.UseSqlServer(tenantConnectionString);
                }
            }
            public async override Task<int> SaveChangesAsync(CancellationToken cancellationToken = default)
            {
                foreach (var entry in ChangeTracker.Entries<IMustHaveTenant>().ToList())
                {
                    switch (entry.State)
                    {
                        case EntityState.Added:
                        case EntityState.Modified:
                            entry.Entity.TenantId = tenantId;
                            break;
                    }
                }
    
                return await base.SaveChangesAsync(cancellationToken);
            }
        }
    

    Yukarıda yapılan çalışmayı izah etmemiz gerekirse eğer; 12 ile 15. satır aralığında HasQueryFilter fonksiyonu ile global filter özelliği kullanılarak ‘TenantId’ye göre bir filtreleme gerçekleştirilmektedir. 16 ile 25. satır aralığında ise ilgili müşterinin veritabanı yaklaşımına göre hangi veritabanına bağlanacaksa connection string’i ayarlanmakta, 26 ile 40. satır aralığında ise yeni eklenen veya güncellenen tüm ‘IMustHaveTenant’ türevi entity’ler de ‘TenantId’ değeri verilmektedir.

    Akabinde artık oluşturulan DbContext’in migration’larını oluşturup, göndermeliyiz. Bunun için öncelikle ‘Persistence’ katmanında ‘ServiceRegistration’ isimli bir static sınıf oluşturalım ve içeriğini aşağıdaki gibi dolduralım.

        public static class ServiceRegistration
        {
            public static async Task AddPersistenceService(this IServiceCollection collection)
            {
                using var provider = collection.BuildServiceProvider();
                var configuration = provider.GetRequiredService<IConfiguration>();
                var tenantSettings = configuration.GetSection("TenantSettings").Get<TenantSettings>();
    
                var defaultConnectionString = tenantSettings.Defaults?.ConnectionString;
                var defaultDbProvider = tenantSettings.Defaults?.DbProvider;
                if (defaultDbProvider.ToLower() == "mssql")
                    collection.AddDbContext<ApplicationDbContext>(option => option.UseSqlServer(e => e.MigrationsAssembly(typeof(ApplicationDbContext).Assembly.FullName)));
    
                using IServiceScope scope = collection.BuildServiceProvider().CreateScope();
    
                foreach (var tenant in tenantSettings.Tenants)
                {
                    string connectionString = tenant.ConnectionString switch
                    {
                        null => defaultConnectionString,
                        not null => tenant.ConnectionString
                    };
                    var dbContext = scope.ServiceProvider.GetRequiredService<ApplicationDbContext>();
                    dbContext.Database.SetConnectionString(connectionString);
                    if (dbContext.Database.GetMigrations().Count() > 0)
                        await dbContext.Database.MigrateAsync();
                }
            }
        }
    

    Yukarıdaki kod bloğunu izah etmemiz gerekirse eğer; 5 ile 7. satır aralığında ‘API’ uygulamasında bulunan ‘appsettings.json’ dosyasından gerekli konfigürasyonlar elde ediliyor. 14 ile 26. satır aralığında ise müşteriye uygun bir şekilde yapılandırmalar yenilenmekte ve var olan(birazdan üreteceğimiz) migration’lar migrate edilmektedir.

    Burada çalışma yapılan kodların sağlıklı bir şekilde çalışabilmesi için Microsoft.Extensions.Configuration, Microsoft.Extensions.Configuration.Binder ve Microsoft.Extensions.Options paketlerinin ‘Persistence’ katmanına yüklü olması gerekmektedir.

    Velhasıl en son ‘API’ uygulamasının ‘Program.cs’ dosyasına gelerek aşağıdaki komutu ekleyelim.

    await builder.Services.AddPersistenceService();
    

    Hatta ‘Program.cs’ dosyasının içeriğini tam olarak paylaşmakta fayda görmekteyim. Son hali aşağıdaki gibi olacaktır. Burada özellikle katmanların servis metotlarının ekleme sıralamasının birbirlerine bağımlılıklarından ötürü özellikle aşağıdaki gibi olmasına özen gösteriniz.

    .
    .
    .
    builder.Services.AddEndpointsApiExplorer();
    builder.Services.AddHttpContextAccessor();
    builder.Services.Configure<TenantSettings>(builder.Configuration.GetSection("TenantSettings"));
    builder.Services.AddInfrastructureService();
    await builder.Services.AddPersistenceService();
    .
    .
    .
    

    Evet, artık migration’ları oluşturmaya hazırız 🙂Asp.NET Core - Multitenancy Uygulama Nasıl OluşturulurYukarıdaki ekran görüntüsünde görüldüğü üzere Package Manager Console üzerinden add-migration mig_1 talimatını vererek migration oluşturma sürecini başlatabiliriz. Burada migration’ların basılabilmesi için ‘Default project’ alanında ‘Persistence’ katmanının seçili olduğuna dikkat ediniz. Bu talimattan sonra ‘Persistence’ katmanına göz atarsanız eğer migration’ın oluştuğunu görebilirsiniz.Asp.NET Core - Multitenancy Uygulama Nasıl Oluşturulur

    Migration’ları migrate etmeyecek miyiz hoca la? dediğinizi duyar gibiyim… Migration’ları tabi ki de migrate edeceğiz ama bunu beklediğiniz update-database komutuyla değil uygulamayı ayağa kaldırarak gerçekleştireceğiz. Nasıl mı? Esasında bunun cevabını yukarıda inşa ettiğimiz uygulama vermektedir. ‘Program.cs’ dosyasında çağırdığımız ‘AddPersistenceService’ metoduna bakarsanız eğer içerisinde şu aşağıdaki kod bloğunu barındırmaktadır:

                foreach (var tenant in tenantSettings.Tenants)
                {
                    string connectionString = tenant.ConnectionString switch
                    {
                        null => defaultConnectionString,
                        not null => tenant.ConnectionString
                    };
                    var dbContext = scope.ServiceProvider.GetRequiredService<ApplicationDbContext>();
                    dbContext.Database.SetConnectionString(connectionString);
                    if (dbContext.Database.GetMigrations().Count() > 0)
                        await dbContext.Database.MigrateAsync();
                }
    

    Bu kod bloğu, uygulama ayağa kaldırıldığında devreye girecek ve tek tek ‘appsettings.json’ dosyasında tanımlanmış olan tenant’lara karşılık connection string değerleri eşliğinde context nesnesini konfigüre edip dbContext.Database.MigrateAsync() komutu ile var olan migration’lar eşliğinde migrate edecektir. O yüzden veritabanlarının migrate edilebilmesi için uygulamayı ayağa kaldırmamız gerekmektedir.

  • Adım 7 (Veritabanının migrate ettirilmesi)
    Bir önceki adımda veritabanının migrate edilebilmesi için uygulamanın ayağa kaldırılması gerekliliğinden bahsetmiştik. Şimdi bu işlemi gerçekleştirmeden önce veritabanı sunucusuna göz atalım, ardından uygulamayı ayağa kaldırdıktan sonra tekrar sunucuya göz atıp neticeyi mukayese edelim.Asp.NET Core - Multitenancy Uygulama Nasıl OluşturulurGörüldüğü üzere veritabanı sunucusu bomboş 🙂 Şimdi uygulamayı ayağa kaldırıp tekrar gözlemleyelim.Asp.NET Core - Multitenancy Uygulama Nasıl Oluşturulurİşte, ‘appsettings.json’ dosyasında tanımladığımız tüm tenant’lara karşılık verilen connection string’lere uygun veritabanları, veritabanı sunucusunda generate edilmiştir.
  • Adım 8 (ProductService’in oluşturulması)
    Şimdi geliştirdiğimiz bu multitenancy altyapıyı test edebilmek için bir ‘ProductService’ inşa edelim ve içerisinde formaliteden birkaç işlem yürütelim.

    Öncelikle ‘Application’ katmanındaki ‘Abstractions’ klasörü içerisinde aşağıdaki arayüzü oluşturalım.

        public interface IProductService
        {
            Task<Product> CreateAsync(string name, string description, int rate);
            Task<Product> GetByIdAsync(int id);
            Task<IReadOnlyList<Product>> GetAllAsnyc();
        }
    

    Ardından ‘Persistence’ katmanında concrete’ini oluşturalım.

        public class ProductService : IProductService
        {
            readonly ApplicationDbContext _context;
    
            public ProductService(ApplicationDbContext context)
            {
                _context = context;
            }
    
            public async Task<Product> CreateAsync(string name, string description, int rate)
            {
                Product product = new(name, description, rate);
                await _context.Products.AddAsync(product);
                await _context.SaveChangesAsync();
                return product;
            }
    
            public async Task<IReadOnlyList<Product>> GetAllAsnyc()
                => await _context.Products.ToListAsync();
    
            public async Task<Product> GetByIdAsync(int id)
                => await _context.Products.FindAsync(id);
        }
    

    Bu işlemden sonra geliştirilen bu servisin ‘Persistence’ katmanındaki ‘ServiceRegistration’ içerisindeki ‘AddPersistenceService’ içerisine aşağıdaki gibi eklenmesi gerekmektedir.

        public static class ServiceRegistration
        {
            public static async Task AddPersistenceService(this IServiceCollection collection)
            {
                collection.AddScoped<IProductService, ProductService>();
                .
                .
                .
            }
        }
    
  • Adım 9 (Controller’ların ayarlanması)
    Şimdi API uygulamasına ‘ProductsController’ adında bir controller ekleyelim ve içerisinde bir önceki adımda oluşturulan ‘ProductService’i aşağıdaki gibi kullanalım.

        [Route("api/[controller]")]
        [ApiController]
        public class ProductsController : ControllerBase
        {
            readonly IProductService _productService;
            public ProductsController(IProductService productService)
            {
                _productService = productService;
            }
    
            [HttpGet]
            public async Task<IActionResult> GetAsync()
                => Ok(await _productService.GetAllAsnyc());
    
            [HttpGet("{id}")]
            public async Task<IActionResult> GetAsync(int id)
                => Ok(await _productService.GetByIdAsync(id));
    
            [HttpPost]
            public async Task<IActionResult> CreateAsync(CreateProductVM createProductVM)
                => Ok(await _productService.CreateAsync(createProductVM.Name, createProductVM.Description, createProductVM.Rate));
        }
    

    Burada ‘CreateAsync’ action’ı içerisinde kullanılan ‘CreateProductVM’ isimli viewmodel sınıfının içeriği aşağıdaki gibi olacaktır.

        public class CreateProductVM
        {
            public string Name { get; set; }
            public string Description { get; set; }
            public int Rate { get; set; }
        }
    

    Tabi bu sınıfı ‘Application’ katmanına eklenen ‘ViewModels’ isimli bir klasör içerisine eklediğimi bilmenizde fayda var.

  • Adım 10 (SON!!! Test edelim)
    Evet…

    Artık yaptığımız inşanın testini gerçekleştirebiliriz. Bunun için ‘Shared’ dahil olmak üzere tüm veritabanlarına tek tek ürün ekleme isteği göndererek başlayalım.

    Not : Bu istekleri Postman kullanarak gerçekleştirebilirdik. Lakin bizlerin makalenin hacmini şişirmemek adına ‘Invoke-WebRequest’ kullanarak ilgili request’leri modelleyeceğimizi ifade etmek isterim.

    TenantId : Bronz,

    $Body = @{
    	"name" = "product 1"
    	"description" = "product 1 desc"
    	"rate"= 3
    }
    
    $Headers = @{
    	"Content-Type" = "application/json"
    	"TenantId" = "bronz"
     }
    
    Invoke-WebRequest 'https://localhost:7237/api/products' -Body ($Body|ConvertTo-Json) -Method 'POST' -Headers $Headers
    

    TenantId : Bronz,

    $Body = @{
    	"name" = "product 2"
    	"description" = "product2 desc"
    	"rate"= 4
    }
    
    $Headers = @{
    	"Content-Type" = "application/json"
    	"TenantId" = "bronz"
     }
    
    Invoke-WebRequest 'https://localhost:7237/api/products' -Body ($Body|ConvertTo-Json) -Method 'POST' -Headers $Headers
    

    TenantId : Gold,

    $Body = @{
    	"name" = "product 3"
    	"description" = "product3 desc"
    	"rate"= 5
    }
    
    $Headers = @{
    	"Content-Type" = "application/json"
    	"TenantId" = "gold"
     }
    
    Invoke-WebRequest 'https://localhost:7237/api/products' -Body ($Body|ConvertTo-Json) -Method 'POST' -Headers $Headers
    

    TenantId : Silver,

    $Body = @{
    	"name" = "product 4"
    	"description" = "product4 desc"
    	"rate"= 66
    }
    
    $Headers = @{
    	"Content-Type" = "application/json"
    	"TenantId" = "silver"
     }
    
    Invoke-WebRequest 'https://localhost:7237/api/products' -Body ($Body|ConvertTo-Json) -Method 'POST' -Headers $Headers
    

    TenantId : _Silver,

    $Body = @{
    	"name" = "product 5"
    	"description" = "product5 desc"
    	"rate"= 12
    }
    
    $Headers = @{
    	"Content-Type" = "application/json"
    	"TenantId" = "_silver"
     }
    
    Invoke-WebRequest 'https://localhost:7237/api/products' -Body ($Body|ConvertTo-Json) -Method 'POST' -Headers $Headers
    

    TenantId : _Gold,

    $Body = @{
    	"name" = "product 6"
    	"description" = "product6 desc"
    	"rate"= 14
    }
    
    $Headers = @{
    	"Content-Type" = "application/json"
    	"TenantId" = "_gold"
     }
    
    Invoke-WebRequest 'https://localhost:7237/api/products' -Body ($Body|ConvertTo-Json) -Method 'POST' -Headers $Headers
    

    TenantId : _Bronz,

    $Body = @{
    	"name" = "product 7"
    	"description" = "product6 desc"
    	"rate"= 25
    }
    
    $Headers = @{
    	"Content-Type" = "application/json"
    	"TenantId" = "_bronz"
     }
    
    Invoke-WebRequest 'https://localhost:7237/api/products' -Body ($Body|ConvertTo-Json) -Method 'POST' -Headers $Headers
    

    Yukarıdaki ürün ekleme istekleri neticesinde veritabanlarını sorguladığımızda gönderilen verilerin isteklerdeki ‘TenantId’ bilgilerine karşılık gelen veritabanlarına işlendiğini görmekteyiz.Asp.NET Core - Multitenancy Uygulama Nasıl OluşturulurŞimdi de verilerimizi tek tek veritabanlarından sorgulayarak gözlemleyelim;
    TenantId : Bronz,

    $Headers = @{
    	"Content-Type" = "application/json"
    	"TenantId" = "bronz"
     }
    
    $Response = Invoke-WebRequest 'https://localhost:7237/api/products' -Method 'GET' -Headers $Headers
    $Response.Content
    

    Asp.NET Core - Multitenancy Uygulama Nasıl OluşturulurTenantId : Gold,

    $Headers = @{
    	"Content-Type" = "application/json"
    	"TenantId" = "gold"
     }
    
    $Response = Invoke-WebRequest 'https://localhost:7237/api/products' -Method 'GET' -Headers $Headers
    $Response.Content
    

    Asp.NET Core - Multitenancy Uygulama Nasıl OluşturulurTenantId : Silver,

    $Headers = @{
    	"Content-Type" = "application/json"
    	"TenantId" = "silver"
     }
    
    $Response = Invoke-WebRequest 'https://localhost:7237/api/products' -Method 'GET' -Headers $Headers
    $Response.Content
    

    Asp.NET Core - Multitenancy Uygulama Nasıl OluşturulurTenantId : _Bronz,

    $Headers = @{
    	"Content-Type" = "application/json"
    	"TenantId" = "_bronz"
     }
    
    $Response = Invoke-WebRequest 'https://localhost:7237/api/products' -Method 'GET' -Headers $Headers
    $Response.Content
    

    Asp.NET Core - Multitenancy Uygulama Nasıl OluşturulurTenantId : _Gold,

    $Headers = @{
    	"Content-Type" = "application/json"
    	"TenantId" = "_gold"
     }
    
    $Response = Invoke-WebRequest 'https://localhost:7237/api/products' -Method 'GET' -Headers $Headers
    $Response.Content
    

    Asp.NET Core - Multitenancy Uygulama Nasıl OluşturulurTenantId : _Silver,

    $Headers = @{
    	"Content-Type" = "application/json"
    	"TenantId" = "_silver"
     }
    
    $Response = Invoke-WebRequest 'https://localhost:7237/api/products' -Method 'GET' -Headers $Headers
    $Response.Content
    

    Asp.NET Core - Multitenancy Uygulama Nasıl OluşturulurÖzellikle sorgulama isteklerinden son üçü olan _bronz, _gold ve _silver ‘TenantId’li olanlara bakarsanız eğer her biri ‘SharedTenantDB’ isimli veritabanından sorgulama yapacağı halde global filter özelliği sayesinde sadece isteğe uygun verileri getirmektedirler. Nasıl ama 🙂

Nihai olarak;
Multitenancy mimarisi(ya da yaklaşımı) kullanıcı dostluğu açısından farklı özelliklerle harmanlanıp uçsuz bucaksız niteliklerle genişletilebilir ve hatta her bir müşteri/kullanıcı grubu için bir kullanıcı tipi oluşturularak sınırlamalar getirilebilir. Bunların yanında uygulamayı kullanacak her bir kiracıyı karşılayabilmek için ‘appsettings.json’ dosyasında tanımlamadan ziyade bu sorumluluk herhangi bir veritabanı tablosu tarafından üstlenilebilir ve böylece daha dinamik temellerde uygulamalar geliştirilebilir. Yani anlayacağınız bu yaklaşımı, bu makaledeki tarafımızca belirlenmiş kısır dayatmalardan ziyade daha geniş ölçeklerde uygulayabilir ve getirilerinden istifade edebilirsiniz.

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

Not : Örnek projeyi aşağıdaki github adresinden edinebilirsiniz.
https://github.com/gncyyldz/Multitenancy.Example

Bunlar da hoşunuza gidebilir...

Bir cevap yazın

E-posta hesabınız yayımlanmayacak.