Multi-Tenant Architecture: Örnek E-Ticaret Saas Projesi

Developer Summit 2023 etkinliğinde yapmış olduğum örnek projeye ait makale ile Örnek bir SaaS uygulanmış E-Ticaret platformu nasıl oluşturulur konusunu ele alıyoruz.

Murat Dinç
Atlastek Labs
9 min readOct 9, 2023

--

Selamlar,

Developer Summit 2023 etkinliği, yoğun katılım ve ilgiyle gerçekten unutulmaz bir deneyim oldu. Etkinlikte gerçekleştirdiğim sunumda öne çıkardığım konuların detaylarına ve uygulama örneklerine merak oluştuğunu fark ettim. Bu nedenle, sunumda yer verdiğim örnekleri daha ayrıntılı bir şekilde açıklamak ve merak edilen soruları yanıtlamak adına bu makaleyi hazırlamaya karar verdim.

Multi-Tenant Architecture hakkında temel bilgilere sahip olmak isteyenler için daha önce yazdığım “Multi-Tenant Architecture: ASP.NET Core ile SaaS Uygulamalar Geliştirmek” başlıklı makalemi okumalarını tavsiye ederim.

Sunum

Ngnix ve CloudFlare

Proje kapsamında Ngnix ve CloudFlare yapılandırmalarını kullandım. Bu konuda daha önce yazdığım makaleden detaylı bilgilere ulaşabilirsiniz.

Proje Hakkında

Solution altında 4 adet projemiz bulunmaktadır.

  • Tenant.Api
  • Commerce.Api
  • BFF.Api
  • Commerce.App

Projede kullanılan teknolojiler ve yaklaşımlar

  • .NET Core 7
  • PostgreSQL
  • MediatR
  • Entity Framework
  • Fluent Validation
  • Redis Cache
  • Refit
  • Repository Pattern
  • Clean Code Architecture

Proje kapsamında ‘Rubic’ olarak adlandırdığım paketler, aslında bir NuGet paketi gibi düşünebileceğimiz yapıdadır. Bu paketler içerisinde, kod tekrarını önlemek adına sıkça ihtiyaç duyduğum özellikleri, kullanıldıkları platformlara göre ayrıştırarak organize ettim.

Tenant.Api

Tüm tenant ile ilgili işlemler bu projede gerçekleştirilmektedir. Proje, ‘tenant_db’ adlı PostgreSQL veritabanı üzerinden işlemlerini sürdürmektedir.

Tablolar

  • Pool: Kullandığımız havuz mantığı ile veritabanı sunucularını sakladığımız tablo
  • Pool Database: Hangı havuzda hangı verıtabanının bulunduğunu saklamak için kullandığımız tablo
  • Tenant: Tenant bilgilerini saklamak için kullandığımız tablo
  • Tenant User: Kullanıcıların hangi tenant ile ilişkilendirildiğini saklamak için kullandığımız tablo
  • User: Kullanıcıları saklamak için kullandığımız tablo

Program.cs

using Microsoft.OpenApi.Models;
using Rubic.AspNetCore;
using Rubic.Caching;
using Tenant.Api.Middleware;
using Tenant.Application;
using Tenant.Infrastructure;

var builder = WebApplication.CreateBuilder(args);

builder.Configuration
.AddJsonFile("appsettings.json", optional: false, reloadOnChange: true)
.AddJsonFile($"appsettings.{builder.Environment.EnvironmentName}.json", optional: true, reloadOnChange: true)
.AddEnvironmentVariables();

builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSwaggerGen(x =>
{
x.SwaggerDoc("v1", new OpenApiInfo
{
Title = $"Tenant.Api",
Version = "v1",
});
});

builder.Services.AddRubicAspNetCore();
builder.Services.AddRubicCaching(builder.Configuration);
builder.Services.AddInfrastructureServices(builder.Configuration);
builder.Services.AddApplicationServices();

var app = builder.Build();

