Overview
Prevent primitive obsession by enforcing strongly typed identifiers and value objects in
domain models. Conversions to/from primitives are permitted only at explicit boundaries
(persistence, transport, serialization), ensuring type safety and validation throughout
the domain layer.
When to Use
- Designing or reviewing domain models (entities, aggregates, commands/events)
- Introducing new entity identifiers (CustomerId, OrderId, TenantId, etc.)
- Working with meaningful primitives that carry validation (EmailAddress, Money, Percentage)
- Reviewing code where primitive types (Guid, string, int) are used for domain concepts
- Implementing API boundaries, persistence layers, or serialization
Core Workflow
- Identify primitive obsession: Locate places where Guid, string, int, or long represent domain concepts
- Define strongly typed IDs: Create typed ID types using source-generation libraries for entity identifiers
- Define value objects: Create value objects for meaningful primitives with validation (EmailAddress, Money)
- Establish boundary conversions: Map to/from primitives only at explicit boundaries (API, persistence, transport)
- Configure serialization: Add JSON converters, EF Core value converters, and type handlers as needed
- Update service signatures: Change service methods to accept domain primitives, not raw types
- Add boundary tests: Test that boundary conversion works and invalid primitives are rejected
Core
Defaults (non-negotiable)
- StronglyTypedIds by default for all entity identifiers in domain/application code.
- No primitive IDs (
Guid, int, long, string) in the domain layer.
- Use value objects for meaningful primitives (e.g.,
EmailAddress, Money, Percentage, TenantId, CorrelationId).
Preferred approach
- Prefer open-source libraries that use source generation for typed IDs.
- Conversions to/from primitives are permitted only at explicit boundaries:
- persistence adapters,
- transport adapters (HTTP, messaging),
- serialization/deserialization.
Review rules
- New domain types must not introduce primitive ID properties/fields.
- Mapping layers must map typed IDs explicitly; no "magic" conversions hidden in core domain types.
Load: examples
Strongly typed ID (source generator style)
- Define a
CustomerId type and use it on entities/commands.
- Map to primitive
Guid at the persistence boundary and transport boundary.
Value object boundaries
- Allow
string in DTOs if required by external contracts.
- Convert to
EmailAddress (value object) inside the application layer.
Load: advanced
Integration guidance
- EF Core: value converters for typed IDs and value objects.
- System.Text.Json: custom converters where needed for typed IDs.
- Dapper: type handlers if Dapper is used for read models.
Operational concerns
- Ensure typed ID types are stable for logging/telemetry (string representation).
- Avoid implicit conversions that obscure boundary crossings.
API Boundary Mapping & Validation
Before: Primitive Obsession at Boundaries
// API Controller - accepts primitives
[ApiController]
[Route("api/[controller]")]
public class CustomersController
{
private readonly ICustomerService _service;
[HttpPost]
public async Task<IActionResult> CreateCustomer(CreateCustomerRequest request)
{
// No type safety - primitives passed directly to service
var result = await _service.CreateCustomer(request.Id, request.Email);
return Ok(result);
}
}
// Service layer - accepts primitives, loses domain context
public class CustomerService
{
public async Task<CustomerDto> CreateCustomer(string id, string email)
{
// Validation scattered across layers
if (string.IsNullOrWhiteSpace(email))
throw new ArgumentException("Email required");
// No connection to domain types
var customer = new Customer { Id = Guid.Parse(id), Email = email };
await _repository.AddAsync(customer);
return new CustomerDto { Id = customer.Id.ToString(), Email = email };
}
}
// DTO - exposes internal structure
public class CreateCustomerRequest
{
public string Id { get; set; }
public string Email { get; set; }
}
Problems:
- No type safety between layers
- Validation scattered across concerns
- Easy to pass invalid primitives
After: Domain Primitives at Boundaries
// Domain types
public partial class CustomerId : IStronglyTypedId<Guid> { }
public partial class EmailAddress : IValueObject<string> { }
// API Controller - explicit boundary conversion
[ApiController]
[Route("api/[controller]")]
public class CustomersController
{
private readonly ICustomerService _service;
private readonly ICustomerMapper _mapper;
[HttpPost]
public async Task<IActionResult> CreateCustomer(CreateCustomerRequest request)
{
// Explicit conversion at boundary
var customerId = new CustomerId(Guid.Parse(request.Id));
var email = EmailAddress.Create(request.Email).ThrowIfFailure();
var result = await _service.CreateCustomer(customerId, email);
return Ok(_mapper.ToResponse(result));
}
}
// Service layer - type-safe, domain-focused
public class CustomerService
{
public async Task<Customer> CreateCustomer(CustomerId id, EmailAddress email)
{
// Domain types ensure validity before service runs
var customer = Customer.Create(id, email).ThrowIfFailure();
await _repository.AddAsync(customer);
return customer;
}
}
// Mapper - explicit conversion layer
public class CustomerMapper
{
public CustomerResponse ToResponse(Customer customer)
{
return new CustomerResponse
{
Id = customer.Id.Value.ToString(), // Explicit back to primitive
Email = customer.Email.Value // Explicit back to primitive
};
}
}
Benefits:
- Type safety enforced across layers
- Validation centralized in domain types
- Clear boundary crossing
- Compiler prevents invalid combinations
Validation Steps for Domain Primitive Implementation
API Controllers:
Service Layer:
Mapping & Serialization:
Testing:
Load: enforcement
Acceptance criteria for PRs
- New entities/aggregates use typed IDs.
- No domain-layer primitive IDs added.
- Boundary conversion is explicit and covered by unit tests.
Red Flags - STOP
These statements indicate primitive obsession patterns:
| Thought |
Reality |
| "Guid is fine for identifiers" |
Primitive IDs lose type safety; use strongly typed IDs |
| "String is good enough for email" |
Value objects centralise validation; prevent invalid data |
| "Implicit conversions are convenient" |
Implicit conversions obscure boundaries; be explicit |
| "Domain types add too much ceremony" |
Source generators eliminate boilerplate; use them |
| "We'll add types later" |
Retrofitting types is expensive; start with them |
| "Validation can happen anywhere" |
Centralise validation in domain types; single source of truth |
1---2name: dotnet-domain-primitives3description: Use when modelling domain types or designing API/persistence boundaries in .NET - prevent primitive obsession by enforcing strongly-typed IDs and value objects.4metadata:5 type: Implementation6 priority: P27---89## Overview1011Prevent primitive obsession by enforcing strongly typed identifiers and value objects in12domain models. Conversions to/from primitives are permitted only at explicit boundaries13(persistence, transport, serialization), ensuring type safety and validation throughout14the domain layer.1516## When to Use1718- Designing or reviewing domain models (entities, aggregates, commands/events)19- Introducing new entity identifiers (CustomerId, OrderId, TenantId, etc.)20- Working with meaningful primitives that carry validation (EmailAddress, Money, Percentage)21- Reviewing code where primitive types (Guid, string, int) are used for domain concepts22- Implementing API boundaries, persistence layers, or serialization2324## Core Workflow25261. **Identify primitive obsession**: Locate places where Guid, string, int, or long represent domain concepts272. **Define strongly typed IDs**: Create typed ID types using source-generation libraries for entity identifiers283. **Define value objects**: Create value objects for meaningful primitives with validation (EmailAddress, Money)294. **Establish boundary conversions**: Map to/from primitives only at explicit boundaries (API, persistence, transport)305. **Configure serialization**: Add JSON converters, EF Core value converters, and type handlers as needed316. **Update service signatures**: Change service methods to accept domain primitives, not raw types327. **Add boundary tests**: Test that boundary conversion works and invalid primitives are rejected3334## Core3536### Defaults (non-negotiable)3738- **StronglyTypedIds by default** for all entity identifiers in domain/application code.39- **No primitive IDs** (`Guid`, `int`, `long`, `string`) in the domain layer.40- Use **value objects** for meaningful primitives (e.g., `EmailAddress`, `Money`, `Percentage`, `TenantId`, `CorrelationId`).4142### Preferred approach4344- Prefer **open-source** libraries that use **source generation** for typed IDs.45- Conversions to/from primitives are permitted only at explicit boundaries:46 - persistence adapters,47 - transport adapters (HTTP, messaging),48 - serialization/deserialization.4950### Review rules5152- New domain types must not introduce primitive ID properties/fields.53- Mapping layers must map typed IDs explicitly; no "magic" conversions hidden in core domain types.5455## Load: examples5657### Strongly typed ID (source generator style)5859- Define a `CustomerId` type and use it on entities/commands.60- Map to primitive `Guid` at the persistence boundary and transport boundary.6162### Value object boundaries6364- Allow `string` in DTOs if required by external contracts.65- Convert to `EmailAddress` (value object) inside the application layer.6667## Load: advanced6869### Integration guidance7071- EF Core: value converters for typed IDs and value objects.72- System.Text.Json: custom converters where needed for typed IDs.73- Dapper: type handlers if Dapper is used for read models.7475### Operational concerns7677- Ensure typed ID types are stable for logging/telemetry (string representation).78- Avoid implicit conversions that obscure boundary crossings.7980## API Boundary Mapping & Validation8182### Before: Primitive Obsession at Boundaries8384```csharp85// API Controller - accepts primitives86[ApiController]87[Route("api/[controller]")]88public class CustomersController89{90 private readonly ICustomerService _service;9192 [HttpPost]93 public async Task<IActionResult> CreateCustomer(CreateCustomerRequest request)94 {95 // No type safety - primitives passed directly to service96 var result = await _service.CreateCustomer(request.Id, request.Email);97 return Ok(result);98 }99}100101// Service layer - accepts primitives, loses domain context102public class CustomerService103{104 public async Task<CustomerDto> CreateCustomer(string id, string email)105 {106 // Validation scattered across layers107 if (string.IsNullOrWhiteSpace(email))108 throw new ArgumentException("Email required");109110 // No connection to domain types111 var customer = new Customer { Id = Guid.Parse(id), Email = email };112 await _repository.AddAsync(customer);113 return new CustomerDto { Id = customer.Id.ToString(), Email = email };114 }115}116117// DTO - exposes internal structure118public class CreateCustomerRequest119{120 public string Id { get; set; }121 public string Email { get; set; }122}123```124125**Problems:**126127- No type safety between layers128- Validation scattered across concerns129- Easy to pass invalid primitives130131### After: Domain Primitives at Boundaries132133```csharp134// Domain types135public partial class CustomerId : IStronglyTypedId<Guid> { }136public partial class EmailAddress : IValueObject<string> { }137138// API Controller - explicit boundary conversion139[ApiController]140[Route("api/[controller]")]141public class CustomersController142{143 private readonly ICustomerService _service;144 private readonly ICustomerMapper _mapper;145146 [HttpPost]147 public async Task<IActionResult> CreateCustomer(CreateCustomerRequest request)148 {149 // Explicit conversion at boundary150 var customerId = new CustomerId(Guid.Parse(request.Id));151 var email = EmailAddress.Create(request.Email).ThrowIfFailure();152153 var result = await _service.CreateCustomer(customerId, email);154 return Ok(_mapper.ToResponse(result));155 }156}157158// Service layer - type-safe, domain-focused159public class CustomerService160{161 public async Task<Customer> CreateCustomer(CustomerId id, EmailAddress email)162 {163 // Domain types ensure validity before service runs164 var customer = Customer.Create(id, email).ThrowIfFailure();165 await _repository.AddAsync(customer);166 return customer;167 }168}169170// Mapper - explicit conversion layer171public class CustomerMapper172{173 public CustomerResponse ToResponse(Customer customer)174 {175 return new CustomerResponse176 {177 Id = customer.Id.Value.ToString(), // Explicit back to primitive178 Email = customer.Email.Value // Explicit back to primitive179 };180 }181}182```183184**Benefits:**185186- Type safety enforced across layers187- Validation centralized in domain types188- Clear boundary crossing189- Compiler prevents invalid combinations190191### Validation Steps for Domain Primitive Implementation1921931. **API Controllers:**194 - [ ] DTO properties remain primitives195 - [ ] Convert DTOs to domain types immediately upon entry196 - [ ] Use mapper/converter class for boundary crossing1971982. **Service Layer:**199 - [ ] Accept domain primitives, not raw types200 - [ ] Never accept `Guid` when `CustomerId` exists201 - [ ] Ensure validation runs before service logic2022033. **Mapping & Serialization:**204 - [ ] JSON serialization handles conversion via custom converters205 - [ ] EF Core value converters map domain types ↔ database columns206 - [ ] No implicit conversions in constructors2072084. **Testing:**209 - [ ] Unit test boundary conversion in mapper210 - [ ] Integration test proves invalid primitives rejected at API211 - [ ] Verify domain type validation runs before service212213## Load: enforcement214215### Acceptance criteria for PRs216217- New entities/aggregates use typed IDs.218- No domain-layer primitive IDs added.219- Boundary conversion is explicit and covered by unit tests.220221## Red Flags - STOP222223These statements indicate primitive obsession patterns:224225| Thought | Reality |226| ------------------------------------- | ------------------------------------------------------------- |227| "Guid is fine for identifiers" | Primitive IDs lose type safety; use strongly typed IDs |228| "String is good enough for email" | Value objects centralise validation; prevent invalid data |229| "Implicit conversions are convenient" | Implicit conversions obscure boundaries; be explicit |230| "Domain types add too much ceremony" | Source generators eliminate boilerplate; use them |231| "We'll add types later" | Retrofitting types is expensive; start with them |232| "Validation can happen anywhere" | Centralise validation in domain types; single source of truth |