Carbonteq
Best Practices/Backend

Type Boundaries: Client, App & DB

How strings, UUIDs, null/undefined, Option<T>, DateTimeFromAny, and structured data flow across client/API, internal application code, and database boundaries — using Host as a concrete example.

Type Boundaries: Client/API ⇄ App ⇄ DB

What is a Type Boundary?

A type boundary is any point where data crosses between “outside your code” and “inside your code”.

  • Outside your code, data is just bytes / strings / JSON / rows.
  • Inside your code, you want meaningful runtime types and invariants you can rely on.

So a type boundary is the place where you must do two things:

  • Decode: turn “serialized stuff” into “runtime stuff”
  • Encode: turn “runtime stuff” back into “serialized stuff”

This is not a Clean Architecture / DDD concept. It’s a practical integration concept: if you can’t fully trust the shape/meaning of a value, you’re at a type boundary.

Quick Mental Model (works in any architecture)

Type boundaries blueprint diagram

Rule of thumb: if a function takes unknown, any, string, or a “row shape”… treat it as boundary code.

What’s happening: your app lives between systems that speak different “wire formats”.

Two common serializations:

  • Protocol serialization (HTTP/JSON): missing fields, strings everywhere, dates in “whatever” formats.
  • Persistence serialization (DB rows/columns): null instead of “missing”, flattened columns, JSON blobs, database-native types.

Why it doesn’t map 1:1:

  • HTTP can omit a field (undefined); DB typically stores absence as null.
  • Your app wants guarantees (UUID, Date, Option<T>); boundaries give you raw primitives.
  • Without a single decoder, invalid variants (wrong combinations of fields) creep in over time.

Goal: decode + validate once at the edge, then encode deterministically when leaving the app.

  • Client/API boundary: Where external requests come in (HTTP, GraphQL, webhooks)
  • Application boundary: Your code’s internal representation (types + invariants you can rely on)
  • Database boundary: Where data is persisted (PostgreSQL, MySQL, MongoDB)

Data crosses boundaries when:

  • A client sends a request → your app decodes it
  • Your app reads/writes the database → you map rows/columns to your runtime types (and back)
  • Your app sends a response → your app encodes it

Ideal Fix: One Schema = One Encoder/Decoder + Variant Validation

What we actually want is one source of truth that can:

  • Decode multiple boundary representations into one runtime type (via unions + transforms)
  • Validate invariants so only valid variants exist (via schema filters/refinements)
  • Encode back out predictably (true two-way transformations)

In other words: instead of “a bit of parsing here, a bit of mapping there”, we aim for a single schema per concept (e.g. HostSchema) that acts as the encoder/decoder for your app.

Concretely, that looks like:

  • Union-based decoding: accept “what the world sends” (string/number/Date, missing vs null, etc.) and normalize it.
  • Schema-derived types: runtime type (S.Schema.Type<...>) and boundary type (S.Schema.Encoded<...>) stay in sync automatically.
  • Filters/refinements: attach rules so invalid combinations never become runtime values.

If you’ve ever felt “why am I converting the same thing in five places?”, that’s the phenomenon called out around step 17 in our Domain Overview doc: manual encoding/decoding spreads everywhere unless you centralize it.

This document shows you exactly how to implement these transformations using a Host entity as a real-world example.

1
Step 1 — Concrete Example: Host Type Mismatch

Let's see the problem with a real example. Here's what a Host entity looks like from the client's perspective vs what your application code expects internally:

The mismatch is clear:
  • Client sends: plain strings, multiple date formats, inconsistent optionals
  • App code wants: branded UUID, normalized Date, explicit Option<T>

The next steps show how to bridge this gap with refined types, schemas, and helper functions.

2
Step 2 — Refined Types: UUID and DateTimeFromAny

Why do we need these? At boundaries, data is messy. Your application code needs guarantees:

  • UUID: A plain string could be any text. We need validated, properly formatted UUIDs so the app can trust IDs won't cause database errors or bugs.
  • DateTimeFromAny: Dates come as ISO strings, timestamps, or Date objects. The app needs a consistent Date type for date math and comparisons.

These refined types and helper functions validate and transform boundary data:

Key takeaway: Refined types act as gatekeepers at boundaries:

  • They validate incoming data (reject invalid UUIDs, unparseable dates)
  • They normalize formats (turn any date format → Date)
  • They brand types (string → UUID with type safety)

Note: For a complete list of refined types (UUID, DateTime, Email, etc.), see Core Utilities → Refined Types.

3
Step 3 — Client Boundary: HostSchema

