DEV Community

Ricardo Groß
Ricardo Groß

Posted on Edited on AI-assisted

The Interval Is Still the Thing: Booking Cottages with Range Types in .NET 10

In June I wrote The Interval Is the Thing, about modelling a period as one value instead of two columns. CodoMetis.ValueRanges was at 3.0 then, with six range types. Today it's 10.4, on .NET 10 LTS, and it does a lot more. It has value sets, a NodaTime package, TimeRange, float ranges, and range operations that run in PostgreSQL and agree with the in-memory answer.

This post doesn't repeat the basics (the five shapes, the factories, the set operators). It builds one small thing end to end: a holiday-cottage booking API. It starts with the domain model, goes through a query, and ends with the question June's example only half answered: which nights are still free?

dotnet add package CodoMetis.ValueRanges.EFCore.PostgreSQL
Enter fullscreen mode Exit fullscreen mode

The model: immutable values, guarded entities

A DateRange and a StringSet are immutable values. Nothing can change one after it's made, and two with the same contents are equal. The entities that hold them should be just as strict: no public setters, a constructor or factory that enforces the rules, and a method for each change the domain allows.

public sealed class Cottage
{
    public Cottage(string name, StringSet amenities, DateRange season)
    {
        ArgumentException.ThrowIfNullOrWhiteSpace(name);
        if (season is DateRange.EmptyRange)
            throw new DomainException($"{name} needs a season.");

        Name = name;
        Amenities = amenities;
        Season = season;
    }

    public int Id { get; private set; }
    public string Name { get; private set; }
    public StringSet Amenities { get; private set; }   // text[]
    public DateRange Season { get; private set; }      // daterange

    public void AddAmenity(string amenity) => Amenities = Amenities.Add(amenity);
}
Enter fullscreen mode Exit fullscreen mode

StringSet is new since June. It's a value set: deduplicated, sorted ordinally, with no nulls, and stored as a native PostgreSQL array. new Cottage("Dune", ["sea-view", "fireplace", "sea-view"], season) holds {fireplace,sea-view}. AddAmenity doesn't modify the set. Add returns a new one, and the cottage swaps it in. That's also how EF Core sees the change: it compares the old set with the new one by value.

A stay is booked, never constructed directly, and the only change it allows afterwards is being cancelled:

public sealed class Stay
{
    public const int MaxNights = 28;

    private Stay(int cottageId, string guest, DateRange nights)
    {
        CottageId = cottageId;
        Guest = guest;
        Nights = nights;
    }

    public int Id { get; private set; }
    public int CottageId { get; private set; }
    public string Guest { get; private set; }
    public DateRange Nights { get; private set; }      // daterange
    public bool IsCancelled { get; private set; }

    public static Stay Book(Cottage cottage, string guest, DateRange nights)
    {
        if (CheckNights(nights) is { } problem)
            throw new DomainException(problem);
        if (!cottage.Season.Contains(nights))
            throw new DomainException($"{cottage.Name} is closed for some of those nights.");

        return new Stay(cottage.Id, guest, nights);
    }

    public void Cancel() => IsCancelled = true;

    // CheckNights comes further down.
}

public sealed class DomainException(string message) : Exception(message);
Enter fullscreen mode Exit fullscreen mode

EF Core is happy with all of this. It materializes through the private constructor and writes Id through the private setter. Arrive on 1 July and leave on 5 July and you sleep four nights:
[2026-07-01,2026-07-04]. That's one value, with no "is the end date inclusive?" discussion in code review.

The setup is one call:

builder.Services.AddDbContext<RentalContext>(options => options
    .UseNpgsql(connectionString, npgsql => npgsql.UseValueRanges()));
Enter fullscreen mode Exit fullscreen mode

No double bookings, enforced by the database

Stay.Book can check the season, but it can't see other bookings. Two requests can check the same cottage at the same moment, both see it free, and both insert. Only a database constraint closes that gap. In PostgreSQL that's an exclusion constraint, and EF Core has no API for one. In June I wrote it as raw SQL.

That gap is why I also maintain EFCore.ComplexIndexes.
It's a small family of three packages: a core package plus .PostgreSQL and .SqlServer. Each one lets OnModelCreating declare what dotnet ef migrations add can't write on its own:

  • indexes on the columns of complex types (your value objects);
  • expression and JSON-member indexes;
  • PostgreSQL's index methods and options;
  • SQL Server's clustered, covering and online indexes;
  • temporal and exclusion constraints.
