A complete solution: expressive range types in your domain layer, full PostgreSQL translation in your data layer - no compromises at either end
The Two-Column Trap
Almost every developer has written it at least once. An object with two date properties:
public class MemberSubscription
{
public int Id { get; set; }
public int MemberId { get; set; }
public DateTime StartDate { get; set; }
public DateTime EndDate { get; set; }
}
Imagine you need to answer a seemingly simple question in a booking system: "Is this subscription still active, and does it conflict with the proposed new one?" With two bare fields, that code ends up looking something like this:
// With two bare DateTime fields — the check you always end up writing
public static bool IsActive(MemberSubscription sub, DateTime at)
=> sub.StartDate <= at && (sub.EndDate == default || sub.EndDate > at);
public static bool ConflictsWith(MemberSubscription a, MemberSubscription b)
{
// Partial overlap: a starts inside b
if (a.StartDate >= b.StartDate && a.StartDate < b.EndDate) return true;
// Partial overlap: b starts inside a
if (b.StartDate >= a.StartDate && b.StartDate < a.StartDate) return true;
// b is fully contained by a
if (a.StartDate <= b.StartDate && a.EndDate >= b.EndDate) return true;
// What about open-ended subscriptions? What about same-day boundaries?
// What about inclusive vs exclusive end dates? ...
return false;
}
It looks perfectly reasonable. But start asking questions — as Steve Smith (Ardalis) does in his essay on making the implicit explicit — and you notice how much invisible knowledge this design requires. Should EndDate ever precede StartDate? The type system doesn't say. Can a subscription have a null end date meaning it never expires? Nothing in the model communicates that. Is a subscription that ends today still active at 11:59 PM? Ask three developers and get three answers.
The EndDate == default sentinel for open-ended subscriptions leaks into every single callsite. The half-open vs. closed boundary question (< vs <= on EndDate) is answered differently by different developers on different days. Edge cases accumulate silently.
This class has no invariants, no encapsulation, and no single representation of the concept it is supposed to model: a temporal interval. The rules about how those two dates relate to each other live scattered across validators, service methods, SQL WHERE clauses, and the tribal knowledge of whoever originally wrote the code.
Now consider the business logic that gets built on top. To prevent overlapping subscriptions you write a check — a query, a loop, a comparison — somewhere in application code. You might miss edge cases around shared endpoints. You might miss the case where one subscription is fully contained by another. You might forget to account for half-open intervals. You will almost certainly duplicate this logic in at least three places before the project is six months old. And none of it will be tested at the type level, because the type has expressed no opinion on the matter.
This is the two-column trap. It is not just about subscriptions. It shows up in shift scheduling, booking systems, pricing tiers, validity windows, rate schedules, campaign periods — anywhere that an interval of values is a meaningful concept in your domain.
What If You Could Model This Instead
Now consider what the same logic looks like when the subscription period is a proper DateRange:
public sealed class MemberSubscription
{
public int MemberId { get; }
public DateRange Period { get; } // one concept, one field
public MemberSubscription(int memberId, DateRange period)
{
if (period is DateRange.EmptyRange)
throw new DomainException("Subscription period must cover at least one day.");
MemberId = memberId;
Period = period;
}
public bool IsActiveOn(DateOnly date)
=> Period.Contains(date); // boundary semantics built into the type
public bool ConflictsWith(MemberSubscription other)
=> Period.Overlaps(other.Period); // all five shape combinations handled internally
}
// Open-ended subscription — no null, no sentinel, no magic date
var lifetime = new MemberSubscription(42, DateRange.CreateUnboundedEnd(DateOnly.FromDateTime(DateTime.Today)));
// Fixed-term subscription
var annual = new MemberSubscription(
7, DateRange.CreateFinite(
new DateOnly(2025, 1, 1),
new DateOnly(2025, 12, 31)
)
);
lifetime.IsActiveOn(new DateOnly(2099, 1, 1)); // true — infinite end, correctly handled
annual.ConflictsWith(lifetime); // true — overlap detected correctly
There is no sentinel value for "never expires". There is no bespoke overlap logic to write, audit, or test. There is no hidden assumption about whether EndDate is inclusive or exclusive — the type encodes that. The invariant that a subscription must cover at least one day is enforced in the constructor, not scattered across callers. The rules are now in the design, not in the tribal knowledge.
What PostgreSQL Already Knows
PostgreSQL has understood this problem since version 9.2. Its range types — int4range, int8range, numrange, daterange, tsrange, tstzrange — let you store an interval as a single, indivisible column value and query it with a full algebra of operators: containment (@>), overlap (&&), adjacency (-|-), union (+), intersection (*), difference (-), and more.
With a daterange column you can express an exclusion constraint that makes overlapping bookings structurally impossible at the database level:
ALTER TABLE bookings ADD CONSTRAINT bookings_no_overlap
EXCLUDE USING gist (resource_id WITH =, period WITH &&);
This does not just prevent bad data — it atomically prevents it, at the storage layer, regardless of how many concurrent application instances are running. There is no race condition to close, no retry loop to write, no Redis lock to manage. The database enforces it. As Radim Marek writes in his article Beyond Start and End: "The real win here is data integrity — you're making it impossible to have invalid state in your database, not just unlikely."
PostgreSQL 14 added multirange types (int4multirange, datemultirange, etc.) that take this further: a single column can hold a set of disjoint intervals, normalised and queryable with the same operators.
All of this is genuinely powerful. The question for .NET developers is: how do you bring this expressiveness into your domain model?
What NpgsqlRange<T> Actually Is
Npgsql, the .NET driver for PostgreSQL, surfaces range types through NpgsqlRange<T>. It is a readonly struct that mirrors PostgreSQL's wire representation: two nullable bounds, a RangeFlags byte-enum packing inclusiveness and infiniteness into bit fields. The documentation on LowerBound is honest about what this means:
The lower bound of the range. Only valid when
LowerBoundInfiniteis false.
That is a validity precondition on a property — a runtime guard disguised as an accessor. You must check flags before touching values. The shape of the range (bounded, unbounded-start, unbounded-end, infinite, empty) is not in the type; it is in the bits.
This is the right design for a wire type. NpgsqlRange<T> was built to shuttle range values between .NET and PostgreSQL and to participate in LINQ-to-SQL translation, and it does both very well. But those goals impose constraints that make it unfit for use as a domain model primitive.
The clearest sign of this is what happens when you try to use it in domain logic. Npgsql's EF Core package (Npgsql.EntityFrameworkCore.PostgreSQL) provides extension methods for range operations — Contains, Overlaps, IsAdjacentTo, Union, Intersect, Except, and so on. Every single one has this body:
public static bool Contains<T>(this NpgsqlRange<T> range, T value)
=> throw new InvalidOperationException(CoreStrings.FunctionOnClient(nameof(Contains)));
public static bool Overlaps<T>(this NpgsqlRange<T> a, NpgsqlRange<T> b)
=> throw new InvalidOperationException(CoreStrings.FunctionOnClient(nameof(Overlaps)));
public static NpgsqlRange<T> Intersect<T>(this NpgsqlRange<T> a, NpgsqlRange<T> b)
=> throw new InvalidOperationException(CoreStrings.FunctionOnClient(nameof(Intersect)));
// ... and every other range operation, without exception
These methods exist purely as LINQ expression markers — stubs that EF Core's translator recognises and rewrites into SQL operators. They are never meant to run in process. Call any of them outside of a LINQ query against an EF Core DbSet and you get an InvalidOperationException at runtime.
This is not a flaw; it is a deliberate and correct design choice for what NpgsqlRange<T> is. But it leaves .NET developers without any in-memory range type. You cannot use NpgsqlRange<T> to write a pure domain method. You cannot unit-test range logic without a database. You cannot pass it across application layer boundaries without importing Npgsql everywhere. It is, in Vladimir Khorikov's terms, an impure dependency — and as he argues in his work on domain model purity, impure dependencies belong at the edges, not in the domain layer.
The gap is real: there is no in-memory, domain-grade range type for .NET. Until now.
Enter CodoMetis.ValueRanges
CodoMetis.ValueRanges is a library that fills exactly that gap.
dotnet add package CodoMetis.ValueRanges
It provides concrete, type-safe range types covering the same six value domains as PostgreSQL's built-in range types, with a full in-memory implementation of every range and multirange operation PostgreSQL exposes. It requires .NET 10 or later and has no dependency on Npgsql, EF Core, or any database driver.
The six supported types, mirroring PostgreSQL exactly:
| .NET type | PostgreSQL equivalent | Element type | Discrete? |
|---|---|---|---|
Int32Range |
int4range |
int |
Yes |
Int64Range |
int8range |
long |
Yes |
DecimalRange |
numrange |
decimal |
— |
DateRange |
daterange |
DateOnly |
Yes |
DateTimeRange |
tsrange |
DateTime |
— |
DateTimeOffsetRange |
tstzrange |
DateTimeOffset |
— |
The supported set is intentionally fixed. Extending it to arbitrary T — any struct that is IComparable and IEquatable — sounds appealing but breaks down in practice. float and double have NaN, infinities, and no reliable total order for interval arithmetic. Guid (even v7) is orderable but the ordering has no meaningful domain semantics for range algebra. The six types in this library correspond to what PostgreSQL itself natively understands and has validated for interval semantics — a deliberate, considered baseline rather than a maximally open generic system.
The Type System: Why Not readonly record struct?
This is one of the most deliberate design decisions in the library, and it is worth explaining properly.
A readonly record struct is an excellent choice for simple value objects: stack-allocated, copy-efficient, equality based on value. But a struct cannot be abstract, cannot have sealed derived types, and cannot participate in polymorphism. You cannot form a discriminated union out of structs in C#.
A range type has fundamentally different shapes:
- A finite range has both a
Startand anEnd. - An unbounded-start range has only an
End. - An unbounded-end range has only a
Start. - An infinite range has neither bound — it covers the entire domain.
- An empty range has nothing at all — it contains no values.
These are not different states of the same data layout. They are genuinely different types with different property surfaces. If you flatten them into a single struct, you end up with nullable bounds and boolean flags — which is exactly the design of NpgsqlRange<T>. The caller has to check flags before accessing any property. Invalid states are not unrepresentable; they are just poorly documented.
CodoMetis.ValueRanges takes the opposite approach: invalid states are unrepresentable by construction. The shape of a range is encoded in its static type. An UnboundedEnd range has no End property — the property simply does not exist on that type. An EmptyRange carries no bound information at all. There is nothing to guard against and nothing to misread.
Each concrete range type is an abstract record with five sealed nested variants:
public abstract record DateRange : IRange<DateOnly>, IRangeFactory<DateRange, DateOnly>
{
private DateRange() { } // no external subtypes possible
public sealed record EmptyRange : DateRange, IEmptyRange<DateOnly>;
public sealed record Finite : DateRange, IFiniteRange<DateOnly> { ... }
public sealed record UnboundedStart : DateRange, IUnboundedStartRange<DateOnly> { ... }
public sealed record UnboundedEnd : DateRange, IUnboundedEndRange<DateOnly> { ... }
public sealed record Infinity : DateRange, IInfinityRange<DateOnly>;
}
The private constructor makes the hierarchy closed to the outside. Because no type outside the assembly can subclass DateRange, the set of possible variants is knowably complete — but C# does not yet exploit this to make switch expressions exhaustive without a default arm. Full native discriminated unions with compiler-enforced exhaustiveness are on the roadmap for C# 15 / .NET 11. Until then, omitting the default arm produces a compiler warning, not an error, so you still need to handle it explicitly — typically by throwing an UnreachableException to signal that any unmatched case is a programming error, not a legitimate runtime condition.
The abstract base is a reference type (class) because polymorphism — virtual dispatch, subtype relationships, sealed hierarchies — is a class-level concept in C#. record syntax gives you structural equality and with-expression support on top of that, which is ideal for immutable domain objects. The result is a discriminated union that is both idiomatic and compiler-enforced.
The cost is a heap allocation per range instance. For domain objects that model intervals in a business context, this is entirely acceptable. The performance-sensitive path — high-throughput range queries against large tables — runs in PostgreSQL, not in the domain layer.
The Interface Hierarchy
The library exposes precisely scoped interfaces, making it straightforward to write generic code that handles any range shape:
| Interface | What it provides |
|---|---|
IRange<T> |
Base marker for all variants |
IFiniteRange<T> |
Start, End, StartInclusive, EndInclusive
|
IUnboundedStartRange<T> |
End, EndInclusive
|
IUnboundedEndRange<T> |
Start, StartInclusive
|
IEmptyRange<T> |
Marker only — no bounds at all |
IInfinityRange<T> |
Marker only — the entire domain |
IRangeFactory<TRange, T> |
Static abstract factories; NextValueAfter/PreviousValueBefore
|
IRangeFactory<TRange, T> uses C# 11's static abstract interface members to require factory methods at the type level — CreateFinite, CreateUnboundedStart, CreateUnboundedEnd, Empty, Infinite — without any runtime dispatch overhead. This is also what allows the set operation extension methods to produce the correct concrete type generically, without reflection.
Creating Ranges
Every type exposes four static factory methods with conventions that mirror PostgreSQL:
// Fully closed — the natural default for discrete types
Int32Range closed = Int32Range.CreateFinite(1, 10); // [1, 10]
// Half-open — the natural default for continuous types
DecimalRange halfOpen = DecimalRange.CreateFinite(1m, 5m); // [1, 5)
// Unbounded on the left
DateRange upTo = DateRange.CreateUnboundedStart(
new DateOnly(2025, 12, 31)); // (-∞, 2025-12-31]
// Unbounded on the right
Int32Range fromFive = Int32Range.CreateUnboundedEnd(5); // [5, +∞)
// The whole domain
Int32Range everything = Int32Range.Infinite; // (-∞, +∞)
// Explicitly empty
Int32Range empty = Int32Range.Empty; // ∅
CreateFinite never throws. Pass inverted or degenerate bounds and it returns Empty. For discrete types, exclusive bounds are normalised to their canonical closed-interval equivalent: (1, 5) over integers becomes [2, 4], and (1, 2) becomes Empty. This means two Int32Range.Finite instances representing the same set of integers are always structurally equal — record equality coincides with set equality.
Pattern Matching
Because the hierarchy is sealed within the assembly, you know statically that only five variants can ever exist — but today's C# compiler does not yet enforce this as a completeness guarantee. Omitting a variant produces a warning and a default fallback is still required. The idiomatic pattern is a default arm that throws UnreachableException, which makes intent clear: any case that reaches it is a bug in the consuming code, not a legitimate path:
string Describe(DateRange range) => range switch
{
DateRange.EmptyRange => "no dates",
DateRange.Finite f => $"{f.Start:yyyy-MM-dd} to {f.End:yyyy-MM-dd}",
DateRange.UnboundedStart s => $"up to {s.End:yyyy-MM-dd}",
DateRange.UnboundedEnd e => $"from {e.Start:yyyy-MM-dd} onwards",
DateRange.Infinity => "all time",
_ => throw new UnreachableException()
};
Full native discriminated unions with compiler-enforced exhaustiveness are planned for C# 15 / .NET 11. When that ships, the _ arm disappears and the compiler takes over — but the type design of this library is already fully aligned with that future: sealed hierarchy, private constructor, five known variants. No structural changes will be needed.
Query Operations: Full Interval Algebra In Process
All query methods are extension methods on IRange<T> and execute entirely in memory — no database, no exceptions:
var sprint = DateRange.CreateFinite(
new DateOnly(2025, 1, 6),
new DateOnly(2025, 1, 17));
sprint.Contains(new DateOnly(2025, 1, 10)); // true — point containment
sprint.Contains(new DateOnly(2025, 1, 20)); // false
var inner = DateRange.CreateFinite(
new DateOnly(2025, 1, 8),
new DateOnly(2025, 1, 14));
sprint.Contains(inner); // true — range containment
inner.IsContainedBy(sprint); // true — symmetric alias
var a = Int32Range.CreateFinite(1, 5);
var b = Int32Range.CreateFinite(5, 10);
a.Overlaps(b); // true — they share the point 5
var c = Int32Range.CreateFinite(6, 10);
a.IsAdjacentTo(c); // true — NextValueAfter(5) == 6
// PostgreSQL &< / &> equivalents
Int32Range.CreateFinite(1, 5)
.DoesNotExtendRightOf(Int32Range.CreateFinite(1, 10)); // true
Int32Range.CreateFinite(1, 3)
.IsStrictlyLeftOf(Int32Range.CreateFinite(5, 9)); // true
Adjacency for discrete types deserves a mention: for integers and DateOnly, [1, 5] and [6, 10] are adjacent because there is no integer between 5 and 6. The step size is encoded via NextValueAfter and PreviousValueBefore in IRangeFactory. Continuous types use the complementary-inclusiveness rule: [1.0, 5.0] and (5.0, 10.0] are adjacent because one side claims 5.0 and the other does not.
Set Operations: Returning Exact Types
Set operations are extension methods constrained to types implementing IRangeFactory<TRange, T>, so they return the concrete range type, not a base interface.
Intersection returns TRange directly. The intersection of two ranges is always expressible as a single range — possibly Empty — so the return type tells the truth:
var a = Int32Range.CreateFinite(1, 10);
var b = Int32Range.CreateFinite(5, 15);
Int32Range intersection = a.Intersect(b); // [5, 10]
Int32Range none = a.Intersect(Int32Range.CreateFinite(11, 20)); // Empty
Union and Except return RangeSet<TRange, T>, because the result genuinely may be one or two disjoint ranges, and the type says exactly that:
var x = Int32Range.CreateFinite(1, 5);
var y = Int32Range.CreateFinite(7, 10);
x.Union(y); // { [1, 5], [7, 10] } — 2 elements (disjoint)
x.Union(Int32Range.CreateFinite(3, 8)); // { [1, 10] } — 1 element (overlapping, merged)
var big = Int32Range.CreateFinite(1, 10);
var remove = Int32Range.CreateFinite(4, 6);
big.Except(remove);
// { [1, 3], [7, 10] } — 2 elements (interior cut, split in two)
// boundary inclusiveness is inverted at the cut point so no value is lost or double-counted
RangeSet: The In-Memory Multirange
RangeSet<TRange, T> is the in-memory counterpart of PostgreSQL 14+'s multirange types. It is an immutable, always-normalised collection of disjoint ranges, maintaining the structural invariant on every construction: elements sorted by lower bound, pairwise disjoint, pairwise non-adjacent. Empty ranges are dropped, overlapping or adjacent inputs are merged, and any Infinity element collapses the whole set to RangeSet<TRange, T>.Infinite.
using IntSet = RangeSet<Int32Range, int>;
// Adjacent integers merge automatically on construction
var set = IntSet.From([
Int32Range.CreateFinite(1, 5),
Int32Range.CreateFinite(6, 10), // adjacent to [1, 5] → merges
Int32Range.CreateFinite(20, 30)
]);
// { [1, 10], [20, 30] }
// Full set algebra with operator aliases
set | Int32Range.CreateFinite(11, 19); // { [1, 30] } bridges the gap
set & Int32Range.CreateFinite(5, 25); // { [5, 10], [20, 25] }
set - Int32Range.CreateFinite(4, 6); // { [1, 3], [7, 10], [20, 30] }
set.Complement(); // { (-∞, 0], [11, 19], [31, +∞) }
// Structural equality — normalised sets are equal regardless of how they were built
var a = IntSet.From([Int32Range.CreateFinite(1, 10)]);
var b = IntSet.From([Int32Range.CreateFinite(1, 5), Int32Range.CreateFinite(6, 10)]);
a.Equals(b); // true
RangeSet<TRange, T> implements IReadOnlyList<TRange> — enumerable, countable, indexable — and IEquatable<RangeSet<TRange, T>>.
PostgreSQL Wire Format and JSON: Zero Integration Friction
All six range types and their RangeSet counterparts fully support parsing and formatting in the PostgreSQL range literal format — the same textual representation PostgreSQL uses on the wire:
// Parse from PostgreSQL literal
var range = DateRange.Parse("[2025-01-06,2025-01-17]", null);
// Format back to PostgreSQL literal
range.ToString(); // "[2025-01-06,2025-01-17]"
// RangeSet in multirange literal format
var set = RangeSet<Int32Range, int>.Parse("{[1,5],[7,10]}", null);
set.ToString(); // "{[1,5],[7,10]}"
This means ranges round-trip cleanly through any system that speaks PostgreSQL literals — migrations, raw ADO.NET, logging, and diagnostics.
For JSON, the library ships System.Text.Json converters for every type in the CodoMetis.ValueRanges.Serialization namespace. Ranges serialise as JSON strings in the same PostgreSQL literal format — compact and round-trippable. Registration is one line:
// Standalone
var options = new JsonSerializerOptions().AddRangeConverters();
// ASP.NET Core
builder.Services.ConfigureHttpJsonOptions(o =>
o.SerializerOptions.AddRangeConverters());
Once registered, ranges and multiranges flow through ASP.NET Core endpoints, System.Text.Json serialisation, and any other ecosystem that understands JSON without any additional ceremony:
// In an ASP.NET Core endpoint, this just works:
app.MapGet("/bookings/{id}/period", (int id) =>
{
DateRange period = /* load from domain */;
return Results.Ok(period); // serialises as "[2025-01-06,2025-01-17]"
});
The EF Core Bridge: CodoMetis.ValueRanges.EFCore.PostgreSQL
The companion package closes the loop between domain model and database:
dotnet add package CodoMetis.ValueRanges.EFCore.PostgreSQL
It does not replace NpgsqlRange<T>. It depends on it entirely. The mapping infrastructure converts each CodoMetis range type to and from NpgsqlRange<T> at the EF Core type-mapping boundary — from there, Npgsql's own, well-tested translators emit the correct PostgreSQL operators in SQL.
Enabling it is one line:
options.UseNpgsql(connectionString, npgsql => npgsql.UseValueRanges());
After that, properties of the six range types and of RangeSet<TRange, T> are mapped by convention, with no value converters, comparers, or column types to configure manually:
| Property type | Column type |
|---|---|
Int32Range |
int4range |
RangeSet<Int32Range, int> |
int4multirange |
DateRange |
daterange |
RangeSet<DateRange, DateOnly> |
datemultirange |
| … and so on for all six types |
The full range algebra also translates from LINQ to SQL:
var day = new DateOnly(2025, 6, 15);
// Translates to: b."Period" @> @day
bookings.Where(b => b.Period.Contains(day));
// Translates to: b."Period" && @other
bookings.Where(b => b.Period.Overlaps(other));
// Translates to: b."Period" -|- @other
bookings.Where(b => b.Period.IsAdjacentTo(other));
// Translates to: b."Period" * @other
bookings.Select(b => b.Period.Intersect(other));
The same method calls that run in memory in your domain layer translate to their exact PostgreSQL operator equivalents when used inside an EF Core query. The same code, the same semantics, two different execution environments.
This is the division of labour in practice: NpgsqlRange<T> handles the wire protocol and SQL translation — exactly what it was built for. CodoMetis.ValueRanges handles the domain logic — exactly what it was built for. The EF Core package is the thin bridge between them.
Putting It Together: A Domain Example
Here is what it looks like in a real domain model. The domain layer has no awareness of Npgsql or EF Core:
// Domain — pure, testable, database-independent
public sealed class ShiftAssignment
{
public EmployeeId EmployeeId { get; }
public DateRange Period { get; }
public ShiftAssignment(EmployeeId employeeId, DateRange period)
{
// Pattern matching — the type communicates intent; the compiler enforces completeness
if (period is DateRange.EmptyRange)
throw new DomainException("A shift must cover at least one day.");
EmployeeId = employeeId;
Period = period;
}
public bool ConflictsWith(ShiftAssignment other)
=> Period.Overlaps(other.Period); // in-memory, no database, always works
public bool IsContiguousWith(ShiftAssignment other)
=> Period.IsAdjacentTo(other.Period) || Period.Overlaps(other.Period);
}
// Domain service — fully unit-testable without a database
public static DateRange FindUncoveredPeriod(
DateRange requiredWindow,
IReadOnlyList<ShiftAssignment> assignments)
{
var covered = RangeSet<DateRange, DateOnly>.From(
assignments.Select(a => a.Period));
var uncoveredSet = requiredWindow.Except(covered);
// Returns the part of the required window not yet covered by any assignment
return uncoveredSet.Count == 0
? DateRange.Empty
: uncoveredSet[0];
}
The EF Core package maps ShiftAssignment.Period to a daterange column. LINQ queries against it translate to PostgreSQL range operators. Domain logic runs identically in unit tests (no database, no Npgsql, no mocking) and in production (full PostgreSQL), with the same types and the same semantics throughout.
Source Code and Packages
Both packages are fully open source under the MIT licence:
- 📦 CodoMetis.ValueRanges on NuGet
- 📦 CodoMetis.ValueRanges.EFCore.PostgreSQL on NuGet
- 🔭 Source code on GitHub
Summary
The two-column pattern — separate StartDate and EndDate fields — is one of the most common sources of duplicated logic and subtle bugs in domain models that deal with intervals. PostgreSQL has had a better answer for over a decade in its native range types, but until now there has been no way to bring that expressiveness into the .NET domain layer without importing a database driver.
CodoMetis.ValueRanges fills that gap:
- A discriminated union per range type with five sealed variants whose shapes are encoded in their static types — making invalid states unrepresentable by construction.
- Exhaustive, compiler-enforced pattern matching over all five variants.
- Full interval algebra in process — containment, overlap, adjacency, directional comparisons, intersection, union, difference — all executing in memory, no database dependency.
-
RangeSet<TRange, T>, the in-memory multirange, with structural normalisation, set operators, complement, andIReadOnlyList<T>semantics. -
PostgreSQL wire format parsing and formatting, and
System.Text.Jsonserialisation out of the box, making ranges first-class citizens in ASP.NET Core endpoints and any JSON-speaking ecosystem. -
An EF Core / PostgreSQL companion package that bridges domain types to
NpgsqlRange<T>at the mapping boundary and translates the full range algebra from LINQ to SQL. - Abstract record base types — deliberate reference types, not structs — because the polymorphic, pattern-matchable discriminated union this design requires is a class-level concept in C#.
If you work with intervals in your .NET domain model, give it a try. If it solves a problem you have been working around for a while, I would love to hear about it in the comments.
And if you find the library useful, a ⭐ on GitHub goes a long way — it helps others find it, and it means a great deal to an independent open-source author. Feedback, issues, and ideas are equally welcome.
Top comments (0)