Migrate from UnitsNet to UnitsNet.Modular

August 1, 2026 ยท View on GitHub

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.

How the two packages differ

ConcernUnitsNetUnitsNet.Modular
Quantity implementationPrecompiled types supplied by UnitsNet.dllSource-generated types compiled into a consumer-owned assembly
CatalogThe complete UnitsNet catalogOnly the quantities and units selected by the module
NamespacesUnitsNet and UnitsNet.UnitsThe same by default for built-ins, or an application namespace selected by the module
Custom quantitiesSeparate from the built-in generated catalogBuilt-in, application, and third-party definitions generate together
ConfigurationRuntime setup and mutable global registrationsCompile-time selection and immutable generated metadata
Dynamic discoveryProcess-wide UnitsNet catalogOne immutable registry for the selected module
Unit-system policyUnitsNet.UnitSystem stores constituent unit enumsUnitsNet.UnitSystem keeps the familiar source name but stores invariant names so it is neutral to the generated assembly
DeploymentConsumers share the types in UnitsNet.dllConsumers must reference the same assembly containing the generated types
CompatibilityEstablished UnitsNet API and binary identityCommon strongly typed APIs target source compatibility; binary compatibility is not a goal

Why use UnitsNet.Modular?

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.

Migration steps

1. Choose the assembly that owns the generated types

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.

2. Replace the package in the owning project

Remove the UnitsNet package reference and add the prerelease UnitsNet.Modular package:

dotnet remove package UnitsNet
dotnet add package UnitsNet.Modular --prerelease

Remove 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.

3. Declare the module

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.

4. Build and fix source-incompatible APIs

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.

5. Migrate dynamic and serialized boundaries

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.

6. Verify behavior, then reduce the catalog

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.

Quick reference: source-compatible APIs

Here, source-compatible means the same consumer source compiles against UnitsNet.Modular when:

  • built-ins use their default UnitsNet and UnitsNet.Units namespaces;
  • every referenced quantity and unit is selected; and
  • all projects reference the same assembly containing the generated types.
API areaExamples that remain source-compatibleNotes
Quantity and unit namesLength, Mass, LengthUnit.MeterThe full built-in catalog preserves quantity names, unit enum names, and enum values.
Constructionnew Length(1, LengthUnit.Meter), Length.From(1, unit), Length.FromMeters(1)Generated quantity values currently use double.
Value accesslength.Value, length.Unit, length.Meters, Length.ZeroCode that explicitly names UnitsNet's QuantityValue type must change to double.
Conversionlength.As(unit), length.ToUnit(unit)Conversion behavior is generated from the selected definitions. Modular also adds Length.Convert(value, from, to) for raw values.
ParsingLength.Parse(text), Length.TryParse(...), Length.ParseUnit(...)Selected units and localization determine accepted input.
Formattinglength.ToString(...), Length.GetAbbreviation(unit, provider)Culture-aware built-in abbreviations and formatting are generated.
Arithmetic and comparisona + b, a - b, a * 2, a / b, comparisons and equalityQuantity semantics remain linear, affine, or logarithmic as appropriate.
Cross-quantity relationshipsLength / Duration, Mass * Acceleration, Force / AreaEmitted only when all participating quantities are selected.
Aggregationvalues.Sum(), values.Average(), logarithmic aggregation helpersGenerated extensions delegate to reusable UnitsNet algorithms.
Built-in companion APIsFeetInches, StonePounds, ReferencePressure, Duration/TimeSpan helpersIncluded when their owning built-in quantities are selected.

Quick reference: incompatible or changed APIs

These APIs are not source-compatible even when the full catalog is generated.

