Skip to main content

Command Palette

Search for a command to run...

Stop Modeling Time With Two Columns: CodoMetis.ValueRanges Brings Interval Logic to Your .NET Domain

A complete solution: expressive range types in your domain layer, full PostgreSQL translation in your data layer - no compromises at either end

Updated
β€’12 min read
Stop Modeling Time With Two Columns: CodoMetis.ValueRanges Brings Interval Logic to Your .NET Domain
R
Passionate developer, tech nerd and language enthusiast

TL;DR: CodoMetis.ValueRanges is a .NET library of domain-grade range types with a full in-memory interval algebra, PostgreSQL wire-format compatibility, System.Text.Json serialisation, and an EF Core / PostgreSQL companion package. Install it in seconds:

dotnet add package CodoMetis.ValueRanges

# optional:
dotnet add package CodoMetis.ValueRanges.EFCore.PostgreSQL

A Scheduling Problem That Is More Common Than You Think

Imagine you are building the workforce management module of an HR platform. Employees don't work a fixed schedule forever. They transition between employment contracts β€” full-time, part-time, job shares, reduced-hour agreements, parental leave arrangements, phased returns β€” and each arrangement has its own expected hours per day, sometimes cycling across a bi-weekly pattern:

Week A:  Mon 8h, Tue 8h, Wed 4h, Thu 8h, Fri off
Week B:  Mon 6h, Tue 6h, Wed 6h, Thu 6h, Fri 6h

Each of these arrangements applies over a period of time β€” it has a start, it may or may not have an end, it may overlap a transition, and the business needs to be able to answer questions like:

  • What is this employee's contracted schedule on a given date?
  • Are there any gaps between their arrangements, and if so, which ones?
  • Do two arrangements overlap, which would be a data error?
  • What is the total contracted-hours exposure for a payroll period if some arrangements are only partially within that window?

None of these questions are hard in isolation. Together, they sketch out a domain that is full of temporal intervals β€” and how you model those intervals determines whether your codebase accumulates a mess of fragile date arithmetic, or whether the domain rules live directly in the types.


The Trap Every Developer Walks Into

The natural first instinct is straightforward:

public class WorkArrangement
{
    public int        EmployeeId   { get; set; }
    public DateTime   ValidFrom    { get; set; }
    public DateTime?  ValidTo      { get; set; }
    public decimal    HoursPerDay  { get; set; }
    // ... schedule specifics
}

This looks reasonable. But now try to answer "does this arrangement cover a given date?":

public static bool CoversDate(
   WorkArrangement arrangement, DateTime date
)
    => arrangement.ValidFrom <= date
    && (arrangement.ValidTo == null || 
        arrangement.ValidTo > date);

Already there are hidden decisions embedded in the code: is ValidTo exclusive (> date) or inclusive (>= date)? Is null the right sentinel for "no end date", or should there be a bool flag, or a magic max-date value? Ask three developers and you get three answers β€” and you will find all three in the same codebase six months later.

Now try to detect whether two arrangements overlap:

public static bool Overlaps(WorkArrangement a, WorkArrangement b)
{
    var aEnd = a.ValidTo ?? DateTime.MaxValue;
    var bEnd = b.ValidTo ?? DateTime.MaxValue;

    return a.ValidFrom < bEnd && b.ValidFrom < aEnd;
}

This is the simple case. But what about the arrangement that is truly open-ended β€” not "runs until 9999-12-31" but genuinely unbounded? What if you want to find the gaps between a set of arrangements within a required window? What if an arrangement spans exactly one day: is ValidFrom == ValidTo a zero-length range or a one-day range? Each question exposes another implicit decision that the type system has said nothing about, and that every developer touching this code must re-derive from scratch.

The gap-finding logic is particularly painful. To find which dates within a required window are not covered by any arrangement:

