Anti-Corruption Layer
Definition
Section titled “Definition”The Anti-Corruption Layer (ACL) isolates the internal domain model from external models (third-party APIs, SDKs, legacy formats). A translation layer converts external DTOs into Granit domain types, preventing foreign concepts from “polluting” the framework core.
Diagram
Section titled “Diagram”flowchart LR
subgraph External["External services"]
KC["Keycloak Admin API"]
S3["AWS S3 SDK"]
BR["Brevo API"]
FCM["Firebase FCM"]
MK["MailKit / SMTP"]
end
subgraph ACL["Anti-Corruption Layer"]
KC_DTO["KeycloakUserRepresentation"]
KC_MAP["ToIdentityUser()"]
S3_ADP["S3BlobClient"]
BR_ADP["BrevoNotificationProvider"]
FCM_ADP["GoogleFcmMobilePushSender"]
MK_ADP["MailKitEmailSender"]
end
subgraph Domain["Granit domain model"]
IU["IdentityUser"]
BD["BlobDescriptor"]
EM["EmailMessage"]
PM["MobilePushMessage"]
end
KC --> KC_DTO --> KC_MAP --> IU
S3 --> S3_ADP --> BD
BR --> BR_ADP --> EM
FCM --> FCM_ADP --> PM
MK --> MK_ADP --> EM
style External fill:#fef0f0,stroke:#c44e4e
style ACL fill:#fef3e0,stroke:#e8a317
style Domain fill:#e8fde8,stroke:#2d8a4e
Implementation in Granit
Section titled “Implementation in Granit”Keycloak — the most comprehensive case
Section titled “Keycloak — the most comprehensive case”Granit.Identity.Keycloak is the framework’s canonical ACL. Keycloak responses
are deserialized into internal DTOs (KeycloakUserRepresentation,
KeycloakSessionRepresentation, etc.) then converted to domain models via
private static methods:
// External DTO (internal, never exposed)internal sealed record KeycloakUserRepresentation( [property: JsonPropertyName("id")] string Id, [property: JsonPropertyName("username")] string Username, [property: JsonPropertyName("email")] string? Email, // ...);
// Conversion to domain modelprivate static IdentityUser ToIdentityUser(KeycloakUserRepresentation user) => new(user.Id, user.Username, user.Email, user.FirstName, user.LastName, user.Enabled, FlattenAttributes(user.Attributes));Specifics:
FlattenAttributes()— converts KeycloakDictionary(string, List(string))to GranitDictionary(string, string)(multi-value attributes to single value)ToIdentitySession()— converts Unix timestamps (milliseconds) toDateTimeOffsetToIdentityGroup()— recursive mapping for subgroups
Claims transformation (JWT)
Section titled “Claims transformation (JWT)”KeycloakClaimsTransformation and EntraIdClaimsTransformation extract roles
from proprietary JSON structures (realm_access.roles,
resource_access.{client}.roles) and convert them to standard .NET
ClaimTypes.Role claims.
ACL inventory in Granit
Section titled “ACL inventory in Granit”| External service | ACL package | External DTO to internal model |
|---|---|---|
| Keycloak Admin API | Granit.Identity.Keycloak | KeycloakUserRepresentation to IdentityUser |
| Keycloak JWT | Granit.Authentication.Keycloak | JSON realm_access to ClaimTypes.Role |
| Entra ID JWT | Granit.Authentication.EntraId | JSON roles (v1.0/v2.0) to ClaimTypes.Role |
| AWS S3 SDK | Granit.BlobStorage.S3 | GetPreSignedUrlRequest from BlobUploadRequest |
| MailKit SMTP | Granit.Notifications.Email.Smtp | MimeMessage from EmailMessage |
| Brevo API | Granit.Notifications.Brevo | JSON payload from EmailMessage / SmsMessage / WhatsAppMessage |
| Firebase FCM | Granit.Notifications.MobilePush.GoogleFcm | FcmPayload from MobilePushMessage |
| ImageMagick | Granit.Imaging.MagickNet | MagickFormat to/from ImageFormat (bidirectional) |
| Import systems | Granit.DataExchange.EntityFrameworkCore | External ID (Odoo __export__) to internal Entity ID |
Architectural principles
Section titled “Architectural principles”- External DTOs are
internal— never exposed outside the adapter package - Conversion methods are
private static— isolated from the rest of the code - Error transformation — external errors are parsed and encapsulated in domain exceptions
- Graceful degradation — reads log a warning and return
null; writes propagate the exception [ExcludeFromCodeCoverage]— on adapters requiring a live service (S3, SMTP)
Reference files
Section titled “Reference files”| File | Role |
|---|---|
src/Granit.Identity.Keycloak/Internal/KeycloakIdentityProvider.cs | Primary ACL (9 conversion methods) |
src/Granit.Identity.Keycloak/Internal/KeycloakUserRepresentation.cs | Keycloak external DTO |
src/Granit.Authentication.Keycloak/Authentication/KeycloakClaimsTransformation.cs | JWT claims to Role |
src/Granit.BlobStorage.S3/Internal/S3BlobClient.cs | S3 adapter |
src/Granit.Notifications.Email.Smtp/Internal/MailKitEmailSender.cs | SMTP adapter |
src/Granit.Notifications.Brevo/Internal/BrevoNotificationProvider.cs | Multi-channel Brevo |
src/Granit.Notifications.MobilePush.GoogleFcm/Internal/GoogleFcmMobilePushSender.cs | FCM adapter |
src/Granit.Imaging.MagickNet/Internal/MagickFormatMapper.cs | Image format mapping |
Rationale
Section titled “Rationale”| Problem | ACL solution |
|---|---|
| Keycloak models in the domain = tight coupling | internal DTOs + static conversion |
| Keycloak API change = impact across the entire codebase | Only the adapter changes, the domain remains stable |
| JWT claims format differs between Keycloak and Entra ID | Dedicated transformations, unified ClaimTypes.Role model |
| Multi-value Keycloak attributes vs single-value Granit | FlattenAttributes() in the ACL |
Unix timestamps (ms) in Keycloak vs DateTimeOffset in .NET | Conversion in ToIdentitySession() |
Usage example
Section titled “Usage example”// The endpoint only knows the IdentityUser domain model,// never the Keycloak DTOs
private static async Task<Results<Ok<IdentityUser>, NotFound>> GetUserAsync( string userId, IIdentityProvider identityProvider, CancellationToken cancellationToken){ // IIdentityProvider is implemented by KeycloakIdentityProvider // which does: API call > KeycloakUserRepresentation > ToIdentityUser() IdentityUser? user = await identityProvider .FindByIdAsync(userId, cancellationToken) .ConfigureAwait(false);
return user is not null ? TypedResults.Ok(user) : TypedResults.NotFound();}