UnitsNet API or assumptionUnitsNet.Modular replacementMigration action
Types come from UnitsNet.dllTypes are generated into a consumer-owned assemblyRecompile consumers and make them reference one shared generated assembly; keep an adapter at binary boundaries.
UnitsNet.IQuantity and legacy generic quantity contractsUnitsNet.IQuantity<double> and the generated-runtime capability contractsChange 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 usagedoubleChange explicitly declared values, parameters, and generic arguments to double.
Legacy metadata shapes and Length.QuantityInfoTyped Length.Info, or IQuantityDescriptor and UnitDescriptor from the registryRead 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 catalogThe same facade names describe only the selected moduleAudit 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.IQuantityThe familiar call shapes return UnitsNet.IQuantity<double> from the Modular runtimeChange the receiving interface shape or use var; concrete typed parsing remains unchanged.
UnitConverter and runtime conversion registrationGenerated QuantityType.Convert(...) or QuantityRegistry.Convert(...)Move conversions into definitions and use typed conversion when the quantity is known.
UnitsNetSetup quantity selection and runtime registrationModule interfaces, profiles, unit sets, JSON definitions, and relation definitionsMove configuration to compile time. Rebuild when the catalog changes.
Runtime abbreviation mutationLocalization in definition metadataAdd abbreviations to a definition; runtime mutation is unsupported.
Global Quantity.FromUnitAbbreviation(...)Parse through a known quantity or inspect its descriptorCarry quantity identity at the boundary instead of inferring it from a potentially ambiguous abbreviation.
UnitKey as a stable boundary identifierSemantic QuantityId plus invariant unit namePersist or transmit both values; unit enums are suitable only inside one generated module.
Mutable or enum-backed assumptions about UnitsNet.UnitSystem and UnitsNet.BaseUnitsImmutable Modular implementations under the same UnitsNet namespaceCalls such as UnitSystem.SI remain source-compatible; construct custom policies with invariant constituent unit names.
Legacy BaseDimensions shape in generic/dynamic codeModular UnitsNet.BaseDimensionsThe namespace remains the same; adapt shape-dependent code and access it through Length.Info.BaseDimensions or an IQuantityDescriptor.
Polymorphic JSON inferred from runtime type discoveryGeneratedQuantityRegistry.JsonConverter for selected concrete quantitiesRegister the generated converter. Resolve polymorphic interfaces by semantic quantity ID at the boundary.
Length.ParseFeetInches and Length.TryParseFeetInchesNo specialized compound parserKeep a presentation-layer parser or translate the input into ordinary Length operations.
Pressure.FromElevation and Pressure.ToElevationNo generated elevation modelKeep the empirical atmosphere model in application code and return/accept Pressure.
Runtime additions to a catalogNew or changed definition inputsUpdate the module or definition package and rebuild.

Common fixes

Dynamic quantity contracts

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);

Global conversion

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.

Metadata and unit pickers

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 workflowUnitsNet.Modular equivalentStatus
Quantity.NamesQuantity.Names or registry.NamesSupported, selected module only
Quantity.InfosQuantity.Infos or registry.QuantitiesSupported with immutable descriptors
Quantity.ByName[name]Quantity.ByName[name] or registry.Get(name)Supported, case-insensitive name
Lookup by quantity typeregistry.Get(typeof(Length))Supported
Stable cross-boundary identityregistry.Get(new QuantityId("UnitsNet.Length"))Supported; use namespace-qualified IDs
Lookup from a unit enum typeregistry.GetByUnitType(typeof(LengthUnit))Supported
Quantity.From(value, quantityName, unitName)Same facade callReturns Modular quantity contract
Quantity.From(value, unitEnum)Same facade callReturns Modular quantity contract
Quantity.TryFrom(...)Same facade callReturns 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 callReturns Modular quantity contract
Quantity.TryParse(...)Same facade callReturns Modular quantity contract
Quantity.GetQuantitiesWithBaseDimensions(...)Same facade call or registry.FindByBaseDimensions(...)Supported, selected module only
QuantityInfo / UnitInfo metadataQuantityInfo<TQuantity, TUnit> / UnitInfo<TUnit>; IQuantityDescriptor / UnitDescriptor when type-erasedSupported as immutable metadata
Dynamic formatting through IQuantitydescriptor.Format(value, format, provider)Supported with concrete-type validation
System.Text.Json convertersGeneratedQuantityRegistry.JsonConverterSupported without assembly scanning
Generic quantity algorithmsUnitsNet.IQuantity<...> capability contractsSupported by generated quantities; the familiar namespace does not imply binary identity with legacy UnitsNet contracts
UnitKeyA unit enum in-process; semantic quantity ID plus invariant unit name across boundariesDeliberately changed
UnitSystem / BaseUnitsUnitsNet.UnitSystem / UnitsNet.BaseUnitsSupported as immutable selected-module policy
UnitsNetSetup quantity selectionModule interfaces, profiles, and definition packagesCompile-time replacement
Runtime abbreviation mutationLocalization in definition metadataRuntime mutation unsupported
Runtime conversion registrationDefinition conversion expressions and relation definitionsRuntime mutation unsupported
Global Quantity.FromUnitAbbreviation(...)Parse through a known quantity or present units from its descriptorDeliberately unsupported
Legacy UnitsNet.IQuantity identityModular 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.

Unit-system policy

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.

Dynamic creation, parsing, and formatting

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.

Migration checklist

  • 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.dll remain behind an explicit adapter.
IReadOnlyList<IQuantityDescriptor> distances =
    registry.FindByBaseDimensions(Length.Info.BaseDimensions);

Why the facade stays thin

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.