public static IEnumerable<(DateTime Start, DateTime End)> FindGaps(
    DateTime windowStart, DateTime windowEnd,
    IReadOnlyList<WorkArrangement> arrangements)
{
    var sorted = arrangements
        .OrderBy(a => a.ValidFrom)
        .ToList();

    var cursor = windowStart;

    foreach (var arrangement in sorted)
    {
        // gap before this arrangement
        if (arrangement.ValidFrom > cursor)
            yield return (cursor, arrangement.ValidFrom);  

        var end = arrangement.ValidTo ?? windowEnd;
        if (end > cursor)
            cursor = end;
    }

    // trailing gap
    if (cursor < windowEnd)
        yield return (cursor, windowEnd);  
}

This works β€” until it doesn't. It has no handling for overlapping arrangements. It conflates "open-ended" with "runs to the end of the window". Its boundary semantics (inclusive vs. exclusive at every edge) are implicit and untested. It is fragile, untested at the type level, and it will be reimplemented slightly differently by the next developer who needs something similar.

This is the two-column trap. The interval is a real concept in your domain. Refusing to give it a type makes every piece of logic about intervals a local reconstruction of the same algebra, scattered across the codebase and tested only incidentally.


What the Domain Actually Looks Like

Here is the same domain, modeled with CodoMetis.ValueRanges:

using CodoMetis.ValueRanges;

public sealed class WorkArrangement
{
    public EmployeeId EmployeeId  { get; }
    public DateRange  ValidPeriod { get; }
    public WorkSchedule Schedule  { get; }

    public WorkArrangement(
       EmployeeId employeeId, 
       DateRange validPeriod, 
       WorkSchedule schedule
    )
    {
        if (validPeriod is DateRange.EmptyRange)
            throw new DomainException(
              "A work arrangement must cover at least one day."
            );

        EmployeeId   = employeeId;
        ValidPeriod  = validPeriod;
        Schedule     = schedule;
    }

    public bool CoversDate(DateOnly date)
        => ValidPeriod.Contains(date);

    public bool OverlapsWith(WorkArrangement other)
        => ValidPeriod.Overlaps(other.ValidPeriod);

    public bool IsContiguousWith(WorkArrangement other)
        => ValidPeriod.IsAdjacentTo(other.ValidPeriod)
        || ValidPeriod.Overlaps(other.ValidPeriod);
}

The invariant that a work arrangement must cover at least one day is enforced in the constructor. The boundary semantics are built into DateRange, not scattered across callers. Open-ended arrangements are first-class citizens β€” not null, not DateTime.MaxValue, but DateRange.UnboundedEnd β€” and the type tells you exactly what that means.

Creating arrangements looks like this:

// Fixed-term arrangement
var partTime = new WorkArrangement(
    employeeId,
    DateRange.CreateFinite(
       new DateOnly(2025, 4, 1), 
       new DateOnly(2025, 9, 30)
    ),
    partTimeSchedule
);

// Permanent (open-ended) arrangement β€” the language of the domain, not a sentinel
var permanent = new WorkArrangement(
    employeeId,
    DateRange.CreateUnboundedEnd(new DateOnly(2025, 10, 1)),
    fullTimeSchedule
);

There is no DateTime.MaxValue. There is no nullable sentinel. There is no special-casing in every consuming method. The shape of the range β€” finite, unbounded-end, unbounded-start, covering everything, or empty β€” is encoded in the static type, and pattern matching lets you handle each case explicitly:

string Describe(WorkArrangement arrangement) => arrangement.ValidPeriod switch
{
    DateRange.Finite f => $"Active from {f.Start:d} to {f.End:d}",
    DateRange.UnboundedEnd e => $"Active from {e.Start:d} (no end date)",
    DateRange.UnboundedStart s => $"Active until {s.End:d}",
    DateRange.Infinity => "Always active",
    DateRange.EmptyRange => "Inactive",   // cannot be constructed, but the compiler doesn't know that yet
    _ => throw new UnreachableException()
};

The Payroll Intersection Problem

Here is a question that comes up constantly in scheduling and payroll: for this payroll period, which parts of each arrangement actually apply?