app.UseMiddleware<DatabaseInstallerMiddleware>();

app.UseSwagger();
app.UseSwaggerUI(x =>
{
x.DefaultModelsExpandDepth(-1); // Disable swagger schemas at bottom
});

app.UseDeveloperExceptionPage();
app.UseRubicAspNetCore();
app.MapControllers();
app.Run();

DbContext Registry

// Entity Framework & Repository
services.AddDbContext<TenantDbContext>(
(serviceProvider, dbContextBuilder) =>
{
dbContextBuilder.UseNpgsql(configuration.GetConnectionString("DatabaseConnection"),
optionsBuilder =>
{
optionsBuilder.MigrationsAssembly("Tenant.Infrastructure");
optionsBuilder.MigrationsHistoryTable("__EFMigrationsHistory", "public");
}).UseSnakeCaseNamingConvention();
});

services.AddScoped(typeof(IRepository<>), typeof(LinqToSqlRepository<>));

Commerce.Api

E-Ticaret arayüzü bu proje sayesinde veri alırken, tenant’a özgü veritabanında gerçekleştirilen tüm CRUD işlemleri bu API aracılığı ile yapılır.

Tablolar

  • Product: Tenant’a özgü ürünleri saklamak için kullandığımız tablo.

Program.cs

using Commerce.Api.Context;
using Commerce.Api.Middleware;
using Commerce.Api.Swagger;
using Commerce.Application;
using Commerce.Infrastructure;
using Microsoft.OpenApi.Models;
using Rubic.AspNetCore;
using Rubic.Caching;

var builder = WebApplication.CreateBuilder(args);

builder.Configuration
.AddJsonFile("appsettings.json", optional: false, reloadOnChange: true)
.AddJsonFile($"appsettings.{builder.Environment.EnvironmentName}.json", optional: true, reloadOnChange: true)
.AddEnvironmentVariables();

builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSwaggerGen(x =>
{
x.SwaggerDoc("v1", new OpenApiInfo
{
Title = $"Commerce.Api",
Version = "v1",
});
x.OperationFilter<AddRequiredHeaderParameterAttribute>();
});

builder.Services.AddScoped<Commerce.Infrastructure.Abstracts.IWorkContext, WorkContext>();

builder.Services.AddRubicAspNetCore();
builder.Services.AddRubicCaching(builder.Configuration);
builder.Services.AddInfrastructureServices(builder.Configuration);
builder.Services.AddApplicationServices();

var app = builder.Build();

app.UseSwagger();
app.UseSwaggerUI(x =>
{
x.DefaultModelsExpandDepth(-1); // Disable swagger schemas at bottom
});

app.UseRubicAspNetCore();
app.MapControllers();
app.UseMiddleware<DatabaseInstallerMiddleware>();
app.Run();

DbContext Registry

using Commerce.Infrastructure.Abstracts;
using Microsoft.EntityFrameworkCore;

namespace Commerce.Infrastructure.Repositories;

public class CommerceDbContext : DbContext
{
public string ConnectionString { get; set; }

public CommerceDbContext(DbContextOptions<CommerceDbContext> options, IWorkContext workContext) : base(options)
{
ConnectionString = workContext.ConnectionString;

AppContext.SetSwitch("Npgsql.EnableLegacyTimestampBehavior", true);
}

public DbSet<Entities.Product> Product { get; set; }

protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
{
if (!string.IsNullOrEmpty(ConnectionString))
{
optionsBuilder.UseNpgsql(ConnectionString,
optionsBuilder =>
{
optionsBuilder.MigrationsAssembly("Commerce.Infrastructure");
optionsBuilder.MigrationsHistoryTable("__EFMigrationsHistory", "public");
}).UseSnakeCaseNamingConvention();
}
}

protected override void OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder.HasDefaultSchema("public");

modelBuilder.ApplyConfigurationsFromAssembly(typeof(CommerceDbContext).Assembly);

base.OnModelCreating(modelBuilder);
}

