Skip to content

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

dotnet new classlib -n Library.Lending -o src/Library.Lending
dotnet sln add src/Library.Lending

Reference Contracts (for the shared IDs) and Deepstaging:

src/Library.Lending/Library.Lending.csproj
<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

src/Library.Lending/Domain/Patron.cs
[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; }
}
src/Library.Lending/Domain/Loan.cs
[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

src/Library.Lending/Runtime.cs
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

src/Library.Lending/Domain/Commands/BorrowBook.cs
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

src/Library.Lending/Domain/Commands/ReturnBook.cs
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

test/Library.Tests/Lending/TestLendingRuntime.cs
[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.

Next → Domain Events