← ClearLend series overview

Phase 2 · Masterclass · Technical architecture and first vertical slice

From product idea to a production-minded ASP.NET slice

Modular monolith boundaries, vertical slices, domain rules, persistence and tests

Phase 2 is where we stop describing the product and make engineering decisions. We choose a simple architecture, trace one use case through the system, and define the code, data, security and tests needed for a credible first slice.

Masterclass lessonArchitecture before implementationSenior ASP.NET practice

The question we will answer

How would a senior developer shape ClearLend so the first feature is small, secure and ready to grow?

A technical blueprint for submitting a borrower application: clear boundaries, a domain model, API contract, persistence approach, authorisation rules, failure handling, tests and operational checks.

This lesson is part of the planned ClearLend masterclass. It uses fictional users and synthetic data while showing the decisions, code patterns and tests a real application would need.

01

Choose a modular monolith first

ClearLend has several roles and workflows, but that does not mean it needs several deployable services on day one. A modular monolith gives the team one operational boundary while keeping business capabilities separated in code. Borrower applications, lending products, reviews, payments and notifications can have their own modules, commands, queries and persistence rules. We can deploy, debug and transact simply while the product is still learning. If a capability later needs independent scale or ownership, the boundary is already visible. Starting with microservices would add network calls, distributed transactions, deployment coordination and operational cost before we have evidence that those problems exist.

  • One ASP.NET Core host and one deployable unit for the first slice.
  • Feature folders and internal contracts keep modules from becoming a shared bucket.
  • A capability earns extraction through a real scaling or ownership need.
  • The architecture optimises for changeability and evidence, not fashionable diagrams.

02

Make the solution boundaries explicit

The dependency direction should be easy to explain to a new engineer. The web project translates HTTP. The application project coordinates a use case. The domain project owns invariants. Infrastructure implements persistence and external services. A domain rule must not depend on Entity Framework, a controller, or an email provider. This separation lets us test the important behaviour quickly and replace infrastructure without rewriting the product model.

text
src/
  ClearLend.Api/             // endpoints, auth, ProblemDetails
  ClearLend.Application/    // commands, handlers, DTOs, ports
  ClearLend.Domain/          // aggregates, value objects, rules
  ClearLend.Infrastructure/ // EF Core, files, payments, messaging
  ClearLend.Contracts/       // public request and response contracts

03

Trace one vertical slice end to end

We will begin with a borrower submitting an application. It is valuable enough to prove the product journey and small enough to finish. The request enters one endpoint, is authorised, validated, handled by an application service, checked by the domain aggregate, saved in a transaction, and returned as a stable response. The slice also records history and publishes a follow-up work item without making the HTTP request wait for email or review assignment.

  • The endpoint owns HTTP concerns only.
  • The handler owns orchestration and transaction boundaries.
  • The aggregate owns whether a draft may be submitted.
  • The response exposes a reference and state, not persistence internals.
csharp
public sealed record SubmitLoanApplication(Guid ApplicationId);

app.MapPost("/api/borrower/applications/{id:guid}/submit",
    async (Guid id, SubmitLoanApplicationHandler handler,
           CancellationToken cancellationToken) =>
{
    var result = await handler.Handle(new(id), cancellationToken);
    return result.Match(
        value => Results.Ok(value),
        error => error.ToProblemDetails());
})
.RequireAuthorization("borrower");

04

Put the important rule in the domain

A borrower must not submit a draft without the required evidence, and a submitted application must not be submitted again. Those rules matter wherever the use case is called: an API, a background job, an import, or an administrative tool. The aggregate protects them in one place and records a history entry that can be displayed and audited.

csharp
public sealed class LoanApplication : AggregateRoot
{
    public ApplicationStatus Status { get; private set; } = ApplicationStatus.Draft;
    public bool EvidenceProvided { get; private set; }

    public Result Submit(Instant now)
    {
        if (Status != ApplicationStatus.Draft)
            return Result.Fail("Only a draft can be submitted.");
        if (!EvidenceProvided)
            return Result.Fail("Evidence is required before submission.");

        Status = ApplicationStatus.Submitted;
        AddEvent(new LoanApplicationSubmitted(Id, now));
        return Result.Success();
    }
}

05

Design an API contract that can survive change

The public contract should be intentionally boring. Use a stable route, a consistent success shape, and RFC 7807 ProblemDetails for failures. Do not return an EF Core entity or expose internal status fields simply because they exist. A client needs a reference, current state, next action and links or identifiers needed for the next step. Validation failures should name the field and reason. Domain conflicts such as an already submitted application should return a clear conflict response.

json
201 Created
{
  "applicationReference": "CL-2026-000184",
  "status": "Submitted",
  "nextAction": "A reviewer will assess your evidence"
}

