UnitsNet.Modular preserves the familiar strongly typed UnitsNet programming model, but changes
where quantities come from and who owns them. UnitsNet provides a precompiled catalog in
UnitsNet.dll. UnitsNet.Modular generates the selected catalog into an assembly owned by the
consumer.
That distinction enables smaller, application-specific catalogs, custom quantities in the same type system, and trimming and Native AOT-friendly discovery without assembly scanning. It also means that UnitsNet.Modular is not a drop-in binary replacement for UnitsNet and that some dynamic, metadata, and runtime-configuration APIs require migration.
Experimental: UnitsNet.Modular is an alpha proof of concept. Its API, package structure, and compatibility guarantees may change as the architecture is evaluated.
| Concern | UnitsNet | UnitsNet.Modular |
|---|---|---|
| Quantity implementation | Precompiled types supplied by UnitsNet.dll |
Source-generated types compiled into a consumer-owned assembly |
| Catalog | The complete UnitsNet catalog | Only the quantities and units selected by the module |
| Namespaces | UnitsNet and UnitsNet.Units |
The same by default for built-ins, or an application namespace selected by the module |
| Custom quantities | Separate from the built-in generated catalog | Built-in, application, and third-party definitions generate together |
| Configuration | Runtime setup and mutable global registrations | Compile-time selection and immutable generated metadata |
| Dynamic discovery | Process-wide UnitsNet catalog | One immutable registry for the selected module |
| Unit-system policy | UnitsNet.UnitSystem stores constituent unit enums |
UnitsNet.UnitSystem keeps the familiar source name but stores invariant names so it is neutral to the generated assembly |
| Deployment | Consumers share the types in UnitsNet.dll |
Consumers must reference the same assembly containing the generated types |
| Compatibility | Established UnitsNet API and binary identity | Common strongly typed APIs target source compatibility; binary compatibility is not a goal |
UnitsNet.Modular is a good fit when an application wants one or more of these properties:
- generate only the quantities and units it uses;
- combine UnitsNet definitions with application-specific or third-party definitions;
- generate relationships and operators across built-in and custom quantities;
- own a single units assembly shared by the application's domain, persistence, API, and UI projects;
- avoid runtime assembly scanning and mutable global registration;
- use generated, trimming and Native AOT-friendly discovery and System.Text.Json integration.
UnitsNet remains the simpler choice when an application needs binary compatibility with libraries
compiled against UnitsNet.dll, depends heavily on runtime mutation, or cannot establish one
generation boundary shared by all consumers. UnitsNet.Modular is currently a design probe, not a
committed replacement for UnitsNet.
A generated public type belongs to the assembly into which it is generated. For a multi-project application, create or choose one units project, generate the module there, and reference that project everywhere else:
MyApplication.Units -> UnitsNet.Modular + module declaration
MyApplication.Domain -> MyApplication.Units
MyApplication.Persistence -> MyApplication.Units
MyApplication.Api -> MyApplication.Units
Do not generate the same quantity independently in several projects. Two generated types named
UnitsNet.Length in two assemblies are different CLR types.
Remove the UnitsNet package reference and add the prerelease UnitsNet.Modular package:
dotnet remove package UnitsNet
dotnet add package UnitsNet.Modular --prereleaseRemove direct UnitsNet references from the other application projects and reference the owning
units project instead. A project cannot use the precompiled and generated UnitsNet.Length types
as though they were the same type.
For the lowest-friction migration, generate all built-in quantities:
using UnitsNet.Modular;
using UnitsNet.Modular.Profiles;
[UnitsNetModule]
internal interface ApplicationUnits : IIncludeProfile<AllQuantitiesProfile>;Built-in definitions use UnitsNet and UnitsNet.Units by default, so existing imports such as
these remain valid:
using UnitsNet;
using UnitsNet.Units;To generate a smaller catalog, replace the profile with explicit selections:
using UnitsNet.Modular;
using Catalog = UnitsNet.Modular.BuiltIns;
[UnitsNetModule]
internal interface ApplicationUnits :
IInclude<Catalog.LengthSpec>,
IInclude<Catalog.DurationSpec>,
IInclude<Catalog.SpeedSpec>;Select every quantity participating in an operator or relationship. Affine quantities such as
Temperature also require their offset quantity; generator diagnostics identify missing inputs.
See Configure generation for profiles and unit filters.
Build before changing ordinary quantity code. Construction, conversion, parsing, formatting, arithmetic, and many quantity-specific helpers should continue to compile. Use the two quick reference tables below to identify code that needs an adapter or API change.
Resolve dynamic quantities through the generated module registry, and use semantic quantity IDs plus invariant unit names at persistence, message, plugin, or service boundaries. Register the module's generated System.Text.Json converter instead of relying on runtime discovery:
using System.Text.Json;
using UnitsNet;
var options = new JsonSerializerOptions();
options.Converters.Add(GeneratedQuantityRegistry.JsonConverter);Existing assemblies compiled against UnitsNet.dll must keep UnitsNet at that boundary. Translate
to the application's generated quantities explicitly by numeric value and invariant unit name.
Run application tests with particular attention to persisted payloads, culture-sensitive parsing
and formatting, unit-system selection, dynamic lookup, and plugin or reflection boundaries. After
the full-catalog migration is stable, replace AllQuantitiesProfile with the quantities and unit
sets the application actually needs.
Here, source-compatible means the same consumer source compiles against UnitsNet.Modular when:
- built-ins use their default
UnitsNetandUnitsNet.Unitsnamespaces; - every referenced quantity and unit is selected; and
- all projects reference the same assembly containing the generated types.
| API area | Examples that remain source-compatible | Notes |
|---|---|---|
| Quantity and unit names | Length, Mass, LengthUnit.Meter |
The full built-in catalog preserves quantity names, unit enum names, and enum values. |
| Construction | new Length(1, LengthUnit.Meter), Length.From(1, unit), Length.FromMeters(1) |
Generated quantity values currently use double. |
| Value access | length.Value, length.Unit, length.Meters, Length.Zero |
Code that explicitly names UnitsNet's QuantityValue type must change to double. |
| Conversion | length.As(unit), length.ToUnit(unit) |
Conversion behavior is generated from the selected definitions. Modular also adds Length.Convert(value, from, to) for raw values. |
| Parsing | Length.Parse(text), Length.TryParse(...), Length.ParseUnit(...) |
Selected units and localization determine accepted input. |
| Formatting | length.ToString(...), Length.GetAbbreviation(unit, provider) |
Culture-aware built-in abbreviations and formatting are generated. |
| Arithmetic and comparison | a + b, a - b, a * 2, a / b, comparisons and equality |
Quantity semantics remain linear, affine, or logarithmic as appropriate. |
| Cross-quantity relationships | Length / Duration, Mass * Acceleration, Force / Area |
Emitted only when all participating quantities are selected. |
| Aggregation | values.Sum(), values.Average(), logarithmic aggregation helpers |
Generated extensions delegate to reusable UnitsNet algorithms. |
| Built-in companion APIs | FeetInches, StonePounds, ReferencePressure, Duration/TimeSpan helpers |
Included when their owning built-in quantities are selected. |
These APIs are not source-compatible even when the full catalog is generated.
| UnitsNet API or assumption | UnitsNet.Modular replacement | Migration action |
|---|---|---|
Types come from UnitsNet.dll |
Types are generated into a consumer-owned assembly | Recompile consumers and make them reference one shared generated assembly; keep an adapter at binary boundaries. |
UnitsNet.IQuantity and legacy generic quantity contracts |
UnitsNet.IQuantity<double> and the generated-runtime capability contracts |
Change generic constraints and declared dynamic types, or keep concrete quantity types where possible. The namespace is source-compatible, but the assembly identity and interface shapes differ. |
Explicit QuantityValue usage |
double |
Change explicitly declared values, parameters, and generic arguments to double. |
Legacy metadata shapes and Length.QuantityInfo |
Typed Length.Info, or IQuantityDescriptor and UnitDescriptor from the registry |
Read Length.Info.BaseUnit, Length.Info.Units, and Length.Info.BaseDimensions when the quantity is known; use the registry for type-erased workflows. |
Quantity.Names, Quantity.Infos, and Quantity.ByName imply the complete process-wide catalog |
The same facade names describe only the selected module | Audit code that assumes every UnitsNet quantity is present. Use the registry when dependency injection or explicit ownership is clearer. |
Quantity.From, TryFrom, Parse, and TryParse return legacy UnitsNet.IQuantity |
The familiar call shapes return UnitsNet.IQuantity<double> from the Modular runtime |
Change the receiving interface shape or use var; concrete typed parsing remains unchanged. |
UnitConverter and runtime conversion registration |
Generated QuantityType.Convert(...) or QuantityRegistry.Convert(...) |
Move conversions into definitions and use typed conversion when the quantity is known. |
UnitsNetSetup quantity selection and runtime registration |
Module interfaces, profiles, unit sets, JSON definitions, and relation definitions | Move configuration to compile time. Rebuild when the catalog changes. |
| Runtime abbreviation mutation | Localization in definition metadata | Add abbreviations to a definition; runtime mutation is unsupported. |
Global Quantity.FromUnitAbbreviation(...) |
Parse through a known quantity or inspect its descriptor | Carry quantity identity at the boundary instead of inferring it from a potentially ambiguous abbreviation. |
UnitKey as a stable boundary identifier |
Semantic QuantityId plus invariant unit name |
Persist or transmit both values; unit enums are suitable only inside one generated module. |
Mutable or enum-backed assumptions about UnitsNet.UnitSystem and UnitsNet.BaseUnits |
Immutable Modular implementations under the same UnitsNet namespace |
Calls such as UnitSystem.SI remain source-compatible; construct custom policies with invariant constituent unit names. |
Legacy BaseDimensions shape in generic/dynamic code |
Modular UnitsNet.BaseDimensions |
The namespace remains the same; adapt shape-dependent code and access it through Length.Info.BaseDimensions or an IQuantityDescriptor. |
| Polymorphic JSON inferred from runtime type discovery | GeneratedQuantityRegistry.JsonConverter for selected concrete quantities |
Register the generated converter. Resolve polymorphic interfaces by semantic quantity ID at the boundary. |
Length.ParseFeetInches and Length.TryParseFeetInches |
No specialized compound parser | Keep a presentation-layer parser or translate the input into ordinary Length operations. |
Pressure.FromElevation and Pressure.ToElevation |
No generated elevation model | Keep the empirical atmosphere model in application code and return/accept Pressure. |
| Runtime additions to a catalog | New or changed definition inputs | Update the module or definition package and rebuild. |
Change code that explicitly receives a UnitsNet interface:
// UnitsNet
IQuantity distance = Quantity.From(1.5, "Length", "Kilometer");
// UnitsNet.Modular package
UnitsNet.IQuantity<double> distance =
Quantity.From(1.5, "Length", "Kilometer");The namespace remains UnitsNet, but the Modular interface has a deliberately slimmer shape:
UnitsNet.IQuantity<double> distance = Length.FromMeters(1);Replace dynamic conversion by name with the registry:
// UnitsNet
QuantityValue meters = UnitConverter.ConvertByName(
1.5, "Length", "Kilometer", "Meter");
// UnitsNet.Modular package
using UnitsNet;
var registry = GeneratedQuantityRegistry.Instance;
double meters = registry.Convert(
1.5,
"Length",
"Kilometer",
"Meter");When the quantity is known, prefer the generated typed API:
double meters = Length.Convert(
1.5,
LengthUnit.Kilometer,
LengthUnit.Meter);The registry also supports enum-based conversion when both units belong to one selected quantity.
Replace mutable/global metadata assumptions with a descriptor from the selected module:
using UnitsNet;
IQuantityDescriptor descriptor =
GeneratedQuantityRegistry.Instance.Get("Length");
foreach (UnitDescriptor unit in descriptor.Units)
{
Console.WriteLine(unit.Name);
}| UnitsNet workflow | UnitsNet.Modular equivalent | Status |
|---|---|---|
Quantity.Names |
Quantity.Names or registry.Names |
Supported, selected module only |
Quantity.Infos |
Quantity.Infos or registry.Quantities |
Supported with immutable descriptors |
Quantity.ByName[name] |
Quantity.ByName[name] or registry.Get(name) |
Supported, case-insensitive name |
| Lookup by quantity type | registry.Get(typeof(Length)) |
Supported |
| Stable cross-boundary identity | registry.Get(new QuantityId("UnitsNet.Length")) |
Supported; use namespace-qualified IDs |
| Lookup from a unit enum type | registry.GetByUnitType(typeof(LengthUnit)) |
Supported |
Quantity.From(value, quantityName, unitName) |
Same facade call | Returns Modular quantity contract |
Quantity.From(value, unitEnum) |
Same facade call | Returns Modular quantity contract |
Quantity.TryFrom(...) |
Same facade call | Returns Modular quantity contract |
UnitConverter.ConvertByName(...) |
registry.Convert(value, quantityName, fromName, toName) |
Supported |
UnitConverter.Convert(value, fromEnum, toEnum) |
registry.Convert(value, fromEnum, toEnum) |
Supported for one quantity |
UnitConverter.TryConvert... |
registry.TryConvert(...) |
Supported for one quantity |
Quantity.Parse(type, text) |
Same facade call | Returns Modular quantity contract |
Quantity.TryParse(...) |
Same facade call | Returns Modular quantity contract |
Quantity.GetQuantitiesWithBaseDimensions(...) |
Same facade call or registry.FindByBaseDimensions(...) |
Supported, selected module only |
QuantityInfo / UnitInfo metadata |
QuantityInfo<TQuantity, TUnit> / UnitInfo<TUnit>; IQuantityDescriptor / UnitDescriptor when type-erased |
Supported as immutable metadata |
Dynamic formatting through IQuantity |
descriptor.Format(value, format, provider) |
Supported with concrete-type validation |
| System.Text.Json converters | GeneratedQuantityRegistry.JsonConverter |
Supported without assembly scanning |
| Generic quantity algorithms | UnitsNet.IQuantity<...> capability contracts |
Supported by generated quantities; the familiar namespace does not imply binary identity with legacy UnitsNet contracts |
UnitKey |
A unit enum in-process; semantic quantity ID plus invariant unit name across boundaries | Deliberately changed |
UnitSystem / BaseUnits |
UnitsNet.UnitSystem / UnitsNet.BaseUnits |
Supported as immutable selected-module policy |
UnitsNetSetup quantity selection |
Module interfaces, profiles, and definition packages | Compile-time replacement |
| Runtime abbreviation mutation | Localization in definition metadata | Runtime mutation unsupported |
| Runtime conversion registration | Definition conversion expressions and relation definitions | Runtime mutation unsupported |
Global Quantity.FromUnitAbbreviation(...) |
Parse through a known quantity or present units from its descriptor | Deliberately unsupported |
Legacy UnitsNet.IQuantity identity |
Modular UnitsNet.IQuantity<double> |
Namespace preserved; assembly identity and interface shape deliberately changed |
Persist descriptor.Id and unit.Name, not a localized abbreviation. Abbreviations are for parsing
and display and may be shared by unrelated quantities.
The two packages expose intentionally different UnitSystem implementations under the same
source-compatible namespace. Since the packages cannot be referenced together, ordinary calls do
not need Modular-specific qualification:
Length distance = Length.From(1.5, UnitSystem.SI);
double meters = Length.FromKilometers(1.5).As(UnitSystem.SI);
Length normalized = Length.FromFeet(3).ToUnit(UnitSystem.SI);To define an application policy, use invariant constituent unit names:
var imperial = new UnitSystem(
new BaseUnits(length: "Foot"));
Length distance = new Length(3, imperial);Resolution considers only units selected into the module.
The generated static Quantity facade is useful while preserving familiar call shapes:
UnitsNet.IQuantity<double> byName =
Quantity.From(1.5, "Length", "Kilometer");
UnitsNet.IQuantity<double> byUnit =
Quantity.From(1.5, LengthUnit.Kilometer);
UnitsNet.IQuantity<double> parsed =
Quantity.Parse(typeof(Length), "1.5 km");For injected services and type-erased operations, depend on the registry:
QuantityRegistry registry = GeneratedQuantityRegistry.Instance;
IQuantityDescriptor length = registry.Get(typeof(Length));
UnitsNet.IQuantity<double> parsed = registry.Parse(
typeof(Length),
"1.5 km",
System.Globalization.CultureInfo.InvariantCulture);
string display = length.Format(
parsed,
"0.00",
System.Globalization.CultureInfo.InvariantCulture);Try* registry methods return false for unselected quantities, unselected or undefined units,
cross-quantity enum conversion, and invalid parse input.
- One assembly owns each generated quantity type used by the application.
- Built-ins use the default namespaces unless a deliberate application namespace is desired.
- All quantities, units, affine companions, and relationship participants are selected.
- No migrated project accidentally depends on both precompiled and generated types with the same full name.
- Dynamic code uses Modular contracts and the generated module registry.
- Runtime setup and registrations have moved to definitions and module selection.
- Persisted and external boundaries use semantic quantity IDs and invariant unit names.
- JSON, localization, parsing, formatting, conversion, and unit-system behavior have application tests.
- Binary consumers of
UnitsNet.dllremain behind an explicit adapter.
IReadOnlyList<IQuantityDescriptor> distances =
registry.FindByBaseDimensions(Length.Info.BaseDimensions);UnitsNetSetup, mutable UnitConverter registrations, abbreviation-cache mutation, and mutable
global unit-system defaults are process-wide runtime policy. UnitsNet.Modular instead accepts an
immutable UnitSystem explicitly at each typed or dynamic operation. Selected definitions, units,
conversions, relationships, and localization remain compile-time inputs owned by the application.
The generated Quantity facade delegates to Quantity.Registry and returns
UnitsNet.IQuantity<double>. It does not own another catalog, conversion registry, or mutable
configuration. APIs that would pretend to mutate generated code remain absent.
Use an application adapter when a boundary genuinely needs runtime policy. Keep that adapter outside
UnitsNet.Modular and the generated quantities, and identify values by semantic quantity ID plus
invariant unit name. Existing plugins or assemblies compiled against UnitsNet.dll must retain
UnitsNet at that binary boundary and translate explicitly; matching generated full names do not
make the CLR types assignment-compatible.
For module configuration, custom definitions, diagnostics, and current limitations, continue with the UnitsNet.Modular README.