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 / 710 min read
Give clients a stable, authorized HTTP boundary
The API authenticates a caller, maps HTTP to a use case, and translates its outcome into a predictable contract. It does not contain lending rules.
Design from client behaviour
A borrower needs to create a draft, save edits, and reload one owned draft. Candidate endpoints are POST /api/applications/drafts, PUT /api/applications/{id}/draft, and GET /api/applications/{id}. These are proposed routes, to be finalized alongside the real UI. Request and response records in Contracts expose only needed fields, including an opaque version value for updates.
Proposed HTTP outcomes
Situation
Response
Why
Draft created
201 Created with location, ID and version
The client can resume the new resource.
Invalid request
400 with field details
Input can be corrected without guessing.
Unauthenticated
401
No trusted actor is available.
Not authorized / not found
Policy-dependent 403 or 404
Avoid disclosing another borrower’s resource.
Stale expected version
409 Conflict
The client must inspect current state before retrying.
Trust boundaries
The API validates the authentication token and constructs the trusted actor context. Application authorization checks whether that actor may execute the use case on the requested resource. The Domain still rejects invalid state changes even if another caller bypasses HTTP in a test or worker. Development-only synthetic identity must be clearly isolated; a production deployment needs an agreed identity provider and policies.
Errors, versioning and observability
Map known application outcomes to consistent Problem Details responses. Do not expose exception traces or internal entities. Carry request cancellation to handlers. Record a correlation ID, operation name and outcome in structured logs, without logging sensitive evidence or raw tokens. Publish an OpenAPI description from the actual endpoints. Add contract versioning only when there is a real compatibility obligation; preserve stable response fields meanwhile.
Dependencies, testing and mistakes
Dependency: Application, Contracts and Infrastructure only at the composition root; no business rules in endpoint delegates.
Mistake: accepting BorrowerId from the request as proof of ownership.
Mistake: serializing domain or EF entities directly, making internal changes accidental API breaking changes.
Mistake: returning 500 for expected invalid transitions or leaking exception details to the browser.
Decision to carry forward
The API speaks HTTP clearly; the Application coordinates the use case; the Domain protects the business state.
Step 1 · Chapter 07 / 78 min read
Trace one draft and show what evidence will follow
A senior design is useful when its seams can be tested, observed and revised from evidence.
One request through the layers
A synthetic vetted borrower opens a draft. Angular calls the API with an expected version for an edit.
API authenticates and maps the request; Application checks eligibility and ownership.
Application loads the aggregate; LoanApplication.ChangeRequestedAmount checks Draft status and Money validity.
Infrastructure persists through EF Core using the expected version; SQL reports a conflict if another update won.
API maps success or conflict to the documented response; the UI either shows the saved version or asks the borrower to review newer data.
Test at the boundary that owns the risk
Domain unit tests prove invariants quickly. Application tests prove orchestration and authorization decisions. Architecture tests prove the dependency direction. SQL integration tests prove mappings, migrations, transactions and concurrency. API tests prove HTTP semantics. A browser journey later proves that the borrower can complete the task. One test type cannot substitute for all the others.
Performance without theatre
Use async I/O and cancellation from the outset. Capture baseline latency, throughput, allocation and SQL timings under a named workload before optimizing. A comparison must record machine, data volume, concurrency, warm-up and percentile latency. Introduce parallelism only for independent work with a measured benefit and safe limits; CPU-bound work and provider calls have different constraints. Thread count alone is not a quality metric.
Security and delivery
The first workflow uses only synthetic identities and records. Secrets stay out of the repository. Request logs omit evidence and payment details. The later CI and deployment steps will make checks repeatable, but this chapter is the architecture explanation—not proof of a deployed API.
What the next step must produce
Step 2 will create the first real solution and CI. It must compile locally, start an API health endpoint and fail an architecture test when a dependency points outward. At that point, we can replace proposed file names in this chapter with links to actual code and evidence.