Prevent primitive obsession by enforcing StronglyTypedIds and value objects in domain models and at boundaries.
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.
Guid, int, long, string) in the domain layer.EmailAddress, Money, Percentage, TenantId, CorrelationId).CustomerId type and use it on entities/commands.Guid at the persistence boundary and transport boundary.string in DTOs if required by external contracts.EmailAddress (value object) inside the application layer.// 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:
// 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:
API Controllers:
Service Layer:
Guid when CustomerId existsMapping & Serialization:
Testing:
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 |