Типобезопасный домен: Гексагональная архитектура на базе Effect Пример: Email как Value Object с валидацией
Глава

Пример: Email как Value Object с валидацией

Пошаговое построение production-ready Email VO. Нормализация (trim, lowercase), валидация RFC 5322, branded type. Операции: getLocalPart, getDomain, isCorporate, mask. Интеграция с доменной моделью, портами и HTTP-адаптером. Полный набор тестов.

Пример: Email как Value Object с валидацией

Введение

Email-адрес — это один из самых наглядных примеров Value Object. Это строка, но не любая строка — она должна соответствовать определённому формату, содержать ровно один символ @, иметь валидный домен и так далее. Если в вашей системе email хранится как обычный string, вы теряете все эти гарантии и вынуждены проверять формат каждый раз, когда используете email.

В этой статье мы пошагово построим полноценный Value Object Email на Effect-ts, начиная с наивной реализации и заканчивая production-ready решением. По пути мы разберём все тонкости: нормализацию, сравнение, работу с доменной частью и интеграцию с другими частями системы.


Почему Email — это Value Object

Email удовлетворяет всем критериям Value Object:

  1. Определяется значением: два email user@example.com — это один и тот же email.
  2. Неизменяем: email не может «измениться» — можно только заменить его новым.
  3. Самовалидируется: невалидный email не должен существовать в системе.
  4. Заменяем: любой email user@example.com эквивалентен любому другому user@example.com.
// ❌ Без Value Object — email это просто string
interface User {
  email: string  // "", "invalid", "not@email", "    spaces   " — всё допустимо
}

// ✅ С Value Object — email гарантированно валиден
interface User {
  email: Email  // Только валидный, нормализованный email
}

Шаг 1: Наивная реализация

Начнём с простейшей реализации — branded string с регулярным выражением:

import { Schema } from "effect"

// Простейший Email VO — branded string с regex
const Email = Schema.String.pipe(
  Schema.pattern(/^[^\s@]+@[^\s@]+\.[^\s@]+$/),
  Schema.brand("Email")
)
type Email = typeof Email.Type

Это работает, но имеет множество проблем:

  • " USER@EXAMPLE.COM " — с пробелами и в верхнем регистре не пройдёт.
  • "user@example.com" и "USER@EXAMPLE.COM" — будут разными значениями.
  • Нет нормализации.
  • Нет доступа к частям email (local part, domain).

Шаг 2: Добавляем нормализацию

Email-адреса case-insensitive в доменной части и (по стандарту) case-sensitive в локальной части, но на практике почти все почтовые серверы обрабатывают локальную часть без учёта регистра. Для нашего домена мы будем нормализовать email в нижний регистр и убирать пробелы:

import { Schema } from "effect"

// Email с нормализацией через Schema.transform
const Email = Schema.transform(
  // Входной тип: строка
  Schema.String,
  // Выходной тип: branded строка
  Schema.String.pipe(
    Schema.pattern(/^[a-z0-9._%+-]+@[a-z0-9.-]+\.[a-z]{2,}$/),
    Schema.brand("Email")
  ),
  {
    // Encoding: email -> string (просто возвращаем как есть)
    strict: true,
    encode: (email) => email as string,
    // Decoding: string -> email (нормализация + валидация)
    decode: (input) => input.trim().toLowerCase()
  }
)
type Email = typeof Email.Type

// Теперь работает нормализация:
const email1 = Schema.decodeUnknownSync(Email)("  USER@Example.COM  ")
// email1 = "user@example.com" (нормализован)

Шаг 3: Production-ready реализация

Полноценная реализация Email VO с учётом всех требований:

import { Schema, Effect, Either, pipe, Brand } from "effect"

// ============================================================
// Email Value Object — Production-Ready Implementation
// ============================================================

// Регулярное выражение для валидации email
// Основано на RFC 5322, но упрощённо для практического использования
const EMAIL_REGEX = /^[a-z0-9._%+-]+@[a-z0-9]([a-z0-9-]*[a-z0-9])?(\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)*\.[a-z]{2,}$/

// Максимальная длина email по RFC 5321
const MAX_EMAIL_LENGTH = 254
const MAX_LOCAL_PART_LENGTH = 64

// Schema для нормализованного email
const NormalizedEmailString = Schema.String.pipe(
  Schema.maxLength(MAX_EMAIL_LENGTH, {
    message: () => `Email must not exceed ${MAX_EMAIL_LENGTH} characters`
  }),
  Schema.pattern(EMAIL_REGEX, {
    message: () => "Invalid email format"
  }),
  Schema.brand("Email")
)

