Skip to content

Idempotency Nuget Package #106

Description

@goodtocode

Goal: Turn the included implementation into a universal nuget package to support idempotentcy

NuGet Extraction Plan for Universal Idempotency (Portable)

  1. Split into package layers
    Create 3 packages to keep dependencies clean:
  1. Goodtocode.Idempotency.Abstractions
    • IIdempotentRequest
    • IIdempotencyRequestMetadata
    • IIdempotencyDuplicateWindowPolicy
    • IdempotencyDefaults
    • IdempotencyData
  2. Goodtocode.Idempotency.Mediator
    • IdempotencyBehavior<TRequest,TResponse>
    • Optional mediator registration extensions
  3. Goodtocode.Idempotency.EFCore
    • RequestIdempotencyEntity (or storage model interface + default EF model)
    • EF configuration
    • optional migrations helper
    This avoids forcing EF or Mediator dependencies into all consumers.
  1. Replace solution-specific seams with interfaces
    Abstract these seams into Abstractions package:
    • IRequestIdempotencyStore (lookup/save/replay)
    • IUserScopeAccessor (OwnerId/TenantId or equivalent scope)
    • IResponseSerializer (default System.Text.Json implementation)
    IdempotencyBehavior should depend only on these interfaces, not your concrete DbContext.
  2. Generalize scope model
    Current (TenantId, OwnerId, OperationKey, IdempotencyKey) is good.
    Make scope naming neutral:
    • PartitionKey1, PartitionKey2 (or ScopeTenant, ScopePrincipal)
    • ScopeId optional for aggregate-local dedupe
    • keep OperationKey, RequestHash, ResponseType, ResponsePayload
  3. Define operation key strategy
    Do not hardcode operation constants. Use pluggable strategy:
    • default: typeof(TRequest).FullName
    • optional override via interface/property/attribute
    • optional HTTP strategy: {method}:{route-template}
  4. Provide framework adapters
    Ship optional adapters (separate packages if needed):
    • ASP.NET Core adapter:
    • reads Idempotency-Key header
    • writes into request envelope/context
    • HTTP client adapter:
    • stamps Idempotency-Key on unsafe verbs
    • EF Core adapter:
    • store implementation + indexes
  5. Standardize duplicate-window policy
    Keep duplicate-window optional and policy-based:
    • default policy: no semantic window
    • metadata-based policy: reads IIdempotencyRequestMetadata.DuplicateWindow
    • allow app-level policy override registration
  6. Add package registration extensions
    Provide one-line setup per layer:
    • services.AddIdempotencyAbstractions()
    • services.AddIdempotencyMediator()
    • services.AddIdempotencyEfCore()
    • services.AddIdempotencyAspNetCore() (optional)
    • services.AddIdempotencyHttpClient() (optional)
  7. Document invariants and compatibility
    Publish explicit rules:
    • replay only when ResponseType matches
    • unsafe method retry guidance
    • idempotency key uniqueness boundary
    • serializer versioning expectations
    • TTL/cleanup expectations for store records
  8. Add portability test matrix
    Before publish, add tests for:
    • duplicate key replay
    • concurrent race on same key
    • cross-instance replay (shared DB)
    • metadata duplicate window behavior
    • behavior with/without HTTP adapters
    • serializer backward compatibility
  9. Migration + rollout plan
    Adopt incrementally:
  1. Introduce package interfaces in current app
  2. Swap concrete behavior to package behavior
  3. Move EF store to package
  4. Move HTTP adapters
  5. Remove old in-repo copies
  6. Publish prerelease NuGet
  7. Validate in second solution
  8. Publish stable

Current implementation

UNIVERSAL IDEMPOTENCY IMPLEMENTATION BUNDLE

FILE: Core.Application/Common/Idempotency/IIdempotentRequest.cs
PURPOSE: Marks request as idempotent and provides operation identity.

namespace Goodtocode.AgentFramework.Core.Application.Common.Idempotency;

public interface IIdempotentRequest
{
string? IdempotencyKey { get; set; }

string OperationKey { get; }

}

FILE: Core.Application/Common/Idempotency/IdempotentUserScopedRequest.cs
PURPOSE: Reusable base request for authenticated idempotent commands.

namespace Goodtocode.AgentFramework.Core.Application.Common.Idempotency;

public abstract class IdempotentUserScopedRequest : UserScopedRequest, IIdempotentRequest
{
public string? IdempotencyKey { get; set; }

public virtual string OperationKey => GetType().FullName ?? GetType().Name;

}