public override async Task<int> SaveChangesAsync(CancellationToken cancellationToken = new CancellationToken())
{
return await base.SaveChangesAsync(cancellationToken);
}
}

BFF.Api

Bu proje, hem bir gateway hem de bir BFF olarak kullanılmaktadır. JWT ile Authentication mekanizması bu projede aktif olarak çalışmaktadır ve buradan alınan credential bilgisiyle alt servislere erişim sağlanarak ilgili işlemler gerçekleştirilmektedir.

Program.cs

using BFF.Api.Context;
using BFF.Api.Extensions;
using Microsoft.OpenApi.Models;
using Rubic.AspNetCore;

var builder = WebApplication.CreateBuilder(args);

builder.Configuration
.AddJsonFile("appsettings.json", optional: false, reloadOnChange: true)
.AddJsonFile($"appsettings.{builder.Environment.EnvironmentName}.json", optional: true, reloadOnChange: true)
.AddEnvironmentVariables();

builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSwaggerGen(x =>
{
x.SwaggerDoc("v1", new OpenApiInfo
{
Title = $"BFF.Api",
Version = "v1",
});

x.AddSecurityDefinition(name: "Bearer", securityScheme: new OpenApiSecurityScheme
{
Name = "Authorization",
Description = "Enter the Bearer Authorization string as following: `Bearer Generated-JWT-Token`",
In = ParameterLocation.Header,
Type = SecuritySchemeType.ApiKey,
Scheme = "Bearer"
});

x.AddSecurityRequirement(new OpenApiSecurityRequirement
{
{
new OpenApiSecurityScheme
{
Reference = new OpenApiReference
{
Type = ReferenceType.SecurityScheme,
Id = "Bearer"
}
},
new string[] { }
}
});
});

// Work Context
builder.Services.AddTransient<IWorkContext, WorkContext>();

// Rubic
builder.Services.AddRubicAspNetCore();
builder.Services.AddRubicSecurityConfiguration(builder.Configuration);
builder.Services.AddRubicJwtAuthentication(builder.Configuration.GetValue<string>("Rubic:Security:SecretKey"));
builder.Services.AddRubicSHA1CryptographyProvider(builder.Configuration.GetValue<string>("Rubic:Security:SecretKey"));
builder.Services.AddBffServices(builder.Configuration);

var app = builder.Build();

app.UseSwagger();
app.UseSwaggerUI(x =>
{
x.DefaultModelsExpandDepth(-1); // Disable swagger schemas at bottom
});

app.UseRubicAspNetCore();
app.UseRubicAuthentication();
app.UseStaticFiles();
app.MapControllers();
app.Run();

Projelerimizi genel bir bakıştan sonra işlevsel kodları üzerinde detaylıca inceleyelim.

Kullanıcının tenant oluşturabilmesi için öncelikle sisteme kayıt olması gerekmektedir. Bu nedenle BFF’de bulunan /api/v1/users ucuna POST isteği göndererek bir kullanıcı kaydediyoruz.

Request

{
"name": "Murat",
"surname": "Dinç",
"email": "info@muratdinc.dev",
"password": "123123"
}

Giriş yapabilmek ve bir token elde edebilmek için kayıt işleminin ardından oturum açma işlemi gerçekleştiriyoruz. Bu amaçla /api/v1/authentication ucuna gerekli bilgilerle birlikte POST isteği gönderiyor ve token’ı elde ediyoruz.

Tenant.API altında bulunan bu uca BFF üzerinden giderek MediatR ile oluşturduğumuz Command nesnesini tetikleyerek ilgili kullanıcı oluşturma işlemini sağlıyoruz.

Request

{
"email": "info@muratdinc.dev",
"password": "123123"
}

Response

