Skip to content
bitzorcas
中EN

Recipe

Practical Guide: Implement Hexagonal Infrastructure Adapters

Master Hexagonal Ports and Adapters in BitzOrcas.Modern: define domain ports, implement production infrastructure adapters, encapsulate third-party network errors, configure Polly resilience, and register fallback mocks.

Last updated

In enterprise software systems, core business domains must remain pure: they should never depend directly on specific third-party vendor SDKs or cloud APIs (e.g. digital signature platforms, payment processors, or corporate business registries). Direct coupling to vendor SDKs severely impedes unit testing, couples the domain to volatile vendor contract changes, and risks cascading outages across the entire application when a vendor service suffers downtime.

BitzOrcas.Modern strictly enforces Hexagonal Architecture (Ports and Adapters):

  • Domain Layer Defines Narrow Ports: Defines abstract interfaces using domain types only;
  • Infrastructure Layer Implements Adapters: Encapsulates HTTP calls, token signing, Polly retries, and vendor error mapping;
  • Provides Fallback Mock Implementations: Ensures seamless local development, isolated unit tests, and offline CI runs.

This guide demonstrates building a production-grade infrastructure adapter using an Enterprise Cloud Digital Signature Service (e.g. Fadada/Tencent eSign).

Ports & Adapters Architecture Flow

External Cloud ServiceInfrastructure TierDomain & Application LayerInvokes PortImplements PortImplements PortPolly Retry & CircuitBreaker

MatterIntakeCommandHandler
(Domain Use Case Handler)

IElectronicSignaturePort
(Inverted Narrow Port)

FadadaSignatureAdapter
(Production Cloud Adapter)

MockSignatureAdapter
(Local Dev & CI Mock)

Third-Party Cloud API


Step 1: Define the Inverted Narrow Port in the Domain Layer

Create the interface in the domain project (e.g. src/Modules/Legal/BitzOrcas.Modules.Legal.Domain/Ports/IElectronicSignaturePort.cs).

[!IMPORTANT] Port Design Commandments:

  1. Expose only the minimal surface required by the business use case (Narrow Port Principle);
  2. Inputs and return types must never leak vendor-specific SDK classes—use domain primitives or Result<T>;
  3. Always accept and propagate a CancellationToken.
src/Modules/Legal/BitzOrcas.Modules.Legal.Domain/Ports/IElectronicSignaturePort.cs
using System.Threading;
using System.Threading.Tasks;
using BitzOrcas.Domain.Results;
namespace BitzOrcas.Modules.Legal.Domain.Ports;
/// <summary>
/// Digital signature third-party service port abstraction
/// </summary>
/// <remarks>
/// <para>The domain layer interacts exclusively with this port, fully decoupling from vendor SDKs.</para>
/// <para>Implemented by production cloud adapters and mirrored by local test mocks.</para>
/// </remarks>
public interface IElectronicSignaturePort
{
/// <summary>
/// Initiates a multi-party contract signature workflow asynchronously
/// </summary>
/// <param name="contractCode">Contract tracking code.</param>
/// <param name="contractTitle">Signature docket title.</param>
/// <param name="signerIdCard">Signer national identification number.</param>
/// <param name="signerMobile">Signer mobile phone for OTP verification.</param>
/// <param name="documentStorageUrl">Read-only object storage download URL for the target document.</param>
/// <param name="cancellationToken">Asynchronous cancellation token.</param>
/// <returns>A Result containing the third-party signature task transaction ID.</returns>
Task<Result<string>> InitiateSignatureAsync(
string contractCode,
string contractTitle,
string signerIdCard,
string signerMobile,
string documentStorageUrl,
CancellationToken cancellationToken = default);
/// <summary>
/// Queries the real-time status of a digital signature task
/// </summary>
/// <param name="signatureTaskId">Third-party signature task ID.</param>
/// <param name="cancellationToken">Asynchronous cancellation token.</param>
/// <returns>Status code (1 = Pending, 2 = Completed, 3 = Rejected).</returns>
Task<Result<int>> QuerySignatureStatusAsync(
string signatureTaskId,
CancellationToken cancellationToken = default);
}

Step 2: Implement the Production Cloud Adapter in Infrastructure

In src/Platform/BitzOrcas.Infrastructure/Signature/, create FadadaSignatureAdapter.cs. This adapter leverages HttpClient and Polly to handle transient network issues, authenticate API payloads, and translate vendor errors into domain Result values:

