Skip to content

Commit 7c25926

Browse files
committed
feat: Implement Marten multi-tenancy for localized projections by generating tenant-specific projection documents and removing the old localization helper.
1 parent eeb88b3 commit 7c25926

9 files changed

Lines changed: 538 additions & 503 deletions

File tree

docs/localization-guide.md

Lines changed: 76 additions & 98 deletions
Original file line numberDiff line numberDiff line change
@@ -4,159 +4,137 @@ This guide explains how to configure and use localization in the BookStore API.
44

55
## Overview
66

7-
The BookStore API supports multiple languages for localized content (category names, book descriptions, author biographies). The API automatically detects the client's preferred language from the `Accept-Language` HTTP header and returns localized content accordingly.
7+
The BookStore API supports multiple languages for localized content (category names, book descriptions, author biographies).
8+
9+
**Architecture Strategy:**
10+
The localization strategy uses **Write-Time Localization** via **Marten's Conjoined Tenancy**.
11+
- **Events** contain all translations.
12+
- **Projections** are multi-tenanted, with one document stored per supported language (tenant).
13+
- **APIs** simply query the tenant corresponding to the user's preferred language.
14+
15+
This approach ensures high performance by eliminating complex runtime fallback logic during data retrieval.
816

917
## Configuration
1018

11-
### Two-Letter Language Codes
19+
### Supported Languages
1220

13-
The API uses **two-letter ISO 639-1 language codes** for universal variant support:
21+
The API is configured using **ISO 639-1 language codes**. You can configure either generic codes (e.g., `en`, `pt`) or specific regional cultures (e.g., `en-US`, `pt-BR`).
1422

23+
**Standard Configuration (Generic)**:
24+
Suitable for applications where a single translation per language works for all regions.
1525
```json
1626
{
1727
"Localization": {
1828
"DefaultCulture": "en",
19-
"SupportedCultures": ["pt", "en", "fr", "de", "es"]
29+
"SupportedCultures": ["en", "pt", "fr", "de", "es"]
30+
}
31+
}
32+
```
33+
34+
**Regional Configuration (Specific)**:
35+
Suitable when you need different content for specific regions (e.g., "Color" vs "Colour").
36+
```json
37+
{
38+
"Localization": {
39+
"DefaultCulture": "en-US",
40+
"SupportedCultures": ["en-US", "en-GB", "pt-PT", "pt-BR"]
2041
}
2142
}
2243
```
2344

24-
**Why two-letter codes?**
25-
- ✅ Any regional variant automatically maps to the base language (pt-BR, pt-PT, pt-AO → pt)
26-
- ✅ Simpler configuration - no need to list every regional variant
27-
- ✅ Easier maintenance - new regional variants work immediately
45+
### Marten Configuration
46+
47+
Projections are configured as multi-tenanted to support separate documents for each culture. This is defined in `MartenConfigurationExtensions.cs`.
48+
49+
```csharp
50+
options.Schema.For<BookSearchProjection>().MultiTenanted();
51+
options.Schema.For<AuthorProjection>().MultiTenanted();
52+
options.Schema.For<CategoryProjection>().MultiTenanted();
53+
```
54+
55+
The system iterates through all configured `SupportedCultures` to generate a projection document for each one.
2856

2957
### Cache Configuration
3058

31-
**Critical**: All localized endpoints must vary cache by `Accept-Language`:
59+
**Critical**: All localized endpoints must verify the cache by the `Accept-Language` header to ensure users receive the correct language version.
3260

3361
```csharp
3462
.CacheOutput(policy => policy
3563
.Expire(TimeSpan.FromMinutes(5))
3664
.SetVaryByHeader("Accept-Language"))
3765
```
3866

39-
Without this, cached responses ignore the language header.
40-
4167
## Translation Storage
4268

43-
Translations can be **culture-specific** or **culture-invariant**:
69+
Translations are captured at the source in Domain Events using a dictionary.
4470

45-
**Culture-Invariant** (recommended):
71+
**Mixed Storage Example**:
72+
You can store both generic and specific keys.
4673
```json
4774
{
4875
"pt": "Programação",
49-
"en": "Programming"
50-
}
51-
```
52-
53-
**Hybrid** (specific + invariant):
54-
```json
55-
{
5676
"pt-BR": "Programação (Brasil)",
57-
"pt": "Programação",
5877
"en": "Programming"
5978
}
6079
```
6180

62-
## Fallback Strategy
81+
## Fallback Strategy (Write-Time)
82+
83+
The API applies fallback logic **during projection generation** to ensure every supported culture has content.
6384

64-
The API uses a 6-step fallback to find the best translation:
85+
**Logic sequence for a target culture (e.g., `pt-BR`):**
6586

