serialization-persistence
Implement serialization (DTOs, JSON, Unity serializers) and persistence (database, CQRS) for domain models (C# by default, F# on request) following "Domain Modeling Made Functional." Use this skill wh
SKILL.mdplugins/dmf/skills/serialization-persistence/SKILL.md
GitHubSerialization & Persistence (DMF Methodology)
This skill covers bridging domain types to the outside world: converting to/from DTOs, serializing to JSON/XML, and persisting to databases. It assumes domain types and workflow implementations already exist.
Language
Code in this skill is F#, the notation of the book.
Write C# unless the user asks for F# or the project is an F# project: read ${CLAUDE_PLUGIN_ROOT}/references/csharp-mapping.md first and express every pattern below with it.
The project profile's Mapping section names the functional library and wins over the mapping's defaults.
In Unity, also read the "Serialization and DTOs in Unity" and "Versioning Persisted Data" sections of the mapping before choosing a serializer.
Core Principles
- Domain types are NOT serialization types - Always create separate DTO types.
- DTOs are the contract - Between bounded contexts, the DTO format IS the API.
- Push I/O to the edges - Pure domain logic in the center, I/O at the boundaries.
- Bounded contexts own their data - No shared databases between contexts.
- CQS - Functions that return data don't mutate. Functions that mutate don't return data.
The DTO Pattern
For every domain type that crosses a boundary, create a corresponding DTO type where all fields are primitives (string, int, DateTime, etc.).
Domain Type ──→ fromDomain ──→ DTO Type ──→ Serialize ──→ JSON/XML
JSON/XML ──→ Deserialize ──→ DTO Type ──→ toDomain ──→ Domain Type (Result)
Key rule: fromDomain always succeeds. toDomain returns Result because
deserialized data is untrusted and needs validation.
DTO Translation Rules
Read references/dto-patterns.md for the complete translation table. Summary:
| Domain Type | DTO Type |
|---|---|
Single-case DU (OrderId of string) |
Primitive (string) |
option |
Nullable / null |
| Record | Record with primitive fields |
| List / Set | Array |
| Discriminated Union (enum-like) | .NET enum or string |
| Discriminated Union (with data) | Record with tag + nullable fields per case |
| Map | Array of key-value records, or parallel arrays |
Complete Serialization Example
Domain type
module Domain =
type String50 = String50 of string
type Birthdate = Birthdate of DateTime
type Person = {
First : String50
Last : String50
Birthdate : Birthdate
}
DTO type
module Dto =
type Person = {
First : string
Last : string
Birthdate : DateTime
}
Conversion functions
module Dto =
module Person =
let fromDomain (person: Domain.Person) : Dto.Person =
let first = person.First |> String50.value
let last = person.Last |> String50.value
let birthdate = person.Birthdate |> Birthdate.value
{ First = first; Last = last; Birthdate = birthdate }
let toDomain (dto: Dto.Person) : Result<Domain.Person, string> =
result {
let! first = dto.First |> String50.create "First"
let! last = dto.Last |> String50.create "Last"
let! birthdate = dto.Birthdate |> Birthdate.create
return { First = first; Last = last; Birthdate = birthdate }
}
JSON wrapper
module Json =
open Newtonsoft.Json
let serialize obj = JsonConvert.SerializeObject obj
let deserialize<'a> str =
try JsonConvert.DeserializeObject<'a> str |> Ok
with ex -> Error ex
Full pipeline
// Domain → JSON (always succeeds)
let jsonFromDomain (person: Domain.Person) =
person |> Dto.Person.fromDomain |> Json.serialize
// JSON → Domain (can fail)
type DtoError =
| ValidationError of string
| DeserializationException of exn
let jsonToDomain jsonString : Result<Domain.Person, DtoError> =
result {
let! dto =
jsonString
|> Json.deserialize<Dto.Person>
|> Result.mapError DeserializationException
let! domain =
dto
|> Dto.Person.toDomain
|> Result.mapError ValidationError
return domain
}
Serializing Choice Types
Choice types need a tag field + nullable data fields for each case.
// Domain
type Example =
| A
| B of int
| C of string list
| D of Name
// DTO
type ExampleDto = {
Tag : string // "A", "B", "C", or "D"
BData : Nullable<int>
CData : string[]
DData : NameDto // nullable via Unchecked.defaultof
}
See references/dto-patterns.md for full fromDomain/toDomain implementations.
Connecting Serialization to the Workflow
Serialization is just another pipeline stage at the edges:
let workflowWithSerialization jsonString =
jsonString
|> deserializeInputDto // JSON → DTO
|> inputDtoToDomain // DTO → Domain (validation here)
|> workflow // Pure domain logic
|> outputDtoFromDomain // Domain → DTO
|> serializeOutputDto // DTO → JSON
Persistence
Push I/O to the Edges (Sandwich Pattern)
I/O (load) → Pure Domain Logic → I/O (save)
// BAD: I/O mixed with domain logic
let payInvoice invoiceId payment =
let invoice = loadInvoiceFromDatabase invoiceId // I/O
invoice.ApplyPayment payment // logic
if invoice.IsFullyPaid then
markAsFullyPaidInDb invoiceId // I/O
// GOOD: pure domain function returns a decision
type InvoicePaymentResult =
| FullyPaid
| PartiallyPaid of UpdatedInvoice
let applyPayment unpaidInvoice payment =
let updated = applyPaymentLogic unpaidInvoice payment
if isFullyPaid updated then FullyPaid
else PartiallyPaid updated
// I/O wrapper at the edge
let payInvoiceHandler payInvoiceCommand =
// Load (I/O)
let invoice = loadInvoiceFromDatabase payInvoiceCommand.InvoiceId
// Pure decision
let result = applyPayment invoice payInvoiceCommand.Payment
// Act on decision (I/O)
match result with
| FullyPaid ->
markAsFullyPaidInDb payInvoiceCommand.InvoiceId
postInvoicePaidEvent payInvoiceCommand.InvoiceId
| PartiallyPaid updated ->
updateInvoiceInDb updated
If you need multiple I/O + logic steps, use the "layer cake" pattern:
I/O → Pure → I/O → Pure → I/O
Or break into smaller workflows, each with its own sandwich.
CQS / CQRS
Command-Query Separation: Functions that return data don't mutate. Functions that mutate return unit.
// Persistence function signatures
type InsertData = Data -> DbResult<unit>
type ReadData = Query -> DbResult<Data>
type UpdateData = Data -> DbResult<unit>
type DeleteData = Key -> DbResult<unit>
type DbResult<'a> = AsyncResult<'a, DbError>
CQRS: Separate types for reading and writing.
// WRONG: same type for both
type SaveCustomer = Customer -> DbResult<unit>
type LoadCustomer = CustomerId -> DbResult<Customer>
// RIGHT: separate read/write models
type SaveCustomer = WriteModel.Customer -> DbResult<unit>
type LoadCustomer = CustomerId -> DbResult<ReadModel.Customer>
Why separate:
- Queries often return denormalized/joined data
- Commands need only the fields being updated
- Read and write models evolve independently
Document Database Persistence
Straightforward: domain → DTO → JSON → store.
let savePersonDtoToBlob personDto =
let blobId = sprintf "Person%i" personDto.PersonId
let json = Json.serialize personDto
blob.UploadText json
Relational Database Mapping
Records map to tables. Each field becomes a column with its primitive type.
CREATE TABLE Customer (
CustomerId int NOT NULL,
Name NVARCHAR(50) NOT NULL,
Birthdate DATETIME NULL, -- option → NULL
CONSTRAINT PK_Customer PRIMARY KEY (CustomerId)
)
Choice types → two approaches:
- Single table (default): tag columns + nullable data columns
CREATE TABLE ContactInfo (
ContactId int NOT NULL,
IsEmail bit NOT NULL,
IsPhone bit NOT NULL,
EmailAddress NVARCHAR(100) NULL,
PhoneNumber NVARCHAR(25) NULL,
PRIMARY KEY (ContactId)
)
- Multi-table: parent table with flags + child tables per case (Use when cases have very different, large data sets.)
Nested types:
- Entity (has identity) → separate table with FK to parent
- Value Object (no identity) → inline columns in parent table
CREATE TABLE [Order] (
OrderId int NOT NULL,
-- Value Object: inline
ShippingAddress1 varchar(50),
ShippingCity varchar(50),
-- Entity reference: separate table
PRIMARY KEY (OrderId)
)
CREATE TABLE OrderLine (
OrderLineId int NOT NULL,
OrderId int NOT NULL, -- FK to parent aggregate
ProductCode varchar(20) NOT NULL,
Quantity decimal NOT NULL,
PRIMARY KEY (OrderLineId)
)
Reading from DB: toDomain with validation
Treat the database as an untrusted source:
let toDomain (dbRecord: ReadOneCustomer.Record) : Result<Customer, _> =
result {
let! customerId = dbRecord.CustomerId |> CustomerId.create
let! name = dbRecord.Name |> String50.create "Name"
let! birthdate = dbRecord.Birthdate |> Result.bindOption Birthdate.create
return { CustomerId = customerId; Name = name; Birthdate = birthdate }
}
Or, if you trust the DB and want panics on bad data:
let toDomain dbRecord : Customer =
let customerId = dbRecord.CustomerId |> CustomerId.create |> panicOnError "CustomerId"
let name = dbRecord.Name |> String50.create "Name" |> panicOnError "Name"
{ CustomerId = customerId; Name = name; Birthdate = birthdate }
Bounded Context Data Ownership
- Each bounded context owns its data store and schema
- No direct cross-context DB access
- Other contexts use the public API (events/commands) to get data
- Reporting/BI is a separate context that subscribes to events
Transactions
- One aggregate = one transaction (default)
- For multi-aggregate atomicity: use DB transactions if same store, or compensating transactions if different stores
// Compensating transaction pattern
markAsFullyPaid connection invoiceId
let result = markPaymentCompleted connection paymentId
match result with
| Error _ -> unmarkAsFullyPaid connection invoiceId // compensate
| Ok _ -> ()
File Organization
Infrastructure/
├── Dto.fs -- All DTO types + fromDomain/toDomain
├── Json.fs -- JSON serializer wrapper
├── Database.fs -- DB access functions (read/write)
├── CompositionRoot.fs -- Wire everything together
Checklist
- Every domain type that crosses a boundary has a DTO
-
fromDomain(domain → DTO) always succeeds -
toDomain(DTO → domain) returns Result and validates - JSON serializer is wrapped to return Result on failure
- Domain types never appear in serialization/DB code directly
- I/O is at the edges; domain logic is pure in the center
- Each bounded context has its own data store
- Read and write models are separate types (or at minimum, separate functions)
- Every persisted DTO has a version field and one migration per shipped version
- The chosen serializer can serialize the DTO shape (Unity: see the serializer table in the mapping)
Output Format
Save the output as 08-serialization-bridge.md in the design directory cc-docs/design/<topic>/ (see ${CLAUDE_PLUGIN_ROOT}/references/design-directory.md for choosing <topic>).
Start the file with a > Source: line naming the files it consumed (used for staleness checks).
Input files: 04-domain-model.md (domain types to serialize) + 06-workflow-pipelines.md (I/O points at pipeline edges) + 07-service-wiring.md §6 (DTO boundaries between services) and §3 (event catalog with payloads) if it exists (from the lowy plugin).
If 07-service-wiring.md does not exist, derive the boundaries from 01-domain-discovery.md §2-3 (each cross-context relationship in the context map), every persistence store and external system named in 01-domain-discovery.md, 04-domain-model.md or 06-workflow-pipelines.md, and the event payloads from the 01-domain-discovery.md §4 domain events that cross a context boundary, and say in the output that no service wiring was used.
If 01-domain-discovery.md does not exist either, derive them from the bounded contexts and event types in 04-domain-model.md and the I/O points in 06-workflow-pipelines.md.
Output file: 08-serialization-bridge.md - this is the final architecture artifact.
Organize the output by boundary. For each boundary (between services or bounded contexts, to a persistence store, or to an external system) and each event channel, include:
- DTO type definitions (all fields as primitives)
fromDomainfunctions (domain → DTO, always succeeds)toDomainfunctions (DTO → domain, returns Result)- Serialization format (JSON/XML/binary) and any config
- Persistence strategy per service or bounded context (document DB, relational, event store, in-memory)
- Read model definitions (if CQRS applies)
- Database schema sketch (if relational - tables, key columns, choice-type mapping strategy)
- Versioning and migration plan for every persisted DTO