diff --git a/JsonApiDotNetCore.sln b/JsonApiDotNetCore.sln
index 7237596aed..0a8ed12d2a 100644
--- a/JsonApiDotNetCore.sln
+++ b/JsonApiDotNetCore.sln
@@ -52,6 +52,8 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "SourceGeneratorTests", "tes
EndProject
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "JsonApiDotNetCore.Annotations", "src\JsonApiDotNetCore.Annotations\JsonApiDotNetCore.Annotations.csproj", "{83FF097C-C8C6-477B-9FAB-DF99B84978B5}"
EndProject
+Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "DatabasePerTenantExample", "src\Examples\DatabasePerTenantExample\DatabasePerTenantExample.csproj", "{60334658-BE51-43B3-9C4D-F2BBF56C89CE}"
+EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
@@ -266,6 +268,18 @@ Global
{83FF097C-C8C6-477B-9FAB-DF99B84978B5}.Release|x64.Build.0 = Release|Any CPU
{83FF097C-C8C6-477B-9FAB-DF99B84978B5}.Release|x86.ActiveCfg = Release|Any CPU
{83FF097C-C8C6-477B-9FAB-DF99B84978B5}.Release|x86.Build.0 = Release|Any CPU
+ {60334658-BE51-43B3-9C4D-F2BBF56C89CE}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {60334658-BE51-43B3-9C4D-F2BBF56C89CE}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {60334658-BE51-43B3-9C4D-F2BBF56C89CE}.Debug|x64.ActiveCfg = Debug|Any CPU
+ {60334658-BE51-43B3-9C4D-F2BBF56C89CE}.Debug|x64.Build.0 = Debug|Any CPU
+ {60334658-BE51-43B3-9C4D-F2BBF56C89CE}.Debug|x86.ActiveCfg = Debug|Any CPU
+ {60334658-BE51-43B3-9C4D-F2BBF56C89CE}.Debug|x86.Build.0 = Debug|Any CPU
+ {60334658-BE51-43B3-9C4D-F2BBF56C89CE}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {60334658-BE51-43B3-9C4D-F2BBF56C89CE}.Release|Any CPU.Build.0 = Release|Any CPU
+ {60334658-BE51-43B3-9C4D-F2BBF56C89CE}.Release|x64.ActiveCfg = Release|Any CPU
+ {60334658-BE51-43B3-9C4D-F2BBF56C89CE}.Release|x64.Build.0 = Release|Any CPU
+ {60334658-BE51-43B3-9C4D-F2BBF56C89CE}.Release|x86.ActiveCfg = Release|Any CPU
+ {60334658-BE51-43B3-9C4D-F2BBF56C89CE}.Release|x86.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
@@ -288,6 +302,7 @@ Global
{87D066F9-3540-4AC7-A748-134900969EE5} = {24B15015-62E5-42E1-9BA0-ECE6BE7AA15F}
{0E0B5C51-F7E2-4F40-A4E4-DED0E9731DC9} = {24B15015-62E5-42E1-9BA0-ECE6BE7AA15F}
{83FF097C-C8C6-477B-9FAB-DF99B84978B5} = {7A2B7ADD-ECB5-4D00-AA6A-D45BD11C97CF}
+ {60334658-BE51-43B3-9C4D-F2BBF56C89CE} = {026FBC6C-AF76-4568-9B87-EC73457899FD}
EndGlobalSection
GlobalSection(ExtensibilityGlobals) = postSolution
SolutionGuid = {A2421882-8F0A-4905-928F-B550B192F9A4}
diff --git a/JsonApiDotNetCore.sln.DotSettings b/JsonApiDotNetCore.sln.DotSettings
index 92cce56c47..c4f0d3d36d 100644
--- a/JsonApiDotNetCore.sln.DotSettings
+++ b/JsonApiDotNetCore.sln.DotSettings
@@ -631,6 +631,7 @@ $left$ = $right$;
WARNING
True
True
+ True
True
True
True
diff --git a/src/Examples/DatabasePerTenantExample/Controllers/EmployeesController.cs b/src/Examples/DatabasePerTenantExample/Controllers/EmployeesController.cs
new file mode 100644
index 0000000000..5e17afab9b
--- /dev/null
+++ b/src/Examples/DatabasePerTenantExample/Controllers/EmployeesController.cs
@@ -0,0 +1,15 @@
+using JsonApiDotNetCore.Controllers.Annotations;
+using Microsoft.AspNetCore.Mvc;
+
+namespace DatabasePerTenantExample.Controllers;
+
+// Workaround for https://youtrack.jetbrains.com/issue/RSRP-487028
+public partial class EmployeesController
+{
+}
+
+[DisableRoutingConvention]
+[Route("api/{tenantName}/employees")]
+partial class EmployeesController
+{
+}
diff --git a/src/Examples/DatabasePerTenantExample/Data/AppDbContext.cs b/src/Examples/DatabasePerTenantExample/Data/AppDbContext.cs
new file mode 100644
index 0000000000..c70fc8655f
--- /dev/null
+++ b/src/Examples/DatabasePerTenantExample/Data/AppDbContext.cs
@@ -0,0 +1,80 @@
+using System.Net;
+using DatabasePerTenantExample.Models;
+using JetBrains.Annotations;
+using JsonApiDotNetCore.Errors;
+using JsonApiDotNetCore.Serialization.Objects;
+using Microsoft.EntityFrameworkCore;
+
+namespace DatabasePerTenantExample.Data;
+
+[UsedImplicitly(ImplicitUseTargetFlags.Members)]
+public sealed class AppDbContext : DbContext
+{
+ private readonly IHttpContextAccessor _httpContextAccessor;
+ private readonly IConfiguration _configuration;
+ private string? _forcedTenantName;
+
+ public DbSet Employees => Set();
+
+ public AppDbContext(IHttpContextAccessor httpContextAccessor, IConfiguration configuration)
+ {
+ _httpContextAccessor = httpContextAccessor;
+ _configuration = configuration;
+ }
+
+ public void SetTenantName(string tenantName)
+ {
+ _forcedTenantName = tenantName;
+ }
+
+ protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
+ {
+ string connectionString = GetConnectionString();
+ optionsBuilder.UseNpgsql(connectionString);
+ }
+
+ private string GetConnectionString()
+ {
+ string? tenantName = GetTenantName();
+ string connectionString = _configuration[$"Data:{tenantName ?? "Default"}Connection"];
+
+ if (connectionString == null)
+ {
+ throw GetErrorForInvalidTenant(tenantName);
+ }
+
+ string postgresPassword = Environment.GetEnvironmentVariable("PGPASSWORD") ?? "postgres";
+ return connectionString.Replace("###", postgresPassword);
+ }
+
+ private string? GetTenantName()
+ {
+ if (_forcedTenantName != null)
+ {
+ return _forcedTenantName;
+ }
+
+ if (_httpContextAccessor.HttpContext != null)
+ {
+ string? tenantName = (string?)_httpContextAccessor.HttpContext.Request.RouteValues["tenantName"];
+
+ if (tenantName == null)
+ {
+ throw GetErrorForInvalidTenant(null);
+ }
+
+ return tenantName;
+ }
+
+ return null;
+ }
+
+ private static JsonApiException GetErrorForInvalidTenant(string? tenantName)
+ {
+ return new JsonApiException(new ErrorObject(HttpStatusCode.BadRequest)
+ {
+ Title = "Missing or invalid tenant in URL.",
+ Detail = $"Tenant '{tenantName}' does not exist."
+ });
+ }
+}
diff --git a/src/Examples/DatabasePerTenantExample/DatabasePerTenantExample.csproj b/src/Examples/DatabasePerTenantExample/DatabasePerTenantExample.csproj
new file mode 100644
index 0000000000..b243e99ec2
--- /dev/null
+++ b/src/Examples/DatabasePerTenantExample/DatabasePerTenantExample.csproj
@@ -0,0 +1,16 @@
+
+
+ $(TargetFrameworkName)
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/Examples/DatabasePerTenantExample/Models/Employee.cs b/src/Examples/DatabasePerTenantExample/Models/Employee.cs
new file mode 100644
index 0000000000..cc79449880
--- /dev/null
+++ b/src/Examples/DatabasePerTenantExample/Models/Employee.cs
@@ -0,0 +1,19 @@
+using JetBrains.Annotations;
+using JsonApiDotNetCore.Resources;
+using JsonApiDotNetCore.Resources.Annotations;
+
+namespace DatabasePerTenantExample.Models;
+
+[UsedImplicitly(ImplicitUseTargetFlags.Members)]
+[Resource]
+public sealed class Employee : Identifiable
+{
+ [Attr]
+ public string FirstName { get; set; } = null!;
+
+ [Attr]
+ public string LastName { get; set; } = null!;
+
+ [Attr]
+ public string CompanyName { get; set; } = null!;
+}
diff --git a/src/Examples/DatabasePerTenantExample/Program.cs b/src/Examples/DatabasePerTenantExample/Program.cs
new file mode 100644
index 0000000000..b6f960831d
--- /dev/null
+++ b/src/Examples/DatabasePerTenantExample/Program.cs
@@ -0,0 +1,58 @@
+using DatabasePerTenantExample.Data;
+using DatabasePerTenantExample.Models;
+using JsonApiDotNetCore.Configuration;
+using Microsoft.EntityFrameworkCore;
+
+WebApplicationBuilder builder = WebApplication.CreateBuilder(args);
+
+// Add services to the container.
+
+builder.Services.AddSingleton();
+builder.Services.AddDbContext(options => options.UseNpgsql());
+
+builder.Services.AddJsonApi(options =>
+{
+ options.Namespace = "api";
+ options.UseRelativeLinks = true;
+ options.SerializerOptions.WriteIndented = true;
+});
+
+WebApplication app = builder.Build();
+
+// Configure the HTTP request pipeline.
+
+app.UseRouting();
+app.UseJsonApi();
+app.MapControllers();
+
+await CreateDatabaseAsync(null, app.Services);
+await CreateDatabaseAsync("AdventureWorks", app.Services);
+await CreateDatabaseAsync("Contoso", app.Services);
+
+app.Run();
+
+static async Task CreateDatabaseAsync(string? tenantName, IServiceProvider serviceProvider)
+{
+ await using AsyncServiceScope scope = serviceProvider.CreateAsyncScope();
+ var dbContext = scope.ServiceProvider.GetRequiredService();
+
+ if (tenantName != null)
+ {
+ dbContext.SetTenantName(tenantName);
+ }
+
+ await dbContext.Database.EnsureDeletedAsync();
+ await dbContext.Database.EnsureCreatedAsync();
+
+ if (tenantName != null)
+ {
+ dbContext.Employees.Add(new Employee
+ {
+ FirstName = "John",
+ LastName = "Doe",
+ CompanyName = tenantName
+ });
+
+ await dbContext.SaveChangesAsync();
+ }
+}
diff --git a/src/Examples/DatabasePerTenantExample/Properties/launchSettings.json b/src/Examples/DatabasePerTenantExample/Properties/launchSettings.json
new file mode 100644
index 0000000000..1ab75296f7
--- /dev/null
+++ b/src/Examples/DatabasePerTenantExample/Properties/launchSettings.json
@@ -0,0 +1,30 @@
+{
+ "$schema": "http://json.schemastore.org/launchsettings.json",
+ "iisSettings": {
+ "windowsAuthentication": false,
+ "anonymousAuthentication": true,
+ "iisExpress": {
+ "applicationUrl": "http://localhost:14147",
+ "sslPort": 44340
+ }
+ },
+ "profiles": {
+ "IIS Express": {
+ "commandName": "IISExpress",
+ "launchBrowser": true,
+ "launchUrl": "api/AdventureWorks/employees",
+ "environmentVariables": {
+ "ASPNETCORE_ENVIRONMENT": "Development"
+ }
+ },
+ "Kestrel": {
+ "commandName": "Project",
+ "launchBrowser": true,
+ "launchUrl": "api/AdventureWorks/employees",
+ "applicationUrl": "https://localhost:44347;http://localhost:14147",
+ "environmentVariables": {
+ "ASPNETCORE_ENVIRONMENT": "Development"
+ }
+ }
+ }
+}
diff --git a/src/Examples/DatabasePerTenantExample/appsettings.json b/src/Examples/DatabasePerTenantExample/appsettings.json
new file mode 100644
index 0000000000..c065f66c64
--- /dev/null
+++ b/src/Examples/DatabasePerTenantExample/appsettings.json
@@ -0,0 +1,15 @@
+{
+ "Data": {
+ "DefaultConnection": "Host=localhost;Port=5432;Database=DefaultTenantDb;User ID=postgres;Password=###",
+ "AdventureWorksConnection": "Host=localhost;Port=5432;Database=AdventureWorks;User ID=postgres;Password=###",
+ "ContosoConnection": "Host=localhost;Port=5432;Database=Contoso;User ID=postgres;Password=###"
+ },
+ "Logging": {
+ "LogLevel": {
+ "Default": "Warning",
+ "Microsoft.Hosting.Lifetime": "Information",
+ "Microsoft.EntityFrameworkCore.Database.Command": "Information"
+ }
+ },
+ "AllowedHosts": "*"
+}