Records in Domain-Driven Design
Model value objects, DTOs, and domain events as records to enforce immutability and improve code clarity.
Records in Domain-Driven Design is a free C# Academy lesson on CoddyKit — lesson 4 of 4. You can read the complete lesson below for free — then practise it hands-on in the browser with a built-in code editor and a 24/7 AI tutor. It is part of the C# Academy learning path, one of 4 lessons in the course, and your progress syncs across the web and the CoddyKit app.
DDD and Value Objects
In Domain-Driven Design (DDD), a Value Object is defined by its properties, not identity. Two Money(100, "USD") values are interchangeable. Records are the perfect C# representation for value objects.
Value Objects as Records
Model domain value objects as records. Their structural equality, immutability, and concise syntax align perfectly with DDD principles.
// Value objects — defined by their values, not identity
public record Money(decimal Amount, string Currency);
public record Email(string Value);
public record PhoneNumber(string CountryCode, string Number);
public record Address(string Street, string City, string Country, string PostalCode);
// Two Money objects with same values are equal:
var price = new Money(99.99m, "USD");
var same = new Money(99.99m, "USD");
Console.WriteLine(price == same); // TrueValidation in Value Object Constructors
Add a compact constructor to validate invariants. Value objects should only be creatable in a valid state — invalid state should throw at construction time.
public record Email(string Value)
{
// Compact constructor for validation
public Email : this(Value)
{
if (string.IsNullOrWhiteSpace(Value) || !Value.Contains('@'))
throw new ArgumentException("Invalid email address", nameof(Value));
Value = Value.Trim().ToLowerInvariant();
}
}
// Usage:
var email = new Email(" Alice@Example.COM "); // normalized to alice@example.com
new Email("not-an-email"); // throws ArgumentExceptionEntities vs Value Objects
Entities have identity (tracked by ID across time); Value Objects have no identity and are interchangeable if values match. Model them differently in C#.
// ENTITY: has identity, mutable state
public class Customer
{
public Guid Id { get; } = Guid.NewGuid();
public string Name { get; set; } = "";
public Email Email { get; set; } = default!; // value object
}
// VALUE OBJECT: no identity, immutable
public record Email(string Value);
public record Money(decimal Amount, string Currency);
// Two customers with same name are DIFFERENT entities
// Two Money(100, "USD") are the SAME valueRich Value Objects with Domain Methods
Value objects can contain domain logic as methods. Operations return new value objects rather than mutating the existing one.
public record Money(decimal Amount, string Currency)
{
public Money Add(Money other)
{
if (Currency != other.Currency)
throw new DomainException("Cannot add different currencies");
return this with { Amount = Amount + other.Amount };
}
public Money Subtract(Money other) =>
Amount >= other.Amount
? this with { Amount = Amount - other.Amount }
: throw new DomainException("Insufficient funds");
public Money ApplyDiscount(decimal percent) =>
this with { Amount = Amount * (1 - percent / 100) };
public static Money Zero(string currency) => new(0, currency);
}Domain Events as Records
Domain events describe things that happened in the domain. Records are perfect: they're immutable, descriptive, and use structural equality which simplifies testing.
// Domain events as records
public abstract record DomainEvent(DateTime OccurredAt);
public record OrderPlaced(
Guid OrderId,
Guid CustomerId,
Money Total,
DateTime OccurredAt) : DomainEvent(OccurredAt);
public record OrderShipped(
Guid OrderId,
string TrackingNumber,
DateTime OccurredAt) : DomainEvent(OccurredAt);
public record PaymentReceived(
Guid OrderId,
Money Amount,
DateTime OccurredAt) : DomainEvent(OccurredAt);DTOs and API Contracts as Records
Request/response DTOs are naturally value objects — they carry data with no identity. Records make them concise and immutable.
// Request DTOs
public record CreateOrderCommand(
Guid CustomerId,
IReadOnlyList<OrderLineDto> Lines);
public record OrderLineDto(Guid ProductId, int Quantity);
// Response DTOs
public record OrderCreatedResponse(
Guid OrderId,
string Status,
decimal Total,
DateTime CreatedAt);
// These can be compared in tests by value:
var expected = new OrderCreatedResponse(id, "Pending", 99m, now);
Assert.Equal(expected, actual);Storing Value Objects in EF Core
Use owned entities to persist value objects in EF Core. Each value object's properties are stored in the owner's table (or a separate table) without a separate PK.
public class Order
{
public int Id { get; set; }
public Money Total { get; set; } = new(0, "USD");
public Address ShipTo { get; set; } = default!;
}
// EF Core config:
modelBuilder.Entity<Order>(e =>
{
e.OwnsOne(o => o.Total, money =>
{
money.Property(m => m.Amount).HasColumnType("decimal(18,2)");
money.Property(m => m.Currency).HasMaxLength(3);
});
e.OwnsOne(o => o.ShipTo);
});Aggregate Root with Value Objects
Bring it all together: an Order aggregate root that uses records for value objects and domain events, with all business rules enforced in methods.
public class Order
{
private readonly List<DomainEvent> _events = new();
public IReadOnlyList<DomainEvent> DomainEvents => _events;
public Guid Id { get; } = Guid.NewGuid();
public Address ShipTo { get; private set; } = default!; // value obj
public Money Total { get; private set; } = Money.Zero("USD");
public void PlaceOrder(Address shipTo, IEnumerable<OrderLine> lines)
{
ShipTo = shipTo;
Total = lines.Aggregate(Money.Zero("USD"),
(acc, l) => acc.Add(l.Price));
_events.Add(new OrderPlaced(Id, CustomerId, Total, DateTime.UtcNow));
}
}Real-World: Type-Safe IDs
Use records to wrap primitive IDs and prevent accidental mixing of different entity IDs — a common DDD technique called "Strongly Typed IDs".
// Strongly typed IDs — can't confuse CustomerId with OrderId
public record CustomerId(Guid Value)
{
public static CustomerId New() => new(Guid.NewGuid());
public static implicit operator Guid(CustomerId id) => id.Value;
}
public record OrderId(Guid Value)
{
public static OrderId New() => new(Guid.NewGuid());
}
// Type safety:
void ShipOrder(OrderId orderId, CustomerId customerId) { }
ShipOrder(OrderId.New(), CustomerId.New()); // OK
ShipOrder(CustomerId.New(), OrderId.New()); // COMPILE ERROR!Quick Check
What defines a DDD Value Object, and why are records a natural fit?
Recap: Records in Domain-Driven Design
Key takeaways:
- Value Objects: defined by values, no identity — use records
- Entities: have identity (ID) and mutable state — use classes
- Validate invariants in compact constructors — invalid state is unrepresentable
- Domain events as records: immutable, comparable, self-documenting
- EF Core owned entities for persisting value objects
- Strongly typed IDs using records prevent mixing entity IDs
Frequently asked questions
Is the “Records in Domain-Driven Design” lesson free?
Yes — the full text of “Records in Domain-Driven Design” is free to read here on the web, and the C# Academy course includes 4 lessons in total. To practise it interactively (a built-in code editor and a 24/7 AI tutor) and unlock the rest of the C# Academy course, upgrade to CoddyKit PRO.
What will I learn in “Records in Domain-Driven Design”?
Model value objects, DTOs, and domain events as records to enforce immutability and improve code clarity. You practise C# Academy with hands-on code you run directly in the browser, and a 24/7 AI tutor answers your questions as you work through the lesson.
Do I need any experience to start C# Academy?
No prior experience is required. C# Academy on CoddyKit is structured for beginners through advanced learners; this is — lesson 4 of 4, so you can start here or from the beginning and move at your own pace.
How long does the “Records in Domain-Driven Design” lesson take?
Most CoddyKit lessons take about 5–10 minutes. Each one is bite-sized and interactive, so you make steady progress and pick up exactly where you left off across the web and the app.
Can I write and run code in this C# Academy lesson?
Yes. Every C# Academy lesson includes a built-in code editor, so you write and run real code right in your browser and get instant AI feedback — no local setup required.
All lessons in this course
- Record Types: Basics & Syntax
- Immutability with init & with
- Value Equality & Deconstruction
- Records in Domain-Driven Design