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.
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:
namespace Hub.Contracts;
public interface ICoordinates
{
decimal Latitude { get; set; }
decimal Longitude { get; set; }
}
Coordinates is consumed by several entities, e.g. Address.Coordinates:
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).
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 : Unitconstraint means the unit is itself a persisted master-data entity (a DB row), not an inline string. - The non-generic
IDimensioninterface (declared in the same file) exposes aUnit? Unitview. Infrastructure code that must treat all dimensions uniformly — regardless of unit type — keys offIDimension(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 |
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.
[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, …):
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:
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:
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.:
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:
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; }
}
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:
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 usesRuntimeHelpers.GetHashCode(this)(identity hash), regardless of freeze state.
The freeze mechanism (from IFreezable) also makes seeded/cached master data effectively immutable:
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:
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¶
- Entities — identity-based counterparts and the Hub inheritance spine.
- Aggregate roots —
ShipmentBase,Shipment,Consol(consumers ofDimension<…>). - Master / reference data —
CodeEntity,Unit, freeze/seeding, static enums, external code mapping. - DDD overview — where value objects sit in the layered design.
- DTOs —
[AutoGenerateDto]generation pipeline. - Entity Framework Core — owned types and the
HubDbContext. - ABP reference: Value objects · DDD building blocks · Entities & aggregate roots