FILE: Core.Application/Common/Idempotency/IdempotencyDefaults.cs
PURPOSE: Shared header and key normalization.

namespace Goodtocode.AgentFramework.Core.Application.Common.Idempotency;

public static class IdempotencyDefaults
{
public const string HeaderName = "Idempotency-Key";

public static string ResolveKey(string? idempotencyKey)
    => string.IsNullOrWhiteSpace(idempotencyKey)
        ? Guid.NewGuid().ToString("N")
        : idempotencyKey.Trim();

}

FILE: Core.Application/Common/Idempotency/IIdempotencyRequestMetadata.cs
PURPOSE: Optional metadata for request-hash and duplicate-window behavior.

namespace Goodtocode.AgentFramework.Core.Application.Common.Idempotency;

public interface IIdempotencyRequestMetadata
{
string BuildRequestHash();

Guid? ScopeId { get; }

TimeSpan? DuplicateWindow { get; }

}

FILE: Core.Application/Common/Idempotency/IIdempotencyDuplicateWindowPolicy.cs
PURPOSE: Resolves duplicate window per request type.

namespace Goodtocode.AgentFramework.Core.Application.Common.Idempotency;

public interface IIdempotencyDuplicateWindowPolicy
{
TimeSpan ResolveWindow(IIdempotentRequest request);
}

FILE: Core.Application/Common/Idempotency/IdempotencyDuplicateWindowPolicy.cs
PURPOSE: Default policy uses request metadata when provided.

namespace Goodtocode.AgentFramework.Core.Application.Common.Idempotency;

public sealed class IdempotencyDuplicateWindowPolicy : IIdempotencyDuplicateWindowPolicy
{
public static readonly TimeSpan NoWindow = TimeSpan.Zero;

public TimeSpan ResolveWindow(IIdempotentRequest request)
{
    ArgumentNullException.ThrowIfNull(request);

    if (request is IIdempotencyRequestMetadata metadata
        && metadata.DuplicateWindow.HasValue
        && metadata.DuplicateWindow.Value > TimeSpan.Zero)
    {
        return metadata.DuplicateWindow.Value;
    }

    return NoWindow;
}

}

FILE: Core.Application/Common/Idempotency/IdempotencyData.cs
PURPOSE: Universal normalize/hash/serialize helpers.

using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using System.Text.RegularExpressions;

namespace Goodtocode.AgentFramework.Core.Application.Common.Idempotency;

public static partial class IdempotencyData
{
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web);

[GeneratedRegex("\\s+")]
private static partial Regex MultiWhitespaceRegex();

public static string NormalizeText(string? value)
{
    var normalized = value?.Trim() ?? string.Empty;
    if (normalized.Length == 0)
    {
        return string.Empty;
    }

    return MultiWhitespaceRegex().Replace(normalized, " ").ToLowerInvariant();
}

public static string Sha256(string value)
{
    ArgumentNullException.ThrowIfNull(value);

    var bytes = SHA256.HashData(Encoding.UTF8.GetBytes(value));
    return Convert.ToHexString(bytes);
}

public static string Serialize<T>(T value)
    => JsonSerializer.Serialize(value, JsonOptions);

public static T? Deserialize<T>(string payload)
    => string.IsNullOrWhiteSpace(payload)
        ? default
        : JsonSerializer.Deserialize<T>(payload, JsonOptions);

}

FILE: Core.Domain/Common/RequestIdempotencyEntity.cs
PURPOSE: Universal persistence record for idempotent replay and duplicate detection.

namespace Goodtocode.AgentFramework.Core.Domain.Common;