400 ProblemDetails  // invalid input
403 ProblemDetails  // wrong role or ownership
409 ProblemDetails  // invalid state transition

06

Persist state with EF Core, transactions and concurrency

The application record, its history and the outbox message must be committed together. That prevents a successful response from being returned when the audit trail was not saved. A concurrency token protects against two browser tabs or workers changing the same application at once. The database is the source of truth; dashboards can use read models later, but they must not bypass the command path.

  • Use a unique idempotency key for retried commands.
  • Treat payment and external callbacks as pending, completed, failed, reversed or unknown.
  • Never overwrite financial history; append adjustments with a reason.
  • Keep migrations and seed data reviewable in source control.
csharp
public sealed class LoanApplicationConfiguration
    : IEntityTypeConfiguration<LoanApplication>
{
    public void Configure(EntityTypeBuilder<LoanApplication> b)
    {
        b.HasKey(x => x.Id);
        b.Property(x => x.Status).HasConversion<string>();
        b.Property(x => x.Version).IsRowVersion();
        b.HasMany(x => x.History).WithOne().IsRequired();
    }
}

await db.SaveChangesAsync(cancellationToken); // state + history + outbox

07

Authorise ownership, role and action separately

Authentication answers who is signed in. Authorisation answers whether that person may perform this action on this record. A borrower may submit their own draft; they may not submit another borrower’s draft. A CRM reviewer may decide an assigned case; they may not edit lender settlement records. Policies and resource checks should be tested at the application boundary, not left to the Angular navigation menu.

  • Role permission: is this role allowed to use the capability?
  • Resource ownership: is this the record this user may access?
  • Action permission: is the current state suitable for this action?
  • Audit context: who acted, when, why and from which request?
csharp
if (!currentUser.IsBorrower || application.BorrowerId != currentUser.Id)
    return Errors.Forbidden("You can only submit your own application.");

// The UI can hide a button, but the API must enforce the rule.

08

Test the behaviour before polishing the screen

A vertical slice is credible when its decisions are executable. Unit tests cover aggregate transitions without infrastructure. Integration tests run the real API against a disposable database and prove authorisation, persistence and ProblemDetails. Contract tests protect the response consumed by Angular. A small test suite gives the team confidence to change the implementation while keeping the product promise stable.

csharp
[Fact]
public void Submit_requires_evidence()
{
    var application = LoanApplication.Draft();

    var result = application.Submit(clock.GetCurrentInstant());

    result.Error.Should().Be("Evidence is required before submission.");
    application.Status.Should().Be(ApplicationStatus.Draft);
}

[Fact]
public async Task Borrower_cannot_submit_another_borrowers_application()
{
    var response = await client.PostAsJsonAsync(
        $"/api/borrower/applications/{otherId}/submit", new { });

    response.StatusCode.Should().Be(HttpStatusCode.Forbidden);
}

09

Add operational quality from the first slice

Performance and reliability are design inputs, not a later clean-up task. Measure the endpoint, database query count, response time and failure rate. Add structured logs with a correlation ID, metrics for submitted applications and rejected transitions, and traces around database and external calls. Do not log identity documents or sensitive financial values. A health check should tell operators whether the database and message delivery path are usable.

  • Performance: paginated queries, selected columns and indexes for borrower ownership.
  • Reliability: cancellation tokens, timeouts, retries only for safe operations, and idempotency.
  • Security: least privilege, secret management, rate limits and redacted logs.
  • Observability: correlation ID, structured event name, duration, outcome and actor type.
  • Accessibility: clear error text, keyboard flow and status announced to assistive technology.

010

Define done and hand the work to Phase 3

Phase 2 is complete when another engineer can implement the first slice without inventing the rules. The definition of done includes the API contract, domain transitions, persistence mapping, authorisation policy, failure responses, tests, migration, structured logging and a short runbook. Phase 3 can then create the solution and deliver the draft-to-submitted workflow, with each decision visible in code and reviewable in a pull request.

  • One end-to-end borrower submission path works with synthetic data.
  • Invalid state, missing evidence, wrong owner and duplicate retry are covered.
  • The database records state, history and an outbox event atomically.
  • The endpoint has metrics, logs, health checks and safe error responses.
  • A reviewer can run the tests and understand the next extension point.

Practise the decision

Turn one story into a delivery slice

Choose one user story. Write its acceptance criteria, business rules, failure cases, non-functional expectations and the smallest sequence of implementation steps.

What to produce

  • A one-page model or scope statement
  • Three role, value, or story definitions
  • At least one fee or failure rule
  • A testable completion rule

What to ask next

Which part is still ambiguous? What evidence would make the next engineering decision safer? Bring that question into Phase 3 when the first vertical slice begins.