Skip to content

Value Objects

A value object is defined by its attributes rather than by an identity. Two value objects with the same attribute values are considered the same thing; they are typically immutable and side-effect free. This contrasts with an entity, which has a stable Id and a lifecycle.

ABP ships a ValueObject base class that gives you structural value-equality out of the box (you override GetAtomicValues() and ABP compares the yielded values). Cargonerds does not use that base class anywhere. Instead the Hub domain models value-object-style concepts as small, plain classes that EF Core maps as owned types, plus a family of enriched master-data entities (Unit and its subclasses) that back the measured values.

These are not ABP ValueObjects

A repo-wide search for : ValueObject / Volo.Abp.Domain.Values / GetAtomicValues() returns no production code. Coordinates and Dimension<TUnit> are plain classes with no value-equality — two Coordinates instances with identical lat/long are not equal, and the default object.Equals/GetHashCode (reference identity) applies. Treat them as data holders, not as equality-comparable VOs. See Gotchas.


The two real value objects

The Hub domain has exactly two plain value-object types — Coordinates and the generic Dimension<TUnit> — both living in modules/hub/src/Hub.Domain.

Coordinates

A pure latitude/longitude pair.

modules/hub/src/Hub.Domain/Coordinates.cs
using Hub.Contracts;
using Nextended.Core.Attributes;

namespace Hub; // root Hub namespace, not Hub.Base

[AutoGenerateDto(GenerateMapping = false)]
public class Coordinates : ICoordinates
{
    public decimal Latitude { get; set; }
    public decimal Longitude { get; set; }
}

The interface lives in the *.Domain.Shared layer so contracts/clients can reference it without a dependency on Hub.Domain:

modules/hub/src/Hub.Domain.Shared/Contracts/ICoordinates.cs
namespace Hub.Contracts;

public interface ICoordinates
{
    decimal Latitude { get; set; }
    decimal Longitude { get; set; }
}

Coordinates is consumed by several entities, e.g. Address.Coordinates:

modules/hub/src/Hub.Domain/Entities/Address.cs (excerpt)
public class Address : CustomerOwnedGuidEntity, IMasterData
{
    // ...
    public Coordinates? Coordinates { get; set; }
}

It is also used on Port, and on tracking/leg types (TrackingLeg.Location, VizionEvent.Location, ShipmentLegLocation) via owned-type configurations.

Dimension<TUnit>

A generic measured value: a numeric Value paired with a Unit. Generics give weights, volumes, lengths and temperatures one shared shape while keeping the unit type-safe at compile time (a Dimension<WeightUnit> can never be assigned a VolumeUnit).

modules/hub/src/Hub.Domain/Base/Dimension.cs
using Nextended.Core.Attributes;

namespace Hub.Base;

[AutoGenerateDto(GenerateMapping = false)]
public partial class Dimension<TUnit> : IDimension
    where TUnit : Unit
{
    public decimal? Value { get; set; }

    public virtual TUnit? Unit { get; set; }

    Unit? IDimension.Unit => Unit; // non-generic view used by infrastructure

    public Dimension() { }

    public Dimension(decimal? value, TUnit? unit)
    {
        Value = value;
        Unit = unit;
    }
}

public interface IDimension
{
    decimal? Value { get; set; }
    Unit? Unit { get; }
}

Notes:

  • The where TUnit : Unit constraint means the unit is itself a persisted master-data entity (a DB row), not an inline string.
  • The non-generic IDimension interface (declared in the same file) exposes a Unit? Unit view. Infrastructure code that must treat all dimensions uniformly — regardless of unit type — keys off IDimension (see the interceptor gotcha).
  • The class is partial; source generation extends it (the [AutoGenerateDto] machinery and the OData/EDM surface).

Dimension<TUnit> is used pervasively across the domain. A representative sample:

Entity (file) Property Unit type
Base/ShipmentBase.cs TotalWeight Dimension<WeightUnit>?
Base/ShipmentBase.cs TotalVolume Dimension<VolumeUnit>?
Entities/Container.cs GrossWeight, Volume, Temperature Dimension<WeightUnit>?, Dimension<VolumeUnit>?, Dimension<TemperatureUnit>?
Entities/PackLine.cs Weight, Length, Height, Width, Volume WeightUnit / LengthUnit / VolumeUnit
Entities/OrderLine.cs Weight, Volume, OuterPack{Length,Height,Width} WeightUnit / VolumeUnit / LengthUnit
Entities/Pricing/Quotation.cs TotalWeight, TotalChargeableWeight, TotalVolume WeightUnit / VolumeUnit
Entities/Pricing/PackLineRequest.cs Weight, Length, Height, Width, Volume WeightUnit / LengthUnit / VolumeUnit
modules/hub/src/Hub.Domain/Entities/Container.cs (excerpt)
public Dimension<TemperatureUnit>? Temperature { get; set; }
public Dimension<WeightUnit>? GrossWeight { get; set; }
public Dimension<VolumeUnit>? Volume { get; set; }

The Unit backing entity

Dimension<TUnit> is parameterised by a Unit, which is not a value object but a CodeEntity — a persisted master-data row that also carries the conversion factors needed to normalise quantities to an SI base unit.

modules/hub/src/Hub.Domain/Base/Unit.cs
[AutoGenerateDto(GenerateMapping = false)]
public class Unit : CodeEntity
{
    public Unit() { }

    public Unit(string code, string? description = null,
        decimal? baseUnitFactor = null, decimal? nullPointShift = null)
        : base(code, description)
    {
        BaseUnitFactor = baseUnitFactor;
        NullPointShift = nullPointShift;
    }

    /// <summary>Factor to convert from this unit to the SI base unit
    /// (Temperature: C, Length: M, Mass: KG, Volume: M3). e.g. kg -> g is 1000.</summary>
    public decimal? BaseUnitFactor { get; set; }

    /// <summary>Null-point offset vs. the base unit. Temperature only,
    /// e.g. F vs. C is 32 because 0 C = 32 F.</summary>
    public decimal? NullPointShift { get; set; }
}

The concrete unit types are real classes under modules/hub/src/Hub.Domain/Entities/: WeightUnit, VolumeUnit, LengthUnit, TemperatureUnit (and related code lists such as ContainerPenaltyTimeUnit). Each defines its members as seeded static instances and plugs into the static-enum/super-type seeding machinery described under Master / reference data.

WeightUnit is the richest example — note how each member's BaseUnitFactor expresses the conversion to the SI base (KGM = 1, GRM = 0.001, TNE = 1000, …):

modules/hub/src/Hub.Domain/Entities/WeightUnit.cs (excerpt)
public class WeightUnit
    : Unit,
        ISuperType<WeightUnit>,
        IMapableTo<WeightUnit, StaticServiceType.CW1>,
        IMapableTo<WeightUnit, StaticServiceType.Pricing>,
        IMapableTo<WeightUnit, StaticServiceType.UnitsNet>,
        IHubCacheableEntity
{
    [Cw1Codes("KG")]
    [PricingCodes("kg")]
    [UnitsNetCodes<UnitsNet.Units.MassUnit>(UnitsNet.Units.MassUnit.Kilogram)]
    public static WeightUnit Kilogram { get; } = new("KGM", "Kilogram", 1m);

    [Cw1Codes("G")]
    public static WeightUnit Gram { get; } = new("GRM", "Gram", 0.001m);

    [Cw1Codes("T")]
    public static WeightUnit MetricTon { get; } = new("TNE", "Metric Ton", 1_000m);

    [Cw1Codes("LB")]
    public static WeightUnit Pound { get; } = new("LBR", "Pound", 0.45359237m);
    // ... Microgram, Milligram, Hectogram, Ounce, Long/Short Ton, Troy Ounce, ...

    static List<WeightUnit> ISuperType<WeightUnit>.Initialize(List<WeightUnit> existingItems) =>
        InitializeSuperType(existingItems);
}

