06 - Stage Walkthrough

Trace Stage 1 Registration end-to-end through the backend. By understanding this one flow you can navigate any other stage in the codebase.

Goal

Flutter app POSTs /api/v1/registration/signup with mobile + name + consents. Backend creates a Lead row, generates an OTP, stores it, and returns the new leadId. This is the simplest full flow.

Step 1 - Request Arrives at RegistrationController

The client sends a POST to /api/v1/registration/signup. ASP.NET routing maps it to RegistrationController.Signup().

backend/src/MO.Ekyc.Api/Controllers/Stage1/RegistrationController.csC#
[ApiController]
public class RegistrationController : ControllerBase
{
    private readonly RegistrationService _service;
    private readonly ILogger<RegistrationController> _logger;

    [HttpPost(ApiRoutes.RegistrationSignup)]
    public async Task<IActionResult> Signup(
        [FromBody] SignupCommand command,
        CancellationToken ct)
    {
        var result = await _service.SignupAsync(command, ct);

        if (!result.Success)
            return BadRequest(ApiResponse<SignupResult>.Fail(result.ErrorMessage, ...));

        return Ok(ApiResponse<SignupResult>.Ok(result, "Lead created", new JourneyStatus
        {
            CurrentStage = StageDefinitions.Registration,
            CurrentState = "INITIATED",
            ProgressPercent = StageDefinitions.GetProgressPercent(StageDefinitions.Registration)
        }));
    }
}

Step 2 - Filters Run (None for Stage 1)

Stage 1 is the first real stage, so there is no [RequiresStage] attribute - any valid session can hit signup. Stage 6 onward use [RequiresStage(N)] to enforce progression.

Step 3 - Controller Delegates to RegistrationService

Controllers are thin. All logic lives in the service.

backend/src/MO.Ekyc.Infrastructure/Services/Stage1/RegistrationService.csC#
public async Task<SignupResult> SignupAsync(SignupCommand command, CancellationToken ct)
{
    // 1. Validate mobile format, name, consents
    if (!command.MobileNumber.IsValidIndianMobile())
        return SignupResult.Fail(ErrorCodes.FeRegMobileInvalid, "Invalid mobile number");

    // 2. Check mobile dedup (window from Journey:MobileDedupeWindowDays)
    var existing = await _db.Leads
        .Where(l => l.MobileHash == _hashing.HashMobile(command.MobileNumber))
        .Where(l => l.CreatedAt > DateTimeOffset.UtcNow.AddDays(-_dedupeDays))
        .FirstOrDefaultAsync(ct);

    if (existing != null)
        return SignupResult.Fail(ErrorCodes.BeRegDuplicate, "Mobile already registered");

    // 3. Create new Lead entity
    var lead = new Lead
    {
        LeadId = Guid.NewGuid(),
        Mobile = command.MobileNumber,
        MobileHash = _hashing.HashMobile(command.MobileNumber),
        FullName = command.FullName,
        CurrentStage = (short)StageDefinitions.Registration,
        CurrentState = "INITIATED",
        ConsentTerms = command.ConsentTerms,
        ConsentBankAutoverify = command.ConsentBankAutoverify,
        CreatedAt = DateTimeOffset.UtcNow,
        UpdatedAt = DateTimeOffset.UtcNow
    };
    _db.Leads.Add(lead);

    // 4. Generate OTP (fixed 1234 in mock mode, real otherwise)
    var otp = await _otpGenerator.GenerateAndStoreAsync(lead.LeadId, lead.Mobile, "MOBILE", ct);

    // 5. Send SMS (or skip in FIXED_OTP mode)
    await _notificationService.SendSmsAsync(lead.Mobile, $"Your OTP is {otp}", ct);

    // 6. Save all changes in a single transaction
    await _db.SaveChangesAsync(ct);

    // 7. Log state transition + record downstream event
    await _journeyTracker.RecordStateTransitionAsync(
        lead.LeadId, null, "INITIATED", "SIGNUP", (short)StageDefinitions.Registration, ct);

    // 8. Fire downstream event to Kafka (CLEVERTAP, CDP, DATALAKE)
    await _downstream.PublishAsync("LEAD_CREATED", lead.LeadId, new[] { "CLEVERTAP", "CDP", "DATALAKE" }, ct);

    // 9. Return result with leadId for the client to store
    return new SignupResult
    {
        Success = true,
        LeadId = lead.LeadId,
        State = "INITIATED"
    };
}