src/Platform/BitzOrcas.Infrastructure/Signature/FadadaSignatureAdapter.cs
using System;
using System.Net.Http;
using System.Net.Http.Json;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using BitzOrcas.Domain.Results;
using BitzOrcas.Modules.Legal.Domain.Ports;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
namespace BitzOrcas.Infrastructure.Signature;
/// <summary>
/// Static error catalog for electronic signature adapter
/// </summary>
public static class SignatureErrors
{
/// <summary>
/// Third-party gateway returned an HTTP error status code
/// </summary>
public static readonly Error RemoteGatewayError =
Error.Failure("Signature.RemoteGatewayError", "Vendor signature gateway returned an error.");
/// <summary>
/// Third-party gateway rejected the business operation
/// </summary>
public static readonly Error BusinessRejected =
Error.Failure("Signature.BusinessRejected", "Vendor signature gateway rejected the request.");
/// <summary>
/// Network timeout communicating with signature gateway
/// </summary>
public static readonly Error NetworkTimeout =
Error.Failure("Signature.NetworkTimeout", "Signature gateway communication timed out. Please retry.");
/// <summary>
/// Requested signature task was not found
/// </summary>
public static readonly Error TaskNotFound =
Error.NotFound("Signature.TaskNotFound", "Specified signature task was not found.");
/// <summary>
/// Querying signature status failed
/// </summary>
public static readonly Error QueryFailed =
Error.Failure("Signature.QueryFailed", "Failed to query signature status.");
}
/// <summary>
/// Electronic signature configuration options
/// </summary>
public sealed class FadadaSignatureOptions
{
/// <summary>
/// Configuration section path in appsettings.json
/// </summary>
public const string SectionName = "Integrations:FadadaSignature";
/// <summary>
/// Third-party gateway base URL
/// </summary>
public string BaseUrl { get; set; } = "https://api.fadada.com/v2/";
/// <summary>
/// Developer application ID
/// </summary>
public string AppId { get; set; } = string.Empty;
/// <summary>
/// Developer application secret key
/// </summary>
public string AppSecret { get; set; } = string.Empty;
}
/// <summary>
/// Production electronic signature cloud adapter
/// </summary>
/// <remarks>
/// <para>Implements <see cref="IElectronicSignaturePort"/> to execute HTTP communication with vendor gateways.</para>
/// <para>Encapsulates payload signing, network timeouts, and maps proprietary vendor errors to domain results.</para>
/// </remarks>
public sealed class FadadaSignatureAdapter : IElectronicSignaturePort
{
private readonly HttpClient _httpClient;
private readonly FadadaSignatureOptions _options;
private readonly ILogger<FadadaSignatureAdapter> _logger;
/// <summary>
/// Initializes digital signature cloud adapter
/// </summary>
/// <param name="httpClient">Resilient HTTP client.</param>
/// <param name="options">Configuration options.</param>
/// <param name="logger">Diagnostic logger.</param>
public FadadaSignatureAdapter(
HttpClient httpClient,
IOptions<FadadaSignatureOptions> options,
ILogger<FadadaSignatureAdapter> logger)
{
_httpClient = httpClient;
_options = options.Value;
_logger = logger;
}
/// <summary>
/// Initiates signature workflow asynchronously
/// </summary>
public async Task<Result<string>> InitiateSignatureAsync(
string contractCode,
string contractTitle,
string signerIdCard,
string signerMobile,
string documentStorageUrl,
CancellationToken cancellationToken = default)
{
try
{
// 1. Build vendor-specific request payload
var payload = new
{
app_id = _options.AppId,
timestamp = DateTimeOffset.UtcNow.ToUnixTimeSeconds(),
contract_no = contractCode,
title = contractTitle,
doc_url = documentStorageUrl,
signer = new
{
id_card = signerIdCard,
mobile = signerMobile
}
};
// 2. Dispatch HTTP POST request
var response = await _httpClient.PostAsJsonAsync("contract/initiate", payload, cancellationToken);
if (!response.IsSuccessStatusCode)
{
_logger.LogWarning("Vendor signature API returned HTTP error: {StatusCode}", response.StatusCode);
return Result.Failure<string>(
SignatureErrors.RemoteGatewayError.WithDescription($"Vendor signature gateway returned HTTP {response.StatusCode}."));
}
var responseData = await response.Content.ReadFromJsonAsync<FadadaApiResponse>(cancellationToken);
if (responseData is null || responseData.Code != 1000)
{
_logger.LogWarning("Vendor signature API rejected request: Code={Code}, Msg={Msg}", responseData?.Code, responseData?.Message);
return Result.Failure<string>(
SignatureErrors.BusinessRejected.WithDescription(responseData?.Message ?? "Vendor signature gateway rejected the request."));
}
// 3. Extract vendor task ID
return Result.Success(responseData.Data.TaskId);
}
catch (OperationCanceledException)
{
throw; // Propagate cancellation upstream
}
catch (Exception ex)
{
_logger.LogError(ex, "Unhandled network exception contacting digital signature vendor: ContractCode={ContractCode}", contractCode);
return Result.Failure<string>(SignatureErrors.NetworkTimeout);
}
}
/// <summary>
/// Queries signature task status asynchronously
/// </summary>
public async Task<Result<int>> QuerySignatureStatusAsync(
string signatureTaskId,
CancellationToken cancellationToken = default)
{
try
{
var response = await _httpClient.GetFromJsonAsync<FadadaApiResponse>(
$"contract/status?task_id={Uri.EscapeDataString(signatureTaskId)}", cancellationToken);
if (response is null || response.Code != 1000)
{
return Result.Failure<int>(SignatureErrors.TaskNotFound);
}
return Result.Success(response.Data.Status);
}
catch (Exception ex)
{
_logger.LogError(ex, "Error querying signature status: TaskId={TaskId}", signatureTaskId);
return Result.Failure<int>(SignatureErrors.QueryFailed);
}
}
private sealed class FadadaApiResponse
{
public int Code { get; set; }
public string Message { get; set; } = string.Empty;
public FadadaData Data { get; set; } = new();
}
private sealed class FadadaData
{
public string TaskId { get; set; } = string.Empty;
public int Status { get; set; }
}
}