Why this schema? Raw client JSON can't be trusted. HostSchema is the boundary layer that:

  • Validates IDs using StringToUUID (rejects invalid UUIDs)
  • Normalizes dates using DateTimeFromAny (converts any format → Date)
  • Transforms messy input → clean, validated output your app can safely use

This schema enforces type safety at the boundary: invalid data is rejected before it reaches your application code.

4
Step 4 — Make “Missing” Consistent (Maybe<T> → Option<T>)

Why this utility? External systems are inconsistent with optional fields. You might receive:

  • A real value: "profile.jpg"
  • null (explicitly no value)
  • undefined (field missing)
  • Already an Option<T> from another module/package

The app wants consistency: it expects Option<T> everywhere. Instead of handling all 4 cases repeatedly, we use the Maybe<T> utility:

Helper functions:

  • normalizeMaybe: Converts any optional format → Option<T> (for entering the app)
  • optionToMaybe: Converts Option<T>T | null (for exiting the app)

Note: See Domain Overview → Maybe<T> for more details.

5
Step 5 — Build Your Internal Entity from Decoded Data

Why this conversion? The entity (Host) uses strict types internally. The constructor:

  • Restores base fields (id, createdAt, updatedAt) from BaseEntity
  • Casts validated string IDs → UUID (safe because validation already happened at schema boundary)
  • Normalizes Maybe<T>Option<T> using the normalizeMaybe helper
  • Accepts already-constructed value objects (address, socialLinks)