TemperatureUnit is almost empty by comparison; its members differ via NullPointShift rather than a multiplicative factor:

modules/hub/src/Hub.Domain/Entities/TemperatureUnit.cs
public class TemperatureUnit : Unit, IHubCacheableEntity { }

Why a Dimension references an entity, not a string

Pairing a number with a Unit row (rather than a string like "kg") means the conversion factors (BaseUnitFactor, NullPointShift) travel with the value, and unit codes are normalised/mapped against external systems (CW1, Pricing, UnitsNet) through IMapableTo and the *Codes attributes. See Master / reference data for the seeding and external code-mapping mechanics.


How the pieces fit together

classDiagram
    class IDimension {
        <<interface>>
        +decimal? Value
        +Unit? Unit
    }
    class Dimension~TUnit~ {
        +decimal? Value
        +TUnit? Unit
    }
    class CodeEntity {
        <<abstract>>
        +Guid Id
        +bool IsFrozen
        +Equals(CodeEntity)
    }
    class Unit {
        +decimal? BaseUnitFactor
        +decimal? NullPointShift
    }
    class WeightUnit
    class VolumeUnit
    class LengthUnit
    class TemperatureUnit
    class Coordinates {
        +decimal Latitude
        +decimal Longitude
    }

    IDimension <|.. Dimension~TUnit~
    Dimension~TUnit~ --> Unit : TUnit : Unit
    CodeEntity <|-- Unit
    Unit <|-- WeightUnit
    Unit <|-- VolumeUnit
    Unit <|-- LengthUnit
    Unit <|-- TemperatureUnit

    note for Coordinates "Owned type. Plain class.\nNo value-equality."
    note for Dimension~TUnit~ "Owned type. Plain class.\nNo value-equality."

Persistence: EF Core owned types

Because they have no identity of their own, the value objects are mapped as owned types — their columns live inside the owner's table rather than in a table of their own. They are registered up-front in HubDbContext:

modules/hub/src/Hub.EntityFrameworkCore/EntityFrameworkCore/HubDbContext.cs (OnModelCreating)
modelBuilder.Owned<Dimension<TemperatureUnit>>();
modelBuilder.Owned<Dimension<WeightUnit>>();
modelBuilder.Owned<Dimension<VolumeUnit>>();
modelBuilder.Owned<Dimension<LengthUnit>>();
// ...
modelBuilder.Owned<Coordinates>();

Individual owners then declare the owned relationship with OwnsOne, e.g.:

.../TypeConfigurations/Hub/AddressTypeConfiguration.cs
builder.OwnsOne(a => a.Coordinates);

Similar OwnsOne(...) calls exist for Port.Coordinates, TrackingLeg.Location, VizionEvent.Location, CalculatedShipment.LastLocation and others.

Owned ≠ value-equality

Marking a type Owned<> is purely an EF persistence decision (columns inlined into the owner table). It does not give the CLR type value-equality, and it does not turn the class into an ABP ValueObject. The two concerns are independent.


DTO generation

