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

Source page: https://appointme.dev/docs/concepts/ids-and-value-objects/

* **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`

## Strongly-typed IDs

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

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

```cs
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

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

*Figure 1. One Guid value, two ID types, no shared type between the modules.*

1. **CRM** — `CustomerId(guid)` on `Customer`
2. **CustomerRegisteredEvent** — carries the bare `Guid`
3. **Booking** — `AttendeeId(guid)` on `Attendee`

src/Booking/AppointMe.Booking/Attendees/ReconcileAttendees/UpsertAttendee.cs

```cs
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](/docs/concepts/modular-monolith/)), and Booking’s `Appointment.AttendeeId` refers to Booking’s own record of the person.

## 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:

src/AppointMe.Shared/Domain/Common/Email.cs

```cs
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

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

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

```cs
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

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

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

```cs
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

| 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

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

Gotcha

The validating factories are the only place rules are checked. Nothing stops `new DateOfBirth(...)` with a date in the future, so a primary constructor in a handler is a bug even when it compiles. Search for `new Email(`, `new PersonName(` and `new DateOfBirth(` in code review.

### Why not validate in the constructor?

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?

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.

## Next

* [CQRS: EF Core writes, Dapper reads](/docs/concepts/cqrs/) (Concept)
* [Modular monolith](/docs/concepts/modular-monolith/) (Concept)
* [Shared kernel](/docs/modules/shared/) (Module)
