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.
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 contracts03
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.
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.
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.
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 transition06
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.
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 + outbox07
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?
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.
[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.