66-
1. Exact preferred culture (e.g., `"pt-PT"`)
67-
2. Two-letter preferred code (e.g., `"pt"`)
68-
3. Exact default culture (e.g., `"en"`)
69-
4. Two-letter default code (e.g., `"en"`)
70-
5. First available translation
71-
6. Default value (empty string)
87+
1. **Exact Match**: Look for a translation with key `"pt-BR"`.
88+
2. **Parent Culture**: Look for a translation with key `"pt"`.
89+
3. **Default Culture**: Look for a translation with the key of the `DefaultCulture`.
90+
4. **Any**: Use the first available translation.
91+
5. **Empty**: Fallback to an empty string.
7292

73-
**Example**: Request with `Accept-Language: pt-BR`
74-
- Tries `"pt-BR"``"pt"``"en"` → first available → default
75-
- With hybrid translations above, returns `"Programação (Brasil)"`
93+
This robust fallback ensures that even if a specific translation is missing, the system provides the most relevant available content.
7694

7795
## Usage
7896

7997
### Making Requests
8098

81-
Include the `Accept-Language` header in your HTTP requests:
99+
Clients request a specific language using the `Accept-Language` header.
82100

83101
```http
84102
GET /api/books HTTP/1.1
85103
Accept-Language: pt-BR
86104
```
87105

88-
The API returns localized content based on the header value.
106+
If the requested culture is not supported (e.g., `ja-JP`), the API will automatically fall back to the configured `DefaultCulture`.
89107

90-
### Testing Different Languages
108+
### Endpoint Implementation
91109

92-
**Using curl**:
93-
```bash
94-
curl -H "Accept-Language: pt" https://localhost:7001/api/categories
95-
curl -H "Accept-Language: en" https://localhost:7001/api/categories
96-
```
97-
98-
**Using Postman/Insomnia**:
99-
1. Add header: `Accept-Language: pt`
100-
2. Send request
101-
3. Observe localized response
110+
Endpoints are simplified to purely read operations. They resolve the current culture (handled by ASP.NET Core middleware) and query the corresponding database tenant.
102111

103-
## Localized Endpoints
104-
105-
All public-facing endpoints return localized content:
112+
```csharp
113+
// 1. Resolve culture (e.g., "pt-BR")
114+
var culture = CultureInfo.CurrentCulture.Name;
106115

107-
- **Categories** (`/api/categories`): Category names
108-
- **Books** (`/api/books`): Descriptions, category names, author biographies, language display names
109-
- **Authors** (`/api/authors`): Author biographies
116+
// 2. Open a session specific to that culture
117+
await using var session = store.QuerySession(culture);
110118

111-
## Implementation Details
119+
// 3. Query normally - Marten automatically filters by the tenant
120+
var books = await session.Query<BookSearchProjection>().ToListAsync();
121+
```
112122

113-
The localization system uses:
114-
- **ASP.NET Core Middleware**: `RequestLocalizationMiddleware` determines culture from `Accept-Language` header
115-
- **LocalizationHelper**: Centralized helper with reusable methods:
116-
- `GetLocalizedValue<T>()`: Generic method for translating any `Dictionary<string, T>`
117-
- `LocalizeLanguageName()`: Gets localized display names for language codes
118-
- `GetPreferredCulture()`: Retrieves user's preferred culture from middleware
119-
- **Generic Translation Support**: Works with any translation type via selector functions
120-
- **Null Safety**: Uses `[NotNullWhen(true)]` attribute for compile-time null safety
123+
### Projection Implementation
121124

122-
### Usage Example
125+
Projections are responsible for "fanning out" changes to all supported cultures. When an event (like `BookAdded`) occurs, the projection updates the documents for **all** configured tenants.
123126