{
"token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJodHRwOi8vc2NoZW1hcy54bWxzb2FwLm9yZy93cy8yMDA1LzA1L2lkZW50aXR5L2NsYWltcy9zaWQiOiIzIiwidW5pcXVlX25hbWUiOiJNdXJhdCIsImZhbWlseV9uYW1lIjoiRGluw6ciLCJlbWFpbCI6ImluZm9AbXVyYXRkaW5jMi5kZXYiLCJuYmYiOjE2OTY3MTQ4MzMsImV4cCI6MTY5NzMxOTYzMywiaWF0IjoxNjk2NzE0ODMzfQ.8HonkHuM3xkMdIKqZa7U4Io6pol9Mhw1k25lFLViiw8",
"expiryDate": "2023-10-14T21:40:33.6515603Z"
}

/api/v1/authenticate — GET ucuna Token’la yaptığımız istekte aktif oturumda bulunan bilgilerimizi inceleyebiliriz.

{
"id": 3,
"name": "Murat",
"surname": "Dinç",
"email": "info@muratdinc.dev",
"tenantIds": [
]
}

tenantIds alanının boş geldiğini fark edeceksiniz. Kullanıcı için bir tenant tanımlaması yapmadığımız için yetkili olduğu tenant’lar üzerinde herhangi bir veri bulunmamaktadır.

Token’ı elde ettikten sonra tenant oluşturmak için bu token’ı kullanarak /api/v1/tenants ucuna gerekli bilgilerle birlikte POST isteği gönderiyoruz.

Request

{
"title": "Medium Blog Store",
"slug": "mediumblog"
}

İstekte bulunan parametreler arasında ‘Slug’ adlı parametre, subdomain altındaki erişilebilir temsilci değeri ifade ederken, ‘Title’ parametresi mağazamızın ismini belirtmektedir.

Response

{
"id": 3,
"slug": "Medium Blog Store",
"databaseName": "app_db_Medium Blog Store",
"connectionString": "Host=db; Port=5432; Database=app_db_Medium Blog Store; Username=postgres; Password=postgres"
}

Tenant başarıyla oluşturulduktan sonra, kullanıcının yetkili olduğu tenant listesinde bu yeni tenant artık listelenecektir. Tenant oluşturma kodunu inceleyelim.

using MediatR;
using Microsoft.EntityFrameworkCore;
using Rubic.AspNetCore.Exceptions;
using Rubic.Caching;
using Rubic.EntityFramework.Repositories.Abstracts;
using Tenant.Application.Commands.Tenant.Dto;
using Tenant.Application.Events.Caching;
using Tenant.Domain.Constants;

namespace Tenant.Application.Commands.Tenant;

public record CreateTenantCommand : IRequest<CreateTenantResultDto>
{
public int UserId { get; init; }
public string Slug { get; init; }
public string Title { get; init; }
}