An employee might move from part-time to full-time mid-month. You need to charge the correct number of contracted hours for each segment. With raw date fields this means writing a clipping function, testing all the edge cases around boundary dates, and hoping nobody changes the boundary semantics later. With DateRange.Intersect, it is one line:

var payrollPeriod = DateRange.CreateFinite(
    new DateOnly(2025, 6, 1),
    new DateOnly(2025, 6, 30)
);

foreach (var arrangement in employeeArrangements)
{
    var applicableSegment = arrangement.ValidPeriod
       .Intersect(payrollPeriod);

    // this arrangement doesn't touch this payroll period
    if (applicableSegment is DateRange.EmptyRange)
        continue;

    // applicableSegment is a DateRange.Finite β€” the overlap window
    // charge accordingly
}

The intersection of two ranges is itself always a valid range β€” possibly empty, otherwise finite β€” and the return type is DateRange directly, not a nullable tuple, not a result object. You pattern-match on it and the compiler surfaces the cases.


Finding Scheduling Gaps

The gap-finding problem from earlier becomes a set operation:

public DateRange? FindUncoveredGap(
    DateRange requiredWindow,
    IReadOnlyList<WorkArrangement> arrangements
)
{
    // Build a normalised set of all covered periods
    var covered = RangeSet<DateRange, DateOnly>
       .From(arrangements.Select(a => a.ValidPeriod));

    // Subtract the covered set from the required window
    var gaps = requiredWindow.Except(covered);

    return gaps.Count == 0
        ? null
        : gaps[0];   // RangeSet is IReadOnlyList<DateRange>
}

RangeSet<DateRange, DateOnly> is the in-memory equivalent of PostgreSQL's datemultirange. It is always normalised on construction: overlapping ranges are merged, adjacent ranges (days that touch) are merged, empty ranges are dropped. Except returns a RangeSet containing the parts of the window not covered by any arrangement.

This is no longer a hand-rolled loop with cursor arithmetic. It is set difference, which is exactly what gap-finding is.


Detecting Overlapping Arrangements

Ensuring no two arrangements overlap for the same employee is a data-integrity concern. In domain logic, you express it directly:

public class EmployeeArrangementService
{
    public void AddArrangement(
        Employee employee,
        IReadOnlyList<WorkArrangement> existing,
        WorkArrangement newArrangement
    )
    {
        var conflict = existing.FirstOrDefault(
           a => a.OverlapsWith(newArrangement)
        );
        if (conflict is not null)
            throw new DomainException(
                $"""
                 New arrangement overlaps with existing 
                 arrangement starting {conflict.ValidPeriod}.
                """
         );

        // persist...
    }
}

Unit-testable without a database. No fragile DateTime arithmetic. No hidden decisions about boundary inclusiveness buried in helper methods.


EF Core and PostgreSQL: The Full Stack

When you bring in CodoMetis.ValueRanges.EFCore.PostgreSQL, the domain model maps directly to PostgreSQL's native range types with a single registration:

builder.Services.AddDbContext<HrDbContext>(options =>
    options.UseNpgsql(connectionString, npgsql =>
        npgsql.UseValueRanges()));

After that, DateRange properties map to daterange columns and RangeSet<DateRange, DateOnly> maps to datemultirange β€” no value converters, no column type annotations, no manual configuration. The full table:

.NET type PostgreSQL column type
DateRange daterange
RangeSet<DateRange, DateOnly> datemultirange
Int32Range int4range
DecimalRange numrange
DateTimeRange tsrange
DateTimeOffsetRange tstzrange

LINQ queries translate to the correct PostgreSQL range operators automatically:

var day = new DateOnly(2025, 6, 15);

// WHERE "ValidPeriod" @> @day
dbContext.WorkArrangements
    .Where(a => a.ValidPeriod.Contains(day));

// WHERE "ValidPeriod" && @window  
dbContext.WorkArrangements
    .Where(a => a.ValidPeriod.Overlaps(payrollPeriod));

// SELECT "ValidPeriod" * @window
dbContext.WorkArrangements
    .Select(a => new { 
        a.EmployeeId, 
        Segment = a.ValidPeriod.Intersect(payrollPeriod) 
     });