/**
 * Email Value Object
 *
 * Представляет валидный, нормализованный email-адрес.
 * - Автоматическая нормализация: trim + lowercase
 * - Валидация формата по RFC 5322 (упрощённая)
 * - Ограничение длины: 254 символа (RFC 5321)
 * - Branded type: нельзя перепутать с обычной строкой
 *
 * @example
 * const email = Schema.decodeUnknownSync(Email)("User@Example.COM")
 * // => "user@example.com" : Email
 */
export const Email = Schema.transform(
  Schema.String.pipe(
    Schema.annotations({
      title: "Raw Email Input",
      description: "Raw email string before normalization"
    })
  ),
  NormalizedEmailString,
  {
    strict: true,
    decode: (raw) => {
      const normalized = raw.trim().toLowerCase()

      // Дополнительная проверка: нет двойных точек в локальной части
      const localPart = normalized.split("@")[0] ?? ""
      if (localPart.includes("..")) {
        throw new Error("Email local part must not contain consecutive dots")
      }

      // Проверка длины локальной части
      if (localPart.length > MAX_LOCAL_PART_LENGTH) {
        throw new Error(
          `Email local part must not exceed ${MAX_LOCAL_PART_LENGTH} characters`
        )
      }

      return normalized
    },
    encode: (email) => email as string
  }
).pipe(
  Schema.annotations({
    identifier: "Email",
    title: "Email Address",
    description: "A validated, normalized email address (RFC 5322 compliant)"
  })
)

export type Email = typeof Email.Type

// ============================================================
// Smart Constructors
// ============================================================

/** Создание Email с валидацией — возвращает Effect */
export const createEmail = Schema.decodeUnknown(Email)

/** Создание Email с валидацией — синхронный, бросает при ошибке */
export const createEmailSync = Schema.decodeUnknownSync(Email)

/** Проверка Email — возвращает Either */
export const parseEmail = Schema.decodeUnknownEither(Email)

// ============================================================
// Операции над Email (чистые функции)
// ============================================================

/** Извлекает локальную часть email (до @) */
export const getLocalPart = (email: Email): string =>
  (email as string).split("@")[0]!

/** Извлекает доменную часть email (после @) */
export const getDomain = (email: Email): string =>
  (email as string).split("@")[1]!

/** Проверяет, принадлежит ли email указанному домену */
export const belongsToDomain = (email: Email, domain: string): boolean =>
  getDomain(email) === domain.toLowerCase()

/** Проверяет, является ли email корпоративным (не бесплатным) */
export const isCorporate = (email: Email): boolean => {
  const freeProviders = [
    "gmail.com", "yahoo.com", "hotmail.com", "outlook.com",
    "mail.ru", "yandex.ru", "protonmail.com", "icloud.com"
  ] as const
  return !freeProviders.includes(getDomain(email) as any)
}

/** Маскирует email для отображения: u***r@example.com */
export const mask = (email: Email): string => {
  const local = getLocalPart(email)
  const domain = getDomain(email)
  if (local.length <= 2) {
    return `${local[0]}***@${domain}`
  }
  return `${local[0]}${"*".repeat(Math.min(local.length - 2, 3))}${local[local.length - 1]}@${domain}`
}

/** Генерирует Gravatar URL для email */
export const gravatarUrl = (email: Email, size: number = 80): string => {
  // В реальном коде использовался бы crypto.subtle.digest для MD5
  // Здесь упрощённая версия для демонстрации
  const hash = Array.from(email as string)
    .reduce((acc, char) => ((acc << 5) - acc + char.charCodeAt(0)) | 0, 0)
    .toString(16)
    .padStart(32, "0")
  return `https://www.gravatar.com/avatar/${hash}?s=${size}&d=identicon`
}

Использование Email VO

В доменной модели

import { Schema } from "effect"
import { Email } from "./value-objects/Email"

// Email как поле Entity
class User extends Schema.Class<User>("User")({
  id: Schema.String.pipe(Schema.brand("UserId")),
  email: Email,
  name: Schema.String.pipe(Schema.minLength(1)),
  createdAt: Schema.DateFromSelf
}) {}

В порте (контракте)

import { Context, Effect } from "effect"
import { Email } from "./value-objects/Email"