public record CreateTenantCommandHandler : IRequestHandler<CreateTenantCommand, CreateTenantResultDto>
{
private readonly IRepository<Entities.Pool> _poolRepository;
private readonly IRepository<Entities.Tenant> _tenantRepository;
private readonly IRepository<Entities.TenantUser> _tenantUserRepository;
private readonly IRepository<Entities.User> _userRepository;
private readonly IStaticCacheManager _staticCacheManager;
private readonly IPublisher _publisher;

public CreateTenantCommandHandler(IRepository<Entities.Pool> poolRepository,
IRepository<Entities.Tenant> tenantRepository,
IRepository<Entities.TenantUser> tenantUserRepository,
IRepository<Entities.User> userRepository,
IStaticCacheManager staticCacheManager,
IPublisher publisher)
{
_poolRepository = poolRepository;
_tenantRepository = tenantRepository;
_tenantUserRepository = tenantUserRepository;
_userRepository = userRepository;
_staticCacheManager = staticCacheManager;
_publisher = publisher;
}

public async Task<CreateTenantResultDto> Handle(CreateTenantCommand request, CancellationToken cancellationToken)
{
var tenant = await _tenantRepository.Table.AsNoTracking().FirstOrDefaultAsync(x => x.Slug.ToLower() == request.Slug.ToLower());
if (tenant != null)
throw new StatusException(status: StatusCode.BadRequest, "There is an app with the same name!");

var user = await _userRepository.Table.AsNoTracking().FirstOrDefaultAsync(x => x.Id == request.UserId);
if (user == null)
throw new StatusException(status: StatusCode.BadRequest, "User Not Found!");

// Yeni açılacak tenant için veritabanı havuzlarımız arasında müsait bir havuz arıyoruz.
var pool = await _poolRepository.Table.AsNoTracking().FirstOrDefaultAsync(x => !x.Deleted);
if (pool == null)
throw new StatusException(status: StatusCode.BadRequest, "Pool Not Found!");

var tenantEntity = new Entities.Tenant
{
AliasId = Guid.NewGuid(),
Slug = request.Slug,
Title = request.Title,
CreatedOnUtc = DateTimeOffset.UtcNow,
PoolDatabase = new Entities.PoolDatabase
{
PoolId = pool.Id,
Name = $"app_db_{request.Slug}"
}
};

await _tenantRepository.InsertAsync(tenantEntity);
await _tenantRepository.SaveAllAsync();

var tenantUserEntity = new Entities.TenantUser
{
TenantId = tenantEntity.Id,
UserId = user.Id
};

await _tenantUserRepository.InsertAsync(tenantUserEntity);
await _tenantUserRepository.SaveAllAsync();

// Cache Clear Event
await _publisher.Publish(new DeleteCacheKeysEvent
{
Keys = new List<string>
{
_staticCacheManager.PrepareKey(new CacheKey(CacheKeyConstants.UserById), request.UserId).Key
}
});

return new CreateTenantResultDto
{
Id = tenantEntity.AliasId,
Slug = request.Slug,
DatabaseName = $"app_db_{request.Slug}",
ConnectionString = $"Host={pool.Host}; Port={pool.Port}; Database={tenantEntity.PoolDatabase.Name}; Username={pool.Username}; Password={pool.Password}"
};
}
}

Bu kod parçasında en belirgin kısım, ConnectionString oluşturduğumuz bölümdür. Sunumda da vurguladığım gibi, kullanıcıları belirli bir veritabanı havuzu ve bu havuzun içinde yer alan veritabanıyla ilişkilendirerek Pool yöntemini kullanıyoruz. Bu yaklaşım sayesinde her kullanıcının hangi veritabanı sunucusunda ve hangi veritabanında bulunduğunu kolayca belirleyebiliyoruz. ConnectionString de bu bilgilere bağlı olarak şekillendirilmektedir.

💡 Pool Mantığı

Havuz kavramı, bir uygulamanın birden fazla veritabanı sunucusunu desteklemesini sağlar. Multi-Tenant yapıda olan projelerde en büyük maliyet genellikle veritabanı tarafından oluşur. Her sunucunun taşıyabileceği veritabanı sayısı, sunucunun kapasitesi ile sınırlıdır. Bu nedenle, projenizin büyüklüğüne ve ihtiyaçlarına bağlı olarak birden fazla veritabanı sunucusuna ihtiyaç duyabilirsiniz.

Ürününüz için hesap oluşturmanın ücretsiz olduğunu varsayalım. Bu durumda, bir reklam kampanyası ile büyük bir kullanıcı potansiyeli çekebilirsiniz. Üstelik hesap oluşturmak tamamen ücretsiz ve her bir mağaza için 14 gün deneme süresi tanıyorsunuz. Bu senaryoda, pool mantığı kullanmazsanız, her yeni tenant için ya aynı sunucuda ya da ayrı bir veritabanı sunucusunda oluşturma yapacaksınız. Eğer 1000 yeni kayıt alırsanız, tek bir veritabanı sunucusunun bu büyüklükteki veritabanını etkin bir şekilde yönetemeyeceğini görebilirsiniz.

