A simple demo Web API demonstrating multi-tenant localisation and globalisation patterns. Each tenant is a company in a different European country. The API automatically resolves their language and culture on every request.
Request → TenantResolutionMiddleware → Culture Set → Endpoint → Localised Response
- Every request must include an
X-API-Keyheader - The middleware hashes the key (SHA256) and checks the cache
- On cache miss, BCrypt verifies the key against the database
- On success,
CurrentCultureandCurrentUICultureare set for the request - The endpoint returns a response in the tenant's language
| Tenant | Company | Country | Culture |
|---|---|---|---|
| tenant-be | Gale Belgium | Belgium | fr-BE |
| tenant-uk | Gale UK | United Kingdom | en-GB |
| tenant-de | Gale Germany | Germany | de-DE |
| tenant-fr | Gale France | France | fr-FR |
- Runtime: .NET 10
- Database: PostgreSQL 16
- ORM: Entity Framework Core 10
- Caching: IDistributedCache (in-memory, Redis-ready)
- Password Hashing: BCrypt.Net-Next
- Testing: xUnit + WebApplicationFactory + SQLite in-memory
MultiTenantLocalisation/
├── MultiTenantLocalisationApi/
│ ├── Data/
│ │ ├── Configuration/ # EF Core entity configurations
│ │ ├── Entities/ # Database entities
│ │ ├── Migrations/ # EF Core migrations
│ │ ├── AppDbContext.cs
│ │ └── SeedData.cs
│ ├── Middleware/
│ │ └── TenantResolutionMiddleware.cs
│ ├── Models/
│ │ ├── ApiError.cs # Structured error response shape
│ │ ├── ApiErrors.cs # Error factory
│ │ └── TenantInfo.cs # Per-request tenant DTO
│ ├── Resources/
│ │ ├── SharedResource.resx # Default (English)
│ │ ├── SharedResource.fr.resx # French
│ │ └── SharedResource.de.resx # German
│ ├── Services/
│ │ └── TenantCacheService.cs
│ ├── SharedResource.cs # Localisation marker class
│ └── Program.cs
└── MultiTenantLocalisationApi.Tests/
└── Middleware/
├── CustomWebApplicationFactory.cs
└── TenantResolutionMiddlewareTests.cs
- .NET 10 SDK
- Docker
docker run --name saas-localisation-db \
-e POSTGRES_USER=saasuser \
-e POSTGRES_PASSWORD=saaspassword \
-e POSTGRES_DB=saaslocalisationdb \
-p 5433:5432 \
-d postgres:16cd MultiTenantLocalisationApi
dotnet ef database updatedotnet runThe API starts on http://localhost:5000. On first run, tenants are seeded automatically.
GET /greet/{name}
Headers: X-API-Key: <api-key>
Example: Gale UK:
curl -H "X-API-Key: gale-uk-api-key-2024" http://localhost:5000/greet/John{ "message": "Hello, John! Welcome to our platform." }Example: Gale France:
curl -H "X-API-Key: gale-france-api-key-2024" http://localhost:5000/greet/John{ "message": "Bonjour, John! Bienvenue sur notre plateforme." }GET /culture-demo
Headers: X-API-Key: <api-key>
Demonstrates globalisation i.e the same date, currency, and number formatted differently per tenant culture. No translation involved as .NET formats automatically based on CurrentCulture.
Example: Gale France:
{
"tenant": "Gale France",
"culture": "fr-FR",
"date": "mercredi 6 mai 2026",
"shortDate": "06/05/2026",
"currency": "1 234 567,89 €",
"number": "1 234 567,890"
}Example: Gale UK:
{
"tenant": "Gale UK",
"culture": "en-GB",
"date": "Wednesday, 6 May 2026",
"shortDate": "06/05/2026",
"currency": "£1,234,567.89",
"number": "1,234,567.890"
}Example: Gale Germany:
{
"tenant": "Gale Germany",
"culture": "de-DE",
"date": "Mittwoch, 6. Mai 2026",
"shortDate": "06.05.2026",
"currency": "1.234.567,89 €",
"number": "1.234.567,890"
}All errors follow a consistent structure:
{
"code": "MISSING_API_KEY",
"message": "Missing API key",
"timestamp": "2026-05-06T10:23:11Z",
"traceId": "0HNLBA6AK80OQ:00000001"
}| HTTP Status | Code | Reason |
|---|---|---|
| 401 | MISSING_API_KEY |
No X-API-Key header |
| 401 | INVALID_API_KEY |
Key not found in database |
| 403 | TENANT_INACTIVE |
Tenant has been disabled |
cd MultiTenantLocalisationApi.Tests
dotnet testTests use SQLite in-memory. No Docker required.
| Tenant | API Key |
|---|---|
| Gale Belgium | gale-belgium-api-key-2024 |
| Gale UK | gale-uk-api-key-2024 |
| Gale Germany | gale-germany-api-key-2024 |
| Gale France | gale-france-api-key-2024 |
These keys are for development only. In production, API keys are generated securely and never stored in plain text.