public class RequestIdempotencyEntity : SecuredEntity
{
public string OperationKey { get; private set; } = string.Empty;
public string IdempotencyKey { get; private set; } = string.Empty;
public string RequestHash { get; private set; } = string.Empty;
public string ResponseType { get; private set; } = string.Empty;
public string ResponsePayload { get; private set; } = string.Empty;
public string? ResourceType { get; private set; }
public Guid? ResourceId { get; private set; }
public Guid? ScopeId { get; private set; }

protected RequestIdempotencyEntity() : base() { }

private RequestIdempotencyEntity(
    Guid id,
    string canonicalKey,
    Guid ownerId,
    Guid tenantId,
    Guid createdBy,
    DateTime createdOn,
    DateTimeOffset timestamp,
    string operationKey,
    string idempotencyKey,
    string requestHash,
    string responseType,
    string responsePayload,
    string? resourceType,
    Guid? resourceId,
    Guid? scopeId)
    : base(id: id, partitionKey: tenantId.ToString(), rowKey: canonicalKey,
           ownerId: ownerId, tenantId: tenantId, createdBy: createdBy,
           createdOn: createdOn, timestamp: timestamp)
{
    OperationKey = operationKey;
    IdempotencyKey = idempotencyKey;
    RequestHash = requestHash;
    ResponseType = responseType;
    ResponsePayload = responsePayload;
    ResourceType = resourceType;
    ResourceId = resourceId;
    ScopeId = scopeId;
}

public static RequestIdempotencyEntity Create(
    Guid ownerId,
    Guid tenantId,
    string operationKey,
    string idempotencyKey,
    string requestHash,
    string responseType,
    string responsePayload,
    string? resourceType,
    Guid? resourceId,
    Guid? scopeId)
{
    return new RequestIdempotencyEntity(
        id: Guid.NewGuid(),
        canonicalKey: Guid.NewGuid().ToString(),
        ownerId: ownerId,
        tenantId: tenantId,
        createdBy: ownerId,
        createdOn: DateTime.UtcNow,
        timestamp: DateTimeOffset.UtcNow,
        operationKey: operationKey,
        idempotencyKey: idempotencyKey,
        requestHash: requestHash,
        responseType: responseType,
        responsePayload: responsePayload,
        resourceType: resourceType,
        resourceId: resourceId,
        scopeId: scopeId);
}

}

FILE: Infrastructure.SqlServer/Persistence/Configurations/RequestIdempotencyConfig.cs
PURPOSE: EF configuration and indexes.

using Goodtocode.AgentFramework.Core.Domain.Common;

namespace Goodtocode.AgentFramework.Infrastructure.SqlServer.Persistence.Configurations;

public sealed class RequestIdempotencyConfig : IEntityTypeConfiguration
{
public void Configure(EntityTypeBuilder builder)
{
ArgumentNullException.ThrowIfNull(builder);

    builder.ToTable("RequestIdempotency");

    builder.HasKey(x => x.Id).IsClustered(false);
    builder.Property(x => x.Id).ValueGeneratedOnAdd();
    builder.Ignore(x => x.PartitionKey);
    builder.HasIndex(x => x.Timestamp).IsClustered().IsUnique();

    builder.Property(x => x.OperationKey)
        .HasMaxLength(300)
        .IsRequired();

    builder.Property(x => x.IdempotencyKey)
        .HasMaxLength(128)
        .IsRequired();

    builder.Property(x => x.RequestHash)
        .HasMaxLength(128)
        .IsRequired();

    builder.Property(x => x.ResponseType)
        .HasMaxLength(500)
        .IsRequired();

    builder.Property(x => x.ResponsePayload)
        .HasColumnType("nvarchar(max)")
        .IsRequired();

    builder.Property(x => x.ResourceType)
        .HasMaxLength(200)
        .IsRequired(false);

    builder.HasIndex(x => new { x.TenantId, x.OwnerId, x.OperationKey, x.IdempotencyKey })
        .IsUnique()
        .HasDatabaseName("IX_RequestIdempotency_TenantOwnerOperationKey");

    builder.HasIndex(x => new { x.TenantId, x.OwnerId, x.OperationKey, x.ScopeId, x.RequestHash, x.Timestamp })
        .HasDatabaseName("IX_RequestIdempotency_DuplicateWindowLookup");
}

}

FILE: Core.Application/Common/Behaviors/IdempotencyBehavior.cs
PURPOSE: Universal mediator pipeline replay/persist behavior.

using Goodtocode.AgentFramework.Core.Application.Abstractions;
using Goodtocode.AgentFramework.Core.Application.Common.Auth;
using Goodtocode.AgentFramework.Core.Application.Common.Idempotency;
using Goodtocode.AgentFramework.Core.Domain.Common;
using Microsoft.EntityFrameworkCore;

namespace Goodtocode.AgentFramework.Core.Application.Common.Behaviors;

