Skip to content
Back to skills

Functional Crossing Io Boundaries

ASecurity

Use when mapping domain types to JSON, database rows or payloads, when storage shape leaks into the model, or when a read-only screen rebuilds aggregates and a bad row blanks it.

  • 2 stars
  • 0 votes
  • 0 copies
  • 0 views
  • Added October 21, 2026
ai-agentsgoshellapidatabase

Works with

  • cli
  • api

Security analysis

A100/100

Pro scans all 4 files and shows the line behind each finding

Scanned October 21, 2026

npx -y skills add yutna/functional-design-skills --skill functional-crossing-io-boundaries --agent claude-code

Installs into .claude/skills of the current project.

Are you the author of Functional Crossing Io Boundaries?

Add the live security badge to your README. It updates with every re-scan.

Security grade badge for Functional Crossing Io Boundaries
[![Security: A — Skills Directory](https://www.skillsdirectory.com/api/skills/yutna-functional-crossing-io-boundaries/badge)](https://www.skillsdirectory.com/skills/yutna-functional-crossing-io-boundaries)

More formats (shields.io, HTML) on the badges page. Keep it an A: scan every change in CI with Pro.

Download with Pro
SKILL.md
---
name: functional-crossing-io-boundaries
description: Use when mapping domain types to JSON, database rows or payloads, when storage shape leaks into the model, or when a read-only screen rebuilds aggregates and a bad row blanks it.
license: MIT
metadata:
  pack: functional-design-skills
  version: 3.3.0
---

# Crossing IO Boundaries

**In this pack:** [how much design a task warrants](../functional-design/references/calibration.md),
[the notation](../functional-design/references/notation.md), and
[syntax per language](../functional-design/SKILL.md#language-packs).

## Overview

Domain types are designed for rules; wire and storage types are designed
for transmission and querying. They are different jobs, and letting one
type do both means every schema change is a domain change and every
domain change is a migration.

The boundary is an explicit, deliberate mapping in one place: parse
inward into fully valid domain values, serialise outward into the shape
the outside expects. Everything between those two functions can then
assume its values are correct, and everything outside can evolve on its
own schedule.

## When to use

- Designing an API payload, message, or database schema
- A domain type has nullable fields that only storage needs
- Adding a field to a domain type requires a migration and a version bump
- Deciding where validation happens for incoming data
- An ORM entity is being used as a domain object

Not for: deciding what the domain types should be, which is
[functional-modeling-with-algebraic-types](../functional-modeling-with-algebraic-types/SKILL.md).

## Core rules

1. Rule. **Two type families, wherever a rule is enforced.** Domain types
   inside; transfer types at the edge, even when they look identical
   today. A view that only reads and enforces nothing is outside this
   rule; [worked-read-model.md](references/worked-read-model.md) says
   when.
2. Rule. **Transfer types are dumb.** Primitives, plain records, nullable
   fields, no invariants. Their job is to survive transmission.
3. Rule. **Parse inward, serialise outward.** Where a rule is enforced,
   write one function in each direction for each boundary and each
   transfer version.
4. Rule. **Parsing returns `Result`.** Code that enforces a rule parses
   into a `Result`, because the outside can always send something the
   domain forbids.
5. Rule. **Serialising is total.** A valid domain value can always be written
   out, so it needs no failure case.
6. Rule. **Validate once, at the boundary.** Where a rule is enforced,
   nothing downstream re-checks it.
7. Default. **Version the transfer type, not the domain type.** Add a new
   transfer version and a new mapping; leave the domain alone. A new version is
   owed while an old client or an old row still has to be read. A shape that is
   read only by code deployed together with the change, and that no stored row
   has, is edited in place with its mapping. Where you cannot tell who reads it,
   add the version.
8. Rule. **Keep the domain persistence-ignorant.** No annotations, no base
   types, no lazy-loading proxies, no framework attributes.

## Pattern

Domain type doing both jobs:

```text
type Booking = {
  id: String,                  -- because JSON has no Uuid
  status: String,              -- because the column is varchar
  treatments: List<BookedTreatment>,  -- may be empty: the join can miss
  cancelledAt: Option<Instant>,  -- because the column is nullable
  version: Integer,            -- because the ORM needs it
}
```

Every compromise of two external systems is now permanent in the rules.

Two families and a mapping:

```text
-- domain: designed for rules
type Booking = {
  id: BookingId,
  treatments: NonEmptyList<BookedTreatment>,
  lifecycle: Lifecycle,
}

-- transfer: designed for the wire
type BookingDto = {
  id: String,
  treatments: List<BookedTreatmentDto>,
  status: String,
  cancelledAt: String?,
}

toDto : Booking -> BookingDto
fromDto : BookingDto -> Result<Booking, BookingDtoError>
```

The domain regains `NonEmptyList` and a real lifecycle. The wire keeps
its nullable strings. Neither constrains the other.

## Direction rules

| Direction | Total?             | Where it runs          |
| --------- | ------------------ | ---------------------- |
| Inward    | No, returns Result | Edge, before workflows |
| Outward   | Yes                | Edge, after workflows  |

Inward failure is normal: a payload from an older client, a row written
by an earlier version, a hand-crafted request. Model it, do not crash.

## Field mappings

| Domain              | Transfer                      |
| ------------------- | ----------------------------- |
| Single-case wrapper | The primitive inside          |
| Choice type         | Tag field plus payload fields |
| `NonEmptyList<A>`   | Array, checked on the way in  |
| `Option<A>`         | Nullable, or field absent     |
| `Money`             | Minor units integer plus code |
| `Instant`           | Text in a fixed format, UTC   |
| Aggregate           | One document, or several rows |

Details and worked mappings in
[dto-mapping.md](references/dto-mapping.md).

## Persistence specifically

Storage is a boundary like any other. Three rules apply to it here, and
[persistence-patterns.md](references/persistence-patterns.md) covers its
three extra concerns: transactions, identity, and concurrency.

- **The domain does not know it is stored.** No repository interface
  inside the domain; the handler takes `LoadBooking` and `SaveBooking`
  function types. See
  [functional-parameterizing-dependencies](../functional-parameterizing-dependencies/SKILL.md).
- **One aggregate per transaction.** See
  [functional-enforcing-consistency-boundaries](../functional-enforcing-consistency-boundaries/SKILL.md).
- **Separate commands from queries.** Writes go through the domain;
  read-only views may query storage directly with their own types, and
  need not reconstruct aggregates.

More in
[persistence-patterns.md](references/persistence-patterns.md).

## Red flags

- A domain type with a nullable field that only the database needs
- Framework annotations on a domain type
- JSON field names appearing in business logic
- Validation repeated in the controller and in the service
- A schema migration required by a pure refactor of the domain
- A query returning entities that the domain then mutates
- Serialising a domain type directly to a response

## Common mistakes

- **Skipping the mapping because the types match.** They match today.
  The mapping is what lets them stop matching.
- **Auto-deriving serialisation from domain types.** Convenient, and it
  welds the wire format to the internals; a rename becomes a breaking API
  change.
- **Parsing in several places.** Each copy drifts. One parser per
  boundary.
- **Making serialisation fallible.** If a valid domain value cannot be
  written out, the transfer type is wrong.
- **Using the ORM entity as the aggregate.** Lazy loading, identity maps,
  and change tracking are the opposite of a value with invariants.
- **Reconstructing aggregates for read-only screens.** Query a view type
  instead.

## Related skills

- [functional-separating-pure-core-from-shell](../functional-separating-pure-core-from-shell/SKILL.md)
- [functional-hiding-information](../functional-hiding-information/SKILL.md)
- [functional-handling-errors-with-results](../functional-handling-errors-with-results/SKILL.md)
- [functional-constraining-primitive-values](../functional-constraining-primitive-values/SKILL.md)

## Further reading

- [dto-mapping.md](references/dto-mapping.md) covers the mapping for each
  kind of domain type, plus versioning and evolution.
- [persistence-patterns.md](references/persistence-patterns.md) covers
  transactions, concurrency, queries, and event storage.
- [worked-read-model.md](references/worked-read-model.md) builds one
  screen end to end, where nothing is enforced, so the rules above do
  not apply and a query returns its own view type.

Files in this skill

  • SKILL.md7.8 KB
  • references/dto-mapping.md5.6 KB
  • references/persistence-patterns.md6.5 KB
  • references/worked-read-model.md7.5 KB

Attribution

Is this your skill, or is something wrong with this listing? Request removal or report an issue. Author removals are honored within 72 hours.

Comments

Loading comments…

Newsletter

Get the weekly list of safe new skills, and the ones we pulled.