Result: Clean, type-safe internal entity with:

  • Branded UUID types (can't accidentally use plain strings)
  • Normalized Date objects (consistent date handling)
  • Explicit Option<T> (no more null/undefined checks scattered everywhere)
6
Step 6 — Validate Invariants Once (Host.create)

Why a factory? The constructor handles type transformations (already normalizes via normalizeMaybe). The factory adds invariant validation (business rules) before construction. Host.create is the safe entry point that:

  • Validates business rules (valid DOB, valid phone format) on the boundary data
  • Delegates to constructor for type normalization
  • Returns a fully validated entity instance

Separation of concerns:

  • Boundary layer (schemas): Type validation and transformation (string → UUID, various formats → Date)
  • Factory (Host.create): Business rule validation (valid DOB range, phone format) + value object creation
  • Constructor: Type normalization (Maybe<T>Option<T>)
7
Step 7 — Encode Back Out (serialize)

Why serialize? Your code uses strict internal types (UUID, Option<T>, Date), but external systems expect:

  • Plain strings (not branded UUIDs)
  • null for missing values (not Option.none())
  • Serializable date formats
  • Plain objects (not value object instances)

The serialize() method converts internal types back to boundary-compatible types:

Boundary transformation (reverse):

  • UUID → plain string (unwrapped from branded type)
  • Option<T>T | null (via optionToMaybe helper)
  • Date → stays as Date (schema's encode can convert to ISO string if needed)
  • Value objects → plain objects (via their own serialize() methods)
8
Step 8 — Map DB Rows to the Same Boundary Shape

Why another boundary? The database has its own types:

  • Primitive types only (no Option<T>, no branded types)
  • null for missing values
  • Plain JSON for nested structures

Flow: DB Row (primitives, null, JSON) → SerializedHost (Maybe<T>) → Host (UUID, Option<T>, Date, ValueObjects)

The same boundary utilities (normalizeMaybe, StringToUUID, DateTimeFromAny) work for both client and database boundaries.

// Client-facing shape (API DTO)
type HostRequestDTO = {
id: string; // Just a string - could be anything!
userId: string; // No validation that it's a valid UUID
createdAt: string | number | Date;
Dates arrive in multiple formats: ISO string, timestamp, or Date object
updatedAt: string | number | Date;
dob?: string | number | Date | null; // Optional + multiple formats
phoneNumber?: number | null; // null vs undefined vs missing
profileImage?: string | null;
address?: unknown;
socialLinks?: unknown;
};
// Internal/runtime expectation (inside the app)
import { BaseEntity, IEntity } from "@domain/utils/base.entity";
import { UUID } from "@domain/utils/refined.types";
import { Option as O } from "effect";
interface IHost extends IEntity {
userId: UUID; // Validated, branded UUID type
dob: O.Option<Date>; // Normalized to Date, explicit optionality
phoneNumber: O.Option<number>; // Explicit optionality with Option<T>
profileImage: O.Option<string>;
}

Key Lessons

  1. Keep serialized shapes at the edges:

    • Outside the app: strings, numbers, nulls, “maybe missing”, and loosely structured objects.
    • Inside the app: refined (UUID), normalized (Date), explicit (Option<T>).
  2. Schemas give you symmetry:

    • StringToUUID and DateTimeFromAny decode messy inputs and give you predictable encoding on the way out.
  3. Pick one “missing” representation internally:

    • normalizeMaybe turns null | undefined | value | Option<value> into Option<T>.
  4. Use Maybe<T> only at boundaries:

    • Keep it permissive at the edges; keep it strict in the core.
  5. Validate once, then trust your types:

    • Entities/value objects enforce invariants; schemas handle parsing/coercion/encoding.

Throughout this document (and the Domain Overview doc), you may have noticed that address and socialLinks are handled differently from scalar fields like dob or phoneNumber. This is an application modeling choice (how you structure your internal types), not a boundary concern.

The Pattern You've Seen

In the constructor:

constructor(data: SerializedHost, address: Address, socialLinks: SocialLinks) {
super();
this._fromSerialized({
id: data.id,
createdAt: data.createdAt,
updatedAt: data.updatedAt
});
// Scalar fields use boundary utilities
this.userId = data.userId as UUID;
this.dob = normalizeMaybe(data.dob);
this.phoneNumber = normalizeMaybe(data.phoneNumber);
this.profileImage = normalizeMaybe(data.profileImage);
// Value objects passed directly
this.address = address;
this.socialLinks = socialLinks;
}

And when serializing:

serialize(): SerializedHost {
return {
...this._serialize(),
userId: this.userId,
dob: optionToMaybe(this.dob),
phoneNumber: optionToMaybe(this.phoneNumber),
profileImage: optionToMaybe(this.profileImage),
// Value objects serialize themselves
address: this.address.serialize(),
socialLinks: this.socialLinks.serialize()
};
}

Why This Difference?

Value Objects are used when multiple primitives represent one cohesive concept:

Address Could Have Been Primitives:

interface IHost extends IEntity {
userId: UUID;
dob: O.Option<Date>;
phoneNumber: O.Option<number>;
profileImage: O.Option<string>;
// Scattered primitives
country: string;
city: string;
state: string;
zipCode: string;
street: string;
}

Problems:

  • Primitive obsession: Five separate fields for one concept
  • Scattered validation: Address rules mixed with Host validation
  • Hard to reuse: Other entities duplicate these fields
  • No encapsulation: Can't enforce country-specific rules (US addresses need state, UK addresses don't)

Instead, Extract a Value Object:

interface IHost extends IEntity {
userId: UUID;
dob: O.Option<Date>;
phoneNumber: O.Option<number>;
profileImage: O.Option<string>;
// Cohesive value object
address: Address;
socialLinks: SocialLinks;
}

Benefits:

  • Conceptual integrity: All address fields stay together
  • Encapsulated validation: Address validates itself
  • Reusability: User, Organization all reuse Address
  • Type safety: Can't pass individual strings where Address expected

Value Object Implementation

A value object follows the same boundary patterns as entities:

// address.vo.ts
export const AddressSchema = S.Struct({
country: S.String,
city: S.String,
state: S.optional(S.String),
zipCode: S.String,
street: S.optional(S.String)
});
export type AddressType = S.Schema.Type<typeof AddressSchema>;
export type SerializedAddress = S.Schema.Encoded<typeof AddressSchema>;
export class Address {
readonly country: string;
readonly city: string;
readonly state: O.Option<string>;
readonly zipCode: string;
readonly street: O.Option<string>;
private constructor(data: AddressType) {
this.country = data.country;
this.city = data.city;
this.state = data.state;
this.zipCode = data.zipCode;
this.street = data.street;
}
static create(input: SerializedAddress): E.Effect<Address, AddressError, never> {
return pipe(
S.decodeUnknown(AddressSchema)(input),
E.flatMap((data) => {
// Address-specific validation
if (!AddressGuards.isValidZipCode(data.country, data.zipCode)) {
return E.fail(new AddressValidationError("Invalid zip code"));
}
return E.succeed(new Address(data));
})
);
}
serialize(): SerializedAddress {
return {
country: this.country,
city: this.city,
state: optionToMaybe(this.state),
zipCode: this.zipCode,
street: optionToMaybe(this.street)
};
}
}

Integration with Host

Value objects are created independently in the factory method:

static create(input: SerializedHost): E.Effect<Host, HostError, never> {
return pipe(
S.decodeUnknown(HostSchema)(input),
E.flatMap((data) => {
// Create value objects with their own validation
return E.all([
Address.create(input.address),
SocialLinks.create(input.socialLinks)
]).pipe(
E.map(([address, socialLinks]) =>
new Host(data, address, socialLinks))
);
})
);
}

Decision Guide

Use primitives when:

  • Single atomic concept (phoneNumber, age, email)
  • No related fields to validate together

Use value objects when:

  • Multiple primitives form one concept (Address)
  • Has its own validation rules
  • Will be reused across entities