Backend architecture. Build the right boundaries first.
A step-by-step engineering series for ClearLend on .NET 10 and C# 14. Step 1 explains the layers and decisions; later steps will prove them with runnable code.
Step 1 is published. Choose a chapter on the left to read it on the right. The roadmap distinguishes completed design work from implementation still to come.
Design explanation · Synthetic data · No backend yet
Step 1 · Chapter 01 / 75 min read
From blueprint to evidence, one step at a time
Phase 4 starts by making the backend architecture teachable. Only this design step is complete; implementation steps are published as a roadmap.
01
Backend architecture and layer design
Complete
Outcome: A documented dependency direction, layer responsibilities and decision method.
Acceptance: Readers can trace one draft-application rule from HTTP through application and domain to persistence, and see what remains a proposal.
02
Runnable solution and CI
Coming soon
Outcome: .NET 10 solution, architectural checks, local startup and automated build.
Acceptance: A fresh checkout builds, the API health endpoint responds and CI passes.
03
Domain model and tests
Coming soon
Outcome: Draft-application aggregate, value objects, state rules and focused tests.
Acceptance: Invalid changes are rejected by domain methods; valid draft transitions pass unit tests.
04
Persistence and concurrency
Coming soon
Outcome: EF Core mappings, SQL migration, ownership-aware storage and conflict tests.
Acceptance: A draft round-trips through SQL; stale updates fail without overwriting a newer version.
05
CQRS API features
Coming soon
Outcome: Create/update commands, read queries, HTTP contracts and authorization.
Acceptance: Authenticated requests return the documented success and failure responses in integration tests.
06
First usable draft journey
Coming soon
Outcome: Angular create, edit, save and reload flow with clear states.
Acceptance: A synthetic vetted borrower can resume their own draft; another borrower cannot see it.
07
Measured engineering evidence
Coming soon
Outcome: Reproducible latency, throughput and resource baselines with justified improvements.
Acceptance: Results include workload, environment, before/after measurements and remaining limits.
Phase 3 proposed a modular monolith, a borrower application slice, audit and outbox boundaries, SQL Server, and an Azure delivery path. Here we turn those proposals into a sequence of reviewable engineering results. The site explains decisions first. Later steps will add runnable code and report evidence from tests and measurements.
What is complete today
The proposed .NET 10 / C# 14 solution shape and dependency direction.
A decision method for Domain, Application, Infrastructure, API and Contracts.
One draft-application example traced through those boundaries.
A seven-step plan with acceptance criteria and explicit status.
What is still proposed
There is no ClearLend backend repository, SQL migration, deployed API, working borrower form or measured performance result in this phase yet. The examples below are design sketches. Each future step changes its status only after its acceptance evidence exists.
Decision to carry forward
A roadmap is credible when each step names an observable result and never confuses a design with running software.
Step 1 · Chapter 02 / 77 min read
Keep business decisions at the centre
Use a modular monolith with Clean Architecture inside each capability, CQRS for use cases, and feature folders for the work a person actually does.
Proposed solution, not created projects
ClearLend.slnx
src/
ClearLend.Domain/ business concepts and invariants
ClearLend.Application/ commands, queries and ports
ClearLend.Infrastructure/ EF Core and external adapters
ClearLend.Contracts/ public request and response types
ClearLend.Api/ HTTP, auth and composition root
tests/
ClearLend.Domain.Tests/
ClearLend.Application.Tests/
ClearLend.Architecture.Tests/
ClearLend.Api.IntegrationTests/
docs/decisions/
Dependency direction
Project
May depend on
Reason
Domain
No ClearLend project
Business rules remain usable without HTTP, SQL or cloud packages.
Application
Domain
Use cases coordinate domain behaviour through ports.
Infrastructure
Application and Domain
Adapters implement ports and map domain state to storage.
Contracts
No internal implementation project
The public API shape does not expose domain entities or EF models.
API
Application, Infrastructure and Contracts
The outermost host authenticates requests and composes implementations.
Why these boundaries?
The Phase 3 modules remain business boundaries: Applications owns draft and submission writes; Finance owns financial entries; Documents owns evidence metadata. The first implementation only exercises Applications. A separate assembly for every module would be ceremony at this stage, so we begin with project-level layer boundaries and feature folders inside the Application and API projects. Architecture tests can reject accidental outward dependencies; extraction remains an evidence-based later choice.
CQRS is a use-case distinction
A command requests a state change and returns a small outcome. A query reads an authorized projection without changing business state. This does not require two databases, event sourcing or a mediator package. Separate read models become useful when the borrower dashboard differs from the aggregate used to protect writes.
The dependency rule is more valuable than a folder diagram: policy points inward; technical details plug in from outside.
Step 1 · Chapter 03 / 726 min read
Discover the model from promises and forbidden states
The Domain layer owns the meaning of a draft application: identity, valid values, allowed changes and the rules that must hold after every change.
How we decide what belongs here
Start with Phase 2 stories and examples. Underline business nouns, verbs and rules: borrower, application, requested amount, save, submit, ownership, limit.
Ask what must remain true even if we replace Angular, SQL Server or the identity provider. Those statements are candidate invariants.
Group rules that must change atomically around an aggregate root. Avoid making one aggregate span every lending capability.
Distinguish identity from value: an Application keeps an identity across edits; Money is equal by amount and currency.
Record uncertain policy as an explicit question. Do not encode an unapproved lending limit in a constructor.
Entities, value objects and aggregate boundary
LoanApplication is a candidate aggregate root because its draft state and requested amount must move together. Its identity is ApplicationId; BorrowerId identifies the owner and should not change after creation. RequestedAmount can be a Money value object to couple decimal amount with currency. An EvidenceReference may be a value object or child entity depending on whether its own lifecycle, identity and history matter. CreditLimit belongs to an onboarding or credit-decision capability; the application can use an approved limit snapshot or policy result without owning the review process.
Choose properties by meaning
Candidate
Why it exists
Access and constraint
ApplicationId
Stable identity for the draft
Assigned on creation; immutable afterward.
BorrowerId
The party who owns the draft
Immutable; authorization also checks it at the application boundary.
Status
Controls allowed transitions
Changed only by named domain methods.
RequestedAmount
Amount requested in one currency
Validated Money value; changed through a draft-only method.
ProductId / product version
Records which published offer was chosen
Required before submission if the approved workflow demands it.
CreatedAt / UpdatedAt
Explains timing of changes
Time supplied by an application port; storage maps it consistently.
Methods express permitted behaviour
Prefer CreateDraft, ChangeRequestedAmount, SelectProduct and Submit over public setters. Each method checks the rules it owns and either produces a valid new state or rejects the request. A draft may change its amount; a submitted application may not. The borrower owner cannot be reassigned by a generic update. Submission requires the agreed evidence and policy checks; those exact requirements will be decided and tested when submission is implemented.
Three complete domain examples
The following C# classes are published design examples, compiled as standalone examples during this step. They are not the ClearLend backend. Borrower and Lender illustrate onboarding and vetting aggregates; Loan illustrates the servicing aggregate after an accepted offer and confirmed funding. LoanApplication remains a separate Applications aggregate and will be implemented in a later step. The references between aggregates are IDs, not mutable object graphs.
Shared IDs, rule error and Money value object
Strongly typed IDs make it harder to pass a LenderId where a BorrowerId belongs. Money keeps amount and currency together, rejects negative values, and prevents cross-currency subtraction. Zero is permitted for an outstanding balance, while each domain operation requiring a positive amount checks that rule explicitly. Currency precision and rounding policy still need a product and finance decision.
Shared types · illustrative C# 14
namespace ClearLend.Domain.Examples;
public sealed class DomainRuleException(string code, string message)
: Exception(message)
{
public string Code { get; } = code;
}
public readonly record struct BorrowerId(Guid Value);
public readonly record struct LenderId(Guid Value);
public readonly record struct LoanId(Guid Value);
public sealed record Money
{
public decimal Amount { get; }
public string Currency { get; }
private Money(decimal amount, string currency)
=> (Amount, Currency) = (amount, currency);
public static Money Create(decimal amount, string currency)
{
if (amount < 0)
throw new DomainRuleException("money.negative", "Amount cannot be negative.");
if (string.IsNullOrWhiteSpace(currency))
throw new DomainRuleException("money.currency", "Currency is required.");
var code = currency.Trim().ToUpperInvariant();
if (code.Length != 3 || code.Any(c => c is < 'A' or > 'Z'))
throw new DomainRuleException("money.currency", "Use a three-letter currency code.");
return new Money(amount, code);
}
public Money Subtract(Money other)
{
ArgumentNullException.ThrowIfNull(other);
if (Currency != other.Currency)
throw new DomainRuleException("money.currency_mismatch", "Currencies must match.");
if (other.Amount > Amount)
throw new DomainRuleException("money.insufficient", "Amount exceeds the balance.");
return Create(Amount - other.Amount, Currency);
}
}
Example 1 · Borrower
A borrower is an entity because their identity persists while vetting status changes. The legal name is held here because the vetting decision applies to that identity; a corrected name is allowed only before approval in this example. Status and approval data are read-only to callers. The application layer supplies IDs, time and the authorized decision reference; it cannot assign Vetted directly.
Only ApproveVetting can set them together after a positive decision.
SuspensionReason, LastChangedAt
Explain why new requests stop and when state last moved.
Suspend requires a reason; time cannot move backwards.
CorrectLegalName
Fix input before a decision.
A vetted name is locked pending a separate review workflow.
ReopenVetting, CanRequest
Require reapproval after suspension; check the simple limit gate.
Reopening clears approval. Eligibility needs vetted status, matching currency and a positive amount.
Borrower.cs · illustrative C# 14
namespace ClearLend.Domain.Examples;
public enum BorrowerStatus { PendingVetting, Vetted, Suspended }
public sealed class Borrower
{
public BorrowerId Id { get; }
public string LegalName { get; private set; }
public BorrowerStatus Status { get; private set; }
public Money? ApprovedLimit { get; private set; }
public string? VettingDecisionReference { get; private set; }
public DateTimeOffset? VettedAt { get; private set; }
public string? SuspensionReason { get; private set; }
public DateTimeOffset RegisteredAt { get; }
public DateTimeOffset LastChangedAt { get; private set; }
private Borrower(BorrowerId id, string legalName, DateTimeOffset registeredAt)
{
Id = id;
LegalName = legalName;
Status = BorrowerStatus.PendingVetting;
RegisteredAt = LastChangedAt = registeredAt;
}
public static Borrower Register(
BorrowerId id, string legalName, DateTimeOffset registeredAt)
{
if (id.Value == Guid.Empty)
throw new DomainRuleException("borrower.id", "Borrower ID is required.");
if (registeredAt == default)
throw new DomainRuleException("borrower.time", "Registration time is required.");
return new Borrower(id, RequireName(legalName), registeredAt);
}
public void CorrectLegalName(string legalName, DateTimeOffset changedAt)
{
EnsureTime(changedAt);
if (Status != BorrowerStatus.PendingVetting)
throw new DomainRuleException("borrower.name_locked", "A vetted name needs a new review.");
LegalName = RequireName(legalName);
LastChangedAt = changedAt;
}
public void ApproveVetting(
Money approvedLimit, string decisionReference, DateTimeOffset decidedAt)
{
EnsureTime(decidedAt);
ArgumentNullException.ThrowIfNull(approvedLimit);
if (Status != BorrowerStatus.PendingVetting || approvedLimit.Amount <= 0)
throw new DomainRuleException("borrower.vetting", "Pending review and a positive limit are required.");
if (string.IsNullOrWhiteSpace(decisionReference))
throw new DomainRuleException("borrower.decision", "A decision reference is required.");
ApprovedLimit = approvedLimit;
VettingDecisionReference = decisionReference.Trim();
VettedAt = LastChangedAt = decidedAt;
Status = BorrowerStatus.Vetted;
}
public void Suspend(string reason, DateTimeOffset changedAt)
{
EnsureTime(changedAt);
if (Status == BorrowerStatus.Suspended || string.IsNullOrWhiteSpace(reason))
throw new DomainRuleException("borrower.suspension", "A new suspension needs a reason.");
SuspensionReason = reason.Trim();
Status = BorrowerStatus.Suspended;
LastChangedAt = changedAt;
}
public void ReopenVetting(DateTimeOffset changedAt)
{
EnsureTime(changedAt);
if (Status != BorrowerStatus.Suspended)
throw new DomainRuleException("borrower.reopen", "Only a suspended borrower can reopen review.");
Status = BorrowerStatus.PendingVetting;
ApprovedLimit = null;
VettingDecisionReference = null;
VettedAt = null;
SuspensionReason = null;
LastChangedAt = changedAt;
}
public bool CanRequest(Money amount)
{
ArgumentNullException.ThrowIfNull(amount);
return Status == BorrowerStatus.Vetted
&& ApprovedLimit is not null
&& amount.Amount > 0
&& amount.Currency == ApprovedLimit.Currency
&& amount.Amount <= ApprovedLimit.Amount;
}
private void EnsureTime(DateTimeOffset at)
{
if (at < LastChangedAt)
throw new DomainRuleException("borrower.time", "A change cannot predate the last change.");
}
private static string RequireName(string value)
{
if (string.IsNullOrWhiteSpace(value))
throw new DomainRuleException("borrower.name", "Legal name is required.");
return value.Trim();
}
}
Example 2 · Lender
A lender is a distinct entity representing a vetted organization, not a borrower with a different role flag. RegisteredName is immutable in this example because a legal-name change should use a controlled re-verification workflow. Active status and a single-commitment limit are granted by a recorded decision. Organization membership and employee permissions live in Access, not in this aggregate.
Current permission to lend and its decision basis.
ApproveForLending needs pending vetting and a positive limit.
SuspensionReason, LastChangedAt
Records a restriction and chronological state.
Suspend requires a reason; reopening clears current approval.
AllowsSingleCommitment
Checks a proposed amount against the approved per-commitment cap.
It does not claim total available capital or reserve funds.
Lender.cs · illustrative C# 14
namespace ClearLend.Domain.Examples;
public enum LenderStatus { PendingVetting, Active, Suspended }
public sealed class Lender
{
public LenderId Id { get; }
public string RegisteredName { get; }
public LenderStatus Status { get; private set; }
public Money? SingleCommitmentLimit { get; private set; }
public string? VettingDecisionReference { get; private set; }
public DateTimeOffset? ApprovedAt { get; private set; }
public string? SuspensionReason { get; private set; }
public DateTimeOffset RegisteredAt { get; }
public DateTimeOffset LastChangedAt { get; private set; }
private Lender(LenderId id, string registeredName, DateTimeOffset registeredAt)
{
Id = id;
RegisteredName = registeredName;
Status = LenderStatus.PendingVetting;
RegisteredAt = LastChangedAt = registeredAt;
}
public static Lender Register(
LenderId id, string registeredName, DateTimeOffset registeredAt)
{
if (id.Value == Guid.Empty)
throw new DomainRuleException("lender.id", "Lender ID is required.");
if (string.IsNullOrWhiteSpace(registeredName) || registeredAt == default)
throw new DomainRuleException("lender.registration", "Name and time are required.");
return new Lender(id, registeredName.Trim(), registeredAt);
}
public void ApproveForLending(
Money singleCommitmentLimit, string decisionReference, DateTimeOffset decidedAt)
{
EnsureTime(decidedAt);
ArgumentNullException.ThrowIfNull(singleCommitmentLimit);
if (Status != LenderStatus.PendingVetting || singleCommitmentLimit.Amount <= 0)
throw new DomainRuleException("lender.vetting", "Pending review and a positive limit are required.");
if (string.IsNullOrWhiteSpace(decisionReference))
throw new DomainRuleException("lender.decision", "A decision reference is required.");
SingleCommitmentLimit = singleCommitmentLimit;
VettingDecisionReference = decisionReference.Trim();
ApprovedAt = LastChangedAt = decidedAt;
Status = LenderStatus.Active;
}
public void Suspend(string reason, DateTimeOffset changedAt)
{
EnsureTime(changedAt);
if (Status == LenderStatus.Suspended || string.IsNullOrWhiteSpace(reason))
throw new DomainRuleException("lender.suspension", "A new suspension needs a reason.");
SuspensionReason = reason.Trim();
Status = LenderStatus.Suspended;
LastChangedAt = changedAt;
}
public void ReopenVetting(DateTimeOffset changedAt)
{
EnsureTime(changedAt);
if (Status != LenderStatus.Suspended)
throw new DomainRuleException("lender.reopen", "Only a suspended lender can reopen review.");
Status = LenderStatus.PendingVetting;
SingleCommitmentLimit = null;
VettingDecisionReference = null;
ApprovedAt = null;
SuspensionReason = null;
LastChangedAt = changedAt;
}
public bool AllowsSingleCommitment(Money amount)
{
ArgumentNullException.ThrowIfNull(amount);
return Status == LenderStatus.Active
&& SingleCommitmentLimit is not null
&& amount.Amount > 0
&& amount.Currency == SingleCommitmentLimit.Currency
&& amount.Amount <= SingleCommitmentLimit.Amount;
}
private void EnsureTime(DateTimeOffset at)
{
if (at < LastChangedAt)
throw new DomainRuleException("lender.time", "A change cannot predate the last change.");
}
}
Example 3 · Loan
A loan is created only after an accepted offer and a verified funding confirmation. It is not a draft application or a payment request. The accepted offer ID, principal, fixed nominal rate and term are immutable snapshots, so a later product edit cannot rewrite the contract. This example assumes a fixed-rate first product; the final rate and schedule policy still require agreement.
Loan properties and methods
Member
Why it belongs
Protected rule
Id, BorrowerId, LenderId, AcceptedOfferId
Tie one loan to its parties and accepted terms.
All IDs are required; aggregates are linked by IDs.
Positive principal and term, nonnegative rate; no public setters.
FundingConfirmationReference, FundedAt
Distinguish confirmed funding from a provider request.
Factory requires both before the loan becomes Active.
OutstandingPrincipal, Status, PrincipalClearedAt
Track a transactional principal summary and lifecycle.
Confirmed principal cannot exceed the balance or be applied after principal is cleared.
ApplyConfirmedPrincipal
Accept an allocation already confirmed by Finance.
Only positive, same-currency principal; reaching zero marks PrincipalCleared.
Loan.cs · illustrative C# 14
namespace ClearLend.Domain.Examples;
public enum LoanStatus { Active, PrincipalCleared }
public sealed class Loan
{
public LoanId Id { get; }
public BorrowerId BorrowerId { get; }
public LenderId LenderId { get; }
public Guid AcceptedOfferId { get; }
public Money Principal { get; }
public decimal FixedNominalAnnualRatePercent { get; }
public int TermMonths { get; }
public string FundingConfirmationReference { get; }
public DateTimeOffset FundedAt { get; }
public Money OutstandingPrincipal { get; private set; }
public LoanStatus Status { get; private set; }
public DateTimeOffset? PrincipalClearedAt { get; private set; }
private Loan(
LoanId id, BorrowerId borrowerId, LenderId lenderId,
Guid acceptedOfferId, Money principal, decimal annualRatePercent,
int termMonths, string fundingReference, DateTimeOffset fundedAt)
{
Id = id;
BorrowerId = borrowerId;
LenderId = lenderId;
AcceptedOfferId = acceptedOfferId;
Principal = OutstandingPrincipal = principal;
FixedNominalAnnualRatePercent = annualRatePercent;
TermMonths = termMonths;
FundingConfirmationReference = fundingReference;
FundedAt = fundedAt;
Status = LoanStatus.Active;
}
public static Loan OpenFromConfirmedFunding(
LoanId id, BorrowerId borrowerId, LenderId lenderId,
Guid acceptedOfferId, Money principal, decimal annualRatePercent,
int termMonths, string fundingReference, DateTimeOffset fundedAt)
{
ArgumentNullException.ThrowIfNull(principal);
if (id.Value == Guid.Empty || borrowerId.Value == Guid.Empty
|| lenderId.Value == Guid.Empty || acceptedOfferId == Guid.Empty)
throw new DomainRuleException("loan.identity", "Loan and party IDs are required.");
if (principal.Amount <= 0 || annualRatePercent < 0 || termMonths <= 0)
throw new DomainRuleException("loan.terms", "Positive principal, nonnegative rate and term are required.");
if (string.IsNullOrWhiteSpace(fundingReference) || fundedAt == default)
throw new DomainRuleException("loan.funding", "Confirmed funding reference and time are required.");
return new Loan(id, borrowerId, lenderId, acceptedOfferId, principal,
annualRatePercent, termMonths, fundingReference.Trim(), fundedAt);
}
public void ApplyConfirmedPrincipal(
Money principalPaid, DateTimeOffset confirmedAt)
{
ArgumentNullException.ThrowIfNull(principalPaid);
if (Status != LoanStatus.Active)
throw new DomainRuleException("loan.principal_cleared", "A principal-cleared loan cannot receive more principal.");
if (principalPaid.Amount <= 0 || confirmedAt < FundedAt)
throw new DomainRuleException("loan.allocation", "Positive principal and a valid time are required.");
OutstandingPrincipal = OutstandingPrincipal.Subtract(principalPaid);
if (OutstandingPrincipal.Amount == 0)
{
Status = LoanStatus.PrincipalCleared;
PrincipalClearedAt = confirmedAt;
}
}
}
How to test these examples
Borrower: registration rejects empty identity; approval requires a positive limit and decision reference; suspension blocks a request; reopening clears the current approval.
Lender: a vetted organization accepts a matching-currency commitment within its per-commitment limit; suspended or unvetted lenders fail the local gate.
Loan: confirmed funding creates immutable terms; an over-allocation or wrong currency fails; the final principal allocation marks principal cleared without claiming full settlement.
Cross-boundary tests later prove that authorization, decision evidence, allocation idempotency, audit history and SQL transactions hold around these domain methods.
Avoid an anemic model
A class with public setters and a handler full of status if-statements leaves no single place that guarantees validity. Keep state changes behind meaningful methods. Do not force every rule into the aggregate: authorization against the authenticated caller, database uniqueness, and calls to another service need an application or infrastructure boundary.
Dependencies, testing and mistakes
Dependency: pure C# types and intentional domain abstractions; no DbContext, HttpContext, API DTO or provider SDK.
Unit tests: creation, value equality, draft-only edits, rejected transitions and invariant preservation.
Mistake: mapping each database table to a domain entity before understanding behaviour. A table is a storage choice, not a model discovery method.
Mistake: storing a mutable currency amount as an unguarded decimal or using floating-point arithmetic for money.
Mistake: adding a generic Update method that can silently change ownership or status.
Decision to carry forward
Choose domain types from rules that must stay true, then make invalid state changes difficult to express.
Step 1 · Chapter 04 / 729 min read
Turn user intentions into coordinated use cases
The Application layer connects one authorized intention to domain behaviour and persistence. Three complete command/query examples show that path without putting SQL or HTTP into a handler.
What this layer owns
The Application layer names and coordinates use cases. It obtains a trusted actor, checks whether the action is permitted, asks the Domain to change valid state, calls ports for I/O, and returns a typed outcome. It may choose a transaction boundary and an ordering of calls. It does not define interest mathematics, mutate entity properties directly, parse HttpContext, construct SQL, or send provider SDK calls. A handler is an orchestrator, not a second domain model.
Find features from acceptance examples
Start with “As a vetted borrower, I can create, change and reopen my own draft.” The verbs imply CreateDraft and ChangeDraftAmount commands plus a GetDraft query. The ownership and stale-save clauses are not optional technical details: they shape the actor port, owned lookups and versioned save. We deliberately make each use case small enough that a reviewer can trace its inputs, decision and outcome.
Shared contains only contracts genuinely used by multiple features. A one-off validator stays with its feature. These folders describe the proposed backend: they are not generated projects. A handler can be registered directly with dependency injection; a mediator library is optional, not a prerequisite for CQRS.
CQRS responsibilities in one deployable application
Feature
Reads or changes?
Loads
Returns
CreateDraft
Command: creates state.
Eligibility decision; no existing draft.
New application ID and version.
ChangeDraftAmount
Command: changes state.
Owned aggregate plus version.
New version or a conflict.
GetDraft
Query: observes state.
Owned scalar projection.
Draft details; no tracked aggregate.
A small Domain collaborator for the examples
The three handlers need an aggregate with CreateDraft and ChangeRequestedAmount. This compact collaborator exists only so the Application examples are complete and compilable; it is not the production LoanApplication. Step 3 will design that aggregate against approved product rules and tests. Notice that the handler calls named methods and cannot assign Status or RequestedAmount.
LoanApplication collaborator · illustrative C# 14
namespace ClearLend.Domain.Examples;
public readonly record struct LoanApplicationId(Guid Value);
public enum DraftStatus { Draft, Submitted }
// A compact collaborator for this chapter; Step 3 will build the real aggregate.
public sealed class LoanApplication
{
public LoanApplicationId Id { get; }
public BorrowerId BorrowerId { get; }
public Money RequestedAmount { get; private set; }
public DraftStatus Status { get; private set; }
public DateTimeOffset UpdatedAt { get; private set; }
private LoanApplication(LoanApplicationId id, BorrowerId borrowerId,
Money requestedAmount, DateTimeOffset now)
{
Id = id;
BorrowerId = borrowerId;
RequestedAmount = requestedAmount;
Status = DraftStatus.Draft;
UpdatedAt = now;
}
public static LoanApplication CreateDraft(LoanApplicationId id,
BorrowerId borrowerId, Money requestedAmount, DateTimeOffset now)
{
ArgumentNullException.ThrowIfNull(requestedAmount);
if (id.Value == Guid.Empty || borrowerId.Value == Guid.Empty
|| requestedAmount.Amount <= 0 || now == default)
throw new DomainRuleException("draft.creation", "A draft needs valid identity, amount and time.");
return new LoanApplication(id, borrowerId, requestedAmount, now);
}
// Repository reconstitution; the real Step 3 aggregate will validate
// every field required by each persisted status before returning.
public static LoanApplication Rehydrate(LoanApplicationId id,
BorrowerId borrowerId, Money requestedAmount,
DraftStatus status, DateTimeOffset updatedAt)
{
if (!Enum.IsDefined(status))
throw new DomainRuleException("draft.status", "Stored status is unknown.");
var application = CreateDraft(id, borrowerId, requestedAmount, updatedAt);
application.Status = status;
return application;
}
public void ChangeRequestedAmount(Money requestedAmount, DateTimeOffset now)
{
ArgumentNullException.ThrowIfNull(requestedAmount);
if (Status != DraftStatus.Draft)
throw new DomainRuleException("draft.not_editable", "Only a draft can change amount.");
if (requestedAmount.Amount <= 0 || now < UpdatedAt
|| requestedAmount.Currency != RequestedAmount.Currency)
throw new DomainRuleException("draft.amount", "Amount and currency must remain valid.");
RequestedAmount = requestedAmount;
UpdatedAt = now;
}
}
Ports and typed outcomes
A port states the capability the use case needs, not the storage technology. ICurrentActor is populated from authentication, never from request JSON. IBorrowerEligibility is an Onboarding contract: Applications asks whether this borrower can start this amount without querying Onboarding tables. The writer loads an owned aggregate and compares an opaque version; the reader returns a small projection. AppResult separates expected failures from exceptions used for unexpected faults. TimeProvider and the ID port make tests deterministic.
Application contracts and ports · illustrative C# 14
using ClearLend.Domain.Examples;
namespace ClearLend.Application.Examples;
public enum FailureKind { Unauthenticated, Invalid, Ineligible, NotFound, Conflict }
public sealed record AppFailure(FailureKind Kind, string Code, string Message);
public sealed class AppResult<T>
{
public T? Value { get; }
public AppFailure? Failure { get; }
public bool Succeeded => Failure is null;
private AppResult(T? value, AppFailure? failure)
=> (Value, Failure) = (value, failure);
public static AppResult<T> Ok(T value) => new(value, null);
public static AppResult<T> Fail(FailureKind kind, string code, string message)
=> new(default, new AppFailure(kind, code, message));
}
// The API constructs this from validated authentication, never from request JSON.
public interface ICurrentActor
{
BorrowerId? BorrowerId { get; }
}
// An Onboarding module contract; no direct read of its tables.
public interface IBorrowerEligibility
{
Task<bool> CanStartDraftAsync(BorrowerId borrowerId, Money amount,
CancellationToken cancellationToken);
}
public interface ILoanApplicationIds
{
LoanApplicationId NewLoanApplicationId();
}
public sealed record LoadedDraft(LoanApplication Draft, string Version);
public sealed record SavedDraft(bool Saved, string? NewVersion);
// Implemented by Infrastructure. Save uses ID + owner + expected version.
public interface IDraftWriter
{
Task<string> AddAsync(LoanApplication draft, CancellationToken cancellationToken);
Task<LoadedDraft?> FindOwnedForChangeAsync(LoanApplicationId id,
BorrowerId borrowerId, CancellationToken cancellationToken);
Task<SavedDraft> SaveAsync(LoanApplication draft, string expectedVersion,
CancellationToken cancellationToken);
}
public sealed record DraftDetails(LoanApplicationId Id, decimal RequestedAmount,
string Currency, string Status, DateTimeOffset UpdatedAt, string Version);
// A direct owned projection; it need not materialize the aggregate.
public interface IDraftReader
{
Task<DraftDetails?> ReadOwnedAsync(LoanApplicationId id, BorrowerId borrowerId,
CancellationToken cancellationToken);
}
public static class RequestedMoney
{
public static Money? TryCreate(decimal amount, string? currency)
{
if (amount <= 0 || string.IsNullOrWhiteSpace(currency)) return null;
var code = currency.Trim().ToUpperInvariant();
if (code.Length != 3 || code.Any(c => c is < 'A' or > 'Z')) return null;
return Money.Create(amount, code); // Domain remains the final guard.
}
}
Case 1 · Create a draft
The command carries requested amount and currency, but no borrower ID. The handler takes the borrower from a trusted actor context, converts the input to Money, asks Onboarding whether this vetted borrower can start a request of that amount, invokes the domain factory, and writes once. It returns the new ID and storage version so the browser can resume and later update the draft.
CreateDraft decision points
Order
Why
1 · Resolve actor
No caller-supplied borrower ID can impersonate an owner.
2 · Check input
Return a useful Invalid result before storage or external calls.
3 · Ask eligibility
Onboarding owns vetting and approved-limit policy.
4 · Create aggregate
Domain owns valid draft state.
5 · Persist and return version
The client receives a durable identity and concurrency token.
CreateDraftCommand + handler · illustrative C# 14
using ClearLend.Domain.Examples;
namespace ClearLend.Application.Examples;
public sealed record CreateDraftCommand(decimal RequestedAmount, string Currency);
public sealed record CreatedDraft(LoanApplicationId Id, string Version);
public sealed class CreateDraftHandler(
ICurrentActor actor,
IBorrowerEligibility eligibility,
IDraftWriter writer,
ILoanApplicationIds ids,
TimeProvider clock)
{
public async Task<AppResult<CreatedDraft>> Handle(
CreateDraftCommand command, CancellationToken cancellationToken)
{
var borrowerId = actor.BorrowerId;
if (borrowerId is null)
return AppResult<CreatedDraft>.Fail(FailureKind.Unauthenticated,
"actor.missing", "A borrower sign-in is required.");
var amount = RequestedMoney.TryCreate(command.RequestedAmount, command.Currency);
if (amount is null)
return AppResult<CreatedDraft>.Fail(FailureKind.Invalid,
"draft.amount", "Enter a positive amount and a three-letter currency.");
if (!await eligibility.CanStartDraftAsync(borrowerId.Value, amount, cancellationToken))
return AppResult<CreatedDraft>.Fail(FailureKind.Ineligible,
"borrower.ineligible", "Borrower is not approved for this request.");
var draft = LoanApplication.CreateDraft(ids.NewLoanApplicationId(),
borrowerId.Value, amount, clock.GetUtcNow());
var version = await writer.AddAsync(draft, cancellationToken);
return AppResult<CreatedDraft>.Ok(new CreatedDraft(draft.Id, version));
}
}
A focused test injects a fixed TimeProvider, deterministic ID and fake ports. It proves an anonymous actor does not call eligibility or storage; invalid input does not create a draft; ineligible borrowers do not write; and a successful call passes the same cancellation token to both I/O ports. The later API integration test verifies the 201 response and authorization mapping.
Case 2 · Change the draft amount safely
An update needs two independent safeguards. First, the lookup is scoped to the authenticated borrower; an unowned ID returns the same NotFound outcome as a missing ID. Second, the client sends the version it last saw. The handler rejects an already stale version before changing the aggregate, then the adapter compares it again during the SQL update. The second check closes the race between read and write.
Two writers and one draft
Moment
Borrower tab A
Borrower tab B
Both read
Version 4
Version 4
A saves
Writes version 4; receives version 5.
Still holds version 4.
B saves
No further change.
Conflict; must reload before deciding what to keep.
using ClearLend.Domain.Examples;
namespace ClearLend.Application.Examples;
public sealed record ChangeDraftAmountCommand(LoanApplicationId Id,
decimal RequestedAmount, string Currency, string ExpectedVersion);
public sealed record ChangedDraft(LoanApplicationId Id, string Version);
public sealed class ChangeDraftAmountHandler(
ICurrentActor actor, IDraftWriter writer, TimeProvider clock)
{
public async Task<AppResult<ChangedDraft>> Handle(
ChangeDraftAmountCommand command, CancellationToken cancellationToken)
{
var borrowerId = actor.BorrowerId;
if (borrowerId is null)
return AppResult<ChangedDraft>.Fail(FailureKind.Unauthenticated,
"actor.missing", "A borrower sign-in is required.");
if (command.Id.Value == Guid.Empty || string.IsNullOrWhiteSpace(command.ExpectedVersion))
return AppResult<ChangedDraft>.Fail(FailureKind.Invalid,
"draft.identity", "Draft ID and expected version are required.");
var amount = RequestedMoney.TryCreate(command.RequestedAmount, command.Currency);
if (amount is null)
return AppResult<ChangedDraft>.Fail(FailureKind.Invalid,
"draft.amount", "Enter a positive amount and a three-letter currency.");
var loaded = await writer.FindOwnedForChangeAsync(command.Id,
borrowerId.Value, cancellationToken);
if (loaded is null)
return AppResult<ChangedDraft>.Fail(FailureKind.NotFound,
"draft.not_found", "Draft was not found.");
if (!StringComparer.Ordinal.Equals(loaded.Version, command.ExpectedVersion))
return AppResult<ChangedDraft>.Fail(FailureKind.Conflict,
"draft.stale", "Reload the draft before saving changes.");
try
{
loaded.Draft.ChangeRequestedAmount(amount, clock.GetUtcNow());
}
catch (DomainRuleException error) when (
error.Code is "draft.not_editable" or "draft.amount")
{
return AppResult<ChangedDraft>.Fail(FailureKind.Conflict,
error.Code, error.Message);
}
// The adapter compares the expected version again in SQL. A second
// writer can win after our read; a failed compare must not overwrite it.
var saved = await writer.SaveAsync(loaded.Draft,
command.ExpectedVersion, cancellationToken);
if (!saved.Saved || saved.NewVersion is null)
return AppResult<ChangedDraft>.Fail(FailureKind.Conflict,
"draft.stale", "Reload the draft before saving changes.");
return AppResult<ChangedDraft>.Ok(new ChangedDraft(command.Id, saved.NewVersion));
}
}
Tests should cover anonymous access, wrong owner, absent draft, malformed version, stale version before mutation, a race that causes SaveAsync to report conflict, a submitted aggregate rejecting edits, and a successful save returning the new version. The SQL integration test later proves the actual rowversion behavior; a fake repository alone cannot.
Case 3 · Read an owned draft
GetDraft is a query: it does not load a tracked LoanApplication just to render a page. The reader filters by ID and borrower in the data store and selects only fields required by DraftDetails. No EF entity or sensitive evidence object escapes through the application response. The missing and unowned cases deliberately look the same to this borrower.
GetDraftQuery + handler · illustrative C# 14
using ClearLend.Domain.Examples;
namespace ClearLend.Application.Examples;
public sealed record GetDraftQuery(LoanApplicationId Id);
public sealed class GetDraftHandler(ICurrentActor actor, IDraftReader reader)
{
public async Task<AppResult<DraftDetails>> Handle(
GetDraftQuery query, CancellationToken cancellationToken)
{
var borrowerId = actor.BorrowerId;
if (borrowerId is null)
return AppResult<DraftDetails>.Fail(FailureKind.Unauthenticated,
"actor.missing", "A borrower sign-in is required.");
if (query.Id.Value == Guid.Empty)
return AppResult<DraftDetails>.Fail(FailureKind.Invalid,
"draft.id", "Draft ID is required.");
// Reader filters by both ID and borrower. Missing and unowned drafts
// are intentionally indistinguishable to this caller.
var draft = await reader.ReadOwnedAsync(query.Id,
borrowerId.Value, cancellationToken);
return draft is null
? AppResult<DraftDetails>.Fail(FailureKind.NotFound,
"draft.not_found", "Draft was not found.")
: AppResult<DraftDetails>.Ok(draft);
}
}
Query tests prove the actor is required, invalid IDs do not reach storage, the owner ID is passed to the reader, the cancellation token is forwarded, and a missing projection produces NotFound. SQL and API tests later prove the database filter and actual HTTP response.
Where each rule goes
API/Contracts: parse route and JSON, authenticate, map AppFailure to consistent HTTP Problem Details.
Application: choose use-case order, actor scope, eligibility request, cancellation, transaction boundary and typed outcome.
Domain: protect draft-only edits, amount validity and state transitions even if a worker or another interface calls it.
Infrastructure: implement owner-filtered queries, EF mappings, rowversion comparison and durable commit.
Cross-module decisions: ask the owning module through an explicit contract; do not reach across to its tables.
Transactions, async work and later submission
Create and change commit one draft update. The writer must return a new version only after that commit succeeds. The later SubmitDraft use case must commit the domain transition, audit record and outbox message in one SQL transaction; publishing a message before that commit would make an event describe a change that never happened. Async belongs on repository and remote I/O; domain methods run synchronously. Carry cancellation through the stack, avoid blocking .Result or .Wait, and measure before introducing parallel calls or caches.
Review checklist and common mistakes
Can a reviewer locate a use case, its ports, outcome and tests in one feature folder?
Does every write derive owner from trusted identity and preserve the aggregate invariant?
Does every query filter at the data source and return only fields the caller may see?
Can a second writer win after the first read without silent data loss?
Are expected failures typed while unknown faults remain visible to telemetry?
Avoid a giant ApplicationService, generic repository with every CRUD method, direct DbContext in handlers, Task.Run around synchronous rules, and returning domain entities as API contracts.
A good Application handler has a narrow job: trust the actor, coordinate the use case, call the domain, persist through ports, and report a precise outcome.
Step 1 · Chapter 05 / 725 min read
Make use-case promises durable without moving policy into SQL
Infrastructure turns Application ports into SQL and provider calls. These compiled .NET 10 / EF Core 10 examples explain the proposed design; no ClearLend database or API has been built yet.
Start with a capability, then choose an adapter
Infrastructure may reference Application and Domain; neither points back to Infrastructure. The Application chapter already defined IDraftWriter and IDraftReader from the needs of CreateDraft, ChangeDraftAmount and GetDraft. Those three operations justify two focused EF adapters. They do not justify a generic repository exposing every table or a cross-module DbContext. Identity, documents, notifications and payments get separate adapters only when a use case needs them.
Proposed folder structure · no backend files created yet
src/ClearLend.Infrastructure/
DependencyInjection/InfrastructureRegistration.cs
Persistence/
ClearLendDbContext.cs
Applications/
DraftRow.cs
DraftRowMap.cs
DraftMapping.cs
DraftVersionCodec.cs
EfDraftWriter.cs
EfDraftReader.cs
Migrations/
20xx..._InitialDrafts.cs
Integrations/
Onboarding/ eligibility adapter when its contract is real
Documents/ storage adapter when evidence is in scope
Notifications/ sender and outbox worker in a later step
tests/ClearLend.Infrastructure.IntegrationTests/
DraftPersistenceTests.cs
DraftConcurrencyTests.cs
What each boundary owns
Concern
Owner
Reason
Draft validity and transition
Domain
The rule must hold regardless of SQL or HTTP.
Use-case order and port signatures
Application
Handlers coordinate policy and persistence.
SQL schema, EF mapping and migrations
Infrastructure
Provider choices can change without redefining lending rules.
Opaque version in a request
Contracts and API
Clients need a stable shape, not SQL Server byte arrays.
Version comparison at commit
Infrastructure
Only the database can arbitrate simultaneous writers.
Case 1 · Choose a persistence shape and make reconstruction explicit
The Domain aggregate has private mutation methods and a Money value; it is not a table DTO. This example uses an infrastructure-only DraftRow so EF mapping and SQL rowversion do not enter the Domain. The adapter translates Money to amount plus currency and calls a validated Domain reconstitution factory when it loads a write model. A direct EF mapping of the aggregate could reduce translation, but it would couple constructor and backing-field choices to EF. We choose the extra mapping for a clearer dependency boundary and review its cost after profiling.
DbContext, row, EF mapping and version codec · illustrative C# 14
using ClearLend.Domain.Examples;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
namespace ClearLend.Infrastructure.Examples;
// Persistence shape stays in Infrastructure; it is never returned by the API.
public sealed class DraftRow
{
public Guid Id { get; set; }
public Guid BorrowerId { get; set; }
public decimal RequestedAmount { get; set; }
public string Currency { get; set; } = string.Empty;
public DraftStatus Status { get; set; }
public DateTimeOffset UpdatedAt { get; set; }
public byte[] Version { get; set; } = [];
}
public sealed class ClearLendDbContext(DbContextOptions<ClearLendDbContext> options)
: DbContext(options)
{
internal DbSet<DraftRow> Drafts => Set<DraftRow>();
protected override void OnModelCreating(ModelBuilder modelBuilder)
=> modelBuilder.ApplyConfiguration(new DraftRowMap());
}
internal sealed class DraftRowMap : IEntityTypeConfiguration<DraftRow>
{
public void Configure(EntityTypeBuilder<DraftRow> row)
{
row.ToTable("LoanApplications", "applications", table =>
{
table.HasCheckConstraint("CK_Draft_Amount", "[RequestedAmount] > 0");
table.HasCheckConstraint("CK_Draft_Status",
"[Status] IN ('Draft', 'Submitted')");
});
row.HasKey(x => x.Id);
row.Property(x => x.BorrowerId).IsRequired().IsConcurrencyToken();
row.Property(x => x.RequestedAmount).HasPrecision(28, 10);
row.Property(x => x.Currency).HasColumnType("char(3)").IsRequired();
row.Property(x => x.Status).HasConversion<string>()
.HasMaxLength(24).IsRequired();
row.Property(x => x.UpdatedAt).IsRequired();
row.Property(x => x.Version).IsRowVersion();
}
}
public static class DraftMapping
{
public static DraftRow ToRow(LoanApplication draft) => new()
{
Id = draft.Id.Value,
BorrowerId = draft.BorrowerId.Value,
RequestedAmount = draft.RequestedAmount.Amount,
Currency = draft.RequestedAmount.Currency,
Status = draft.Status,
UpdatedAt = draft.UpdatedAt
};
public static LoanApplication ToDomain(DraftRow row)
=> LoanApplication.Rehydrate(new LoanApplicationId(row.Id),
new BorrowerId(row.BorrowerId),
Money.Create(row.RequestedAmount, row.Currency),
row.Status, row.UpdatedAt);
}
public static class DraftVersionCodec
{
public static string Encode(byte[] version) => Convert.ToBase64String(version);
public static bool TryDecode(string? encoded, out byte[] version)
{
version = [];
if (string.IsNullOrWhiteSpace(encoded)) return false;
try
{
var decoded = Convert.FromBase64String(encoded);
if (decoded.Length != 8) return false; // SQL Server rowversion is 8 bytes.
version = decoded;
return true;
}
catch (FormatException) { return false; }
}
}
Draft row decisions
Column
Decision
What must be checked
Id / BorrowerId
Stable GUID identities; owner is immutable
Owner comes from trusted identity; SQL update also constrains owner.
RequestedAmount / Currency
Decimal plus ISO-style three-character code
Agree currency and rounding policy before final precision; avoid floating point.
Status / UpdatedAt
Explicit enum conversion and UTC-aware timestamp
Review every stored state and migration when transitions expand.
Version
SQL Server rowversion, encoded as opaque Base64
It is a change token, not a timestamp or business field.
Migration is reviewed source code, not an invisible side effect
The migration below shows the schema this mapping expects. In Step 4 we will generate the actual migration from the real model, review its SQL and rollback implications, then apply it in a controlled environment. Startup should not silently migrate a production database. A constraint rejects impossible storage values even if a writer bypasses the domain, but it does not replace domain validation.
Initial SQL Server migration shape · illustrative C# 14
using Microsoft.EntityFrameworkCore.Migrations;
namespace ClearLend.Infrastructure.Examples;
// Representative migration; generate and review the real migration in Step 4.
public sealed class InitialDrafts : Migration
{
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.EnsureSchema(name: "applications");
migrationBuilder.CreateTable(
name: "LoanApplications",
schema: "applications",
columns: table => new
{
Id = table.Column<Guid>(type: "uniqueidentifier", nullable: false),
BorrowerId = table.Column<Guid>(type: "uniqueidentifier", nullable: false),
RequestedAmount = table.Column<decimal>(type: "decimal(28,10)", nullable: false),
Currency = table.Column<string>(type: "char(3)", nullable: false),
Status = table.Column<string>(type: "nvarchar(24)", maxLength: 24, nullable: false),
UpdatedAt = table.Column<DateTimeOffset>(type: "datetimeoffset", nullable: false),
Version = table.Column<byte[]>(type: "rowversion", rowVersion: true, nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_LoanApplications", x => x.Id);
table.CheckConstraint("CK_Draft_Amount", "[RequestedAmount] > 0");
table.CheckConstraint("CK_Draft_Status", "[Status] IN ('Draft', 'Submitted')");
});
}
protected override void Down(MigrationBuilder migrationBuilder)
=> migrationBuilder.DropTable(name: "LoanApplications", schema: "applications");
}
Case 2 · Save one draft with an atomic version check
The write adapter first loads by application ID and borrower ID, without tracking, then reconstructs the aggregate for the Domain method. On save, it attaches a row with the expected version as EF’s original concurrency value. EF includes the original rowversion and owner in the UPDATE predicate. If another writer has committed, zero rows match and EF raises DbUpdateConcurrencyException. We translate only that known outcome into a conflict; network failures and invalid SQL are not mislabeled as normal user conflicts.
IDraftWriter EF adapter · illustrative C# 14
using ClearLend.Application.Examples;
using ClearLend.Domain.Examples;
using Microsoft.EntityFrameworkCore;
namespace ClearLend.Infrastructure.Examples;
public sealed class EfDraftWriter(ClearLendDbContext db) : IDraftWriter
{
public async Task<string> AddAsync(LoanApplication draft,
CancellationToken cancellationToken)
{
var row = DraftMapping.ToRow(draft);
db.Drafts.Add(row);
await db.SaveChangesAsync(cancellationToken);
return DraftVersionCodec.Encode(row.Version); // Returned after commit.
}
public async Task<LoadedDraft?> FindOwnedForChangeAsync(
LoanApplicationId id, BorrowerId borrowerId,
CancellationToken cancellationToken)
{
var row = await db.Drafts.AsNoTracking().SingleOrDefaultAsync(
x => x.Id == id.Value && x.BorrowerId == borrowerId.Value,
cancellationToken);
return row is null ? null : new LoadedDraft(
DraftMapping.ToDomain(row), DraftVersionCodec.Encode(row.Version));
}
public async Task<SavedDraft> SaveAsync(LoanApplication draft,
string expectedVersion, CancellationToken cancellationToken)
{
if (!DraftVersionCodec.TryDecode(expectedVersion, out var originalVersion))
return new SavedDraft(false, null);
var row = DraftMapping.ToRow(draft);
row.Version = originalVersion;
db.Drafts.Attach(row);
var entry = db.Entry(row);
entry.Property(x => x.BorrowerId).OriginalValue = draft.BorrowerId.Value;
entry.Property(x => x.Version).OriginalValue = originalVersion;
entry.Property(x => x.RequestedAmount).IsModified = true;
entry.Property(x => x.Currency).IsModified = true;
entry.Property(x => x.Status).IsModified = true;
entry.Property(x => x.UpdatedAt).IsModified = true;
try
{
await db.SaveChangesAsync(cancellationToken);
return new SavedDraft(true, DraftVersionCodec.Encode(row.Version));
}
catch (DbUpdateConcurrencyException)
{
entry.State = EntityState.Detached;
return new SavedDraft(false, null);
}
}
}
Two tabs editing one draft
Moment
Tab A
Tab B
Read
Gets version 4.
Gets version 4.
Save
Commits amount change; receives version 5.
Still holds version 4.
Second save
No new action.
UPDATE with version 4 matches zero rows; show conflict.
The application handler compares the supplied version with the one it loaded to reject an already stale request early. That comparison alone cannot prevent a race between load and save; the SQL update is the decisive check. After a conflict, the borrower must reload and choose what to keep. An automatic blind retry could overwrite a newer decision. The example returns a fresh token only after SaveChangesAsync completes. When real HTTP contracts are built, malformed Base64 should be distinguished from a well-formed stale version, so the former can be a 400 rather than a 409.
Case 3 · Read only the authorized projection
GetDraft is a CQRS query, not an aggregate mutation. The reader filters on both requested application ID and the borrower derived from trusted identity before selecting response fields. AsNoTracking avoids change-tracker work for a read-only response. The projection also avoids loading evidence or future child collections the screen does not need. An absent ID and another owner’s ID both return no row to this borrower.
IDraftReader EF projection · illustrative C# 14
using ClearLend.Application.Examples;
using ClearLend.Domain.Examples;
using Microsoft.EntityFrameworkCore;
namespace ClearLend.Infrastructure.Examples;
public sealed class EfDraftReader(ClearLendDbContext db) : IDraftReader
{
public async Task<DraftDetails?> ReadOwnedAsync(LoanApplicationId id,
BorrowerId borrowerId, CancellationToken cancellationToken)
{
var row = await db.Drafts.AsNoTracking()
.Where(x => x.Id == id.Value && x.BorrowerId == borrowerId.Value)
.Select(x => new
{
x.Id, x.RequestedAmount, x.Currency,
x.Status, x.UpdatedAt, x.Version
})
.SingleOrDefaultAsync(cancellationToken);
return row is null ? null : new DraftDetails(
new LoanApplicationId(row.Id), row.RequestedAmount,
row.Currency, row.Status.ToString(), row.UpdatedAt,
DraftVersionCodec.Encode(row.Version));
}
}
The API project calls the Infrastructure registration method at startup. Scoped DbContext and adapters share one request scope; a new scope is needed for background jobs. The connection string comes from configuration or a secret store, never a source literal. Domain and Application tests can supply fakes without loading SQL packages.
using ClearLend.Application.Examples;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
namespace ClearLend.Infrastructure.Examples;
public static class InfrastructureRegistration
{
public static IServiceCollection AddClearLendInfrastructure(
this IServiceCollection services, string connectionString)
{
ArgumentException.ThrowIfNullOrWhiteSpace(connectionString);
services.AddDbContext<ClearLendDbContext>(options =>
options.UseSqlServer(connectionString));
services.AddScoped<IDraftWriter, EfDraftWriter>();
services.AddScoped<IDraftReader, EfDraftReader>();
return services;
}
}
Where transactions begin and end
The current create and edit examples each save one row in one SaveChangesAsync call. For a later SubmitDraft command, a single database transaction should commit the state transition, append-only audit record and outbox message together. A worker publishes the outbox item after commit, records delivery attempts and uses a stable idempotency key. A remote notification or simulated payment call must not be made inside the SQL transaction; the provider may succeed while SQL rolls back, or vice versa. We will design and test that workflow when submission is implemented.
External adapters use explicit contracts
Onboarding eligibility: call the owning module’s interface with borrower identity and cancellation; do not query its private tables through the Applications DbContext.
Document storage: store a reference and metadata in the owning module; never place raw evidence blobs or access tokens in a draft read model or log.
Notification delivery: keep retry, timeout and idempotency behavior in a dedicated adapter or worker, and distinguish accepted delivery from completed business action.
Clock and IDs: inject a TimeProvider and ID source so use cases remain repeatable in tests; Infrastructure or the host supplies production implementations.
How Step 4 will prove the hard parts
Build a fresh SQL Server schema from the generated migration and round-trip a draft through the adapter, including value conversion and time.
Use two DbContext instances to read the same version; let one save, then assert the other gets a conflict and the winning amount remains in SQL.
Attempt reads and writes with another borrower ID and confirm no data is disclosed or changed.
Exercise rollback after an audit or outbox insert fails; no partial submission may survive.
Inspect generated SQL and query plans under representative data before deciding on indexes or compiled queries. EF InMemory and SQLite cannot prove SQL Server rowversion semantics.
Performance, security and common mistakes
Use async EF I/O and forward CancellationToken. Do not wrap synchronous domain rules in Task.Run or assume parallel calls through one DbContext are safe.
Project narrow read models, measure request and SQL timings, and add indexes for observed predicates. The primary-key lookup serves this single-draft query; a later borrower list may justify a BorrowerId index after measurement.
Keep credentials in managed configuration, use least-privilege database access, parameterized EF queries and structured logs that omit application evidence and tokens.
Do not return DraftRow or tracked EF entities through Application or API; do not let a handler call DbContext directly.
Do not swallow DbUpdateException as Conflict, silently auto-retry stale changes, or call a remote provider inside a long-held SQL transaction.
Infrastructure is credible when the storage design is explicit, concurrent writes are settled by SQL, and provider-specific behavior is proven with provider tests.
Step 1 · Chapter 06 / 745 min read
API design masterclass: a contract clients can trust
Follow one borrower from creating a draft to reopening it and safely changing its amount. Every route, identity check, response and limit has a reason. The API translates HTTP into the Application use cases from the previous chapters; lending rules remain in the Domain.
Read this chapter as a sequence of decisions
Design the contract: resources, methods, editable fields and compatibility.
Establish trust: validate the token, check permission, then enforce ownership.
Protect changes: require a version and make the database decide who wins.
Control cost: rate limits, body limits, time budgets and bounded queries.
Make failure usable: safe errors, predictable retries and observable outcomes.
Prove the boundary: contract, security, concurrency and deployment tests.
1. Start with the borrower journey
Phase 3 established an Applications boundary and an owned draft journey. That gives us three client intentions: start a draft, reopen an existing draft, and change its requested amount. Define each operation with its caller, permission, input, success response and failure behavior before choosing endpoint syntax. An endpoint is a public promise; a database table is an internal storage choice.
Proposed v1 resource contract
Operation
Request
Success and meaning
Start a draft
POST /api/v1/applications/drafts
201 Created; Location identifies the new resource; response contains its ID.
Reopen an owned draft
GET /api/v1/applications/{id}
200 with a deliberately small representation and a strong ETag.
Change the amount
PATCH /api/v1/applications/{id} + If-Match
204, no response body; ETag identifies the updated representation.
POST allocates a new identity. GET has no requested side effects. PATCH changes a defined part of the same resource that GET reads, so its If-Match condition refers to that resource. Our application/json patch document requires both requestedAmount and currency; it is a specifically documented operation, not JSON Patch or JSON Merge Patch. Currency confirms the existing currency and cannot switch it. If we later support independent optional fields, we must define omitted versus null values and atomicity explicitly. PUT would mean replacing the target representation; calling a partial amount edit PUT would mislead clients.
The v1 prefix is a proposed compatibility boundary, not evidence of a versioning framework. UUIDs make identities convenient to generate; their unpredictability is never an access-control mechanism. A well-formed ID owned by another borrower receives the same 404 response as a missing ID.
2. Give HTTP code a deliberate home
Proposed API and Contracts folders · implementation comes later
Contracts contains transport records, not EF entities or Domain aggregates. API owns HTTP headers, status codes, bearer configuration and the trusted actor adapter. Application owns use cases and ports. Infrastructure is referenced at the composition root to supply implementations. The examples use an Examples namespace to keep their educational status explicit; filenames can be split along the proposed structure when implementation begins.
3. Model only what the client may control
Request records contain amount and currency. They cannot set borrower ownership, approval state, timestamps or persistence versions. That prevents mass assignment by construction. Output records expose the fields required to resume a draft without leaking internal navigation properties or future compliance data. Validate the input boundary, then let Application and Domain enforce their own rules for callers that never use HTTP.
Contracts · complete request and response records
using System.ComponentModel.DataAnnotations;
namespace ClearLend.Contracts.Examples;
// Only client-editable fields appear in a request. Owner, status and version
// are deliberately absent: they come from identity, domain and HTTP headers.
public sealed record CreateDraftRequest(
[property: Range(typeof(decimal), "0.01", "999999999999.99")]
decimal RequestedAmount,
[property: Required, RegularExpression("^[A-Z]{3}$")]
string Currency);
public sealed record ChangeDraftAmountRequest(
[property: Range(typeof(decimal), "0.01", "999999999999.99")]
decimal RequestedAmount,
[property: Required, RegularExpression("^[A-Z]{3}$")]
string Currency);
public sealed record CreatedDraftResponse(Guid Id);
public sealed record DraftResponse(Guid Id, decimal RequestedAmount,
string Currency, string Status, DateTimeOffset UpdatedAt);
These annotations reject a non-positive or oversized amount and require an uppercase three-letter currency shape. Shape does not prove that a currency is supported. The earlier Money value object and eligibility policy must still validate business meaning. Before SQL implementation, agree currency-specific decimal precision and reject excess precision rather than silently rounding monetary input. .NET 10 AddValidation enables the annotated Minimal API validation; malformed JSON and binding failures are separate framework errors. Verify their actual response shape instead of assuming every 400 is identical.
4. Separate authentication, permission and ownership
Authentication answers who presented a valid access token. Endpoint policies answer whether that identity has the read or write scope and a valid borrower identity. The use case and storage adapter then answer whether this particular draft belongs to that borrower. Passing the first two checks does not grant access to every application. Keep owner predicates in both reads and writes; a check followed by an unscoped update is not sufficient.
API / Security · request-scoped actor mapping
using System.Security.Claims;
using ClearLend.Application.Examples;
using ClearLend.Domain.Examples;
namespace ClearLend.Api.Examples;
// Scoped to one request. Only the configured authentication handler establishes
// trust; decoding an arbitrary JWT yourself would not establish identity.
public sealed class HttpCurrentActor(IHttpContextAccessor accessor) : ICurrentActor
{
public BorrowerId? BorrowerId => ReadBorrower(accessor.HttpContext?.User);
public static BorrowerId? ReadBorrower(ClaimsPrincipal? user)
{
if (user?.Identity?.IsAuthenticated != true) return null;
var claims = user.FindAll("borrower_id").ToArray();
return claims.Length == 1
&& Guid.TryParse(claims[0].Value, out var id) && id != Guid.Empty
? new BorrowerId(id) : null;
}
}
This proposal trusts one configured issuer to supply exactly one non-empty borrower_id claim. The issuer must control that mapping; a user-editable profile field is unsuitable. An alternative is a server-side lookup from issuer and subject to the internal borrower ID. MapInboundClaims is disabled so the host and policies use the issuer’s original claim names. A multi-issuer or multi-tenant design must include the trust domain in identity and limiter keys and add tenant ownership checks throughout storage.
Use an established OIDC/OAuth provider and validate access tokens: signature, issuer, audience and lifetime. An ID token is not an API access token.
The API returns a bearer challenge for unauthenticated callers. It does not redirect JSON clients to a login page.
Missing scope or an invalid borrower mapping returns 403 after successful authentication. An owned-resource lookup uses 404 to avoid an existence disclosure.
The browser login flow remains a separate decision. Compare Authorization Code with PKCE and a backend-for-frontend. Do not treat browser token storage as a solved problem.
Cookie authentication requires a CSRF design for state-changing requests. CORS alone does not supply that protection.
5. Make failures a stable part of the contract
Clients need to distinguish a correctable input error, an expired sign-in, a stale edit and an unavailable dependency. Use HTTP status for the broad meaning, a stable machine code for the use case, and safe human text for explanation. Clients should not branch on English message text. RFC 9457 Problem Details supplies the envelope; validation responses may additionally contain field errors.
Failure contract and client action
Status
ClearLend meaning
Client response
400
Malformed input, validation failure or unsupported If-Match format
Correct input; display field errors when present.
401
Missing or invalid access token; includes WWW-Authenticate
Re-establish sign-in through the client authentication flow.
403
Valid identity lacks scope, borrower mapping or eligibility
Explain the restriction; do not retry unchanged credentials.
404
Missing or unowned draft
Show the same unavailable message for either case.
409
Draft state forbids the operation, such as editing a submitted draft
Reconcile the business state.
412
The supplied strong ETag is stale
Reload, show the current value and obtain an explicit new edit.
428
The edit omitted If-Match
Read the draft and send its ETag.
413 / 415
Body too large / unsupported content type
Correct the request; infrastructure may supply the response body.
Show a reference ID; retry only under an operation-specific policy.
API / Http · application outcomes to Problem Details
using ClearLend.Application.Examples;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
namespace ClearLend.Api.Examples;
public static class ApiFailures
{
public static IResult From(AppFailure failure, HttpContext http)
{
// Let the configured bearer handler issue the mandatory challenge.
if (failure.Kind == FailureKind.Unauthenticated) return Results.Challenge();
var (status, title) = failure switch
{
{ Kind: FailureKind.Unauthenticated } => (401, "Authentication required"),
{ Kind: FailureKind.Invalid } => (400, "Invalid request"),
{ Kind: FailureKind.Ineligible } => (403, "Action not permitted"),
{ Kind: FailureKind.NotFound } => (404, "Draft not found"),
{ Code: "draft.stale" } => (412, "Draft has changed"),
{ Kind: FailureKind.Conflict } => (409, "Draft state conflict"),
_ => (500, "Unexpected error")
};
// Detail is a curated application message, never an exception body.
var problem = new ProblemDetails
{
Type = "about:blank",
Title = title,
Status = status,
Detail = status == 500 ? null : failure.Message,
Instance = http.Request.Path
};
problem.Extensions["code"] = failure.Code;
problem.Extensions["traceId"] = http.TraceIdentifier;
return Results.Problem(problem);
}
public static IResult MissingIfMatch(HttpContext http)
=> Problem(http, 428, "precondition.required",
"Send the ETag from the latest draft as If-Match.");
public static IResult InvalidIfMatch(HttpContext http)
=> Problem(http, 400, "precondition.invalid",
"If-Match must contain one strong draft ETag.");
private static IResult Problem(HttpContext http, int status,
string code, string message)
{
var problem = new ProblemDetails
{
Type = "about:blank",
Title = status == 428 ? "Precondition required" : "Invalid precondition",
Status = status,
Detail = message,
Instance = http.Request.Path
};
problem.Extensions["code"] = code;
problem.Extensions["traceId"] = http.TraceIdentifier;
return Results.Problem(problem);
}
}
The stale application result is checked before the general Conflict branch so it maps to 412. Other domain-state conflicts retain 409. Results.Challenge delegates the 401 challenge header to the configured bearer handler. The host’s status-code middleware fills otherwise empty errors; infrastructure-generated failures and unsupported Accept headers can still differ. Do not promise a universal envelope until the integration tests exercise those paths. This excerpt uses about:blank; stable, documented problem-type URIs can follow when their documentation exists.
6. Prevent lost updates with a real precondition
Imagine two browser tabs both loading version A. Tab one changes the amount and creates version B. Tab two still sends If-Match for A. It must receive 412 rather than overwrite tab one. The header is an opaque concurrency condition, not an authorization credential. The Application handler checks the loaded version for a useful early failure, and Infrastructure repeats the comparison atomically in the SQL update because another writer can win after the read.
API / Http · strong opaque draft ETag codec
using Microsoft.Extensions.Primitives;
namespace ClearLend.Api.Examples;
// A strong ETag wraps the opaque Base64 storage version. A future provider
// can change its bytes while clients still treat this value as an opaque tag.
public static class DraftEtags
{
public static string Write(string version) => string.Concat((char)34, version, (char)34);
public static bool TryRead(StringValues header, out string version)
{
version = string.Empty;
if (header.Count != 1) return false;
var value = header[0];
if (value is null || value.Length is < 3 or > 174
|| value[0] != '"' || value[^1] != '"') return false;
var candidate = value[1..^1];
Span<byte> decoded = stackalloc byte[128];
if (!Convert.TryFromBase64String(candidate, decoded, out var bytesWritten)
|| bytesWritten == 0) return false;
if (Convert.ToBase64String(decoded[..bytesWritten]) != candidate) return false;
version = candidate;
return true;
}
}
This deliberately narrow contract accepts one quoted, canonical Base64 token of at most 128 decoded bytes. It rejects weak tags, wildcard tags and lists rather than silently weakening the lost-update protection. HTTP permits broader If-Match syntax; those forms are outside this endpoint’s supported contract and receive 400. Clients must return the exact ETag without decoding it. A SQL rowversion can back the tag only while every field in the selected representation changes that version. Related-table fields, personalized projections or serializer changes may require a representation-specific validator strategy.
Illustrative wire exchange · synthetic data
GET /api/v1/applications/11111111-1111-4111-8111-111111111111
Authorization: Bearer <access-token>
HTTP/1.1 200 OK
ETag: "AAAAAAAAAAE="
Cache-Control: no-store
Content-Type: application/json
{"id":"11111111-1111-4111-8111-111111111111","requestedAmount":25000,
"currency":"GBP","status":"Draft","updatedAt":"2026-10-08T09:00:00Z"}
PATCH /api/v1/applications/11111111-1111-4111-8111-111111111111
Authorization: Bearer <access-token>
Content-Type: application/json
If-Match: "AAAAAAAAAAE="
{"requestedAmount":30000,"currency":"GBP"}
HTTP/1.1 204 No Content
ETag: "AAAAAAAAAAI="
Cache-Control: no-store
# A later edit with the old tag receives 412, not a silent overwrite.
7. Keep the endpoint thin, but complete
Each endpoint binds HTTP data, invokes the matching handler and translates its result. The cancellation token flows through the handler to database I/O. The handlers shown in the Application chapter supply eligibility, ownership and domain coordination. The API does not duplicate those rules. Route groups collect common policy and metadata so a new endpoint has an obvious security boundary.
API / Features / Applications / V1 · three endpoint implementations
using ClearLend.Application.Examples;
using ClearLend.Contracts.Examples;
using ClearLend.Domain.Examples;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Routing;
namespace ClearLend.Api.Examples;
public static class DraftEndpoints
{
public static RouteGroupBuilder MapDraftEndpoints(this IEndpointRouteBuilder routes)
{
var group = routes.MapGroup("/api/v1/applications")
.WithTags("Applications")
.RequireAuthorization()
.RequireRateLimiting("borrower");
// Draft data is private; browsers and shared caches must not retain it.
group.AddEndpointFilter(async (context, next) =>
{
context.HttpContext.Response.Headers.CacheControl = "no-store";
return await next(context);
});
group.MapPost("/drafts", Create)
.WithName("CreateDraft")
.RequireAuthorization("BorrowerWrite")
.Produces<CreatedDraftResponse>(201)
.ProducesProblem(400).ProducesProblem(401).ProducesProblem(403)
.ProducesProblem(429);
group.MapGet("/{id:guid}", Get)
.WithName("GetDraft")
.RequireAuthorization("BorrowerRead")
.Produces<DraftResponse>(200)
.ProducesProblem(400).ProducesProblem(401).ProducesProblem(403)
.ProducesProblem(404).ProducesProblem(429);
group.MapPatch("/{id:guid}", ChangeAmount)
.WithName("ChangeDraftAmount")
.RequireAuthorization("BorrowerWrite")
.Produces(204)
.ProducesProblem(400).ProducesProblem(401).ProducesProblem(403).ProducesProblem(404)
.ProducesProblem(409).ProducesProblem(412).ProducesProblem(428)
.ProducesProblem(429);
return group;
}
private static async Task<IResult> Create(CreateDraftRequest request,
CreateDraftHandler handler, HttpContext http, CancellationToken ct)
{
var result = await handler.Handle(new CreateDraftCommand(
request.RequestedAmount, request.Currency), ct);
if (!result.Succeeded) return ApiFailures.From(result.Failure!, http);
var created = result.Value!;
http.Response.Headers.ETag = DraftEtags.Write(created.Version);
return TypedResults.Created($"/api/v1/applications/{created.Id.Value}",
new CreatedDraftResponse(created.Id.Value));
}
private static async Task<IResult> Get(Guid id, GetDraftHandler handler,
HttpContext http, CancellationToken ct)
{
var result = await handler.Handle(
new GetDraftQuery(new LoanApplicationId(id)), ct);
if (!result.Succeeded) return ApiFailures.From(result.Failure!, http);
var draft = result.Value!;
http.Response.Headers.ETag = DraftEtags.Write(draft.Version);
return TypedResults.Ok(new DraftResponse(draft.Id.Value,
draft.RequestedAmount, draft.Currency, draft.Status, draft.UpdatedAt));
}
private static async Task<IResult> ChangeAmount(Guid id,
ChangeDraftAmountRequest request, ChangeDraftAmountHandler handler,
HttpContext http, CancellationToken ct)
{
if (!http.Request.Headers.TryGetValue("If-Match", out var header))
return ApiFailures.MissingIfMatch(http);
if (!DraftEtags.TryRead(header, out var version))
return ApiFailures.InvalidIfMatch(http);
var result = await handler.Handle(new ChangeDraftAmountCommand(
new LoanApplicationId(id), request.RequestedAmount,
request.Currency, version), ct);
if (!result.Succeeded) return ApiFailures.From(result.Failure!, http);
http.Response.Headers.ETag = DraftEtags.Write(result.Value!.Version);
return TypedResults.NoContent();
}
}
Create returns Location and the new ID so the browser can reopen the resource. Read returns the representation and ETag. Edit returns no body and the replacement tag. Cache-Control: no-store protects this private draft representation; ETag remains useful for concurrency even when caching is disabled. The host also sets no-store before authentication so rejected API responses receive the policy. Endpoint metadata documents expected responses, but ProducesProblem does not implement a response or verify it.
8. Compose the request pipeline deliberately
The host below is a complete security configuration excerpt with intentionally missing business adapters. Authentication runs before the actor-based limiter, which runs before authorization and endpoint execution. The global limiter is process-local admission control; because it also runs after authentication, it does not protect token-validation work. An upstream gateway needs its own unauthenticated traffic controls. Set trusted proxy handling before HTTPS redirection and authentication when deploying behind a proxy; accept forwarded headers only from known infrastructure.
API / Program.cs · proposed host configuration, not a runnable backend
using System.Globalization;
using System.Threading.RateLimiting;
using ClearLend.Api.Examples;
using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.AspNetCore.Mvc;
using Microsoft.IdentityModel.Tokens;
using Microsoft.AspNetCore.Authorization;
using ClearLend.Application.Examples;
var builder = WebApplication.CreateBuilder(args);
var authority = builder.Configuration["Authentication:Authority"]
?? throw new InvalidOperationException("Authentication authority is required.");
var audience = builder.Configuration["Authentication:Audience"]
?? throw new InvalidOperationException("API audience is required.");
var origins = builder.Configuration.GetSection("Web:AllowedOrigins").Get<string[]>()
?? throw new InvalidOperationException("Allowed browser origins are required.");
if (origins.Length == 0) throw new InvalidOperationException("Set an allowed origin.");
builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
.AddJwtBearer(options =>
{
options.Authority = authority; // Trusted issuer and signing keys.
options.Audience = audience; // Token must target this API.
options.RequireHttpsMetadata = true;
options.MapInboundClaims = false; // Keep sub and scope names unchanged.
options.IncludeErrorDetails = false;
options.TokenValidationParameters = new TokenValidationParameters
{
ValidateIssuer = true, ValidateAudience = true,
ValidateLifetime = true, ValidateIssuerSigningKey = true
};
});
builder.Services.AddAuthorizationBuilder()
.SetFallbackPolicy(new AuthorizationPolicyBuilder().RequireAuthenticatedUser().Build())
.AddPolicy("BorrowerRead", policy => policy.RequireAuthenticatedUser()
.RequireAssertion(context => HttpCurrentActor.ReadBorrower(context.User) is not null)
.RequireAssertion(context => context.User.FindAll("scope")
.SelectMany(c => c.Value.Split(' ', StringSplitOptions.RemoveEmptyEntries))
.Contains("applications.read")))
.AddPolicy("BorrowerWrite", policy => policy.RequireAuthenticatedUser()
.RequireAssertion(context => HttpCurrentActor.ReadBorrower(context.User) is not null)
.RequireAssertion(context => context.User.FindAll("scope")
.SelectMany(c => c.Value.Split(' ', StringSplitOptions.RemoveEmptyEntries))
.Contains("applications.write")));
builder.Services.AddCors(options => options.AddPolicy("ClearLendWeb", policy =>
policy.WithOrigins(origins)
.WithMethods("GET", "POST", "PATCH")
.WithHeaders("Authorization", "Content-Type", "If-Match")
.WithExposedHeaders("ETag", "Location", "Retry-After")));
builder.Services.AddProblemDetails(options => options.CustomizeProblemDetails = context =>
context.ProblemDetails.Extensions["traceId"] = context.HttpContext.TraceIdentifier);
builder.Services.AddValidation();
builder.Services.AddOpenApi();
builder.Services.AddHttpContextAccessor();
builder.Services.AddScoped<ICurrentActor, HttpCurrentActor>();
builder.Services.AddScoped<CreateDraftHandler>();
builder.Services.AddScoped<ChangeDraftAmountHandler>();
builder.Services.AddScoped<GetDraftHandler>();
builder.Services.AddSingleton(TimeProvider.System);
builder.WebHost.ConfigureKestrel(options => options.Limits.MaxRequestBodySize = 16 * 1024);
builder.Services.AddRateLimiter(options =>
{
options.RejectionStatusCode = StatusCodes.Status429TooManyRequests;
options.GlobalLimiter = PartitionedRateLimiter.Create<HttpContext, string>(http =>
RateLimitPartition.GetFixedWindowLimiter(
"edge:" + (http.Connection.RemoteIpAddress?.ToString() ?? "unknown"),
_ => new FixedWindowRateLimiterOptions
{
PermitLimit = 120, Window = TimeSpan.FromMinutes(1),
QueueLimit = 0, AutoReplenishment = true
}));
options.AddPolicy("borrower", http => RateLimitPartition.GetTokenBucketLimiter(
"actor:" + (HttpCurrentActor.ReadBorrower(http.User)?.Value.ToString()
?? http.Connection.RemoteIpAddress?.ToString() ?? "unknown"),
_ => new TokenBucketRateLimiterOptions
{
TokenLimit = 30, TokensPerPeriod = 30,
ReplenishmentPeriod = TimeSpan.FromMinutes(1),
QueueLimit = 0, AutoReplenishment = true
}));
options.OnRejected = async (context, _) =>
{
var http = context.HttpContext;
http.Response.StatusCode = StatusCodes.Status429TooManyRequests;
if (context.Lease.TryGetMetadata(MetadataName.RetryAfter, out var delay))
http.Response.Headers.RetryAfter = Math.Ceiling(delay.TotalSeconds)
.ToString(CultureInfo.InvariantCulture);
await Results.Problem(statusCode: 429, title: "Too many requests",
type: "about:blank",
extensions: new Dictionary<string, object?>
{ ["code"] = "request.rate_limited", ["traceId"] = http.TraceIdentifier })
.ExecuteAsync(http);
};
});
// Design excerpt: register IDraftWriter, IDraftReader, IBorrowerEligibility
// and ILoanApplicationIds before running the real host in Step 2/5.
var app = builder.Build();
app.UseExceptionHandler();
app.UseStatusCodePages(); // Problem Details for otherwise empty error responses.
app.Use(async (http, next) =>
{
if (http.Request.Path.StartsWithSegments("/api"))
http.Response.Headers.CacheControl = "no-store";
await next(http);
});
app.UseHttpsRedirection();
app.UseRouting();
app.UseCors("ClearLendWeb");
app.UseAuthentication();
app.UseRateLimiter();
app.UseAuthorization();
if (app.Environment.IsDevelopment()) app.MapOpenApi().AllowAnonymous();
app.MapDraftEndpoints();
app.Run();
Use tested .NET 10 package versions for JWT bearer and OpenAPI support. The Web SDK supplies the validation support used here; project layout changes can require explicit validation registration for referenced assemblies. Keep issuer, audience and allowed origins in environment configuration. Validate that deployment supplies HTTPS origins and a trusted authority. Add production HSTS at the appropriate HTTPS boundary, secret storage, readiness checks and explicit adapter registration when building the real host.
9. Treat rate limiting as a resource budget
The example admits at most 120 requests per IP per fixed minute and a borrower burst of 30 with 30 tokens replenished per minute. These are illustrative numbers, not measured production capacity. QueueLimit is zero so rejected work does not accumulate behind slow requests. A fixed window permits bursts around its boundary; the token bucket permits a controlled burst after idle time. Measure normal usage and expensive operations before choosing limits.
Choose the limiter for the scarce resource
Mechanism
Useful for
Trade-off
Fixed window
Simple per-period quotas
Boundary bursts and shared-IP fairness.
Sliding window
Smoother rolling request budgets
More bookkeeping and still no execution-cost model.
Token bucket
Interactive edits with a bounded burst
A token is not a measure of SQL or provider cost.
Concurrency limiter
Bounding simultaneous expensive operations
Controls in-flight work, not requests per minute.
Gateway quota
A coordinated policy across replicas
Requires deployment configuration and its own failure policy.
Partition by a trusted actor for authenticated operations. Never take the partition key from a user-supplied borrower header. IP limits can penalize users behind the same office or mobile gateway.
In-process counters are independent on every replica and reset on restart. Three replicas can admit more aggregate traffic; a shared quota needs an upstream or distributed design.
Reject with 429 and Retry-After when the limiter can estimate a delay. Some algorithms cannot; clients need bounded exponential backoff with jitter rather than a tight loop.
Body size, JSON complexity, request time, result size and concurrent downstream calls need separate budgets. One cheap GET and one expensive export are not equivalent work.
Limit untrusted key cardinality and monitor partition growth. Gateway protections and network controls are needed for volumetric abuse; application middleware alone is not DDoS protection.
10. Plan retries and idempotency before adding them
The current POST example does not implement idempotency. If the response is lost after commit, an automatic retry can create a second draft. Disabling a button helps usability but cannot prove uniqueness. Before enabling create retries, persist an idempotency record scoped to the authenticated actor and operation, with a key, canonical request fingerprint, state, response reference and expiry. Reserve the key and commit the outcome atomically with the write. The same key with different content must fail; concurrent duplicates must not execute twice.
For an edit whose response was lost, replaying the old If-Match can produce 412 even if the first write succeeded. Read the resource and let the user reconcile intent; never fetch the new ETag and silently reapply old input. Request cancellation also does not prove rollback: a commit may finish just before the client disconnects. Reliable recovery needs durable identifiers and explicit operation semantics.
11. Protect the rest of the boundary
CORS: allow only the intended browser origins and expose ETag, Location and Retry-After. CORS controls browser access to responses; it does not authenticate curl, a mobile app or another server.
Input and output: reject unsupported content types, cap request sizes at both proxy and host, and avoid logging request bodies, bearer tokens, financial evidence or raw provider responses.
Queries: future list endpoints need bounded page sizes, deterministic ordering with a unique tie-breaker, authorized filters and allowlisted sort fields. An opaque cursor still needs validation and must never bypass ownership checks.
External calls: use server-configured destinations, timeouts and bounded retries. Do not fetch arbitrary user-provided URLs. Validate provider output before it changes a domain decision.
Compatibility: adding optional fields can still break strict clients; enum additions and changed validation can be breaking. Diff OpenAPI in CI, test actual consumers and agree a migration window before retiring a version.
Operations: inventory every route and version, restrict administrative and diagnostic endpoints, rotate credentials and audit sensitive state changes separately from diagnostic logs.
12. Observe behavior without exposing the borrower
Capture request duration, route template, method, status, use-case outcome, rate-limit rejection count and dependency duration. Use route templates rather than raw IDs in metric labels to avoid unbounded cardinality. Correlate diagnostic events with a server-generated request reference and distributed trace context. A trace ID is a support aid, not an authorization token. Keep business audit records explicit about actor, action, target and outcome; protect and retain them under an agreed policy.
Async I/O releases a thread while waiting; Task.Run around an EF call does not make the database faster. Propagate cancellation, use bounded timeouts and avoid concurrent operations on the same DbContext. Performance evidence needs a stated workload, data volume, environment, warm-up and percentile latency. Track authorization failures and 412 rates as possible usability or abuse signals, not just 500s.
13. Prove the promises at their boundaries
Compilation checks API usage and example coherence. It cannot prove that JWT validation, JSON binding, middleware order, SQL races or browser CORS behavior work. The later runnable API step must use an HTTP test host with realistic policies, then verify persistence against SQL Server and the deployed proxy topology. A fake authentication scheme can exercise authorization but cannot prove JWT signature or issuer validation.
Implementation acceptance matrix
Risk
Exercise
Evidence required
Contract drift
Create, read and edit through HTTP
Status, Location, ETag, no-store, JSON schema and OpenAPI match.
Input ambiguity
Malformed JSON, missing currency, bad precision, oversized body
Documented rejection without a write or exception leak.
401 and bearer challenge using the real token handler.
Broken object access
Borrower A reads and edits borrower B’s draft
Same 404 shape as missing; database unchanged.
Policy bypass
Read-only scope, absent/duplicate borrower claim
403 before the handler; no mutation.
Lost update
Two independently loaded SQL versions race
One write wins; the other is rejected; winning value survives.
Precondition handling
Missing, weak, wildcard, list and stale tags
428, supported-format 400 or stale 412 as documented.
Exhaustion
Burst traffic and slow dependency under controlled load
Bounded memory/work; 429 and retry behavior; measured fairness.
Lost response
Disconnect at the commit boundary
Durable outcome can be reconciled; no blind duplicate creation.
Deployment mismatch
Multiple replicas, proxy headers and CORS preflight
Trusted client identity, intentional quota scope and visible headers.
14. The implementation handoff
This chapter is complete as an API design: the public contract, trust boundary, concurrency rule, resource budgets, failure vocabulary and evidence plan are explicit. The next implementation step should proceed in this order so each result has a small feedback loop.
Freeze the v1 decisions: route shapes, currency precision, claim mapping, scopes, allowed origins and initial limits.
Create the .NET solution and layer references; add architecture checks before feature code grows.
Implement the Domain and Application draft flow with focused unit tests and typed outcomes.
Add the SQL model, migration and owner-plus-version concurrency update; run persistence race tests.
Connect the Minimal API, bearer authentication, Problem Details, ETags, CORS and rate limits.
Run the acceptance matrix over HTTP, then record the exact responses and known limitations in the implementation notes.
Only after those checks pass, update the roadmap status from Coming soon to Complete.
Common mistakes to catch in review
A thin endpoint that delegates to a handler with no ownership check is still insecure.
A status-code declaration in OpenAPI is not an implemented error path.
A successful pre-read version check is not an atomic concurrency guarantee.
A rowversion encoded as Base64 is neither a secret nor permission to access the record.
An in-memory rate limiter is not a global customer quota.
An exception message is not a safe client error, and a generic 500 is not a useful business outcome.
Design examples that compile are not evidence of a deployed, load-tested backend.
Sources and decisions to revisit
The HTTP semantics and framework behavior below inform the examples. ClearLend-specific route shapes, claim names, limits and compatibility policy remain explicit proposals to confirm during implementation.
A strong API makes trust, state changes, failures and resource costs explicit. Step 5 must turn these design promises into executable HTTP and database evidence.
Step 1 · Chapter 07 / 738 min read
Turn architecture promises into evidence a reviewer can reproduce
A diagram explains intent; evidence shows whether the running system keeps that intent under failure, concurrency and hostile access. This chapter defines what to prove, where to prove it and what must be recorded before a roadmap step is called complete.
Read evidence as a chain, not a test count
Requirement: name the borrower promise or forbidden outcome.
Decision: identify the layer responsible for keeping that promise.
Exercise: trigger the success, boundary and failure cases at that layer.
Observation: capture the externally visible result and the durable state that remains.
Reproduction: record code revision, configuration, data shape and command.
Limitation: state what the exercise does not prove and which later boundary must cover it.
1. Trace one edit from intention to durable state
A vetted borrower reopens a draft and changes £25,000 to £30,000. The browser sends the last ETag. API authenticates the token and requires write scope. Application derives the borrower from the trusted actor, loads only that borrower’s draft and invokes a named Domain method. Infrastructure updates the owned row with the expected SQL version. API returns 204 and the replacement ETag. Every arrow has a distinct claim; a single end-to-end happy-path test cannot diagnose all of them.
Traceability ledger for ChangeDraftAmount
Promise
Owner
Primary evidence
Failure evidence
Only an authenticated borrower can edit
API
Valid access token reaches the handler
Missing/invalid token produces 401 and challenge
The token permits application writes
API policy
Write scope passes policy
Read-only scope produces 403 before mutation
A borrower cannot edit another owner’s draft
Application + Infrastructure
Owned lookup and update succeed
Cross-borrower ID produces the same 404 shape as missing
Only a draft may change amount
Domain
Draft transition updates Money and timestamp
Submitted state rejects without mutation
A stale browser cannot overwrite a newer save
Infrastructure + API
Expected version commits and returns a new ETag
Competing SQL update loses; API returns 412
Private data is not cached or logged
API + Operations
Response has no-store; telemetry uses safe dimensions
Automated scan finds no token, ID or body fields
2. Put each proof at the boundary that owns the risk
Use the smallest boundary that can settle the question. Domain tests are fast because no infrastructure is needed. Application tests use fakes to verify order, identity and cancellation. SQL Server tests are slower but necessary for rowversion, mappings and transactions. HTTP tests are the first place middleware, model binding and headers become real. A browser journey proves the user can complete the task. Repeating the same happy path at every level adds cost without adding confidence.
Evidence portfolio
Boundary
Must prove
Must not claim
Domain unit
Money rules, draft-only edits, state transitions
Authorization, persistence or HTTP behavior
Application unit
Actor use, orchestration, typed outcomes, cancellation
EF mappings or atomic concurrency
Architecture
Assembly dependency direction and forbidden references
Runtime correctness or business behavior
SQL integration
Migration, mapping, ownership predicates, rowversion and rollback
Bearer validation or browser usability
API integration
Routing, binding, policies, Problem Details, headers and middleware order
Real identity-provider configuration unless that provider participates
Browser journey
Create, reopen, edit, stale-state recovery and accessible feedback
Replica-wide limits or database race behavior
Deployment check
Proxy headers, TLS, CORS, health, secrets and multi-replica behavior
Every business rule already covered below
3. Enforce dependency direction continuously
A solution diagram becomes stale unless the build rejects forbidden dependencies. Architecture tests should name the actual assemblies and fail with the offending type names. They complement project references: a package or namespace can introduce coupling even when the folder still looks correct. Keep the rules few and structural—Domain stays independent; Application does not reference API, Infrastructure or EF; Contracts do not expose internal entities.
Architecture tests · executable dependency rules for Step 2
using NetArchTest.Rules;
namespace ClearLend.ArchitectureTests;
public sealed class DependencyTests
{
private static readonly Assembly Domain = typeof(LoanApplication).Assembly;
private static readonly Assembly Application = typeof(CreateDraftHandler).Assembly;
[Fact]
public void Domain_does_not_depend_on_outer_layers()
{
var result = Types.InAssembly(Domain)
.ShouldNot()
.HaveDependencyOnAny(
"ClearLend.Application",
"ClearLend.Infrastructure",
"ClearLend.Api",
"Microsoft.EntityFrameworkCore")
.GetResult();
Assert.True(result.IsSuccessful,
string.Join(Environment.NewLine,
result.FailingTypeNames ?? Array.Empty<string>()));
}
[Fact]
public void Application_does_not_depend_on_technical_adapters()
{
var result = Types.InAssembly(Application)
.ShouldNot()
.HaveDependencyOnAny(
"ClearLend.Infrastructure",
"ClearLend.Api",
"Microsoft.EntityFrameworkCore")
.GetResult();
Assert.True(result.IsSuccessful);
}
}
The first runnable solution should include one deliberate mutation exercise during review: temporarily add an EF reference to Domain, observe this test fail, then remove it. That proves the rule is connected to real assemblies rather than passing because its namespace pattern matches nothing.
4. Prove business rules without technical noise
Create a draft with deterministic IDs and time; assert owner, Draft status, Money and timestamps.
Reject zero, negative, unsupported-currency and over-precision values according to the approved money policy.
Allow an amount change only while Draft; assert a failed transition leaves every field unchanged.
Test submission prerequisites when that transition is introduced; avoid tests for public setters the model should not expose.
Name tests after observable rules. Coverage percentage can reveal untouched code, but it cannot decide whether the important examples exist.
5. Prove Application orchestration and trust
Application tests should make side effects observable through narrow fakes. For an anonymous actor, eligibility and storage must receive zero calls. For an ineligible borrower, the writer must not run. For a cross-owner lookup, the result is NotFound without revealing existence. For success, the same cancellation token reaches each I/O port and the returned version comes from the committed save. Verify meaningful order only where order protects a rule; brittle call-by-call scripts obscure intent.
CreateDraft decision table
Actor
Input
Eligibility
Expected result
Write count
Missing
Valid
Not consulted
Unauthenticated
0
Borrower
Invalid
Not consulted
Invalid
0
Borrower
Valid
Ineligible
Ineligible
0
Borrower
Valid
Eligible
Created ID + committed version
1
Borrower
Valid
Dependency fault
Exception reaches error boundary and telemetry
0 or unknown by failure point
6. Let SQL Server arbitrate concurrent writers
The decisive lost-update test uses the real provider and two independent DbContext scopes. Both load version A. The first commits version B. The second attempts an update constrained by owner and version A, affects no row and becomes a conflict. Finally read through a third scope and assert the first value survived. EF InMemory, one shared change tracker or a mocked SaveAsync cannot establish this behavior.
SQL Server concurrency test · implementation target for Step 4
// SQL Server integration test: two independent units of work hold version A.
var id = await fixture.CreateDraftAsync(borrowerA, 25_000m, "GBP");
await using var firstScope = fixture.Services.CreateAsyncScope();
await using var secondScope = fixture.Services.CreateAsyncScope();
var first = firstScope.ServiceProvider.GetRequiredService<IDraftWriter>();
var second = secondScope.ServiceProvider.GetRequiredService<IDraftWriter>();
var a = await first.LoadOwnedAsync(id, borrowerA, ct);
var b = await second.LoadOwnedAsync(id, borrowerA, ct);
Assert.Equal(a!.Version, b!.Version);
a.Aggregate.ChangeRequestedAmount(Money.Create(30_000m, "GBP"), now);
b.Aggregate.ChangeRequestedAmount(Money.Create(35_000m, "GBP"), now);
var firstSave = await first.SaveAsync(a.Aggregate, borrowerA, a.Version, ct);
var secondSave = await second.SaveAsync(b.Aggregate, borrowerA, b.Version, ct);
Assert.True(firstSave.Succeeded);
Assert.Equal(SaveOutcome.Conflict, secondSave.Outcome);
Assert.Equal(30_000m, (await fixture.ReadDraftAsync(id, borrowerA)).RequestedAmount);
Build an empty database from the migration and verify every required column, constraint and rowversion mapping.
Round-trip maximum approved precision and UTC timestamps without silent rounding or kind changes.
Attempt owned reads and writes with a second borrower and inspect both the result and unchanged database state.
Force failure between submission state, audit and outbox writes; assert the transaction leaves none of them partially committed.
Run tests against an isolated database per suite or reset state deterministically; parallel tests must not share mutable borrower records.
7. Exercise the API as an HTTP client sees it
Use WebApplicationFactory or the equivalent test host so routing, filters, middleware and serialization participate. Replace only external boundaries deliberately. A test authentication handler can create identities and scopes for authorization cases; a separate small suite must exercise the real JWT bearer handler with valid and invalid signed tokens. Assert status, headers, media type, stable machine code and durable state—not an entire JSON string whose property order is irrelevant.
API integration tests · ownership and stale-write promises
public sealed class DraftContractTests(ClearLendApiFactory factory)
: IClassFixture<ClearLendApiFactory>
{
[Fact]
public async Task Another_borrowers_draft_looks_missing()
{
var id = await factory.SeedDraftAsync(Borrowers.A, 25_000m, "GBP");
using var client = factory.CreateBorrowerClient(Borrowers.B,
scopes: ["applications.read", "applications.write"]);
var response = await client.GetAsync($"/api/v1/applications/{id}");
var problem = await response.Content.ReadFromJsonAsync<ProblemDetails>();
Assert.Equal(HttpStatusCode.NotFound, response.StatusCode);
Assert.Equal("draft.not_found", problem!.Extensions["code"]?.ToString());
Assert.Equal("no-store", response.Headers.CacheControl?.ToString());
}
[Fact]
public async Task Stale_edit_returns_precondition_failed()
{
using var client = factory.CreateBorrowerClient(Borrowers.A,
scopes: ["applications.read", "applications.write"]);
var created = await factory.CreateDraftThroughApiAsync(client);
var staleTag = created.ETag;
await factory.ChangeAmountThroughApiAsync(client, created.Id, 30_000m, staleTag);
var stale = await factory.ChangeAmountThroughApiAsync(
client, created.Id, 35_000m, staleTag);
Assert.Equal(HttpStatusCode.PreconditionFailed, stale.StatusCode);
}
}
Minimum HTTP contract matrix
Case
Expected HTTP evidence
State evidence
Create valid draft
201, Location, ETag, no-store, response ID
One owned Draft row
Malformed JSON / invalid money
400 with safe validation details
No row or update
No token / bad signature or issuer
401 with bearer challenge
Handler not reached
Read scope attempts edit
403
Draft unchanged
Missing or other owner’s ID
Same 404 contract
No disclosure or update
Missing / malformed / stale If-Match
428 / 400 / 412 as documented
No stale overwrite
Unsupported media type / oversized body
415 / 413
No handler write
Budget exceeded
429 and Retry-After when available
Bounded queued work
8. Test identity claims as hostile input
Reject expired tokens, invalid signatures, wrong issuer and wrong audience through the configured bearer handler.
Reject absent, empty, malformed and duplicate borrower_id claims; never choose the first duplicate silently.
Parse space-separated scopes exactly and prove applications.read cannot satisfy applications.write.
Use two borrowers and a valid target UUID to test broken-object authorization; random invalid IDs do not exercise ownership.
If tenants or multiple issuers are introduced, include the trust domain in actor mapping, ownership predicates and limiter partitions.
9. Make browser recovery part of correctness
The first browser journey is more than a green save button. It creates a draft, reloads the route, restores the amount and ETag, edits successfully, then simulates another tab changing the same draft. The stale tab must explain that newer data exists, preserve the user’s intended value long enough to compare it, and require an explicit decision after reload. Keyboard focus should move to the status message, errors must not rely on color, and disabled controls must not be the only duplicate-submit protection.
Browser states to design and prove
State
What the borrower sees
Required behavior
Loading
Stable skeleton or progress message
Edit controls cannot submit unknown state
Saved
Concise confirmation and current values
Client stores replacement ETag
Validation failed
Field-linked explanation
Focus reaches the first invalid field
Session expired
Sign-in recovery message
No blind replay until identity is restored
Stale edit
Current server value beside intended value
Borrower explicitly reconciles
Temporarily unavailable
Reference ID and safe retry guidance
Retries are bounded and do not duplicate creation
10. Observe the same journey without exposing it
Operational evidence should connect one browser request to API, SQL and external dependencies through trace context while keeping borrower and application identifiers out of metric dimensions. Logs may use protected correlation fields under an agreed retention policy, but must omit bearer tokens, request bodies, evidence content and provider secrets. Record route templates rather than raw paths. Separate diagnostic telemetry from the business audit record that answers who changed what and whether it succeeded.
Which API operation is slow and at what percentile?
Use-case outcome
Operation, stable result code
Are stale edits, validation failures or denials increasing?
SQL duration
Operation name, database system
Is latency inside persistence or elsewhere?
Rate-limit rejection
Policy name, trusted partition class
Is a budget too low, abused or uneven across replicas?
Dependency call
Known destination, operation, status
Which provider is failing or consuming the time budget?
Business audit
Protected actor, action, target, outcome
Who performed a sensitive state change?
11. Measure performance with a falsifiable experiment
A useful performance claim names the workload, environment and threshold before the run. For example: 70% owned GET, 20% PATCH and 10% POST; 100 concurrent synthetic borrowers; 100,000 drafts; two API replicas; a five-minute warm-up and fifteen-minute measurement window. Report p50, p95 and p99 latency, throughput, error rate, CPU, allocation rate, connection-pool pressure and SQL duration. One laptop average without data volume or warm-up is a demonstration, not a baseline.
Performance experiment record
Field
What to record
Revision and build
Commit, Release configuration, runtime and package lock
Topology
Machine/container sizes, replica count, proxy and network placement
Data
Draft count, ownership distribution, indexes and representative row sizes
Workload
Operation mix, arrival model, concurrency, payloads and think time
Budgets
Client timeout, server timeout, pool sizes, rate and concurrency limits
Results
Percentiles, throughput, failures, saturation signals and raw report location
Conclusion
Supported claim, remaining bottleneck and next experiment
Change one meaningful variable at a time. If a borrower index improves the list query, retain before-and-after query plans and workload results. If caching is proposed, define staleness, invalidation, authorization and memory bounds first. A faster response that leaks another borrower’s data or hides fresh changes is a regression.
12. Turn the delivery pipeline into a repeatable reviewer
CI should restore from a lock file, compile with warnings treated according to the agreed policy, run unit and architecture tests, start a disposable SQL Server for provider tests, run API integration tests and publish the deployable artifact. Cache inputs may speed the job but must not replace restore verification. Secrets belong in the CI secret store; pull requests from untrusted contexts should not receive production credentials.
CI shape · illustrative workflow for the runnable backend
Fail when formatting, compilation, architecture rules, tests or migration verification fail.
Generate OpenAPI from the built host and review a contract diff for breaking changes.
Publish test and coverage reports as diagnostic artifacts; do not use one global coverage percentage as the release decision.
Build the deployable artifact once, identify it by revision and promote that same artifact between environments.
Run post-deploy smoke checks for liveness, readiness and one synthetic authorized journey; make rollback criteria explicit.
13. Keep health checks narrow and truthful
Liveness answers whether the process should be restarted; it should not fail because SQL or an identity provider is briefly unavailable. Readiness answers whether this instance can accept its intended traffic and may include a bounded critical-dependency check. A deep diagnostic endpoint belongs behind operational authorization. Health responses must not expose connection strings, exception details, host names or dependency credentials.
14. Assemble the evidence dossier before changing status
A roadmap item moves to Complete only when a reviewer can find the artifact and reproduce its acceptance result. Keep the dossier small enough to maintain: a revision, commands, reports, selected traces or screenshots, migration identity, environment description and known limitations. Link evidence from the chapter rather than copying transient numbers into prose without context.
Completion dossier by roadmap step
Step
Required artifact
Acceptance evidence
02 · Runnable solution and CI
Solution, dependency tests, workflow and health endpoint
Fresh checkout builds; intentional dependency violation fails; CI and health pass
03 · Domain model and tests
Aggregate, values and focused rule tests
Valid transitions pass; forbidden states remain unchanged
04 · Persistence and concurrency
Migration, EF adapters and SQL Server suite
Round-trip succeeds; one of two stale writers loses without overwrite
05 · CQRS API features
Contracts, endpoints, auth and HTTP suite
Contract matrix passes with documented response shapes
06 · First usable journey
Angular flow and browser scenarios
Owner resumes and reconciles a stale edit; another borrower sees nothing
07 · Measured evidence
Versioned workload, raw results and analysis
Environment and percentiles are reproducible; claims match the data
Review questions before publication
Can every important promise point to one primary boundary and one reproducible exercise?
Does each test assert durable state as well as the response when a write is involved?
Are cross-owner and stale-writer cases tested with valid identities and real provider behavior?
Can telemetry diagnose the journey without exposing borrower data or creating unbounded labels?
Do performance numbers state revision, environment, data, workload, warm-up and percentiles?
Does Complete mean the acceptance evidence exists, rather than the implementation merely being started?
Are limitations and deferred production decisions visible beside the evidence they constrain?