İşte tam bu noktada pool mantığını devreye alarak sorunu çözebilirsiniz. Her bir veritabanı havuzu için bir maksimum kapasite belirlersiniz ve bu kapasite dolduğunda diğer sunuculara geçerek veritabanı oluşturma işlemine devam edersiniz. Bu sayede yükü dengede tutar ve performans sorunlarını önleyebilirsiniz.

Pool Tablo Örneği
Pool Database Tablo Örneği
Tenant Tablo Örneği

Ancak, tenant’ı oluşturmadan önce aldığımız token üzerinde bu yeni bilgiler bulunmayacaktır. Bu sebeple, /api/v1/authenticate/change-tenant ucuna POST isteği göndererek, oluşturduğumuz tenant üzerinde çalışmaya devam edebilmek için güncel bir token almalıyız.

Request

{
"tenantId": 1
}

Response

{
"token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJodHRwOi8vc2NoZW1hcy54bWxzb2FwLm9yZy93cy8yMDA1LzA1L2lkZW50aXR5L2NsYWltcy9zaWQiOiIxIiwidW5pcXVlX25hbWUiOiJNdXJhdCIsImZhbWlseV9uYW1lIjoiRGluw6ciLCJlbWFpbCI6ImluZm9AbXVyYXRkaW5jLmRldiIsIlRlbmFudElkIjoiMSIsIkNvbm5lY3Rpb25TdHJpbmciOiJIb3N0PWxvY2FsaG9zdDsgUG9ydD01NDM4OyBEYXRhYmFzZT1hcHBfZGJfZGV2bm90OyBVc2VybmFtZT1wb3N0Z3JlczsgUGFzc3dvcmQ9cG9zdGdyZXMiLCJuYmYiOjE2OTY3NjQ1NDEsImV4cCI6MTY5NzM2OTM0MSwiaWF0IjoxNjk2NzY0NTQxfQ.iMkEmuHcLZ2IqdexIb-xzjliJvKWBMASf1uoAJaPFEk",
"expiryDate": "2023-10-15T11:29:01.79122Z"
}

Şimdi bu token ile işlem yapabiliriz. pgAdmin aracılığıyla veritabanlarını incelediğimizde, henüz veritabanının oluşmadığını görebiliriz. Bunun nedeni, oluşturulan tenant için henüz bir istek gelmemiş olması ve DbContext’e herhangi bir çağrıda bulunulmamış olmasıdır. Commerce.Api’ye bir istek yapıldığında, veritabanı otomatik olarak oluşturulacak ve modelin yapılandırmasına göre şekil alacaktır.

Commerce.Api üzerine istek gelmeden önce önce

Şimdi oluşturduğumuz tenant’ın gerçekten aktif olup olmadığını kontrol edelim. Testlerimi seminer için özel olarak aldığım seminertestleri.com domaini üzerinde gerçekleştirdiğim için, oluşturduğum tenant’ın slug’ı ile bu domaine erişim sağlayacağım.

mediumblog.seminertestleri.com

Tenant başarıyla oluşturuldu, bu yüzden ilgili slug ile sisteme erişim sağlayabiliyoruz. Ancak şu an için veritabanımız hala oluşmadı. Bunun nedeni, Commerce.Api üzerinden ilgili tenant’ın veritabanına bağlanıp veritabanı oluşturma ve migration işlemlerini başlatmadık. Menüdeki ‘Products’ bağlantısına tıkladığımızda, ilgili tenant için connection string ile veritabanına erişmeye çalışacak ve aşağıdaki kod çalıştırılacaktır.

protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
{
if (!string.IsNullOrEmpty(ConnectionString))
{
optionsBuilder.UseNpgsql(ConnectionString,
optionsBuilder =>
{
optionsBuilder.MigrationsAssembly("Commerce.Infrastructure");
optionsBuilder.MigrationsHistoryTable("__EFMigrationsHistory", "public");
}).UseSnakeCaseNamingConvention();
}
}