// Порт нотификации — принимает Email, а не string
class NotificationPort extends Context.Tag("NotificationPort")<
  NotificationPort,
  {
    readonly sendWelcome: (email: Email) => Effect.Effect<void, NotificationError>
    readonly sendPasswordReset: (email: Email) => Effect.Effect<void, NotificationError>
  }
>() {}

// Порт аутентификации
class AuthPort extends Context.Tag("AuthPort")<
  AuthPort,
  {
    readonly findByEmail: (email: Email) => Effect.Effect<User, UserNotFound>
    readonly registerByEmail: (email: Email, password: HashedPassword) => Effect.Effect<User>
  }
>() {}

На границе HTTP-адаптера

import { Schema, Effect, pipe } from "effect"
import { Email, createEmail } from "./value-objects/Email"

// HTTP Request Schema
const CreateUserRequest = Schema.Struct({
  email: Email,        // Автоматическая нормализация и валидация при decode
  name: Schema.String.pipe(Schema.minLength(1)),
  password: Schema.String.pipe(Schema.minLength(8))
})

// В HTTP-адаптере
const handleCreateUser = (rawBody: unknown) =>
  pipe(
    // Шаг 1: Декодируем и валидируем входные данные
    Schema.decodeUnknown(CreateUserRequest)(rawBody),
    // Email уже нормализован и валиден на этом этапе!
    Effect.flatMap(({ email, name, password }) =>
      // Шаг 2: Передаём в application layer
      createUserUseCase({ email, name, password })
    ),
    // Шаг 3: Маппим ошибки валидации в HTTP 400
    Effect.catchTag("ParseError", (err) =>
      Effect.fail(new HttpError({ status: 400, message: err.message }))
    )
  )

В тестах

import { describe, it, expect } from "bun:test"
import { Schema, Either } from "effect"
import {
  Email, createEmailSync, parseEmail,
  getLocalPart, getDomain, belongsToDomain, isCorporate, mask
} from "./value-objects/Email"

describe("Email Value Object", () => {

  // === Создание и валидация ===

  describe("creation", () => {
    it("creates from valid email", () => {
      const email = createEmailSync("user@example.com")
      expect(email).toBe("user@example.com")
    })

    it("normalizes to lowercase", () => {
      const email = createEmailSync("USER@EXAMPLE.COM")
      expect(email).toBe("user@example.com")
    })

    it("trims whitespace", () => {
      const email = createEmailSync("  user@example.com  ")
      expect(email).toBe("user@example.com")
    })

    it("rejects empty string", () => {
      const result = parseEmail("")
      expect(Either.isLeft(result)).toBe(true)
    })

    it("rejects string without @", () => {
      const result = parseEmail("userexample.com")
      expect(Either.isLeft(result)).toBe(true)
    })

    it("rejects string with multiple @", () => {
      const result = parseEmail("user@@example.com")
      expect(Either.isLeft(result)).toBe(true)
    })

    it("rejects email without domain extension", () => {
      const result = parseEmail("user@example")
      expect(Either.isLeft(result)).toBe(true)
    })

    it("rejects email with consecutive dots", () => {
      expect(() => createEmailSync("user..name@example.com")).toThrow()
    })

    it("accepts valid emails with subdomains", () => {
      const email = createEmailSync("user@mail.example.co.uk")
      expect(email).toBe("user@mail.example.co.uk")
    })

    it("accepts email with plus addressing", () => {
      const email = createEmailSync("user+tag@example.com")
      expect(email).toBe("user+tag@example.com")
    })

    it("accepts email with dots in local part", () => {
      const email = createEmailSync("first.last@example.com")
      expect(email).toBe("first.last@example.com")
    })
  })

  // === Операции ===

  describe("operations", () => {
    const email = createEmailSync("john.doe@company.com")

    it("extracts local part", () => {
      expect(getLocalPart(email)).toBe("john.doe")
    })

    it("extracts domain", () => {
      expect(getDomain(email)).toBe("company.com")
    })

    it("checks domain membership", () => {
      expect(belongsToDomain(email, "company.com")).toBe(true)
      expect(belongsToDomain(email, "other.com")).toBe(false)
    })

    it("identifies corporate email", () => {
      expect(isCorporate(email)).toBe(true)
    })

    it("identifies free email", () => {
      const freeEmail = createEmailSync("user@gmail.com")
      expect(isCorporate(freeEmail)).toBe(false)
    })

    it("masks email for display", () => {
      expect(mask(email)).toBe("j***e@company.com")
    })
  })

  // === Равенство ===

  describe("equality", () => {
    it("equal after normalization", () => {
      const email1 = createEmailSync("User@Example.COM")
      const email2 = createEmailSync("user@example.com")
      expect(email1).toBe(email2)
    })

    it("different emails are not equal", () => {
      const email1 = createEmailSync("user1@example.com")
      const email2 = createEmailSync("user2@example.com")
      expect(email1).not.toBe(email2)
    })
  })
})