The [AutoGenerateDto(GenerateMapping = false)] attribute (from Nextended.Core.Attributes) drives generation of a matching DTO + interface for each value object. GenerateMapping = false means no mapping code is generated — mapping is handled elsewhere (Mapperly, per the project's mapping convention). The generated shapes mirror the domain types exactly:

modules/hub/src/Hub.Application.Contracts/Generated/CoordinatesDto.g.cs
public partial interface ICoordinatesDto
{
    decimal Latitude { get; set; }
    decimal Longitude { get; set; }
}

public partial class CoordinatesDto : ICoordinatesDto
{
    public decimal Latitude { get; set; }
    public decimal Longitude { get; set; }
}
modules/hub/src/Hub.Application.Contracts/Generated/DimensionDto.g.cs
public partial interface IDimensionDto<TUnit> where TUnit : UnitDto
{
    decimal? Value { get; set; }
    TUnit? Unit { get; set; }
}

public partial class DimensionDto<TUnit> : IDimensionDto<TUnit> where TUnit : UnitDto
{
    public decimal? Value { get; set; }
    public TUnit? Unit { get; set; }
}

The generated WeightUnitDto mirrors the entity hierarchy too (WeightUnitDto : UnitDto), so the generic DimensionDto<WeightUnitDto> lines up on the wire with Dimension<WeightUnit> on the server. See DTOs for the generation pipeline.


Equality on the Unit family (CodeEntity)

The value objects themselves have no custom equality, but the Unit entities they reference do — and the semantics are mode-dependent, defined on the shared CodeEntity base:

modules/hub/src/Hub.Domain/Base/CodeEntity.cs (excerpt)
public bool Equals(CodeEntity? other)
{
    if (other is null) return false;

    if (!IsFrozen && !other.IsFrozen)
        return ReferenceEquals(this, other);

    // Only if one of the codes is frozen (i.e. coming from a cache),
    // we treat objects the same if their ids match
    return Id == other.Id;
}

public override int GetHashCode() => RuntimeHelpers.GetHashCode(this);

public static bool operator ==(CodeEntity? left, CodeEntity? right) => Equals(left, right);
public static bool operator !=(CodeEntity? left, CodeEntity? right) => !Equals(left, right);

So:

  • Unfrozen instances compare by reference.
  • Frozen instances (typically pulled from the Hub second-level cache) compare by Id.
  • GetHashCode() always uses RuntimeHelpers.GetHashCode(this) (identity hash), regardless of freeze state.

The freeze mechanism (from IFreezable) also makes seeded/cached master data effectively immutable:

modules/hub/src/Hub.Domain/Base/CodeEntity.cs (excerpt)
protected void ThrowIfFrozen([CallerMemberName] string? propertyName = null)
{
    if (IsFrozen)
        throw new InvalidOperationException($"Cannot set {propertyName} after the entity is frozen.");
}

public string Code
{
    get => _code;
    set { ThrowIfFrozen(); _code = value; }
}

Description and CargonerdsCustomerId are guarded the same way. The full freeze/seed/super-type story (and InitializeSuperType<T>) is documented under Master / reference data.

Frozen CodeEntity is risky as a dictionary/set key

Two frozen units with the same Id are Equals, but their GetHashCode() values come from RuntimeHelpers.GetHashCode and will generally differ. Equal-but-different-hash breaks the hash-set/dictionary contract, so do not rely on Unit (or any CodeEntity) as a hashed- collection key when frozen instances are involved.


Gotchas

No value-equality — by design

Coordinates and Dimension<TUnit> do not override Equals/GetHashCode and are not ABP ValueObjects. new Coordinates { Latitude = 1, Longitude = 2 } is never equal to another instance with the same values. If you need structural comparison, do it field-by-field; do not assume == works.

IgnoreFrozenEntityInterceptor skips IDimension properties

During EF identity-resolution updates, IgnoreFrozenEntityInterceptor copies tracked-instance property values from a new entity onto the existing one — except any property whose current value is an IDimension, which is explicitly skipped:

.../IgnoreFrozenEntityInterceptor.cs (excerpt)
if (existingPropertyEntry.CurrentValue is IDimension
    || propertyEntry.CurrentValue is IDimension)
{
    continue;
}

This is why IDimension (the non-generic view) exists. If you change how dimensions are tracked or mapped, be aware this interceptor treats them specially.

Coordinates lives in the root Hub namespace

Unlike Dimension<TUnit> (in Hub.Base), Coordinates is declared in namespace Hub; at the project root (Hub.Domain/Coordinates.cs). Watch the using when referencing it.

Heavy reliance on code generation

The DTO and OData/EDM surface for these types is generated, not hand-written (*.g.cs under Hub.Application.Contracts/Generated/). Editing the value object or its [AutoGenerateDto] attribute ripples through generated code that a plain text search of the domain project will not show. The concrete unit types (WeightUnit, …) are hand-written, but StaticServiceType (used as a generic arg in their IMapableTo<…> declarations) is generated — see Master / reference data.


See also