public sealed class IdempotencyBehavior<TRequest, TResponse>(IAgentFrameworkContext context)
: IPipelineBehavior<TRequest, TResponse>
where TRequest : notnull
{
private readonly IAgentFrameworkContext _context = context;

public async Task<TResponse> Handle(
    TRequest request,
    RequestDelegateInvoker<TResponse> nextInvoker,
    CancellationToken cancellationToken)
{
    if (request is not IIdempotentRequest idempotentRequest
        || request is not IRequiresUserContext scopedRequest)
    {
        return await nextInvoker();
    }

    idempotentRequest.IdempotencyKey = IdempotencyDefaults.ResolveKey(idempotentRequest.IdempotencyKey);
    var operationKey = idempotentRequest.OperationKey;
    var ownerId = scopedRequest.UserContext.OwnerId;
    var tenantId = scopedRequest.UserContext.TenantId;

    var existing = await _context.RequestIdempotency
        .Where(x => x.OwnerId == ownerId
            && x.TenantId == tenantId
            && x.OperationKey == operationKey
            && x.IdempotencyKey == idempotentRequest.IdempotencyKey)
        .OrderByDescending(x => x.Timestamp)
        .FirstOrDefaultAsync(cancellationToken);

    if (existing is not null
        && existing.ResponseType == typeof(TResponse).AssemblyQualifiedName
        && !string.IsNullOrWhiteSpace(existing.ResponsePayload))
    {
        var replay = IdempotencyData.Deserialize<TResponse>(existing.ResponsePayload);
        if (replay is not null)
        {
            return replay;
        }
    }

    var response = await nextInvoker();

    var requestHash = request is IIdempotencyRequestMetadata metadata
        ? metadata.BuildRequestHash()
        : BuildRequestHash(request, operationKey);
    var scopeId = request is IIdempotencyRequestMetadata withScope
        ? withScope.ScopeId
        : null;

    _context.RequestIdempotency.Add(RequestIdempotencyEntity.Create(
        ownerId: ownerId,
        tenantId: tenantId,
        operationKey: operationKey,
        idempotencyKey: idempotentRequest.IdempotencyKey,
        requestHash: requestHash,
        responseType: typeof(TResponse).AssemblyQualifiedName ?? typeof(TResponse).FullName ?? typeof(TResponse).Name,
        responsePayload: IdempotencyData.Serialize(response),
        resourceType: null,
        resourceId: null,
        scopeId: scopeId));

    try
    {
        await _context.SaveChangesAsync(cancellationToken);
    }
    catch (DbUpdateException)
    {
    }

    return response;
}

private static string BuildRequestHash(TRequest request, string operationKey)
{
    var serialized = IdempotencyData.Serialize(request);
    return IdempotencyData.Sha256($"{operationKey}|{serialized}");
}

}

FILE: Core.Application/ConfigureServices.cs EDIT
PURPOSE: Register universal behavior and policy.

Add:
services.AddTransient(typeof(IPipelineBehavior<,>), typeof(IdempotencyBehavior<,>));
services.AddSingleton<IIdempotencyDuplicateWindowPolicy, IdempotencyDuplicateWindowPolicy>();

FILE: Core.Application/Abstractions/IAgentFrameworkContext.cs EDIT
PURPOSE: Expose idempotency storage.

Add:
DbSet RequestIdempotency { get; }

FILE: Infrastructure.SqlServer/Persistence/AgentFrameworkContext.cs EDIT
PURPOSE: Implement idempotency DbSet.

Add:
public DbSet RequestIdempotency => Set();

FILE: Presentation.Api/Endpoints/... EDIT
PURPOSE: Map HTTP header into commands.

Pattern:
if (string.IsNullOrWhiteSpace(command.IdempotencyKey)
&& httpContext.Request.Headers.TryGetValue(IdempotencyDefaults.HeaderName, out var idempotencyKeyHeader))
{
command.IdempotencyKey = idempotencyKeyHeader.ToString();
}

FILE: Presentation.Web/Infrastructure/Clients/... EDIT
PURPOSE: Stamp Idempotency-Key header for POST/PUT/PATCH/DELETE requests per send operation.

Use AsyncLocal scope-based idempotency key and add header only for unsafe methods.

REQUEST IMPLEMENTATION PATTERN FOR ANY COMMAND
PURPOSE: How to make any new command universal-idempotent and duplicate-window aware.

  1. Inherit IdempotentUserScopedRequest
  2. Implement IIdempotencyRequestMetadata
  3. BuildRequestHash from operation key + normalized semantic fields
  4. Set ScopeId where duplicate checks must be scoped
  5. Set DuplicateWindow (or null/zero for none)

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions