Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
49 commits
Select commit Hold shift + click to select a range
c498afc
feat(localization): request localization, localized errors and user l…
marcelo-maciel Aug 17, 2026
7962ea4
feat(localization): localize module messages and validators
marcelo-maciel Aug 17, 2026
28c3aaa
fix(localization): resolve exception bodies under the negotiated requ…
marcelo-maciel Sep 8, 2026
92c62f7
build(deps): bump Testcontainers to 4.14.0 and SourceLink past their …
marcelo-maciel Sep 14, 2026
1762333
build(deps): bump Testcontainers to 4.14.0 and SourceLink past their …
marcelo-maciel Sep 14, 2026
faf8165
fix(infra): pull MinIO from quay.io on a pinned tag, not Docker Hub
marcelo-maciel Sep 14, 2026
560111e
fix(infra): pull MinIO from quay.io on a pinned tag, not Docker Hub
marcelo-maciel Sep 14, 2026
68ba4a9
fix(identity): treat a blank profile locale as absent, not as a value
marcelo-maciel Sep 17, 2026
9ee0dcb
Merge remote-tracking branch 'fork/feat/i18n-framework' into pr1382
marcelo-maciel Sep 18, 2026
8dd8121
test(identity): pin the UI culture so validator assertions stop depen…
marcelo-maciel Sep 18, 2026
06abc19
test(localization): pin the UI culture in the three remaining assemblies
marcelo-maciel Sep 18, 2026
a519a26
fix(migrations): order AddUserLocale after the migrations already on …
marcelo-maciel Sep 18, 2026
9fdc469
test(framework): gate placeholder parity between the resx catalogs
marcelo-maciel Sep 18, 2026
c0a2244
refactor(framework): freeze the supported-culture whitelist
marcelo-maciel Sep 18, 2026
f18b072
feat(identity): localize the 401 challenge body
marcelo-maciel Sep 18, 2026
b3819d5
Merge remote-tracking branch 'fork/feat/i18n-framework' into pr1382
marcelo-maciel Sep 18, 2026
3a6b9ad
fix(localization): translate the enum arguments inside localized mess…
marcelo-maciel Sep 18, 2026
182d022
test(architecture): pin the premise Audit.RealExceptionType depends on
marcelo-maciel Sep 18, 2026
fab2509
fix(infra): pull minio/mc from quay.io too, not just minio/minio
marcelo-maciel Sep 18, 2026
d86967d
fix(infra): pull minio/mc from quay.io too, not just minio/minio
marcelo-maciel Sep 18, 2026
4fc3339
build(deps): drop the dead SSH.NET pin
marcelo-maciel Sep 18, 2026
88c0052
build(deps): drop the dead SSH.NET pin
marcelo-maciel Sep 18, 2026
a4dfa82
ci: run Tickets.Tests in the unit-test job
marcelo-maciel Sep 18, 2026
b3e10ee
Merge origin/main into feat/i18n-framework
marcelo-maciel Sep 25, 2026
29c23ea
Merge feat/i18n-framework into feat/i18n-modules
marcelo-maciel Sep 25, 2026
bb40738
test(localization): drop the claim that Notifications has no test pro…
marcelo-maciel Sep 25, 2026
2244291
Merge branch 'main' into feat/i18n-framework
marcelo-maciel Sep 25, 2026
238c2c5
Merge branch 'main' into feat/i18n-modules
marcelo-maciel Sep 25, 2026
aac9246
fix(api): let the container create the cultures request localization …
marcelo-maciel Sep 25, 2026
873c8b6
feat(localization): localize the user-facing errors main added since …
marcelo-maciel Sep 25, 2026
6d54b6a
docs(rules): name the admin language key the app actually uses
marcelo-maciel Sep 25, 2026
951658b
Merge feat/i18n-framework into feat/i18n-modules
marcelo-maciel Sep 25, 2026
c00abcf
Merge origin/main into feat/i18n-framework
marcelo-maciel Sep 25, 2026
c270460
test(identity): drop a null-forgiving operator ShouldNotBeNull alread…
marcelo-maciel Sep 25, 2026
014ee57
feat(localization): localize the confirm-email success message
marcelo-maciel Sep 25, 2026
d3d8b8a
Merge feat/i18n-framework into feat/i18n-modules
marcelo-maciel Sep 25, 2026
b3f7170
Merge branch 'main' into feat/i18n-framework
marcelo-maciel Sep 26, 2026
ce96011
Merge branch 'main' into feat/i18n-modules
marcelo-maciel Sep 26, 2026
d820230
Merge remote-tracking branch 'origin/main' into feat/i18n-modules
marcelo-maciel Oct 2, 2026
ef050ce
test(localization): allow a missing pt-BR entry, fail only on orphans…
marcelo-maciel Oct 2, 2026
c8180b5
Merge branch 'feat/i18n-framework' into feat/i18n-modules
marcelo-maciel Oct 2, 2026
4209d22
test(localization): let module catalogs omit pt-BR entries
marcelo-maciel Oct 2, 2026
9c327c6
Merge remote-tracking branch 'origin/main' into feat/i18n-modules
marcelo-maciel Oct 14, 2026
00af8d7
build(deps): move the .NET 10 packages to 10.0.13 for the October adv…
marcelo-maciel Oct 14, 2026
3e6113b
refactor(localization): attach exception keys through a one-line factory
marcelo-maciel Oct 14, 2026
22dab78
test(localization): fail the build on a key missing from its catalog
marcelo-maciel Oct 14, 2026
79c8c66
docs(agents): scaffold localized throws, validators and module catalogs
marcelo-maciel Oct 14, 2026
96ff906
fix(localization): localize the throws main added after the slice
marcelo-maciel Oct 14, 2026
c64585c
test(localization): harden the key guard after review
marcelo-maciel Oct 14, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .agents/rules/api-conventions.md
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,9 @@ Throw framework exception types; the global handler converts to RFC 9457 `Proble
| `UnauthorizedException` | 401 |
| `CustomException(msg, errors?, HttpStatusCode)` | as specified (default 400) |

Localize every client-visible throw: English text as the message (logs), key attached with
`.Localized<{X}Resources>("{X}.Case", args)` or `.Localized("Error.Case")` for a Core key. See `localization.md`.

Don't catch broadly to swallow. Background loops may `catch (Exception)` to stay alive, but must **log with context** and exclude `OperationCanceledException` (filtered catch or a preceding `catch (OperationCanceledException)`).

## Permissions
Expand Down
20 changes: 8 additions & 12 deletions .agents/rules/localization.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,24 +23,20 @@ Key naming: `Error.<Module>.<Case>` for domain messages (`Catalog.ProductNotFoun

