Skip to content
StarLive demo →
ConceptChecked against main · 31 Oct 20268 min read

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 struct around a Guid
New IDs
NewId(), a version 7 Guid
Value objects
AppointMe.Shared/Domain/Common
Untrusted input
Create or CreateOrNull, never new

Each aggregate has its own ID type, so passing a CustomerId where an AttendeeId is expected doesn’t compile.

src/CRM/AppointMe.Crm/Customers/CustomerId.csView source ↗
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()):

src/AppointMe.Shared/Domain/Id.csView source ↗
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.

When Booking copies a CRM customer, it stores the same Guid under its own AttendeeId, because Booking can’t see CustomerId.

CRMCustomerId(guid) on Customer
CustomerRegisteredEventcarries the bare Guid
BookingAttendeeId(guid) on Attendee
Figure 1. One Guid value, two ID types, no shared type between the modules.
src/Booking/AppointMe.Booking/Attendees/ReconcileAttendees/UpsertAttendee.csView source ↗
var 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.

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:

src/AppointMe.Shared/Domain/Common/Email.csView source ↗
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).

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:

src/CRM/AppointMe.Crm/Customers/RegisterCustomer/RegisterCustomerCommandHandler.csView source ↗
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:

src/Booking/AppointMe.Booking/Attendees/ReconcileAttendees/AttendeeSynchronizer.csView source ↗
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);

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.

src/CRM/AppointMe.Crm/Customers/Database/CustomerTypeConfiguration.csView source ↗
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:

src/CRM/AppointMe.Crm/Customers/Database/CustomerRecord.csView 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.

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
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.