05 - Coding Patterns
Eight patterns used throughout the backend. Recognize these and the whole codebase becomes easy to navigate.
Pattern 1 - Clean Architecture
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)
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
}
}