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