## Exceptions — localize at the boundary, log stays English

Throw with the **English message** as `Exception.Message` (used for logs and fallback) plus the resource key metadata. **Never** pre-localize the message at the throw site.
Throw with the **English message** as `Exception.Message` (used for logs and fallback) and attach the resource key with `.Localized(...)` on any `CustomException` subclass. **Never** pre-localize the message at the throw site.

```csharp
// domain message -> module catalog
// domain message -> module catalog (the marker type names the catalog)
throw new NotFoundException($"Product {id} not found.")
{
MessageKey = "Catalog.ProductNotFound",
MessageArgs = [id],
ResourceSource = typeof(CatalogResources),
};
.Localized<CatalogResources>("Catalog.ProductNotFound", id);

// cross-cutting message -> Core catalog (ResourceSource omitted = SharedResources)
// cross-cutting message -> Core catalog (no type argument = SharedResources)
throw new UnauthorizedException("Tenant context is required.")
{
MessageKey = "Error.TenantContextRequired",
};
.Localized("Error.TenantContextRequired");
```

`.Localized` returns the same instance, so `throw` keeps the subclass and its status code. Its arguments are `params object[]`: pass a list as one string (`string.Join(", ", ids)`), because a `string[]` argument binds as the whole argument array and only its first item reaches `{0}`; never pass a `null` array. `MessageKey`, `MessageArgs` and `ResourceSource` have private setters: `.Localized` is the only way to set them. The BCL wrappers (`LocalizedKeyNotFoundException`, `LocalizedUnauthorizedAccessException`) do not derive from `CustomException` and keep the `{ MessageKey = …, ResourceSource = … }` initializer.

`GlobalExceptionHandler` resolves `Title` (by status) and `Detail` (via `MessageKey` + `ResourceSource`) under the request culture, and falls back to `Exception.Message` when the key is missing (`ResourceNotFound`) or malformed (`FormatException`). Migration is therefore incremental: an un-migrated `throw new NotFoundException("...")` still renders its English literal.