Step 4 - DbContext.SaveChanges Writes to PostgreSQL

EF Core translates the pending changes into SQL INSERT INTO ekyc.leads (...) and executes them inside an implicit transaction.

SQLGenerated SQL (via Serilog EF query log)
INSERT INTO ekyc.leads (lead_id, mobile, mobile_hash, full_name, current_stage, current_state, ...)
VALUES ('8f2a1c9e-...', '9876543210', '$2a$...', 'John Doe', 1, 'INITIATED', ...);

INSERT INTO ekyc.otp_verifications (lead_id, otp_type, otp_hash, ...)
VALUES ('8f2a1c9e-...', 'MOBILE', '$2a$...', ...);

INSERT INTO ekyc.lead_state_transitions (lead_id, from_state, to_state, ...)
VALUES ('8f2a1c9e-...', NULL, 'INITIATED', ...);

Step 5 - OTP Generator: Fixed or Real?

The OTP generator checks Sms.Mode in config. In dev (FIXED_OTP), it stores and returns 1234. In prod (REAL), it generates a 4-digit random number and sends it via Netcore SMS provider.

backend/src/MO.Ekyc.Infrastructure/Services/Common/OtpGeneratorService.csC#simplified
public async Task<string> GenerateAndStoreAsync(Guid leadId, string mobile, string otpType, CancellationToken ct)
{
    var mode = _config["Sms:Mode"] ?? "REAL";
    var otp = mode == "FIXED_OTP"
        ? _config["Sms:FixedOtp"] ?? "1234"
        : GenerateRandom4Digit();

    var record = new OtpVerification
    {
        LeadId = leadId,
        OtpType = otpType,
        OtpHash = _hashing.Hash(otp),
        ExpiresAt = DateTimeOffset.UtcNow.AddMinutes(5),
        AttemptCount = 0
    };
    _db.OtpVerifications.Add(record);
    await _db.SaveChangesAsync(ct);
    return otp;
}

Step 6 - Downstream Event Published to Kafka

After successful signup, the service fires a LEAD_CREATED event via IDownstreamEventPublisher. The publisher writes to the downstream_events table first (outbox pattern), then Hangfire picks it up and publishes to Kafka topics for CLEVERTAP, CDP, DATALAKE, ZOHO_CRM consumers.

Step 7 - Response Flows Back to Client

RegistrationController wraps the service result in ApiResponse<SignupResult> with a JourneyStatus indicating current stage and progress percent. Middleware (Session refresh, exception handling) runs on the way out.

JSONExample response body
{
  "success": true,
  "message": "Lead created",
  "data": {
    "leadId": "8f2a1c9e-0d3a-4e9b-...",
    "state": "INITIATED"
  },
  "journeyStatus": {
    "currentStage": 1,
    "currentState": "INITIATED",
    "progressPercent": 6
  }
}

Apply This to Any Stage

Every stage follows this same pattern:

  1. Controller has a thin action with [RequiresStage(N)] attribute (for stages ≥ 2)
  2. Action delegates to a stage-specific service in Infrastructure/Services/StageN/
  3. Service validates input, reads/writes entities via EkycDbContext
  4. Service calls external providers through ProviderChainExecutor (for stages with external verification)
  5. Service transitions lead state via IJourneyTracker
  6. Service publishes downstream events
  7. Service returns a typed Result DTO
  8. Controller wraps in ApiResponse<T> with JourneyStatus

Open any controller in src/MO.Ekyc.Api/Controllers/Stage*/ and its matching service in src/MO.Ekyc.Infrastructure/Services/Stage*/ - you will recognize this structure.

Frontend counterpart