OnConfiguring metodunda, connection string’e bağlı olarak migration başlatan bir kod bloğu bulunmaktadır. Tenant veritabanının oluşturulduğu yer tam olarak bu kısımdır. Bu bölümde Context ilgili connection string ile ayaklandırılır ve DatabaseInstallerMiddleware devreye girerek oluşturma işlemini modele göre tamamlar.

DatabaseInstallerMiddleware

using Commerce.Infrastructure.Repositories;

namespace Commerce.Api.Middleware;

public class DatabaseInstallerMiddleware
{
private readonly RequestDelegate _next;

public DatabaseInstallerMiddleware(RequestDelegate next)
{
_next = next;
}

public async Task Invoke(HttpContext httpContext, CommerceDbContext dbContext)
{
var connectionString = httpContext.Request.Headers["X-Connection-String"].ToString();
if (!string.IsNullOrWhiteSpace(connectionString))
await dbContext.Database.EnsureCreatedAsync();

await _next.Invoke(httpContext);
}
}
app_db_mediumblog veritabanı başarı ile oluşturulmuş.

Context her gelen istek için devreye girerek connection string ekleyerek

Peki subdomain üzerinden nasıl erişiyoruz?

Commerce.App projemizde bulunan TenantCheckMiddleware, gelen URL’yi ele alıp segmentlere ayırarak subdomain temelli bir ayrım gerçekleştiriyor.

using System.Net;
using Commerce.App.Services.Bff;
using Microsoft.Extensions.Primitives;

namespace Commerce.App.Middleware;

public class TenantCheckMiddleware
{
private readonly RequestDelegate _next;

public TenantCheckMiddleware(RequestDelegate next)
{
_next = next;
}

public async Task Invoke(HttpContext httpContext)
{
if (httpContext.Request.Path.HasValue && httpContext.Request.Path.Value.Contains("/Home/TenantNotFound"))
await _next.Invoke(httpContext);

var fullAddress = httpContext?.Request?.Headers?["Host"].ToString()?.Split('.');

if (fullAddress.Length < 2)
{
httpContext.Response.Redirect("/Home/TenantNotFound");

await _next.Invoke(httpContext);
}

var tenantSlug = fullAddress[0];

try
{
var bffService = httpContext.RequestServices.GetService<IBffService>();
var tenantServiceResponse = await bffService.GetTenantBySlug(tenantSlug);
if (tenantServiceResponse == null)
{
httpContext.Response.Redirect("/Home/TenantNotFound");

await _next.Invoke(httpContext);
}

httpContext.Items.Add("Tenant-Title", tenantServiceResponse.Title);

httpContext.Request.Headers.Add(new KeyValuePair<string, StringValues>("X-TenantId", tenantServiceResponse.Id.ToString()));
httpContext.Request.Headers.Add(new KeyValuePair<string, StringValues>("X-Connection-String", tenantServiceResponse.ConnectionString));
}
catch (Refit.ApiException e)
{
httpContext.Response.Redirect("/Home/TenantNotFound");

await _next.Invoke(httpContext);
}

await _next.Invoke(httpContext);
}
}

Slug bilgisini kullanarak BFF üzerinden Tenant’a ilişkin bilgileri alıyoruz. Eğer girilen tenant sistemde bulunmuyorsa, kullanıcıyı /Home/TenantNotFound yoluyla başka bir sayfaya yönlendiriyor ve sistemi kullanmasını engelliyoruz.

Tenant’a ürün eklemek veya ürünleri listelemek için BFF’deki ‘product’ endpointlerini kullanabilirsiniz.

NOT: Tenant oluşturduktan sonra BFF üzerinden Change Tenant yaparak token değiştirmeyi unutmayın. Bu işlemi yapmadığınız taktirde tenant için connection string bilgisi dönmeyecektir.

Makaleyi faydalı bulduysanız takip ederek destek olabilirsiniz 🙏

Bir sonraki yazıda görüşmek üzere 😊

--

--