Skip to content

Latest commit

 

History

History
329 lines (288 loc) · 9.78 KB

File metadata and controls

329 lines (288 loc) · 9.78 KB
id schema-bidirectional-transforms
title Bidirectional API ↔ Domain ↔ DB Transformations
category transformations
skillLevel intermediate
tags
schema
bidirectional
transformation
api
database
domain-modeling
encode-decode
lessonOrder 3
rule
description
Bidirectional API ↔ Domain ↔ DB Transformations using Schema.
summary Your application lives in three worlds: API contracts (what clients expect), domain models (your business logic), and database schemas (how data persists). Each world has different requirements. The...

Problem

Your application lives in three worlds: API contracts (what clients expect), domain models (your business logic), and database schemas (how data persists). Each world has different requirements. The API uses snake_case timestamps in milliseconds; your domain uses camelCase Dates; the database stores ISO strings. Converting between these three representations is error-prone. You need a unified system that handles both directions—decode from external sources, encode for external consumption.

Solution

import { Schema, Effect } from "effect"

// ============================================
// 1. Domain Layer (source of truth)
// ============================================

type DomainUser = {
  userId: string
  email: string
  fullName: string
  createdAt: Date
  updatedAt: Date
  isActive: boolean
}

// ============================================
// 2. API Contract (client-facing)
// ============================================

type ApiUserRequest = {
  email: string
  full_name: string
}

type ApiUserResponse = {
  user_id: string
  email: string
  full_name: string
  created_at: number // milliseconds
  updated_at: number // milliseconds
  is_active: boolean
}

// ============================================
// 3. Database Schema (persistence layer)
// ============================================

type DbUserRow = {
  user_id: string
  email: string
  full_name: string
  created_at: string // ISO 8601
  updated_at: string // ISO 8601
  is_active: boolean
}

// ============================================
// 4. Define transformations: API → Domain
// ============================================

const ApiUserResponseSchema = Schema.Struct({
  user_id: Schema.String,
  email: Schema.String.pipe(
    Schema.pattern(/^[^\s@]+@[^\s@]+\.[^\s@]+$/)
  ),
  full_name: Schema.String,
  created_at: Schema.transform(Schema.Number, Schema.Date, {
    decode: (ms) => new Date(ms),
    encode: (date) => date.getTime(),
  }),
  updated_at: Schema.transform(Schema.Number, Schema.Date, {
    decode: (ms) => new Date(ms),
    encode: (date) => date.getTime(),
  }),
  is_active: Schema.Boolean,
}).pipe(
  Schema.transform(
    Schema.Struct({
      user_id: Schema.String,
      email: Schema.String,
      full_name: Schema.String,
      created_at: Schema.Date,
      updated_at: Schema.Date,
      is_active: Schema.Boolean,
    }),
    Schema.Struct({
      userId: Schema.String,
      email: Schema.String,
      fullName: Schema.String,
      createdAt: Schema.Date,
      updatedAt: Schema.Date,
      isActive: Schema.Boolean,
    }),
    {
      decode: (api) => ({
        userId: api.user_id,
        email: api.email,
        fullName: api.full_name,
        createdAt: api.created_at,
        updatedAt: api.updated_at,
        isActive: api.is_active,
      }),
      encode: (domain) => ({
        user_id: domain.userId,
        email: domain.email,
        full_name: domain.fullName,
        created_at: domain.createdAt,
        updated_at: domain.updatedAt,
        is_active: domain.isActive,
      }),
    }
  )
)

// ============================================
// 5. Define transformations: DB → Domain
// ============================================

const DbUserRowSchema = Schema.Struct({
  user_id: Schema.String,
  email: Schema.String,
  full_name: Schema.String,
  created_at: Schema.transform(Schema.String, Schema.Date, {
    decode: (isoString) => new Date(isoString),
    encode: (date) => date.toISOString(),
  }),
  updated_at: Schema.transform(Schema.String, Schema.Date, {
    decode: (isoString) => new Date(isoString),
    encode: (date) => date.toISOString(),
  }),
  is_active: Schema.Boolean,
}).pipe(
  Schema.transform(
    Schema.Struct({
      user_id: Schema.String,
      email: Schema.String,
      full_name: Schema.String,
      created_at: Schema.Date,
      updated_at: Schema.Date,
      is_active: Schema.Boolean,
    }),
    Schema.Struct({
      userId: Schema.String,
      email: Schema.String,
      fullName: Schema.String,
      createdAt: Schema.Date,
      updatedAt: Schema.Date,
      isActive: Schema.Boolean,
    }),
    {
      decode: (db) => ({
        userId: db.user_id,
        email: db.email,
        fullName: db.full_name,
        createdAt: db.created_at,
        updatedAt: db.updated_at,
        isActive: db.is_active,
      }),
      encode: (domain) => ({
        user_id: domain.userId,
        email: domain.email,
        full_name: domain.fullName,
        created_at: domain.createdAt,
        updated_at: domain.updatedAt,
        is_active: domain.isActive,
      }),
    }
  )
)