Step 3: Implement the Local Development & Test Mock Adapter

To allow engineers to develop and run integration tests locally without external internet access or valid vendor credentials, provide an in-memory mock implementation:

src/Platform/BitzOrcas.Infrastructure/Signature/MockSignatureAdapter.cs
using System;
using System.Threading;
using System.Threading.Tasks;
using BitzOrcas.Domain.Results;
using BitzOrcas.Modules.Legal.Domain.Ports;
using Microsoft.Extensions.Logging;
namespace BitzOrcas.Infrastructure.Signature;
/// <summary>
/// Mock digital signature adapter for local development and offline CI runs
/// </summary>
/// <remarks>
/// Automatically activated when third-party API credentials are not configured or in test environments.
/// </remarks>
public sealed class MockSignatureAdapter : IElectronicSignaturePort
{
private readonly ILogger<MockSignatureAdapter> _logger;
/// <summary>
/// Initializes mock signature adapter
/// </summary>
public MockSignatureAdapter(ILogger<MockSignatureAdapter> logger)
{
_logger = logger;
}
public Task<Result<string>> InitiateSignatureAsync(
string contractCode,
string contractTitle,
string signerIdCard,
string signerMobile,
string documentStorageUrl,
CancellationToken cancellationToken = default)
{
var mockTaskId = $"MOCK-SIG-{DateTime.UtcNow:yyyyMMdd}-{Random.Shared.Next(10000, 99999)}";
_logger.LogInformation("[MockSignatureAdapter] Generated mock signature task for contract {ContractCode}: {TaskId}", contractCode, mockTaskId);
return Task.FromResult(Result.Success(mockTaskId));
}
public Task<Result<int>> QuerySignatureStatusAsync(
string signatureTaskId,
CancellationToken cancellationToken = default)
{
return Task.FromResult(Result.Success(2)); // Mock completed signature status
}
}

Step 4: Graceful Dependency Injection Registration with TryAdd

Register the port and adapter using TryAddScoped to guarantee clean fallback behavior:

src/Platform/BitzOrcas.Infrastructure/Signature/SignatureServiceCollectionExtensions.cs
using System;
using BitzOrcas.Modules.Legal.Domain.Ports;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions;
namespace BitzOrcas.Infrastructure.Signature;
/// <summary>
/// Electronic signature service registration extensions
/// </summary>
public static class SignatureServiceCollectionExtensions
{
/// <summary>
/// Registers digital signature infrastructure adapters
/// </summary>
/// <param name="services">DI service collection.</param>
/// <param name="configuration">Configuration root.</param>
/// <returns>The service collection for chaining.</returns>
public static IServiceCollection AddElectronicSignatureAdapter(
this IServiceCollection services,
IConfiguration configuration)
{
var optionsSection = configuration.GetSection(FadadaSignatureOptions.SectionName);
var options = optionsSection.Get<FadadaSignatureOptions>();
// If valid AppId and AppSecret are configured, register production adapter with resilient HttpClient
if (options is not null && !string.IsNullOrWhiteSpace(options.AppId))
{
services.Configure<FadadaSignatureOptions>(optionsSection);
services.AddHttpClient<IElectronicSignaturePort, FadadaSignatureAdapter>(client =>
{
client.BaseAddress = new Uri(options.BaseUrl);
client.Timeout = TimeSpan.FromSeconds(15);
});
}
else
{
// Otherwise gracefully fall back to local mock adapter to guarantee zero-config bootstrapping
services.TryAddScoped<IElectronicSignaturePort, MockSignatureAdapter>();
}
return services;
}
}

Summary

The Hexagonal Ports and Adapters pattern provides vital architectural guarantees:

  • Domain Purity: Domain aggregate roots and command handlers interact purely with abstract ports, completely insulated from vendor API changes;
  • High Resilience: Network timeouts and HTTP status codes are translated cleanly into Result.Failure without throwing unhandled exceptions;
  • Zero-Friction Onboarding: Environments without live cloud credentials automatically fall back to mock adapters, enabling sub-second local boot and deterministic CI test suites.

100%

Scroll or use controls to zoom · drag when enlarged · double-click for 100% / 200%