**Do NOT** set `ProblemDetails` from a localized string in logs — the handler logs `Exception.Message` (English) and the type name, never the translated body.
Expand Down Expand Up @@ -69,7 +65,7 @@ Always the `.WithMessage(_ => localizer["Key"])` lambda (resolution is deferred
## Tests (required with every catalog change)

- **Parity** — add every new key to the neutral (English) catalog; the `pt-BR` entry is optional, and a key it lacks falls back to English at runtime, so nobody has to write Portuguese to get a green build. The build fails only on a `pt-BR` key the neutral catalog does not have (an orphan, usually a renamed or deleted key) or on a `pt-BR` value whose `{n}` placeholders differ from the English one. Applies to Core and every `<Module>Resources`. Per-catalog tests live in each module's test project; `CatalogParityTests` in `Architecture.Tests` enumerates every module catalog generically, so a **new** module catalog is covered without adding a test.
- **Code → resx guard** — every referenced key (`MessageKey`, `localizer["…"]`) must exist in its catalog, or the build fails. This is what catches a forgotten/typo `ResourceSource` (which would otherwise fall back silently).
- **Code → resx guard** — `LocalizedKeyReferenceTests` (`Architecture.Tests`) scans `src/BuildingBlocks` and `src/Modules` for every literal key passed to `.Localized<T>(…)`, `.Localized(…)`, a wrapper's `MessageKey = "…"` and `localizer["…"]`, and fails when the key is missing from the neutral catalog it targets or when the entry's `{n}` placeholders need more arguments than the call passes. This is what catches a typo or the wrong catalog, which would otherwise fall back to English (or show the raw key) silently. It resolves a `…localizer` / `…Localizer` name through the file's `IStringLocalizer<T>` declaration, so a localizer of unknown type fails too; a localizer field named otherwise is not scanned. Only string-literal keys are checked: a `.Localized(...)` call whose key is a variable fails until the site is listed in `NonLiteralKeySites`, and an indexer with a variable key (`localizer[titleKey]`) is skipped entirely, so keep those lookups few and covered by a test of their own.
- Build validators/handlers with a real localizer from the embedded catalog via `SharedResourcesLocalizerFactory.Create()` (test-project `Support/` helper), not a stub.

## Emails / background handlers
Expand Down
28 changes: 28 additions & 0 deletions .agents/skills/add-entity/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,33 @@ public sealed class {Entity} : AggregateRoot<Guid>, IHasTenant, IAuditableEntity
Notes: setters are `private set`; `TenantId`/audit/soft-delete members are settable by the framework
(interceptor + Finbuckle) so they aren't `private set`. Use `Guid.CreateVersion7()`, never `Guid.NewGuid()`.

## Business-rule violations — localized `CustomException`

`ArgumentException` guards are for programming errors (they surface as a 500). A rule a client can
break (an illegal state transition, a duplicate) throws a `CustomException` with the matching status,
English text as the message (for logs) and the key attached with `.Localized`:

```csharp
public void Archive()
{
if (Status == {Entity}Status.Archived)
{
throw new CustomException(
$"{Entity} {Id} is already archived.",
errors: null,
HttpStatusCode.Conflict)
.Localized<{X}Resources>("{X}.{Entity}AlreadyArchived", Id);
}

Status = {Entity}Status.Archived;
}
```

Add the key in English to `src/Modules/{X}/Modules.{X}/Localization/{X}Resources.resx` (`{0}` for each
argument, in order); the pt-BR entry is optional. Enum arguments are translated when the catalog has a
`{EnumType}.{Member}` entry. `LocalizedKeyReferenceTests` fails the build on a missing key. Rules:
`.agents/rules/localization.md`.

## Domain event — inherit `DomainEvent` (abstract record)

```csharp
Expand Down Expand Up @@ -121,5 +148,6 @@ dotnet ef migrations add Add{Entity} \

- [ ] `sealed`, `AggregateRoot<Guid>` (+ `IHasTenant`/`IAuditableEntity`/`ISoftDeletable` as needed), private ctor, static `Create` using `Guid.CreateVersion7()`
- [ ] Domain event inherits `DomainEvent`; raised via `DomainEvent.Create` + `AddDomainEvent`
- [ ] Client-breakable rules throw `CustomException(...).Localized<{X}Resources>("…")`; keys added in English to `{X}Resources.resx`
- [ ] EF config: no manual soft-delete/tenant filter; `ValueGeneratedNever()` on nav-collection children
- [ ] `DbSet` added; build green; migration created with `--context {X}DbContext`
34 changes: 30 additions & 4 deletions .agents/skills/add-feature/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -57,17 +57,41 @@ public sealed class Create{Entity}CommandHandler(CatalogDbContext dbContext)
}
```

Throw `NotFoundException` / `CustomException(msg, errors, HttpStatusCode)` (`FSH.Framework.Core.Exceptions`) — the global handler maps them to ProblemDetails.
Throw `NotFoundException` / `CustomException(msg, errors, HttpStatusCode)` (`FSH.Framework.Core.Exceptions`) — the global handler maps them to ProblemDetails. **Every throw a client can see is localized**: keep the English text as the message (it is what the logs show) and attach the key with `.Localized`:

```csharp
var entity = await dbContext.{Entities}.FindAsync([command.Id], cancellationToken).ConfigureAwait(false)
?? throw new NotFoundException($"{Entity} {command.Id} not found.")
.Localized<{X}Resources>("{X}.{Entity}NotFound", command.Id);

// generic message already in the Core catalog: no type argument
throw new UnauthorizedException("No current user.").Localized("Error.NoCurrentUser");
```

## Step 2b — Add the keys to the module catalog

Add each new key to `src/Modules/{X}/Modules.{X}/Localization/{X}Resources.resx` in **English**, with `{0}`, `{1}` for the arguments in the order you pass them. The `{X}Resources.pt-BR.resx` entry is optional: a missing translation falls back to English. Reuse a `SharedResources` key (`Error.*`, `Validation.*`) instead of duplicating a generic message.

```xml
<data name="{X}.{Entity}NotFound" xml:space="preserve">
<value>{Entity} {0} was not found.</value>
</data>
```

`LocalizedKeyReferenceTests` fails the build if a key is missing from the catalog you named, or if its placeholders need more arguments than you passed. Rules: `.agents/rules/localization.md`.

## Step 3 — Validator (required; same folder)

Inject the module's localizer and resolve each custom message lazily with `.WithMessage(_ => localizer["…"])`. Built-in FluentValidation messages (`NotEmpty`, `MaximumLength`, …) come translated from FluentValidation's own language pack under the request culture and need no key (the property name inside them stays as declared).

```csharp
public sealed class Create{Entity}CommandValidator : AbstractValidator<Create{Entity}Command>
{
public Create{Entity}CommandValidator()
public Create{Entity}CommandValidator(IStringLocalizer<{X}Resources> localizer)
{
RuleFor(x => x.Name).NotEmpty().MaximumLength(200);
RuleFor(x => x.PriceCurrency).NotEmpty().Length(3);
RuleFor(x => x.PriceCurrency).NotEmpty().Length(3)
.WithMessage(_ => localizer["Validation.{Entity}CurrencyCode"]);
}
}
```
Expand Down Expand Up @@ -107,7 +131,9 @@ dotnet test src/Tests/{X}.Tests # + add a handler/validator test (see

- [ ] Command/Query in the **Contracts** project (`using Mediator;`), DTOs in `Contracts/Dtos/`
- [ ] Handler `public sealed`, injects `{X}DbContext` (no repository), `ValueTask<T>` + `.ConfigureAwait(false)`
- [ ] `{Name}Validator` exists
- [ ] Every client-visible throw ends in `.Localized<{X}Resources>("…")` (or `.Localized("…")` for a Core key); English text kept as the message
- [ ] New keys added in English to `{X}Resources.resx` (pt-BR optional); `LocalizedKeyReferenceTests` green
- [ ] `{Name}Validator` exists; custom messages via `IStringLocalizer<{X}Resources>` + `.WithMessage(_ => localizer["…"])`
- [ ] Endpoint `internal static …Map{Feature}Endpoint`, `.RequirePermission(...)`, `.WithName/.WithSummary`
- [ ] Wired in `{X}Module.MapEndpoints`
- [ ] Build 0 warnings; test added
5 changes: 3 additions & 2 deletions .agents/skills/add-full-slice/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,8 @@ the order of operations and the **contract** that keeps the two halves in sync.
1. **Backend slice** — `add-feature` (and `add-entity` first if a new entity is needed):
- Command/Query + response DTO in `Modules.{X}.Contracts/v1/{Area}/` (+ `Contracts/Dtos/`).
- Handler (`public sealed`, injects `{X}DbContext`), Validator, Endpoint (`internal static Map…Endpoint`, `.RequirePermission(...)`).
- Wire in `{X}Module.MapEndpoints`. Build + test backend green.
- Localized errors: every client-visible throw ends in `.Localized<{X}Resources>("…", args)`, validator messages go through `IStringLocalizer<{X}Resources>`, and each new key is added in English to `{X}Resources.resx` (pt-BR optional). The API then answers in the caller's language, so the page shows `problem.detail` as is and never re-translates it.
- Wire in `{X}Module.MapEndpoints`. Build + test backend green (`LocalizedKeyReferenceTests` included).
2. **Lock the contract** — note the final **route path**, HTTP method, request shape, and response DTO field names/casing. The React side must match these exactly.
3. **Frontend page** — `add-react-page` in the chosen app:
- API module calls the **same path**; hand-write TS types mirroring the **response DTO** (the API serializes C# records as camelCase JSON — TS fields are camelCase even though admin *query params* are PascalCase).
Expand Down Expand Up @@ -43,7 +44,7 @@ cd clients/{app} && npm run lint && npm run test:e2e

## Checklist

- [ ] Backend slice complete + green (`add-feature`)
- [ ] Backend slice complete + green (`add-feature`), throws and validator messages localized with keys in `{X}Resources.resx`
- [ ] Contract locked: route, request shape, response DTO field names
- [ ] Frontend api module path + TS types match the contract (body JSON camelCase)
- [ ] Page + route added (`add-react-page`); admin permission mirrored + gated (`add-permission`)
Expand Down
31 changes: 31 additions & 0 deletions .agents/skills/add-module/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,36 @@ public sealed class {Name}DbContext : BaseDbContext
}
```

## Step 3b — Localization catalog (module-owned messages)

Every client-visible message the module throws or validates lives in its own resx catalog, so the
module ships localized from its first feature. Mirror `Modules.Tickets/Localization/`:

```csharp
// src/Modules/{Name}/Modules.{Name}/Localization/{Name}Resources.cs
namespace FSH.Modules.{Name}.Localization;

/// <summary>Marker type binding <c>IStringLocalizer&lt;{Name}Resources&gt;</c> to the {Name} resx catalog.</summary>
public sealed class {Name}Resources;
```

- `Localization/{Name}Resources.resx` next to the marker: the neutral (English) catalog. Copy
`TicketsResources.resx` and delete its `<data>` entries. `Localization/{Name}Resources.pt-BR.resx`
is optional; a missing translation falls back to English.
- The embedded resource must be named after the marker's full name
(`FSH.Modules.{Name}.Localization.{Name}Resources`). Because each resx sits next to a `.cs` file of the
same base name, the SDK names it after the class declared in that file
(`EmbeddedResourceUseDependentUponConvention`), so keep the marker's class name equal to its file
name; no `<EmbeddedResource>` item is needed.
- Add `S2094` to the runtime csproj's `<NoWarn>` (the marker is an empty class on purpose).
- No per-module registration: `AddHeroLocalization` in the host serves every catalog.

Throws use `.Localized<{Name}Resources>("{Name}.Case", args)` and validators inject
`IStringLocalizer<{Name}Resources>` (see **add-feature**). Once the module is referenced from
`FSH.Starter.Api` (Step 4), `Architecture.Tests` picks the catalog up on its own: `CatalogParityTests`
checks the pt-BR file against the neutral one and `LocalizedKeyReferenceTests` fails on any key the
code uses that the catalog lacks, which also catches a wrong manifest name. Rules: `.agents/rules/localization.md`.

## Step 4 — Solution + project references

```bash
Expand Down Expand Up @@ -132,6 +162,7 @@ dotnet test src/FSH.Starter.slnx
- [ ] `IModule`: `AddHeroDbContext<T>()`, `PermissionConstants.Register`, version-set group, eventing trio if needed
- [ ] `{Name}DbContext : BaseDbContext`, 4-arg ctor, `base.OnModelCreating` last
- [ ] `{Name}Permissions` in Contracts/Authorization
- [ ] `Localization/{Name}Resources.cs` marker + `{Name}Resources.resx` (English; pt-BR optional), `S2094` in `<NoWarn>`
- [ ] Migrations folder + initial migration (`--context {Name}DbContext`)
- [ ] **Registered in all four places** (Api + DbMigrator × Mediator + moduleAssemblies)
- [ ] Build + Architecture.Tests green
3 changes: 2 additions & 1 deletion .agents/skills/query-patterns/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -82,7 +82,8 @@ public sealed class Get{Entity}QueryHandler({X}DbContext dbContext)
ArgumentNullException.ThrowIfNull(query);
var entity = await dbContext.{Entities}.AsNoTracking()
.FirstOrDefaultAsync(x => x.Id == query.Id, cancellationToken).ConfigureAwait(false)
?? throw new NotFoundException($"{Entity} {query.Id} not found");
?? throw new NotFoundException($"{Entity} {query.Id} not found")
.Localized<{X}Resources>("{X}.{Entity}NotFound", query.Id); // key in {X}Resources.resx
return entity.ToDto();
}
}
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/backend.yml
Original file line number Diff line number Diff line change
Expand Up @@ -96,7 +96,7 @@ jobs:

