05 - Coding Patterns

Eight patterns used throughout the backend. Recognize these and the whole codebase becomes easy to navigate.

Pattern 1 - Clean Architecture

Problem

Mixing controllers, business logic, persistence, and external APIs makes it hard to test and reason about.

Solution: 5 projects, dependencies flow inward. See 04 - Architecture.

Pattern 2 - Command / Result DTOs

Every service method takes a Command (input DTO) and returns a Result (output DTO). No bare parameters, no primitives for complex inputs. Commands and Results live in MO.Ekyc.Application/Models/.

backend/src/MO.Ekyc.Application/Models/Commands/VerifyOtpCommand.csC#
public class VerifyOtpCommand
{
    public Guid LeadId { get; set; }
    public string Otp { get; set; } = null!;
}
backend/src/MO.Ekyc.Infrastructure/Services/Stage2/OtpVerificationService.csC#
public async Task<VerifyOtpResult> VerifyOtpAsync(
    VerifyOtpCommand command,
    CancellationToken ct = default)
{
    // ... load OTP record, verify, transition state ...
    return new VerifyOtpResult
    {
        IsVerified = true,
        State = "OTP_VERIFIED",
        ResumePage = resumePage,
    };
}

Pattern 3 - Repository via EF Core DbContext

No separate repository classes. Services inject EkycDbContext directly and use LINQ. EF Core with snake_case naming convention provides the abstraction.

C#Typical service LINQ query
var lead = await _db.Leads
    .Where(l => l.LeadId == leadId)
    .FirstOrDefaultAsync(ct);

if (lead == null) return VerifyOtpResult.NotFound();

lead.CurrentStage = (short)StageDefinitions.OtpVerification;
lead.CurrentState = "OTP_VERIFIED";
await _db.SaveChangesAsync(ct);

Pattern 4 - Provider Chain Executor (Fallback)

Problem

External providers fail. PAN lookup may need to try Zintlr, then NSDL, then UTI before giving up.

Solution: ProviderChainExecutor<TProvider> reads enabled providers from DB by priority, tries each in order, handles circuit breaker, mock mode, audit logging, health metrics.

backend/src/MO.Ekyc.Infrastructure/ExternalProviders/Common/ProviderChainExecutor.csC#simplified
public async Task<TResult> ExecuteAsync<TResult>(
    string category,
    Func<TProvider, Task<TResult>> operation,
    Guid? leadId = null,
    CancellationToken ct = default)
{
    var configs = await _db.ProviderConfigurations
        .Where(p => p.Category == category && p.Enabled)
        .OrderBy(p => p.Priority)
        .ToListAsync(ct);

    foreach (var config in configs)
    {
        if (_circuitBreaker.IsOpen(config.ProviderName)) continue;

        if (config.MockMode)
        {
            await _auditLogger.LogAsync(..., isMock: true);
            return default!;
        }

        try
        {
            var provider = _providers.First(p => p.ProviderName == config.ProviderName);
            var result = await operation(provider);
            _healthTracker.RecordSuccess(config.ProviderName);
            return result;
        }
        catch (Exception ex)
        {
            _circuitBreaker.RecordFailure(config.ProviderName);
            _healthTracker.RecordFailure(config.ProviderName, ex);
            await _auditLogger.LogAsync(..., isSuccess: false);
        }
    }

    throw new AllProvidersFailedException(category);
}

Pattern 5 - Filter Pipeline (Attribute-Based)

Cross-cutting concerns like stage enforcement and role checks are implemented as IAsyncActionFilter. Controllers opt in via attributes.

backend/src/MO.Ekyc.Api/Controllers/Stage6/BankController.csC#
[ApiController]
[RequiresStage(4)]  // Must have completed Stage 4 (PAN) to reach Stage 6
public class BankController : ControllerBase
{
    [HttpGet(ApiRoutes.BankDetails)]
    public async Task<IActionResult> GetDetails(...) { ... }
}
backend/src/MO.Ekyc.Api/Filters/StageValidationFilter.csC#simplified
public async Task OnActionExecutionAsync(ActionExecutingContext ctx, ActionExecutionDelegate next)
{
    var attr = ctx.ActionDescriptor.EndpointMetadata
        .OfType<RequiresStageAttribute>().FirstOrDefault();
    if (attr == null) { await next(); return; }

    var leadId = ExtractLeadId(ctx);
    var lead = await _db.Leads.FindAsync(leadId);

    if (lead == null) { ctx.Result = new NotFoundObjectResult(...); return; }
    if (lead.CurrentStage < attr.MinimumStage)
    {
        ctx.Result = new ConflictObjectResult(
            ApiResponse.Fail("Lead has not reached required stage"));
        return;
    }

    await next();
}

Pattern 6 - Result Type (ApiResponse<T>)

Every controller returns ApiResponse<T> with success, data, message, errorCode, and optional journeyStatus. Frontend checks result.success.

backend/src/MO.Ekyc.Shared/Models/ApiResponse.csC#
public class ApiResponse<T>
{
    public bool Success { get; set; }
    public T? Data { get; set; }
    public string? Message { get; set; }
    public List<ApiError>? Errors { get; set; }
    public JourneyStatus? JourneyStatus { get; set; }

    public static ApiResponse<T> Ok(T data, string? msg = null, JourneyStatus? js = null) => ...
    public static ApiResponse<T> Fail(string msg, List<ApiError>? errors = null, JourneyStatus? js = null) => ...
}

Pattern 7 - Named HttpClient Factory

Every external provider gets its own named HttpClient registered in Program.cs with Polly retry policies and per-provider timeouts. Providers inject IHttpClientFactory and call CreateClient("ProviderName").

backend/src/MO.Ekyc.Api/Program.csC#HttpClient registration
builder.Services.AddHttpClient("Decentro", client =>
{
    client.BaseAddress = new Uri(config["ExternalProviders:Decentro:BaseUrl"]!);
    client.Timeout = TimeSpan.FromSeconds(15);
});

builder.Services.AddHttpClient("Hyperverge_Esign", client =>
{
    client.BaseAddress = new Uri(config["ExternalProviders:HypervergeEsign:BaseUrl"]!);
    client.Timeout = TimeSpan.FromSeconds(60);
});

// 30+ named clients total

Pattern 8 - Mock Mode Check in Constructor

Services that call external providers read MockMode:GlobalMockEnabled once in the constructor and store it. Every subsequent operation branches early if the flag is true.

C#Typical pattern
public class PanVerificationService
{
    private readonly bool _globalMockEnabled;

    public PanVerificationService(IConfiguration config, ...)
    {
        _globalMockEnabled = config.GetValue("MockMode:GlobalMockEnabled", false);
    }

    public async Task<PanResult> VerifyAsync(Guid leadId, string pan, ...)
    {
        if (_globalMockEnabled)
        {
            _logger.LogInformation("Lead {LeadId}: PAN verify MOCKED", leadId);
            return new PanResult { Verified = true, Name = "MOCK USER" };
        }
        // Real NSDL call via ProviderChainExecutor
    }
}