Skip to content

Anti-Corruption Layer

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.

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

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 model
private static IdentityUser ToIdentityUser(KeycloakUserRepresentation user) =>
new(user.Id, user.Username, user.Email, user.FirstName, user.LastName,
user.Enabled, FlattenAttributes(user.Attributes));

Specifics:

  • FlattenAttributes() — converts Keycloak Dictionary(string, List(string)) to Granit Dictionary(string, string) (multi-value attributes to single value)
  • ToIdentitySession() — converts Unix timestamps (milliseconds) to DateTimeOffset
  • ToIdentityGroup() — recursive mapping for subgroups

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.

External serviceACL packageExternal DTO to internal model
Keycloak Admin APIGranit.Identity.KeycloakKeycloakUserRepresentation to IdentityUser
Keycloak JWTGranit.Authentication.KeycloakJSON realm_access to ClaimTypes.Role
Entra ID JWTGranit.Authentication.EntraIdJSON roles (v1.0/v2.0) to ClaimTypes.Role
AWS S3 SDKGranit.BlobStorage.S3GetPreSignedUrlRequest from BlobUploadRequest
MailKit SMTPGranit.Notifications.Email.SmtpMimeMessage from EmailMessage
Brevo APIGranit.Notifications.BrevoJSON payload from EmailMessage / SmsMessage / WhatsAppMessage
Firebase FCMGranit.Notifications.MobilePush.GoogleFcmFcmPayload from MobilePushMessage
ImageMagickGranit.Imaging.MagickNetMagickFormat to/from ImageFormat (bidirectional)
Import systemsGranit.DataExchange.EntityFrameworkCoreExternal ID (Odoo __export__) to internal Entity ID
  1. External DTOs are internal — never exposed outside the adapter package
  2. Conversion methods are private static — isolated from the rest of the code
  3. Error transformation — external errors are parsed and encapsulated in domain exceptions
  4. Graceful degradation — reads log a warning and return null; writes propagate the exception
  5. [ExcludeFromCodeCoverage] — on adapters requiring a live service (S3, SMTP)
FileRole
src/Granit.Identity.Keycloak/Internal/KeycloakIdentityProvider.csPrimary ACL (9 conversion methods)
src/Granit.Identity.Keycloak/Internal/KeycloakUserRepresentation.csKeycloak external DTO
src/Granit.Authentication.Keycloak/Authentication/KeycloakClaimsTransformation.csJWT claims to Role
src/Granit.BlobStorage.S3/Internal/S3BlobClient.csS3 adapter
src/Granit.Notifications.Email.Smtp/Internal/MailKitEmailSender.csSMTP adapter
src/Granit.Notifications.Brevo/Internal/BrevoNotificationProvider.csMulti-channel Brevo
src/Granit.Notifications.MobilePush.GoogleFcm/Internal/GoogleFcmMobilePushSender.csFCM adapter
src/Granit.Imaging.MagickNet/Internal/MagickFormatMapper.csImage format mapping
ProblemACL solution
Keycloak models in the domain = tight couplinginternal DTOs + static conversion
Keycloak API change = impact across the entire codebaseOnly the adapter changes, the domain remains stable
JWT claims format differs between Keycloak and Entra IDDedicated transformations, unified ClaimTypes.Role model
Multi-value Keycloak attributes vs single-value GranitFlattenAttributes() in the ACL
Unix timestamps (ms) in Keycloak vs DateTimeOffset in .NETConversion in ToIdentitySession()
// 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();
}