IDs & value objects
Each module wraps its Guid IDs in its own record struct, and shared value objects such as Email and PersonName validate through Create factories, never through their constructors.
- IDs
- A
readonly record structaround aGuid - New IDs
NewId(), a version 7Guid- Value objects
AppointMe.Shared/Domain/Common- Untrusted input
CreateorCreateOrNull, nevernew
Strongly-typed IDs
Section titled “Strongly-typed IDs”Each aggregate has its own ID type, so passing a CustomerId where an AttendeeId is expected doesn’t compile.
public readonly record struct CustomerId(Guid Value);| ID | Defined in | Used by |
|---|---|---|
CompanyId, UserId |
AppointMe.Shared (Companies/, Users/) |
CompanyId (the tenant) in every module; UserId (the signed-in user) in Identity and Organizations |
EmployeeId, EmployeeInvitationId |
AppointMe.Organizations |
Organizations only |
CustomerId |
AppointMe.Crm |
CRM only |
AppointmentId, AttendeeId, ServiceProviderId |
AppointMe.Booking |
Booking only |
New IDs come from one helper. Each module imports it with global using static AppointMe.Shared.Domain.Id, so a factory writes new CustomerId(NewId()):
public static class Id{ public static Guid NewId() => Guid.CreateVersion7();}A module’s own IDs never leave it as typed values. Events, snapshots and DTOs carry Guid: CustomerRegisteredEvent.CustomerId, CustomerDto.Id and the generated TypeScript client all see a plain GUID.
Projections use module-local IDs
Section titled “Projections use module-local IDs”When Booking copies a CRM customer, it stores the same Guid under its own AttendeeId, because Booking can’t see CustomerId.
CustomerId(guid) on CustomerGuidAttendeeId(guid) on Attendeevar attendee = await attendees .IgnoreQueryFilters() .SingleOrDefaultAsync(attendee => attendee.Id == new AttendeeId(customerId), cancellationToken);
if (attendee is null){ attendee = Attendee.Create( id: new AttendeeId(customerId), companyId: new CompanyId(companyId), name: name, dateOfBirth: dateOfBirth, email: email);| Booking type | Same Guid as |
From |
|---|---|---|
AttendeeId |
CustomerId |
CRM |
ServiceProviderId |
EmployeeId |
Organizations, as in new ServiceProviderId(@event.EmployeeId) |
BookingCompany.Id |
CompanyId itself |
Shared, so there’s no local type |
Reusing the value keeps the projection simple: an event finds its row by ID, with no mapping table. The separate type keeps the modules apart. CustomerId lives in AppointMe.Crm, which Booking doesn’t reference (see Modular monolith), and Booking’s Appointment.AttendeeId refers to Booking’s own record of the person.
Value objects and their factories
Section titled “Value objects and their factories”Shared value objects are records whose primary constructor doesn’t validate. Validation lives in a companion …Factory class as C# 14 static extension members, so you still call Email.Create(...).
| Type | Holds | Create rejects |
Factory members |
|---|---|---|---|
PersonName |
FirstName, LastName |
An empty first name; either part over 100 chars | Create, CreateOrNull, FromFullName. Create also title-cases both parts. |
Email |
Value |
Empty, over 200 chars, not an address | Create, CreateOrNull. Create trims and lower-cases. |
DateOfBirth |
Value (DateOnly) |
A future date, or more than 150 years ago | Create, CreateOrNull. Both take a TimeProvider. |
LongString |
Value |
Empty, over 4000 chars | Create, CreateOrNull |
DateTimeOffsetPeriod |
Start, End |
A non-UTC offset, or End not after Start |
Create |
Gender |
An enum: Male, Female, Other |
An unknown name | Parse, ParseOrNull, case-insensitive |
Every factory throws ValidationException, which the API returns as 400 with the message in error. Email shows the pattern:
public sealed record Email(string Value){ public const int MaxLength = 200;}
public static class EmailFactory{ extension(Email) { public static Email? CreateOrNull(string? email) { return email is null ? null : Email.Create(email); }
public static Email Create(string email) { email = email.Trim().ToLowerInvariant(); // … return new Email(mailAddress.Address); } }The same mechanism adds members to types you don’t own: TimeZoneInfoFactory gives TimeZoneInfo a validating TimeZoneInfo.Create(id).
Which constructor, when
Section titled “Which constructor, when”Input from outside the module goes through Create or CreateOrNull. The primary constructor is for values that were already validated, such as rows from your own database.
| The value comes from | Use | Example |
|---|---|---|
| An HTTP command | Create, or CreateOrNull for an optional field |
RegisterCustomerCommandHandler |
| Another module’s event | Create or CreateOrNull, because the payload is untrusted |
AttendeeSynchronizer.Apply |
| EF Core reading a row | new, inside HasConversion |
CustomerTypeConfiguration |
| A Dapper row | new |
CustomerRecord.ToDto() |
A command handler builds every value object from the command before it touches the aggregate:
var customer = Customer.Register( companyId: companyId, name: PersonName.Create(command.FirstName, command.LastName), dateOfBirth: DateOfBirth.CreateOrNull(command.DateOfBirth, timeProvider), gender: Gender.ParseOrNull(command.Gender), email: Email.CreateOrNull(command.Email), registrationDate: timeProvider.GetUtcNow());An event handler does the same with another module’s payload, even though CRM validated it once already:
await dbContext.Attendees.UpsertAsync( customerId: snapshot.CustomerId, companyId: snapshot.CompanyId, name: PersonName.Create(snapshot.FirstName, snapshot.LastName), dateOfBirth: DateOfBirth.CreateOrNull(snapshot.DateOfBirth, timeProvider), email: Email.CreateOrNull(snapshot.Email), cancellationToken: cancellationToken);Mapping to EF Core and Dapper
Section titled “Mapping to EF Core and Dapper”EF Core converts IDs and value objects property by property in each entity configuration. Dapper has no converters for them, so repositories pass .Value in and read primitives out.
builder.Property(customer => customer.Id) .HasConversion(id => id.Value, value => new CustomerId(value)) .HasColumnType("uniqueidentifier") .ValueGeneratedNever();// …builder.Property(customer => customer.Email) .HasConversion( email => email != null ? email.Value : null, value => value != null ? new Email(value) : null) .HasMaxLength(Email.MaxLength);| Type | EF Core mapping | Columns |
|---|---|---|
| IDs | HasConversion(id => id.Value, value => new CustomerId(value)) |
One uniqueidentifier |
PersonName, DateTimeOffsetPeriod |
OwnsOne(...) |
Two: FirstName and LastName, or Start and End |
Email, DateOfBirth, LongString |
HasConversion to the wrapped value |
One, sized from the type’s MaxLength where it has one |
Gender, AppointmentStatus |
HasConversion<string>() |
One, holding the enum name |
There’s no model-wide convention for IDs, so each entity configuration repeats the conversion. On the Dapper side, a repository passes new { Id = customerId.Value } as a parameter and reads rows into a record of primitives. Turning the row into a DTO uses the primary constructor, because the database is a trusted source:
internal CustomerDto ToDto(){ var personName = new PersonName(record.FirstName, record.LastName); return new CustomerDto { Id = record.Id, FirstName = personName.FirstName, LastName = personName.LastName, FullName = personName.FullName, Initials = personName.Initials,FullName and Initials are C# 14 extension properties, defined in PersonNameFactory.
Add a value object
Section titled “Add a value object”| Step | Where | What |
|---|---|---|
| 1. Write the record | AppointMe.Shared/Domain/Common/<Name>.cs, or your module if only it needs the type |
public sealed record <Name>(string Value), with a MaxLength constant if it has one |
| 2. Write the factory | The same file | public static class <Name>Factory with an extension(<Name>) block holding Create (throws ValidationException) and CreateOrNull |
| 3. Map it in EF Core | Each entity configuration that uses it | HasConversion(...) both ways, plus HasMaxLength |
| 4. Map it in Dapper | The repository’s record class | Read the primitive, build the value with new in ToDto() |
| 5. Test it | AppointMe.Shared.Tests/Domain/Common/ |
Next to EmailTests and PersonNameTests, cover each rejection |
Gotchas
Section titled “Gotchas”| Symptom | Cause | Fix |
|---|---|---|
400 “Email address is required.” for an empty optional field |
CreateOrNull only skips null. An empty string goes on to Create. |
Send null. The SPA’s customer form turns an empty email into null for this reason. |
A name comes back as Mcdonald or Van Der Berg |
PersonName.Create lower-cases, then title-cases each word |
Change Capitalize in PersonName.cs if your users need other casing |
| A cross-module event ends up in Wolverine’s error queue | Its payload failed a Create check, and the host moves ValidationException straight to the error queue |
Fix the data in the source module. Booking’s daily reconciliation picks it up. |
An Email keeps its spaces or capitals |
It was built with new Email(...), which doesn’t normalize |
Use Email.Create on any input that isn’t from your own database |
Booking code can’t use CustomerId |
It lives in AppointMe.Crm, which Booking doesn’t reference |
Wrap the event’s Guid in a Booking ID |
Why not validate in the constructor?2 min · optional
Materializing a row should never throw. If new Email(value) validated, tightening a rule (a shorter MaxLength, a stricter address check) would make every existing row that breaks the new rule fail to load, in EF Core and in Dapper. Keeping the constructor dumb and putting rules in Create means old data still reads, and new data is checked at the boundary where it enters.
The cost is discipline: the type can’t prove its value is valid, so the rule “untrusted input goes through Create” has to hold in every handler. Writing the factories as static extension members keeps the call site Email.Create(...), so the right call is also the obvious one.
Why one ID type per module, not one per concept?1 min · optional
A shared PersonId would couple CRM and Booking through a type, and a change to one module’s notion of a person would ripple into the other. Module-local IDs keep each module’s model its own, and the compiler stops a Booking attendee from being passed to CRM code. The Guid value is shared on purpose, so projections can be found by ID.