124127
```csharp
125-
// Localize category name
126-
var localizedName = LocalizationHelper.GetLocalizedValue(
127-
context,
128-
options,
129-
category.Translations,
130-
translation => translation.Name,
131-
defaultValue: "Unknown");
132-
133-
// Get localized language name
134-
var languageName = LocalizationHelper.LocalizeLanguageName(
135-
"en",
136-
context,
137-
options); // Returns "English" or "Inglês" depending on user's language
128+
foreach (var culture in _localization.SupportedCultures)
129+
{
130+
using var tenantSession = session.ForTenant(culture);
131+
132+
var projection = new BookSearchProjection
133+
{
134+
// ... map fields ...
135+
Description = GetLocalizedDescription(@event.Data.Translations, culture)
136+
};
137+
138+
tenantSession.Store(projection);
139+
}
138140
```
139-
140-
## Best Practices
141-
142-
1. **Use two-letter codes**: Configure `SupportedCultures` with two-letter codes for universal variant support
143-
2. **Use culture-invariant translations**: Store translations with two-letter keys unless region-specific content is needed
144-
3. **Always vary cache by Accept-Language**: Ensure cached responses are language-specific
145-
4. **Provide fallbacks**: Always include default culture translations
146-
5. **Validate culture codes**: Use `CultureCache.IsValidCultureCode()` to validate codes before use
147-
148-
## Troubleshooting
149-
150-
### Translations not working
151-
- ✅ Check `Accept-Language` header is being sent
152-
- ✅ Verify culture code is in `SupportedCultures`
153-
- ✅ Ensure cache varies by `Accept-Language`
154-
- ✅ Check translation dictionary has the expected keys
155-
156-
### Always returning same language
157-
- ✅ Verify cache configuration includes `.SetVaryByHeader("Accept-Language")`
158-
- ✅ Clear cache and retry
159-
160-
### Regional variant not working
161-
- ✅ Ensure `SupportedCultures` uses two-letter codes
162-
- ✅ Check translation dictionary has two-letter key (e.g., `"pt"` not `"pt-PT"`)

src/ApiService/BookStore.ApiService/Endpoints/AuthorEndpoints.cs

Lines changed: 11 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
using Microsoft.AspNetCore.Http.HttpResults;
77
using Microsoft.AspNetCore.Mvc;
88
using Microsoft.Extensions.Options;
9+
using System.Globalization;
910

1011
namespace BookStore.ApiService.Endpoints;
1112

@@ -31,24 +32,23 @@ public static RouteGroupBuilder MapAuthorEndpoints(this RouteGroupBuilder group)
3132
}
3233

3334
static async Task<Ok<PagedListDto<AuthorDto>>> GetAuthors(
34-
[FromServices] IQuerySession session,
35+
[FromServices] IDocumentStore store,
3536
[FromServices] IOptions<PaginationOptions> paginationOptions,
36-
[FromServices] IOptions<LocalizationOptions> localizationOptions,
3737
[AsParameters] PagedRequest request,
3838
HttpContext context)
3939
{
40+
var culture = CultureInfo.CurrentCulture.Name;
41+
await using var session = store.QuerySession(culture);
4042
var paging = request.Normalize(paginationOptions.Value);
4143

42-
// Use Marten's native pagination for optimal performance
4344
var pagedList = await session.Query<AuthorProjection>()
4445
.OrderBy(a => a.Name)
4546
.ToPagedListAsync(paging.Page!.Value, paging.PageSize!.Value);
4647

47-
// Map to DTOs with localized biographies
4848
var authorDtos = pagedList.Select(author => new AuthorDto(
4949
author.Id,
5050
author.Name,
51-
LocalizeBiography(author, context, localizationOptions.Value)
51+
author.Biography
5252
)).ToList();
5353

5454
return TypedResults.Ok(new PagedListDto<AuthorDto>(
@@ -58,12 +58,14 @@ static async Task<Ok<PagedListDto<AuthorDto>>> GetAuthors(
5858
pagedList.TotalItemCount));
5959
}
6060

61-
static async Task<Microsoft.AspNetCore.Http.HttpResults.Results<Ok<AuthorDto>, NotFound>> GetAuthor(
61+
static async Task<Results<Ok<AuthorDto>, NotFound>> GetAuthor(
6262
Guid id,
63-
[FromServices] IQuerySession session,
64-
[FromServices] IOptions<LocalizationOptions> localizationOptions,
63+
[FromServices] IDocumentStore store,
6564
HttpContext context)
6665
{
66+
var culture = CultureInfo.CurrentCulture.Name;
67+
await using var session = store.QuerySession(culture);
68+
6769
var author = await session.LoadAsync<AuthorProjection>(id);
6870
if (author == null)
6971
{
@@ -73,27 +75,8 @@ static async Task<Ok<PagedListDto<AuthorDto>>> GetAuthors(
7375
var authorDto = new AuthorDto(
7476
author.Id,
7577
author.Name,
76-
LocalizeBiography(author, context, localizationOptions.Value));
78+
author.Biography);
7779

7880
return TypedResults.Ok(authorDto);
7981
}
80-
81-
// Helper method for author biography localization
82-
static string? LocalizeBiography(
83-
AuthorProjection author,
84-
HttpContext context,
85-
LocalizationOptions options)
86-
{
87-
if (author.Translations.Count == 0)
88-
{
89-
return null;
90-
}
91-
92-
return LocalizationHelper.GetLocalizedValue(
93-
context,
94-
options,
95-
author.Translations,
96-
translation => translation.Biography,
97-
defaultValue: string.Empty);
98-
}
9982
}

0 commit comments

Comments
 (0)