18 minlesson

Designing Entity Classes & Enums

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: Guid for IDs, decimal for monetary values, DateTimeOffset for 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

csharp
1public enum ParcelStatus
2{
3 LabelCreated = 0,
4 PickedUp = 1,
5 InTransit = 2,
6 OutForDelivery = 3,
7 Delivered = 4,
8 Exception = 5,
9 Returned = 6
10}

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

csharp
1public enum ServiceType
2{
3 Economy = 0,
4 Standard = 1,
5 Express = 2,
6 Overnight = 3
7}

EventType

csharp
1public enum EventType
2{
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 = 12
16}

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

csharp
1public enum ExceptionReason
2{
3 AddressNotFound = 0,
4 RecipientUnavailable = 1,
5 DamagedInTransit = 2,
6 WeatherDelay = 3,
7 CustomsHold = 4,
8 RefusedByRecipient = 5
9}

Unit Enums

csharp
1public enum WeightUnit
2{
3 Lb = 0,
4 Kg = 1
5}
6
7public enum DimensionUnit
8{
9 In = 0,
10 Cm = 1
11}

The Address Entity

The Address entity stores physical location and contact information. It is referenced by parcels for both shipper and recipient.

csharp
1public class Address
2{
3 public Guid Id { get; set; }
4
5 [Required]
6 [MaxLength(200)]
7 public string Street1 { get; set; } = string.Empty;
8
9 [MaxLength(200)]
10 public string? Street2 { get; set; }
11
12 [Required]
13 [MaxLength(100)]
14 public string City { get; set; } = string.Empty;
15
16 [Required]
17 [MaxLength(100)]
18 public string State { get; set; } = string.Empty;
19
20 [Required]
21 [MaxLength(20)]
22 public string PostalCode { get; set; } = string.Empty;
23
24 [Required]
25 [MaxLength(2)]
26 public string CountryCode { get; set; } = string.Empty;
27
28 public bool IsResidential { get; set; }
29
30 [MaxLength(150)]
31 public string? ContactName { get; set; }
32
33 [MaxLength(200)]
34 public string? CompanyName { get; set; }
35
36 [MaxLength(20)]
37 public string? Phone { get; set; }
38
39 [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.
  • CountryCode with MaxLength(2): Stores ISO 3166-1 alpha-2 codes (US, CA, GB, etc.).
  • IsResidential: Carriers charge different rates for residential vs. commercial deliveries.
  • MaxLength attributes: 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.

csharp
1public class Parcel
2{
3 public Guid Id { get; set; }
4
5 [Required]
6 [MaxLength(50)]
7 public string TrackingNumber { get; set; } = string.Empty;
8
9 [MaxLength(500)]
10 public string? Description { get; set; }
11
12 public ServiceType ServiceType { get; set; }
13 public ParcelStatus Status { get; set; }
14
15 // Address relationships
16 public Guid ShipperAddressId { get; set; }
17 public Address ShipperAddress { get; set; } = null!;
18
19 public Guid RecipientAddressId { get; set; }
20 public Address RecipientAddress { get; set; } = null!;
21
22 // Physical properties
23 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; }
29
30 // Value
31 public decimal DeclaredValue { get; set; }
32
33 [MaxLength(3)]
34 public string Currency { get; set; } = "USD";
35
36 // Dates
37 public DateTimeOffset? EstimatedDeliveryDate { get; set; }
38 public DateTimeOffset? ActualDeliveryDate { get; set; }
39
40 // Delivery tracking
41 public int DeliveryAttempts { get; set; }
42
43 // Audit
44 public DateTimeOffset CreatedAt { get; set; }
45 public DateTimeOffset UpdatedAt { get; set; }
46
47 // Navigation properties
48 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.

csharp
1public class TrackingEvent
2{
3 public Guid Id { get; set; }
4
5 public Guid ParcelId { get; set; }
6 public Parcel Parcel { get; set; } = null!;
7
8 public DateTimeOffset Timestamp { get; set; }
9 public EventType EventType { get; set; }
10
11 [Required]
12 [MaxLength(500)]
13 public string Description { get; set; } = string.Empty;
14
15 [MaxLength(100)]
16 public string? LocationCity { get; set; }
17
18 [MaxLength(100)]
19 public string? LocationState { get; set; }
20
21 [MaxLength(100)]
22 public string? LocationCountry { get; set; }
23
24 [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 full Parcel entity. 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 the ExceptionReason enum here.

The DeliveryConfirmation Entity

This entity captures proof of delivery.

csharp
1public class DeliveryConfirmation
2{
3 public Guid Id { get; set; }
4
5 public Guid ParcelId { get; set; }
6 public Parcel Parcel { get; set; } = null!;
7
8 [MaxLength(200)]
9 public string? ReceivedBy { get; set; }
10
11 [MaxLength(200)]
12 public string? DeliveryLocation { get; set; }
13
14 public string? SignatureImage { get; set; }
15
16 public DateTimeOffset DeliveredAt { get; set; }
17}

Design Decisions

  • One-to-one with Parcel: Configured via ParcelId as a unique foreign key. Only one DeliveryConfirmation per Parcel.
  • ReceivedBy is nullable: Some deliveries do not require a signature (e.g., left at door).
  • SignatureImage as string: Stores a Base64-encoded signature image. For production, you would store this in blob storage and keep a URL here.
  • No MaxLength on SignatureImage: Signature data can be large. EF Core maps unlimited strings to text in 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.

csharp
1public class ParcelContentItem
2{
3 public Guid Id { get; set; }
4
5 public Guid ParcelId { get; set; }
6 public Parcel Parcel { get; set; } = null!;
7
8 [Required]
9 [MaxLength(7)]
10 public string HsCode { get; set; } = string.Empty; // Format: XXXX.XX
11
12 [Required]
13 [MaxLength(200)]
14 public string Description { get; set; } = string.Empty;
15
16 public int Quantity { get; set; }
17
18 public decimal UnitValue { get; set; }
19
20 [MaxLength(3)]
21 public string Currency { get; set; } = "USD"; // ISO 4217
22
23 public decimal Weight { get; set; }
24 public WeightUnit WeightUnit { get; set; }
25
26 [Required]
27 [MaxLength(2)]
28 public string CountryOfOrigin { get; set; } = string.Empty; // ISO 3166-1 alpha-2
29}

Design Decisions

  • HsCode with MaxLength(7): HS codes follow the format XXXX.XX — four digits, a dot, and two digits (e.g., 8471.30 for portable computers). The dot is included in the stored value for readability.
  • UnitValue and Currency: 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.
  • Weight and WeightUnit: Each content item has its own weight. The sum of all item weights should approximate the parcel's total weight.
  • CountryOfOrigin with MaxLength(2): ISO 3166-1 alpha-2 codes identify where the item was manufactured (e.g., CN for China, DE for Germany).
  • Quantity: The number of identical units of this item type. A parcel with 3 identical laptops would have one content item with Quantity = 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.

csharp
1public class ParcelWatcher
2{
3 public Guid Id { get; set; }
4
5 [Required]
6 [MaxLength(254)]
7 public string Email { get; set; } = string.Empty;
8
9 [MaxLength(150)]
10 public string? Name { get; set; }
11
12 public DateTimeOffset CreatedAt { get; set; }
13
14 // Many-to-many navigation
15 public ICollection<Parcel> Parcels { get; set; } = new List<Parcel>();
16}

Design Decisions

  • Email with MaxLength(254): The maximum length of an email address per RFC 5321 is 254 characters.
  • Name is nullable: Some watchers may register with only an email address.
  • ICollection<Parcel> Parcels: This is a skip navigation property. Combined with ICollection<ParcelWatcher> Watchers on the Parcel entity, 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 ParcelId foreign 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:

AnnotationPurpose
[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# TypePostgreSQL TypeNotes
GuiduuidNative UUID type, stored as 16 bytes
stringtext / varchar(n)text when no MaxLength; varchar(n) with MaxLength
intinteger4-byte signed integer
decimalnumeric(p,s)Native exact-precision type with configurable precision and scale
boolbooleanNative true/false type
DateTimeOffsettimestamptzTimestamp with time zone
enumintegerStored 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.cs
5│ │ ├── Parcel.cs
6│ │ ├── TrackingEvent.cs
7│ │ ├── ParcelContentItem.cs
8│ │ ├── DeliveryConfirmation.cs
9│ │ └── ParcelWatcher.cs
10│ └── Enums/
11│ ├── ParcelStatus.cs
12│ ├── ServiceType.cs
13│ ├── EventType.cs
14│ ├── ExceptionReason.cs
15│ ├── WeightUnit.cs
16│ └── DimensionUnit.cs
17├── ParcelTracking.Infrastructure/
18│ └── Data/
19│ ├── ParcelTrackingDbContext.cs
20│ └── Configurations/
21│ ├── AddressConfiguration.cs
22│ ├── ParcelConfiguration.cs
23│ └── ...
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 Guid primary 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.