dotnet add package EFCore.ComplexIndexes.PostgreSQL
Enter fullscreen mode Exit fullscreen mode
protected override void OnModelCreating(ModelBuilder model)
{
    model.Entity<Cottage>().HasIndex(c => c.Amenities).HasMethod("gin");

    model.Entity<Stay>().HasExclusionConstraint(ex => ex
        .WithEquality(s => s.CottageId)
        .WithOverlaps(s => s.Nights)
        .HasFilter(s => !s.IsCancelled)
        .HasName("ex_stays_cottage_nights"));
}
Enter fullscreen mode Exit fullscreen mode

The package registers itself with EF Core's design-time tools, so migrations add picks it up with no further setup. (If you create the schema with EnsureCreated() instead, add .UseNpgsqlComplexIndexes() to the options, or the constraint is silently left out.) The migration includes the btree_gist extension, which the = on an integer needs:

CREATE EXTENSION IF NOT EXISTS btree_gist;
CREATE INDEX "IX_Cottages_Amenities" ON "Cottages" USING gin ("Amenities");
ALTER TABLE "Stays" ADD CONSTRAINT "ex_stays_cottage_nights"
  EXCLUDE USING gist ("CottageId" WITH =, "Nights" WITH &&) WHERE (NOT ("IsCancelled"));
Enter fullscreen mode Exit fullscreen mode

The two families don't reference each other, and they don't need to. ComplexIndexes declares a constraint over a column. ValueRanges makes that column a daterange. So they compose without any glue code, and the constraint lives in your migrations like any other schema change.

The filter is the reason to pick an exclusion constraint over PostgreSQL 18's UNIQUE … WITHOUT OVERLAPS (HasTemporalConstraint in the same package). A cancelled stay stops blocking its nights, and the history stays in the table.

A clash fails with 23P01, which the booking endpoint turns into a 409:

app.MapPost("/stays", async (BookingRequest request, RentalContext db) =>
{
    if (await db.Cottages.FindAsync(request.CottageId) is not { } cottage)
        return Results.NotFound();

    try
    {
        var stay = Stay.Book(cottage, request.Guest, request.Nights);
        db.Stays.Add(stay);
        await db.SaveChangesAsync();
        return Results.Created($"/stays/{stay.Id}", new { stay.Id, stay.Nights });
    }
    catch (DomainException e)
    {
        return Results.ValidationProblem(new Dictionary<string, string[]> { ["nights"] = [e.Message] });
    }
    catch (DbUpdateException e)
        when (e.InnerException is PostgresException { SqlState: PostgresErrorCodes.ExclusionViolation })
    {
        return Results.Conflict(new { error = "Those nights are already booked." });
    }
});

public record BookingRequest(int CottageId, string Guest, DateRange Nights);
Enter fullscreen mode Exit fullscreen mode

Ada has [2026-07-01,2026-07-04]. Grace asks for 4 to 5 July and is refused, because the night of the 4th is taken. She asks for 5 to 7 July and gets it. The two stays are adjacent, not overlapping.
That's the IsAdjacentTo versus Overlaps distinction from June, now enforced by PostgreSQL.

Search: one query, one statement

"A cottage with a fireplace and a sea view, free for these nights":

app.MapGet("/cottages", async (DateRange nights, StringSet? amenities, RentalContext db) =>
{
    if (Stay.CheckNights(nights) is { } problem)
        return Results.ValidationProblem(new Dictionary<string, string[]> { ["nights"] = [problem] });

    var wanted = amenities ?? StringSet.Empty;
    var cottages = await db.Cottages
        .Where(c => c.Amenities.IsSupersetOf(wanted))
        .Where(c => c.Season.Contains(nights))
        .Where(c => !db.Stays.Any(s => s.CottageId == c.Id && !s.IsCancelled && s.Nights.Overlaps(nights)))
        .Select(c => new { c.Id, c.Name, c.Amenities })
        .ToListAsync();

    return Results.Ok(cottages);
});
Enter fullscreen mode Exit fullscreen mode

All of it runs in PostgreSQL:

SELECT c."Id", c."Name", c."Amenities"
FROM "Cottages" AS c
WHERE c."Amenities" @> @wanted AND c."Season" @> @nights AND NOT EXISTS (
    SELECT 1
    FROM "Stays" AS s
    WHERE s."CottageId" = c."Id" AND NOT (s."IsCancelled") AND s."Nights" && @nights)
Enter fullscreen mode Exit fullscreen mode

The set filter and the range filters sit side by side. The GIN index serves the @>. The inner query repeats the constraint's filter, so the constraint's GiST index can serve the &&.

Binding and validation, with nothing registered

curl -G localhost:5000/cottages \
  --data-urlencode 'nights=[2026-07-01,2026-07-05)' \
  --data-urlencode 'amenities={sea-view,fireplace}'
Enter fullscreen mode Exit fullscreen mode

nights and amenities bind straight from the query string, because every type has TryParse. In June you had to call AddRangeConverters() for JSON. Since 9.0 you don't: every range, RangeSet and value set carries its own converter. So BookingRequest binds from a JSON body as it is, and responses write ranges as strings and sets as arrays:

[{"id":1,"name":"Dune","amenities":["fireplace","sea-view"]}]
Enter fullscreen mode Exit fullscreen mode

Two things changed on the way in. The half-open [2026-07-01,2026-07-05) is now the closed [2026-07-01,2026-07-04], and the amenities are sorted. Both still mean exactly what you sent. Text
that isn't a range, such as a stay that ends before it starts, fails to bind and never reaches your handler.

A range that parses isn't always a valid stay, though. Here's the CheckNights that both Stay.Book and the search use:

public static string? CheckNights(DateRange nights) => nights switch
{
    DateRange.Finite { Length: > MaxNights } => $"A stay is at most {MaxNights} nights.",
    DateRange.Finite => null,
    DateRange.EmptyRange => "The stay has no nights.",
    DateRange.UnboundedStart or DateRange.UnboundedEnd or DateRange.Infinity
        => "A stay needs a first and a last night.",
    _ => throw new UnreachableException(),
};
Enter fullscreen mode Exit fullscreen mode

A correction to June: I called this "exhaustive, compiler-enforced pattern matching". It isn't. C# doesn't treat a closed class hierarchy as exhaustive, so the discard arm stays. What is enforced, since 10.0, is that nobody can add a sixth shape: a record derived from a range type throws when it's constructed. The five arms are really all there is, and the discard throws because it can't be reached.

Which nights are still free?

June's FindUncoveredPeriod built a RangeSet by hand, subtracted it from a window, and returned
only the first gap. Here's the whole question, for one cottage and one window:

app.MapGet("/cottages/{id:int}/free", async (int id, DateRange window, RentalContext db) =>
{
    if (await db.Cottages.FindAsync(id) is not { } cottage)
        return Results.NotFound();

    var booked = await db.Stays
        .Where(s => s.CottageId == id && !s.IsCancelled && s.Nights.Overlaps(window))
        .GroupBy(s => s.CottageId)
        .Select(g => g.Select(s => s.Nights).RangeAgg())
        .SingleOrDefaultAsync()
        ?? RangeSet<DateRange, DateOnly>.Empty;

    var free = booked.Gaps(cottage.Season.Intersect(window));
    return Results.Ok(new { window, booked, free });
});
Enter fullscreen mode Exit fullscreen mode

RangeAgg runs in PostgreSQL as range_agg and hands back the cottage's stays as one RangeSet,
which is a datemultirange on the server. Gaps(window) returns every part of the window the set
doesn't cover. With no stays there's no group and the query returns null, so Empty stands in and
the whole window comes back free.

{"window":"[2026-07-01,2026-07-31]","booked":"{[2026-07-01,2026-07-07]}","free":"{[2026-07-08,2026-07-31]}"}
Enter fullscreen mode Exit fullscreen mode

Ada's and Grace's stays touch, so the set holds them as one range. A RangeSet never holds two ranges that overlap or touch, which is the rule PostgreSQL's multiranges follow too. Ask with an open window, [2026-06-28,), and the answer runs to the end of the season.

If you'd rather keep blocked nights in a column, Gaps translates as well: datemultirange(@window) - x.

Occupancy per month

The last feature is a small one that saves a surprising amount of date arithmetic:

foreach (var month in cottage.Season.Split(DateRange.Month))
{
    var nights = booked.Intersect(month).Sum(r => r.Length ?? 0);
    Console.WriteLine($"{month}: {nights} of {month.Length} nights");
}
// [2026-05-01,2026-05-31]: 0 of 31 nights
// ...
// [2026-07-01,2026-07-31]: 7 of 31 nights
Enter fullscreen mode Exit fullscreen mode

Split(DateRange.Month) cuts the season at calendar month boundaries and clips the first and last pieces. DateRange.Quarter, Year and IsoWeek work the same way. Length is the number of nights. It's null for an unbounded range, so an open-ended season can't quietly sum to nonsense.

What PostgreSQL changes, and what it doesn't

Look in the table and you'll see [2026-07-01,2026-07-05), because PostgreSQL stores a daterange half-open. You read it back as [2026-07-01,2026-07-04]. They're the same four nights, and the records compare equal. A season with no end is stored as [2026-05-01,), with no upper bound and not infinity. For timestamp ranges, PostgreSQL keeps six fraction digits where .NET has seven. The library writes the value Npgsql would bind, so a constant in a query and a value in a column never disagree by a microsecond.

None of this is anything you configure. It's the part I'm proudest of, because it's where range libraries usually go quietly wrong.

Where to go next

Next time: how the library tests itself, with PostgreSQL as the judge of every answer.

Top comments (0)