5. The Lending Context¶
Your library has books. Now it needs patrons who borrow them. This is a separate concern with its own data, its own rules, and its own runtime. Same patterns, second project, zero coupling to Catalog.
Create the Project¶
Reference Contracts (for the shared IDs) and Deepstaging:
<ItemGroup>
<ProjectReference Include="..\Library.Contracts\Library.Contracts.csproj" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Deepstaging" />
</ItemGroup>
No reference to Library.Catalog. These two contexts don't know each other exists.
Entities¶
[StoredEntity]
public partial record Patron
{
public PatronId Id { get; init; }
[MaxLength(256)]
public string Name { get; init; } = "";
[MaxLength(256)]
public string Email { get; init; } = "";
public int ActiveLoanCount { get; init; }
}
[StoredEntity]
public partial record Loan
{
public LoanId Id { get; init; }
[Lookup]
public PatronId PatronId { get; init; }
public BookId BookId { get; init; }
public DateTime BorrowedAt { get; init; }
public DateTime DueAt { get; init; }
public DateTime? ReturnedAt { get; init; }
public bool IsOverdue { get; init; }
}
[Lookup] on PatronId generates a query method to find loans by patron — LendingStore.Loans.QueryByPatronId<RT>(...).
The Runtime¶
namespace Library.Lending;
using Deepstaging;
using Deepstaging.Jobs;
[Runtime]
[Uses(typeof(JobsModule))]
public partial class LendingRuntime;
[DataStore]
public static partial class LendingStore;
[DispatchModule]
public static partial class LendingDispatch;
[RestApi(RoutePrefix = "/api/lending", Title = "Library Lending API")]
public partial class LendingRestApi;
[Uses(typeof(JobsModule))] pulls in the built-in Jobs module — we'll need it in Chapter 7 for scheduled overdue checks. Local modules (store, dispatch, API) are discovered automatically.
BorrowBook — with Idempotency¶
public sealed record BorrowBook(
PatronId PatronId,
BookId BookId,
int LoanDays = 14) : ICommand;
public static class BorrowBookHandler
{
[Public, CommandHandler, HttpPost("/borrow"), RateLimit("default"), Idempotent]
public static Eff<LendingRuntime, LoanId> Handle(BorrowBook cmd) =>
from patron in LendingStore.Patrons.Require<LendingRuntime>(cmd.PatronId)
from id in liftEff<LendingRuntime, LoanId>(_ => LoanId.New())
let now = DateTime.UtcNow
let loan = new Loan
{
Id = id,
PatronId = cmd.PatronId,
BookId = cmd.BookId,
BorrowedAt = now,
DueAt = now.AddDays(cmd.LoanDays)
}
from _ in LendingStore.Loans.Save<LendingRuntime>(loan)
select id;
}
[Idempotent] means this handler is safe to retry. Double-clicking the borrow button doesn't create two loans. The framework handles deduplication — you just declare the intent.
Require on the patron fails the entire pipeline if the patron doesn't exist. No null checks, no if statements — the effect short-circuits.
ReturnBook¶
public sealed record ReturnBook(LoanId LoanId) : ICommand;
public static class ReturnBookHandler
{
[Public, CommandHandler, HttpPost("/return")]
public static Eff<LendingRuntime, Unit> Handle(ReturnBook cmd) =>
from loan in LendingStore.Loans.Require<LendingRuntime>(cmd.LoanId)
from _ in LendingStore.Loans.Save<LendingRuntime>(loan with { ReturnedAt = DateTime.UtcNow })
select unit;
}
loan with { ReturnedAt = DateTime.UtcNow } — the with expression creates a new loan record with the return date set. The original is never mutated.
Test the Lending Context¶
[TestRuntime<LendingRuntime>]
public partial class TestLendingRuntime;
[Test]
public async Task BorrowBook_CreatesLoan()
{
var runtime = TestLendingRuntime.CreateConfigured();
var patronId = PatronId.New();
var fin = await (
from _ in LendingStore.Patrons.Save<LendingRuntime>(
new Patron { Id = patronId, Name = "Alice", Email = "alice@example.com" })
from loanId in LendingDispatch.BorrowBook(patronId, BookId.New())
from loans in LendingDispatch.GetPatronLoans(patronId)
select loans
).RunAsync(runtime);
await Assert.That(fin).IsSuccMatching(r => r.Data.Count == 1);
}
Lending has its own runtime, its own store, its own tests. Catalog doesn't know it exists. That's the point — each context is independently testable.