The same method calls β€” Contains, Overlaps, Intersect β€” that run in memory in your domain service and unit tests translate to their PostgreSQL operator equivalents (@>, &&, *) when used inside an EF Core query. The same code. The same semantics. Two execution environments.

This matters especially for the overlap constraint. PostgreSQL lets you enforce non-overlapping arrangements at the database level with an exclusion constraint, which you can add as a migration:

-- Add btree_gist to a baseline migration if not already present
CREATE EXTENSION IF NOT EXISTS btree_gist;

-- Exclusion constraint: no two arrangements for the same employee may overlap
ALTER TABLE "WorkArrangements"
    ADD CONSTRAINT "work_arrangements_no_overlap"
    EXCLUDE USING gist ("EmployeeId" WITH =, "ValidPeriod" WITH &&);

This is not just a belt-and-suspenders check. It is atomic enforcement regardless of how many API instances are running concurrently. Your domain service catches the logical error first; the database makes it structurally impossible for any code path to slip through.


ASP.NET Core: Ranges as First-Class API Citizens

The library ships System.Text.Json converters in the CodoMetis.ValueRanges.Serialization namespace. Register them once in your ASP.NET Core setup:

builder.Services.ConfigureHttpJsonOptions(options =>
    options.SerializerOptions.AddRangeConverters());

After that, range types flow through Minimal API responses and request bodies as JSON strings in PostgreSQL literal format β€” compact, human-readable, and round-trippable:

// GET /employees/{id}/arrangements
app.MapGet("/employees/{id}/arrangements", 
   async (int id, HrDbContext db) =>
{
    var arrangements = await db.WorkArrangements
        .Where(a => a.EmployeeId == id)
        .Select(a => new ArrangementDto(
            a.ValidPeriod, 
            a.Schedule.ContractedHoursPerDay)
         )
        .ToListAsync();

    return Results.Ok(arrangements);
});

// Response JSON:
// [
//   { "validPeriod": "[2025-04-01,2025-09-30]", "contractedHoursPerDay": 4.0 },
//   { "validPeriod": "[2025-10-01,)", "contractedHoursPerDay": 8.0 }
// ]

The notation [2025-10-01,) is the PostgreSQL literal for a range that starts on October 1st and has no upper bound β€” immediately readable to any developer who knows the domain. Parsing works in the opposite direction, so range values in request bodies deserialise cleanly too.


What You Get in Summary

The problem is not new. Scheduling, validity windows, contract periods, pricing tiers, campaign dates β€” every sufficiently complex business application has temporal relations. The industry default has been to reach for two columns and write the algebra by hand, each time, differently, untested at the type level.

CodoMetis.ValueRanges is a toolbox for the .NET ecosystem that changes that default:

  • Five sealed range variants (Finite, UnboundedStart, UnboundedEnd, Infinity, Empty) whose shapes are expressed in their static types β€” no flag-checking, no null-guarding, no sentinel values.
  • Full in-memory interval algebra: containment, overlap, adjacency, directional comparisons, intersection, union, difference β€” all running in process, no database, always testable.
  • RangeSet<TRange, T>, an immutable normalised collection of disjoint ranges with set operators, complement, and IReadOnlyList<T> semantics β€” the in-memory multirange.
  • PostgreSQL literal format parsing and formatting, so ranges round-trip cleanly through migrations, diagnostics, and raw ADO.NET.
  • System.Text.Json serialisation with one-line ASP.NET Core registration.
  • EF Core / PostgreSQL mapping via the companion package β€” convention-based column mapping, full LINQ-to-SQL translation of range operators, and no dependency on Npgsql in your domain layer.

Try It

dotnet add package CodoMetis.ValueRanges
dotnet add package CodoMetis.ValueRanges.EFCore.PostgreSQL

If it solves a problem you have been working around, I would love to hear about it in the comments. And if the library earns a place in your stack, a ⭐ on GitHub goes a long way for an independent open-source project.

CodoMetis.ValueRanges: Interval Types for .NET