Designing Entity Classes & Enums
In this presentation, we design the C# entity classes, enums, and data annotations that form the foundation of our Parcel Tracking API. Good entity design directly affects your database schema, API contracts, and the clarity of your business logic.
Principles of Entity Design
When designing entities for an EF Core application, follow these guidelines:
- Match the domain language: Property names should reflect carrier industry terminology
- Use appropriate types:
Guidfor IDs,decimalfor monetary values,DateTimeOffsetfor timestamps - Leverage enums: Replace magic strings with strongly-typed enums
- Favor explicitness: Include units (WeightUnit, DimensionUnit) rather than assuming
- Keep entities focused: Each entity represents one concept
Defining the Enums
We start with enums because the entities reference them. Define all enums in the Domain project under Enums/.
ParcelStatus
csharp1public enum ParcelStatus2{3 LabelCreated = 0,4 PickedUp = 1,5 InTransit = 2,6 OutForDelivery = 3,7 Delivered = 4,8 Exception = 5,9 Returned = 610}
Assigning explicit integer values ensures the database stores predictable numbers. If you later add a new status between existing ones, existing data remains correct.
ServiceType
csharp1public enum ServiceType2{3 Economy = 0,4 Standard = 1,5 Express = 2,6 Overnight = 37}
EventType
csharp1public enum EventType2{3 LabelCreated = 0,4 PickedUp = 1,5 ArrivedAtFacility = 2,6 DepartedFacility = 3,7 InTransit = 4,8 OutForDelivery = 5,9 Delivered = 6,10 DeliveryAttempted = 7,11 Exception = 8,12 Returned = 9,13 AddressCorrection = 10,14 CustomsClearance = 11,15 HeldAtFacility = 1216}
Notice that EventType has more values than ParcelStatus. A single status like InTransit can be the result of multiple event types (ArrivedAtFacility, DepartedFacility, InTransit).
ExceptionReason
csharp1public enum ExceptionReason2{3 AddressNotFound = 0,4 RecipientUnavailable = 1,5 DamagedInTransit = 2,6 WeatherDelay = 3,7 CustomsHold = 4,8 RefusedByRecipient = 59}
Unit Enums
csharp1public enum WeightUnit2{3 Lb = 0,4 Kg = 15}67public enum DimensionUnit8{9 In = 0,10 Cm = 111}
The Address Entity
The Address entity stores physical location and contact information. It is referenced by parcels for both shipper and recipient.
csharp1public class Address2{3 public Guid Id { get; set; }45 [Required]6 [MaxLength(200)]7 public string Street1 { get; set; } = string.Empty;89 [MaxLength(200)]10 public string? Street2 { get; set; }1112 [Required]13 [MaxLength(100)]14 public string City { get; set; } = string.Empty;1516 [Required]17 [MaxLength(100)]18 public string State { get; set; } = string.Empty;1920 [Required]21 [MaxLength(20)]22 public string PostalCode { get; set; } = string.Empty;2324 [Required]25 [MaxLength(2)]26 public string CountryCode { get; set; } = string.Empty;2728 public bool IsResidential { get; set; }2930 [MaxLength(150)]31 public string? ContactName { get; set; }3233 [MaxLength(200)]34 public string? CompanyName { get; set; }3536 [MaxLength(20)]37 public string? Phone { get; set; }3839 [MaxLength(254)]40 public string? Email { get; set; }41}
Design Decisions
Guid Id: We use GUIDs for primary keys. They are globally unique, making them safe for distributed systems and API responses. There is no risk of exposing sequential IDs to clients.string? Street2: Nullable because not every address has a second line.CountryCodewithMaxLength(2): Stores ISO 3166-1 alpha-2 codes (US, CA, GB, etc.).IsResidential: Carriers charge different rates for residential vs. commercial deliveries.MaxLengthattributes: EF Core uses these to set column sizes, and they serve as validation constraints on the API layer.- No navigation properties: Address does not need to know which parcels reference it. We navigate from Parcel to Address, not the other way.
The Parcel Entity
The Parcel is the central entity. It contains all the metadata about a shipment.
csharp1public class Parcel2{3 public Guid Id { get; set; }45 [Required]6 [MaxLength(50)]7 public string TrackingNumber { get; set; } = string.Empty;89 [MaxLength(500)]10 public string? Description { get; set; }1112 public ServiceType ServiceType { get; set; }13 public ParcelStatus Status { get; set; }1415 // Address relationships16 public Guid ShipperAddressId { get; set; }17 public Address ShipperAddress { get; set; } = null!;1819 public Guid RecipientAddressId { get; set; }20 public Address RecipientAddress { get; set; } = null!;2122 // Physical properties23 public decimal Weight { get; set; }24 public WeightUnit WeightUnit { get; set; }25 public decimal Length { get; set; }26 public decimal Width { get; set; }27 public decimal Height { get; set; }28 public DimensionUnit DimensionUnit { get; set; }2930 // Value31 public decimal DeclaredValue { get; set; }3233 [MaxLength(3)]34 public string Currency { get; set; } = "USD";3536 // Dates37 public DateTimeOffset? EstimatedDeliveryDate { get; set; }38 public DateTimeOffset? ActualDeliveryDate { get; set; }3940 // Delivery tracking41 public int DeliveryAttempts { get; set; }4243 // Audit44 public DateTimeOffset CreatedAt { get; set; }45 public DateTimeOffset UpdatedAt { get; set; }4647 // Navigation properties48 public ICollection<TrackingEvent> TrackingEvents { get; set; } = new List<TrackingEvent>();49 public ICollection<ParcelContentItem> ContentItems { get; set; } = new List<ParcelContentItem>();50 public DeliveryConfirmation? DeliveryConfirmation { get; set; }51 public ICollection<ParcelWatcher> Watchers { get; set; } = new List<ParcelWatcher>();52}
Key Design Choices
Tracking Number: A unique, human-readable string separate from the primary key. Clients use tracking numbers to look up parcels. The Id (GUID) is used internally for relationships.
Two address foreign keys: ShipperAddressId and RecipientAddressId both point to the Address table. This is called multiple relationships to the same entity type, and it requires explicit Fluent API configuration (covered in the next presentation).
Enum properties: ServiceType, Status, WeightUnit, and DimensionUnit are stored as integers in the database by default. This is efficient and sortable.
Nullable dates: EstimatedDeliveryDate and ActualDeliveryDate are nullable because they are not known at parcel creation time.
Navigation properties: TrackingEvents and ContentItems are collections (one-to-many), DeliveryConfirmation is a single nullable reference (one-to-one optional), and Watchers is a many-to-many skip navigation to ParcelWatcher.
= null!: The null-forgiving operator on required navigation properties tells the compiler "EF Core will populate this." It avoids nullable warnings while keeping the property non-nullable in your domain logic.
The TrackingEvent Entity
Each tracking event represents a single step in the parcel's journey.
csharp1public class TrackingEvent2{3 public Guid Id { get; set; }45 public Guid ParcelId { get; set; }6 public Parcel Parcel { get; set; } = null!;78 public DateTimeOffset Timestamp { get; set; }9 public EventType EventType { get; set; }1011 [Required]12 [MaxLength(500)]13 public string Description { get; set; } = string.Empty;1415 [MaxLength(100)]16 public string? LocationCity { get; set; }1718 [MaxLength(100)]19 public string? LocationState { get; set; }2021 [MaxLength(100)]22 public string? LocationCountry { get; set; }2324 [MaxLength(500)]25 public string? DelayReason { get; set; }26}
Design Decisions
ParcelId+ navigation property: The foreign key is explicit so you can set it without loading the fullParcelentity. The navigation property enables eager loading with.Include().- Nullable location fields: Not every event has a location. For example, a "LabelCreated" event happens in software, not at a physical location.
DelayReason: Only populated for events where a delay or exception occurred. Using a nullable string keeps it simple. In a more complex system, you might reference theExceptionReasonenum here.
The DeliveryConfirmation Entity
This entity captures proof of delivery.
csharp1public class DeliveryConfirmation2{3 public Guid Id { get; set; }45 public Guid ParcelId { get; set; }6 public Parcel Parcel { get; set; } = null!;78 [MaxLength(200)]9 public string? ReceivedBy { get; set; }1011 [MaxLength(200)]12 public string? DeliveryLocation { get; set; }1314 public string? SignatureImage { get; set; }1516 public DateTimeOffset DeliveredAt { get; set; }17}
Design Decisions
- One-to-one with Parcel: Configured via
ParcelIdas a unique foreign key. Only oneDeliveryConfirmationperParcel. ReceivedByis nullable: Some deliveries do not require a signature (e.g., left at door).SignatureImageas string: Stores a Base64-encoded signature image. For production, you would store this in blob storage and keep a URL here.- No
MaxLengthonSignatureImage: Signature data can be large. EF Core maps unlimited strings totextin PostgreSQL.
The ParcelContentItem Entity
Each ParcelContentItem describes one type of goods inside a parcel. Real-world carrier APIs require structured customs declarations for international shipments, where each item is classified using a Harmonized System (HS) code.
csharp1public class ParcelContentItem2{3 public Guid Id { get; set; }45 public Guid ParcelId { get; set; }6 public Parcel Parcel { get; set; } = null!;78 [Required]9 [MaxLength(7)]10 public string HsCode { get; set; } = string.Empty; // Format: XXXX.XX1112 [Required]13 [MaxLength(200)]14 public string Description { get; set; } = string.Empty;1516 public int Quantity { get; set; }1718 public decimal UnitValue { get; set; }1920 [MaxLength(3)]21 public string Currency { get; set; } = "USD"; // ISO 42172223 public decimal Weight { get; set; }24 public WeightUnit WeightUnit { get; set; }2526 [Required]27 [MaxLength(2)]28 public string CountryOfOrigin { get; set; } = string.Empty; // ISO 3166-1 alpha-229}
Design Decisions
HsCodewithMaxLength(7): HS codes follow the formatXXXX.XX— four digits, a dot, and two digits (e.g.,8471.30for portable computers). The dot is included in the stored value for readability.UnitValueandCurrency: Each item has its own declared value. The currency is stored per item to support mixed-currency declarations, though in practice most parcels use a single currency.WeightandWeightUnit: Each content item has its own weight. The sum of all item weights should approximate the parcel's total weight.CountryOfOriginwithMaxLength(2): ISO 3166-1 alpha-2 codes identify where the item was manufactured (e.g.,CNfor China,DEfor Germany).Quantity: The number of identical units of this item type. A parcel with 3 identical laptops would have one content item withQuantity = 3, not three separate content items.
The ParcelWatcher Entity
The ParcelWatcher entity represents someone who wants to be notified about parcel status changes. It introduces the first many-to-many relationship in the domain.
csharp1public class ParcelWatcher2{3 public Guid Id { get; set; }45 [Required]6 [MaxLength(254)]7 public string Email { get; set; } = string.Empty;89 [MaxLength(150)]10 public string? Name { get; set; }1112 public DateTimeOffset CreatedAt { get; set; }1314 // Many-to-many navigation15 public ICollection<Parcel> Parcels { get; set; } = new List<Parcel>();16}
Design Decisions
EmailwithMaxLength(254): The maximum length of an email address per RFC 5321 is 254 characters.Nameis nullable: Some watchers may register with only an email address.ICollection<Parcel> Parcels: This is a skip navigation property. Combined withICollection<ParcelWatcher> Watcherson theParcelentity, EF Core recognizes this as a many-to-many relationship and automatically creates a join table (ParcelParcelWatcher) without requiring you to define a join entity.- No
ParcelIdforeign key: Unlike one-to-many relationships, many-to-many relationships use a join table instead of a foreign key on either entity.
Using Data Annotations vs. Fluent API
We use data annotations on the entity classes for simple constraints:
| Annotation | Purpose |
|---|---|
[Required] | Column is NOT NULL |
[MaxLength(n)] | Column has a maximum length |
For relationship configuration, index creation, and complex constraints, we use the Fluent API in the DbContext. This keeps entity classes focused on their properties and moves infrastructure concerns to the configuration layer.
As a rule of thumb:
- Data annotations for property-level constraints (Required, MaxLength, StringLength)
- Fluent API for relationships, composite keys, indexes, value conversions, and anything that involves multiple properties
Property Types and Database Mapping
Understanding how C# types map to PostgreSQL columns helps you make informed choices:
| C# Type | PostgreSQL Type | Notes |
|---|---|---|
Guid | uuid | Native UUID type, stored as 16 bytes |
string | text / varchar(n) | text when no MaxLength; varchar(n) with MaxLength |
int | integer | 4-byte signed integer |
decimal | numeric(p,s) | Native exact-precision type with configurable precision and scale |
bool | boolean | Native true/false type |
DateTimeOffset | timestamptz | Timestamp with time zone |
enum | integer | Stored as the underlying int value |
PostgreSQL has native numeric support, so decimal values like DeclaredValue are stored with exact precision — no workarounds needed. The uuid type stores GUIDs as compact 16-byte values, which is more efficient than the string-based storage used by some other databases.
Organizing the Code
A clean project structure for the entities:
1src/2├── ParcelTracking.Domain/3│ ├── Entities/4│ │ ├── Address.cs5│ │ ├── Parcel.cs6│ │ ├── TrackingEvent.cs7│ │ ├── ParcelContentItem.cs8│ │ ├── DeliveryConfirmation.cs9│ │ └── ParcelWatcher.cs10│ └── Enums/11│ ├── ParcelStatus.cs12│ ├── ServiceType.cs13│ ├── EventType.cs14│ ├── ExceptionReason.cs15│ ├── WeightUnit.cs16│ └── DimensionUnit.cs17├── ParcelTracking.Infrastructure/18│ └── Data/19│ ├── ParcelTrackingDbContext.cs20│ └── Configurations/21│ ├── AddressConfiguration.cs22│ ├── ParcelConfiguration.cs23│ └── ...24└── ParcelTracking.Api/25 └── Program.cs
Each enum gets its own file in the Domain project. Each entity gets its own file. The DbContext and its configurations live in the Infrastructure project under Data/. This multi-project structure enforces dependency boundaries at compile time.
Summary
In this presentation, you learned how to:
- Define enums with explicit integer values for stable database storage
- Design entity classes with appropriate property types, nullable annotations, and data annotations
- Use
Guidprimary keys for globally unique, API-safe identifiers - Separate business identifiers (TrackingNumber) from technical keys (Id)
- Model customs content items with HS codes, quantities, and country of origin
- Organize navigation properties for one-to-many, one-to-one, and many-to-many relationships
- Choose between data annotations and Fluent API for different configuration needs
- Map C# types to PostgreSQL column types
Next, we will configure the DbContext, define relationships with the Fluent API, and create the initial database migration.