- name: Run unit tests with coverage
run: |
for proj in Architecture Auditing Caching Generic Identity Multitenancy Billing Catalog Chat Files Framework Notifications Webhooks; do
for proj in Architecture Auditing Caching Generic Identity Multitenancy Billing Catalog Chat Files Framework Notifications Tickets Webhooks; do
echo "::group::${proj}.Tests"
dotnet test "src/Tests/${proj}.Tests" -c Release --no-build \
--collect:"XPlat Code Coverage" --settings coverage.runsettings \
Expand Down
46 changes: 43 additions & 3 deletions src/BuildingBlocks/Core/Exceptions/CustomException.cs
Original file line number Diff line number Diff line change
Expand Up @@ -23,19 +23,59 @@ public class CustomException : Exception, ILocalizableMessage
/// Optional resource key resolved against <see cref="ResourceSource"/> to localize the
/// response body under the request culture. When null, the literal <see cref="Exception.Message"/>
/// is used. The message itself always stays the (English) fallback for logs.
/// Set through <see cref="Localized{TResource}(string, object[])"/> or <see cref="Localized(string, object[])"/>.
/// </summary>
public string? MessageKey { get; init; }
public string? MessageKey { get; private set; }

/// <summary>
/// Format arguments applied to the localized message ({0}, {1}, …).
/// </summary>
public IReadOnlyList<object> MessageArgs { get; init; } = [];
public IReadOnlyList<object> MessageArgs { get; private set; } = [];