Расширение: Email с дополнительными свойствами

Вариант: Email как Schema.Class (составной VO)

Иногда вам нужно хранить не только нормализованную строку, но и предвычисленные свойства:

import { Schema } from "effect"

class EmailVO extends Schema.Class<EmailVO>("EmailVO")({
  value: Schema.String.pipe(
    Schema.pattern(/^[a-z0-9._%+-]+@[a-z0-9.-]+\.[a-z]{2,}$/)
  ),
  localPart: Schema.String,
  domain: Schema.String
}) {
  static fromString(raw: string): EmailVO {
    const normalized = raw.trim().toLowerCase()
    const [localPart, domain] = normalized.split("@") as [string, string]
    return new EmailVO({ value: normalized, localPart, domain })
  }
}

// Использование
const email = EmailVO.fromString("User@Example.COM")
email.localPart  // "user"
email.domain     // "example.com"
email.value      // "user@example.com"

Вариант: Типизированные домены

import { Schema } from "effect"

// Для систем, где важно различать типы email
const WorkEmail = Schema.String.pipe(
  Schema.pattern(/^[a-z0-9._%+-]+@company\.com$/),
  Schema.brand("WorkEmail")
)
type WorkEmail = typeof WorkEmail.Type

const PersonalEmail = Schema.String.pipe(
  Schema.pattern(/^[a-z0-9._%+-]+@(gmail|yahoo|hotmail|outlook)\.[a-z]+$/),
  Schema.brand("PersonalEmail")
)
type PersonalEmail = typeof PersonalEmail.Type

// Тип, принимающий любой из типов email
type AnyEmail = WorkEmail | PersonalEmail

// Функция, принимающая только рабочий email
const sendCorporateNotification = (email: WorkEmail): Effect.Effect<void> =>
  // Гарантированно корпоративный email
  Effect.void

Паттерн: Композиция Email с другими VO

Email часто используется вместе с другими Value Objects:

import { Schema, Data, Equal } from "effect"

// Контактная информация — составной VO из нескольких простых VO
class ContactInfo extends Schema.Class<ContactInfo>("ContactInfo")({
  email: Email,
  phone: Schema.OptionFromNullOr(
    Schema.String.pipe(
      Schema.pattern(/^\+\d{1,3}\d{4,14}$/),
      Schema.brand("PhoneNumber")
    )
  ),
  preferredChannel: Schema.Literal("email", "phone", "both")
}) {}

// Подписка на рассылку
class NewsletterSubscription extends Schema.Class<NewsletterSubscription>(
  "NewsletterSubscription"
)({
  email: Email,
  topics: Schema.Array(
    Schema.Literal("tech", "business", "design", "ai")
  ),
  subscribedAt: Schema.DateFromSelf
}) {}

Реальные edge cases

При работе с Email VO важно помнить о граничных случаях:

// Валидные (по RFC) но необычные email-адреса:
"user+tag@example.com"         // Plus addressing
"user.name@example.com"        // Dots in local part
"user@sub.domain.example.com"  // Subdomains
"user@example.co.uk"           // Multi-part TLD
"a@b.cc"                       // Минимальный email
"x".repeat(64) + "@b.cc"      // 64 символа local part (максимум)

// Невалидные:
""                             // Пустая строка
"user"                         // Нет @
"@example.com"                 // Нет local part
"user@"                        // Нет domain
"user@.com"                    // Домен начинается с точки
"user@com"                     // Нет TLD
"user @example.com"            // Пробел в local part
"user@exam ple.com"            // Пробел в domain

Резюме

Email как Value Object демонстрирует ключевые принципы:

  1. Нормализация при создании — trim + lowercase, чтобы одинаковые email всегда были равны.
  2. Валидация при создании — regex + бизнес-правила, чтобы невалидный email не существовал.
  3. Branded type — компилятор не позволит передать обычную строку вместо Email.
  4. Чистые функции — getLocalPart, getDomain, isCorporate — без побочных эффектов.
  5. Тестируемость — чистые значения тривиально тестируются.

Этот паттерн применим к любому Simple Value Object: URL, PhoneNumber, Slug, Username и так далее. В следующей статье мы разберём составной Value Object — Money.