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
CustomerIdtype and use it on entities/commands. - Map to primitive
Guidat the persistence boundary and transport boundary.
Value object boundaries
- Allow
stringin 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:
- DTO properties remain primitives
- Convert DTOs to domain types immediately upon entry
- Use mapper/converter class for boundary crossing
-
Service Layer:
- Accept domain primitives, not raw types
- Never accept
GuidwhenCustomerIdexists - Ensure validation runs before service logic
-
Mapping & Serialization:
- JSON serialization handles conversion via custom converters
- EF Core value converters map domain types ↔ database columns
- No implicit conversions in constructors
-
Testing:
- Unit test boundary conversion in mapper
- Integration test proves invalid primitives rejected at API
- Verify domain type validation runs before service
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 |