/// <summary>
/// Marker type identifying the resource catalog for <see cref="MessageKey"/>. When null,
/// the shared (Core) catalog is used; module-specific keys point it at the module's own catalog.
/// </summary>
public Type? ResourceSource { get; init; }
public Type? ResourceSource { get; private set; }

/// <summary>
/// Localizes the response body from the catalog of <typeparamref name="TResource"/> (a module's
/// marker, e.g. <c>IdentityResources</c>), keeping the English message as the log fallback:
/// <c>throw new NotFoundException($"User {id} not found.").Localized&lt;IdentityResources&gt;("Identity.UserNotFound", id);</c>
/// </summary>
/// <typeparam name="TResource">Marker type of the resource catalog that declares <paramref name="key"/>.</typeparam>
/// <param name="key">Resource key; must exist in the neutral catalog (enforced by Architecture.Tests).</param>
/// <param name="args">Format arguments for the localized message's {0}, {1}, … placeholders.</param>
/// <returns>This exception, so the call chains onto <c>throw new ...</c>.</returns>
// The setters are private rather than init so this is the single way to attach a key: the
// analyzers reject an init property with a hand-written backing field (S2292), and two
// syntaxes for the same thing would let half the codebase drift back to the long form.
public CustomException Localized<TResource>(string key, params object[] args)
{
ArgumentException.ThrowIfNullOrWhiteSpace(key);
ArgumentNullException.ThrowIfNull(args);
MessageKey = key;
MessageArgs = args;
ResourceSource = typeof(TResource);
return this;
}

/// <summary>
/// Localizes the response body from the shared (Core) catalog, for the generic keys every
/// module uses (e.g. <c>Error.NoCurrentUser</c>).
/// </summary>
/// <param name="key">Resource key; must exist in the shared neutral catalog (enforced by Architecture.Tests).</param>
/// <param name="args">Format arguments for the localized message's {0}, {1}, … placeholders.</param>
/// <returns>This exception, so the call chains onto <c>throw new ...</c>.</returns>
public CustomException Localized(string key, params object[] args)
{
ArgumentException.ThrowIfNullOrWhiteSpace(key);
ArgumentNullException.ThrowIfNull(args);
MessageKey = key;
MessageArgs = args;
ResourceSource = null;
return this;
}

/// <summary>
/// Initializes a new instance of the <see cref="CustomException"/> class with default message and internal server error status.
Expand Down
Loading
Loading