// ============================================
// 6. Create repositories with transformations
// ============================================

class UserRepository {
  // Decode: DB row → Domain model
  async findById(id: string): Promise<DomainUser | null> {
    // Simulate database query
    const dbRow: DbUserRow = {
      user_id: "user_123",
      email: "alice@example.com",
      full_name: "Alice Smith",
      created_at: "2024-01-15T10:30:00Z",
      updated_at: "2024-12-17T14:22:00Z",
      is_active: true,
    }

    const decoded = await Schema.decodeUnknown(DbUserRowSchema)(dbRow)
    return decoded
  }

  // Encode: Domain model → DB row
  async save(user: DomainUser): Promise<void> {
    const dbRow = await Schema.encode(DbUserRowSchema)(user)
    console.log("📦 Saving to database:", dbRow)
  }
}

// ============================================
// 7. Create API handlers with transformations
// ============================================

class UserApiHandler {
  private repo = new UserRepository()

  // Receive API response → Domain → Store
  async handleIncomingUser(apiData: unknown): Promise<DomainUser> {
    const domainUser = await Schema.decodeUnknown(ApiUserResponseSchema)(apiData)
    await this.repo.save(domainUser)
    return domainUser
  }

  // Retrieve Domain → API response
  async getUserById(id: string): Promise<ApiUserResponse> {
    const domainUser = await this.repo.findById(id)

    if (!domainUser) {
      throw new Error("User not found")
    }

    const apiResponse = await Schema.encode(ApiUserResponseSchema)(domainUser)
    return apiResponse
  }
}

// ============================================
// 8. Application logic
// ============================================

const appLogic = Effect.gen(function* () {
  const handler = new UserApiHandler()

  // Simulate incoming API response from external service
  const incomingApiData: ApiUserResponse = {
    user_id: "user_123",
    email: "alice@example.com",
    full_name: "Alice Smith",
    created_at: 1705317000000, // milliseconds
    updated_at: 1734443520000, // milliseconds
    is_active: true,
  }

  console.log("📥 Incoming API data:", incomingApiData)

  // Decode: API → Domain → Save to DB
  const domainUser = yield* Effect.tryPromise({
    try: () => handler.handleIncomingUser(incomingApiData),
    catch: (error) => {
      const msg = error instanceof Error ? error.message : String(error)
      return new Error(`Failed to process user: ${msg}`)
    },
  })

  console.log("\n✅ Domain model:", {
    userId: domainUser.userId,
    email: domainUser.email,
    fullName: domainUser.fullName,
    createdAt: domainUser.createdAt.toISOString(),
    updatedAt: domainUser.updatedAt.toISOString(),
  })

  // Encode: Domain → API response for client
  const apiResponse = yield* Effect.tryPromise({
    try: () => handler.getUserById("user_123"),
    catch: (error) => {
      const msg = error instanceof Error ? error.message : String(error)
      return new Error(`Failed to retrieve user: ${msg}`)
    },
  })

  console.log("\n📤 Outgoing API response:", apiResponse)

  return { domainUser, apiResponse }
})

// Run application
Effect.runPromise(appLogic)
  .then(() => console.log("\n✅ Transformation complete"))
  .catch((error) => console.error(`Error: ${error.message}`))

Why This Works

Concept Explanation
Layered transformations Each layer has distinct concerns (API, domain, DB)
Decode (external → domain) Parse and validate external data before business logic
Encode (domain → external) Transform domain back to external format for consumption
Single source of truth Domain model is center; all transformations revolve around it
Type safety TypeScript ensures each layer's shape is enforced
Bidirectional Same schema handles both directions seamlessly
Composable Chain transformations: API → domain, domain → DB
No tight coupling External layer changes don't force domain changes

When to Use

  • REST APIs with snake_case that your domain uses camelCase for
  • Timestamp format differences (Unix milliseconds vs ISO strings)
  • Database column names that differ from domain properties
  • Third-party API contracts that don't match your domain
  • Microservice data translation between services
  • Legacy system integration (old DB schema vs new domain)
  • Serialization for caching or message queues
  • GraphQL resolvers mapping queries to domain models

Related Patterns