값 객체: 열거형·검증·실전 패턴
이 문서는 값 객체의 열거형 패턴, 실전 예제, Application Layer 검증 병합, FAQ를 다룹니다. 핵심 개념과 기반 클래스는 05a-value-objects, Union 타입(Discriminated Union)은 05c-union-value-objects를 참고하세요.
들어가며
섹션 제목: “들어가며”05a-value-objects.md에서 값 객체의 핵심 개념과 구현 패턴을 살펴봤습니다. 이 문서에서는 열거형 패턴(SmartEnum), 기반 클래스별 실전 예제, Application Layer에서 여러 검증을 병합하는 Apply 패턴을 다룹니다.
값 객체 구현의 실전 포인트는 기반 클래스별 Create 패턴의 차이를 이해하고, Usecase에서 Apply 병합으로 모든 검증 오류를 한 번에 수집하는 것입니다.
주요 명령
섹션 제목: “주요 명령”// SmartEnum Create 패턴public static Fin<Currency> Create(string currencyCode) => Validate(currencyCode).Map(FromValue).ToFin();
// SimpleValueObject Create 패턴public static Fin<Email> Create(string? value) => CreateFromValidation(Validate(value), v => new Email(v));
// Application Layer에서 Apply 병합(name, description, price, stockQuantity) .Apply((n, d, p, s) => Product.Create(...)) .As().ToFin();주요 절차
섹션 제목: “주요 절차”1. 값 객체 생성:
- 기반 클래스 선택 (
SimpleValueObject<T>,ValueObject,SmartEnum등) Validate()메서드로 검증 규칙 정의Create()메서드로 검증 + 생성 조합
2. Application Layer 검증 병합:
- 각 필드의
VO.Validate()호출 (Validation<Error, T> 반환) Apply로 모든 검증 결과를 병렬 병합- 성공 시 Entity 생성, 실패 시 모든 오류 수집
주요 개념
섹션 제목: “주요 개념”| 개념 | 설명 |
|---|---|
SmartEnum | 값마다 고유 속성/동작이 필요한 열거형 패턴 |
ValidationRules<T> | Domain Layer에서 타입 기반 검증 규칙 체이닝 |
ValidationRules.For() | VO 없는 필드의 문자열 기반 검증 (Named Context) |
Apply 병합 | 독립적 검증을 병렬 수행하여 모든 오류를 수집 |
Bind/Then 체이닝 | 의존적 검증을 순차 수행 (첫 오류에서 중단) |
이제 열거형 패턴부터 살펴보고, 실전 예제를 거쳐 Application Layer에서 여러 검증을 병합하는 방법까지 순서대로 진행합니다.
열거형 구현 패턴
섹션 제목: “열거형 구현 패턴”도메인에서 고정된 선택지(통화 종류, 주문 상태, 회원 등급 등)를 표현할 때 C# 기본 enum 대신 Ardalis.SmartEnum을 사용합니다.
왜 SmartEnum인가요?
C# 기본 enum은 단순한 정수 상수에 불과합니다:
- 값에 추가 속성(표시 이름, 기호 등)을 붙일 수 없음
- 값마다 다른 동작을 정의할 수 없음
- 유효하지 않은 값 캐스팅이 가능함 (
(Currency)999)
SmartEnum은 이를 해결합니다:
- 각 값에 고유 속성과 동작 부여 가능
- 런타임 타입 안전성 보장
- Value Object처럼 검증 로직 포함 가능
SmartEnum은 SimpleValueObject를 상속받지 않으므로 Create 패턴이 약간 다릅니다.
기본 구조
섹션 제목: “기본 구조”다음 예제에서 Validate → Map(FromValue) → ToFin() 체이닝이 SmartEnum 고유의 Create 패턴임을 주목하세요.
using Ardalis.SmartEnum;using Functorium.Domains.ValueObjects;
public sealed class Currency : SmartEnum<Currency, string>, IValueObject{ public sealed record Unsupported : DomainErrorType.Custom;
public static readonly Currency KRW = new(nameof(KRW), "KRW", "한국 원화", "₩"); public static readonly Currency USD = new(nameof(USD), "USD", "미국 달러", "$"); public static readonly Currency EUR = new(nameof(EUR), "EUR", "유로", "€");
public string KoreanName { get; } public string Symbol { get; }
private Currency(string name, string value, string koreanName, string symbol) : base(name, value) { KoreanName = koreanName; Symbol = symbol; }
// SmartEnum 패턴: .Map(FromValue).ToFin() public static Fin<Currency> Create(string currencyCode) => Validate(currencyCode) .Map(FromValue) .ToFin();
public static Currency CreateFromValidated(string currencyCode) => FromValue(currencyCode);
public static Validation<Error, string> Validate(string currencyCode) => ValidateNotEmpty(currencyCode) .Bind(ValidateFormat) .Bind(ValidateSupported);
private static Validation<Error, string> ValidateNotEmpty(string currencyCode) => string.IsNullOrWhiteSpace(currencyCode) ? DomainError.For<Currency>(new Empty(), currencyCode ?? "", $"Currency code cannot be empty") : currencyCode;
private static Validation<Error, string> ValidateFormat(string currencyCode) => currencyCode.Length != 3 || !currencyCode.All(char.IsLetter) ? DomainError.For<Currency>(new WrongLength(3), currencyCode, $"Currency code must be exactly 3 letters") : currencyCode.ToUpperInvariant();
private static Validation<Error, string> ValidateSupported(string currencyCode) { try { FromValue(currencyCode); return currencyCode; } catch (SmartEnumNotFoundException) { return DomainError.For<Currency>(new Unsupported(), currencyCode, $"Currency code is not supported"); } }
public string FormatAmount(decimal amount) => $"{Symbol}{amount:N2}"; public static IEnumerable<Currency> GetAllSupportedCurrencies() => List;}Create 패턴 차이
섹션 제목: “Create 패턴 차이”기반 클래스에 따라 Create 메서드의 조합 방식이 다릅니다. 아래 표로 한눈에 비교할 수 있습니다.
| 기반 클래스 | Create 패턴 |
|---|---|
SimpleValueObject<T> | CreateFromValidation(Validate(value), factory) |
ComparableSimpleValueObject<T> | CreateFromValidation(Validate(value), factory) |
ValueObject | CreateFromValidation(Validate(...), factory) |
SmartEnum<T, TValue> | Validate(value).Map(FromValue).ToFin() |
열거형과 Create 패턴의 차이를 이해했다면, 이제 실전 예제를 통해 다양한 기반 클래스별 구현을 확인해 보겠습니다.
실전 예제
섹션 제목: “실전 예제”지금까지 설명한 패턴들을 적용한 완전한 예제입니다. 각 예제는 실제 프로젝트에서 그대로 사용할 수 있는 수준의 구현을 보여줍니다.
Email (SimpleValueObject)
섹션 제목: “Email (SimpleValueObject)”가장 흔한 패턴인 SimpleValueObject<string>의 완전한 예제입니다. 정규식 검증, 정규화(소문자 변환), 파생 속성(LocalPart, Domain)을 모두 포함합니다.
using Functorium.Domains.ValueObjects;using Functorium.Domains.ValueObjects.Validations.Typed;using System.Text.RegularExpressions;
public sealed class Email : SimpleValueObject<string>{ private static readonly Regex EmailPattern = new( @"^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$", RegexOptions.Compiled); private const int MaxLength = 254;
private Email(string value) : base(value) { var atIndex = value.IndexOf('@'); LocalPart = value[..atIndex]; Domain = value[(atIndex + 1)..]; }
public string LocalPart { get; } public string Domain { get; }
public static Fin<Email> Create(string? value) => CreateFromValidation(Validate(value), v => new Email(v));
public static Validation<Error, string> Validate(string? value) => ValidationRules<Email>.NotEmpty(value ?? "") .ThenNormalize(v => v.ToLowerInvariant()) .ThenMatches(EmailPattern) .ThenMaxLength(MaxLength);
public static implicit operator string(Email email) => email.Value;}Quantity (ComparableSimpleValueObject)
섹션 제목: “Quantity (ComparableSimpleValueObject)”비교 가능한 단일 값 객체의 예제입니다. 수량 비교(q1 > q2)와 정렬이 필요하며, 도메인 연산(Add, Subtract)과 편의 속성(IsZero, IsPositive)을 포함합니다.
using Functorium.Domains.ValueObjects;using Functorium.Domains.ValueObjects.Validations.Typed;
public sealed class Quantity : ComparableSimpleValueObject<int>{ public const int MaxValue = 10000;
private Quantity(int value) : base(value) { }
public static Quantity Zero => new(0); public static Quantity One => new(1);
public bool IsZero => Value == 0; public bool IsPositive => Value > 0;
public static Fin<Quantity> Create(int value) => CreateFromValidation(Validate(value), v => new Quantity(v));
public static Validation<Error, int> Validate(int value) => ValidationRules<Quantity>.NonNegative(value) .ThenAtMost(MaxValue);
public Quantity Add(Quantity other) => new(Value + other.Value); public Quantity Subtract(Quantity other) => new(Math.Max(0, Value - other.Value));
public static implicit operator int(Quantity q) => q.Value;}Money (ValueObject with Apply)
섹션 제목: “Money (ValueObject with Apply)”복합 속성(Amount + Currency)으로 구성된 값 객체의 예제입니다. Apply 패턴으로 두 속성을 병렬 검증하고, 도메인 연산(Add)에서 비즈니스 규칙 위반(다른 통화 더하기)을 DomainError.For<T>()로 처리합니다.
Validate 메서드에서 튜플 + Apply로 두 속성을 병렬 검증하는 부분과, Add 메서드에서 통화 불일치를 DomainError로 처리하는 부분을 주목하세요.
using Functorium.Domains.ValueObjects;using Functorium.Domains.ValueObjects.Validations;using Functorium.Domains.ValueObjects.Validations.Typed;using static Functorium.Domains.Errors.DomainErrorType;
public sealed class Money : ValueObject{ public decimal Amount { get; } public string Currency { get; }
private Money(decimal amount, string currency) { Amount = amount; Currency = currency; }
// Create: CreateFromValidation 헬퍼 사용 public static Fin<Money> Create(decimal amount, string currency) => CreateFromValidation(Validate(amount, currency), v => new Money(v.Amount, v.Currency));
// Validate: 검증된 원시값 튜플 반환 (ValueObject 생성은 Create에서) public static Validation<Error, (decimal Amount, string Currency)> Validate(decimal amount, string currency) => (ValidateAmount(amount), ValidateCurrency(currency)) .Apply((a, c) => (Amount: a, Currency: c));
private static Validation<Error, decimal> ValidateAmount(decimal amount) => ValidationRules<Money>.NonNegative(amount);
private static Validation<Error, string> ValidateCurrency(string currency) => ValidationRules<Money>.NotEmpty(currency) .ThenNormalize(v => v.ToUpperInvariant()) .ThenExactLength(3);
public Fin<Money> Add(Money other) => Currency == other.Currency ? new Money(Amount + other.Amount, Currency) : DomainError.For<Money, string, string>( new Mismatch(), Currency, other.Currency, $"Cannot add different currencies: {Currency} vs {other.Currency}");
protected override IEnumerable<object> GetEqualityComponents() { yield return Amount; yield return Currency; }}개별 값 객체의 구현을 살펴보았으니, 이제 Usecase에서 여러 값 객체의 검증 결과를 하나로 합치는 방법을 알아봅니다.
Application Layer에서 VO 검증 병합
섹션 제목: “Application Layer에서 VO 검증 병합”Usecase에서 여러 ValueObject를 동시에 검증하고 Entity를 생성할 때 Apply 패턴을 사용합니다.
Apply 병합 패턴 (Usecase 내부)
섹션 제목: “Apply 병합 패턴 (Usecase 내부)”각 필드의 Validate()를 개별 호출한 뒤, 튜플 + Apply로 모든 결과를 한꺼번에 합치는 흐름을 주목하세요.
private static Fin<Product> CreateProduct(Request request){ // 1. 모든 필드: VO Validate() 호출 (Validation<Error, T> 반환) var name = ProductName.Validate(request.Name); var description = ProductDescription.Validate(request.Description); var price = Money.Validate(request.Price); var stockQuantity = Quantity.Validate(request.StockQuantity);
// 2. Apply로 병렬 검증 후 Entity 생성 return (name, description, price, stockQuantity) .Apply((n, d, p, s) => Product.Create( ProductName.Create(n).ThrowIfFail(), ProductDescription.Create(d).ThrowIfFail(), Money.Create(p).ThrowIfFail(), Quantity.Create(s).ThrowIfFail())) .As() .ToFin();}패턴 설명
섹션 제목: “패턴 설명”아래 표는 위 코드의 각 단계가 수행하는 역할을 정리한 것입니다.
| 단계 | 설명 |
|---|---|
| Validate() 호출 | 모든 필드의 검증을 Validation<Error, T>로 수집 |
| Apply 병합 | 모든 검증이 성공해야 Entity 생성 진행 |
| ThrowIfFail() | 이미 검증된 값이므로 안전하게 VO 변환 |
VO가 없는 필드의 검증 (Named Context)
섹션 제목: “VO가 없는 필드의 검증 (Named Context)”모든 필드가 Value Object로 정의되지 않을 경우 Named Context 검증을 사용합니다:
private static Fin<Product> CreateProduct(Request request){ // VO가 있는 필드 var name = ProductName.Validate(request.Name); var price = Money.Validate(request.Price);
// VO가 없는 필드: Named Context 사용 var note = ValidationRules.For("Note") .NotEmpty(request.Note) .ThenMaxLength(500);
// 모두 튜플로 병합 - Apply로 병렬 검증 return (name, price, note.Value) .Apply((n, p, noteValue) => Product.Create( ProductName.Create(n).ThrowIfFail(), noteValue, Money.Create(p).ThrowIfFail())) .As() .ToFin();}참고: 자주 사용되는 필드는 Named Context 대신 별도의 ValueObject로 정의하는 것을 권장합니다.
Application Layer에서 Fin<T> 합성 (FinApplyExtensions)
섹션 제목: “Application Layer에서 Fin<T> 합성 (FinApplyExtensions)”위의 Validation<Error, T> Apply 패턴은 VO 내부에서 여러 검증 규칙을 병렬 합성할 때 사용합니다. 반면, Application Layer에서는 이미 생성된 여러 VO의 Create() 결과(Fin<T>)를 합성해야 합니다. 이때 FinApplyExtensions를 사용합니다.
VO.Create()→Fin<T>반환 (성공 또는 실패)- Application Layer에서 여러
Fin<T>결과를 하나로 합성할 때, 개별ThrowIfFail()은 첫 에러에서 중단됨 FinApplyExtensions는 모든Fin<T>를 내부적으로Validation<Error, T>로 변환하여 모든 에러를 누적
사용 예시
섹션 제목: “사용 예시”// Application Layer: 여러 VO Create 결과를 applicative로 합성var contact = ( PersonalName.Create(cmd.FirstName, cmd.LastName), EmailAddress.Create(cmd.Email)).Apply((name, email) => Contact.Create(name, email, now));// → Fin<Contact>, 모든 VO 검증 에러 누적Validation Apply vs Fin Apply 비교
섹션 제목: “Validation Apply vs Fin Apply 비교”| 특성 | Validation Apply | Fin Apply |
|---|---|---|
| 입력 타입 | Validation<Error, T> 튜플 | Fin<T> 튜플 |
| 사용 위치 | VO 내부 Validate 합성 | Application Layer VO Create 합성 |
| 에러 누적 | 모든 에러 수집 | 모든 에러 수집 (내부적으로 Validation 변환) |
| 오버로드 | 2~5 튜플 | 2~5 튜플 |
검증 합성의 레이어별 역할
섹션 제목: “검증 합성의 레이어별 역할”raw 입력(문자열 등)을 VO로 변환하는 검증 책임은 레이어별로 명확히 분리됩니다:
| 레이어 | 검증 경계 | Validate | Create | CreateFromValidated |
|---|---|---|---|---|
| Simple VO | raw → VO | ValidationRules 체인 | string? → Fin<T> | string → T |
| Composite VO | raw → VO | 자식 Validate applicative 합성 | string? → Fin<T> | 자식 VO → T |
| Entity/Aggregate | VO → Entity | — | VO → Entity | VO + ID → Entity (ORM 복원) |
| Application Layer | — | — | FinApply로 N개 Fin<T> applicative 합성 | — |
Entity/Aggregate는 Validate 없이 이미 검증된 VO만 수신합니다. Application Layer에서 여러 VO의 Create 결과(Fin<T>)를 합성할 때는 FinApplyExtensions의 튜플 .Apply()를 사용합니다.
트러블슈팅
섹션 제목: “트러블슈팅”SmartEnum의 Create에서 SmartEnumNotFoundException 발생
섹션 제목: “SmartEnum의 Create에서 SmartEnumNotFoundException 발생”원인: FromValue()에 등록되지 않은 값을 전달한 경우입니다. SmartEnum은 static readonly 필드로 등록된 값만 허용합니다.
해결: Validate() 메서드를 통해 지원 여부를 먼저 검증하세요. ValidateSupported에서 try-catch로 SmartEnumNotFoundException을 잡아 DomainError로 변환하는 패턴을 사용합니다.
private static Validation<Error, string> ValidateSupported(string currencyCode){ try { FromValue(currencyCode); return currencyCode; } catch (SmartEnumNotFoundException) { return DomainError.For<Currency>(new Unsupported(), currencyCode, $"Currency code is not supported"); }}Apply 병합 시 일부 검증 오류만 반환됨
섹션 제목: “Apply 병합 시 일부 검증 오류만 반환됨”원인: Apply 대신 Bind를 사용했거나, 검증 체인 내부에서 Bind(Then*)가 순차 실행되어 첫 오류에서 중단된 경우입니다.
해결: 독립적인 필드 간 검증은 반드시 Apply를 사용하세요. 각 필드 내부의 순차 검증(NotEmpty → Matches → MaxLength)은 Then*을 사용하되, 필드 간 병합은 튜플 + Apply로 처리합니다.
// 필드 간 검증은 Apply (병렬)(ValidateAmount(amount), ValidateCurrency(currency)) .Apply((a, c) => new Money(a, c));ThrowIfFail()에서 예외 발생
섹션 제목: “ThrowIfFail()에서 예외 발생”원인: Apply 병합 후 Entity 생성 시 ThrowIfFail()을 호출하는 구간에서, Apply가 실패했는데 내부 팩토리 함수가 실행된 경우입니다. 이는 Apply가 성공했을 때만 팩토리 함수가 실행되므로 정상적으로는 발생하지 않습니다.
해결: ThrowIfFail()은 Apply 내부의 팩토리 함수에서만 사용하세요. Apply 외부에서 개별 Fin<T>에 대해 ThrowIfFail()을 직접 호출하면 검증 실패 시 예외가 발생합니다.
FAQ
섹션 제목: “FAQ”값 객체 구현 시 자주 받는 질문들입니다. 위 내용을 읽고도 헷갈리는 부분이 있다면 이 섹션을 참고하세요.
Q1. 기반 클래스 선택 기준은?
섹션 제목: “Q1. 기반 클래스 선택 기준은?”값 객체를 만들 때 어떤 기반 클래스를 상속받을지 결정해야 합니다. 핵심 질문 두 가지로 쉽게 선택할 수 있습니다.
첫 번째 질문: 값이 하나인가, 여러 개인가?
- 하나의 값만 감싸는 경우 →
SimpleValueObject<T>계열- 예: 이메일 주소(string 하나), 가격(decimal 하나), 사용자 ID(int 하나)
- 여러 속성으로 구성된 경우 →
ValueObject계열- 예: 금액(amount + currency), 주소(city + street + postalCode), 좌표(x + y)
두 번째 질문: 크기 비교가 필요한가?
- 비교 필요 없음 →
SimpleValueObject<T>또는ValueObject- 예: 이메일은 “어떤 게 더 크다”가 의미 없음
- 비교/정렬 필요 →
ComparableSimpleValueObject<T>또는ComparableValueObject- 예: 가격은 “더 비싸다/싸다” 비교가 필요, 날짜 범위는 정렬이 필요
| 조건 | 선택 |
|---|---|
| 단일 값 래핑 | SimpleValueObject<T> |
| 단일 값 + 비교/정렬 필요 | ComparableSimpleValueObject<T> |
| 복합 속성 | ValueObject |
| 복합 속성 + 비교/정렬 필요 | ComparableValueObject |
| 열거형 + 도메인 로직 | SmartEnum<T, TValue> |
Q2. ValidationRules<T>와 DomainError.For<T>() 사용 기준은?
섹션 제목: “Q2. ValidationRules<T>와 DomainError.For<T>() 사용 기준은?”두 가지 모두 검증 오류를 생성하지만, 용도가 다릅니다.
ValidationRules<T>는 “일반적인 검증 규칙”에 사용합니다.
“비어있으면 안 됨”, “양수여야 함”, “최대 100자” 같은 흔한 검증은 이미 구현되어 있어서 체이닝으로 간단히 사용할 수 있습니다.
// 좋음: 일반적인 검증은 ValidationRules 사용ValidationRules<Email>.NotEmpty(value) .ThenMaxLength(254) .ThenMatches(EmailPattern);DomainError.For<T>()는 “특수한 비즈니스 규칙”에 사용합니다.
“통화가 서로 달라서 더할 수 없음”, “재고가 부족함” 같은 도메인 특화 오류는 직접 생성해야 합니다.
// 좋음: 비즈니스 규칙 위반은 DomainError.For 사용return Currency == other.Currency ? new Money(Amount + other.Amount, Currency) : DomainError.For<Money, string, string>( new Mismatch(), Currency, other.Currency, $"Cannot add different currencies: {Currency} vs {other.Currency}");| 상황 | 권장 |
|---|---|
| 일반적인 검증 | ValidationRules<T> + 체이닝 |
| 커스텀 비즈니스 규칙 | ThenMust 또는 DomainError.For<T>() |
| 도메인 연산 중 오류 | DomainError.For<T>() |
Q3. Bind(Then)와 Apply 사용 기준은?
섹션 제목: “Q3. Bind(Then)와 Apply 사용 기준은?”검증이 여러 개일 때, 오류를 어떻게 보여줄지에 따라 선택합니다.
Bind/Then은 “순차 검증”입니다. 첫 오류에서 멈춥니다.
앞 검증이 실패하면 뒤 검증은 실행되지 않습니다. 검증 간에 의존 관계가 있을 때 사용합니다.
// "비어있지 않아야" 통과해야 "이메일 형식 검사"가 의미 있음ValidationRules<Email>.NotEmpty(value) // 1. 빈 값이면 여기서 중단 .ThenMatches(EmailPattern) // 2. 1 통과 시에만 실행 .ThenMaxLength(254); // 3. 2 통과 시에만 실행Apply는 “병렬 검증”입니다. 모든 오류를 수집합니다.
각 검증이 독립적일 때 사용합니다. 사용자에게 한 번에 모든 문제를 알려줄 수 있어서 UX가 좋습니다.
// amount와 currency 검증은 서로 독립적// 둘 다 틀리면 두 오류 모두 반환(ValidateAmount(amount), ValidateCurrency(currency)) .Apply((a, c) => new Money(a, c));실제 예시로 비교:
| 입력 | Bind 결과 | Apply 결과 |
|---|---|---|
| amount=-100, currency="" | "금액은 양수여야 합니다” (1개) | “금액은 양수여야 합니다”, “통화 코드는 비어있을 수 없습니다” (2개) |
| 전략 | 사용 시점 | 특징 |
|---|---|---|
Bind / Then* | 검증 간 의존 관계 | 첫 오류에서 중단 |
Apply | 독립적인 검증 | 모든 오류 수집 |
Q4. Value 속성에 접근하려면?
섹션 제목: “Q4. Value 속성에 접근하려면?”SimpleValueObject<T>의 Value 속성은 protected로 선언되어 있어서 외부에서 직접 접근할 수 없습니다. 이는 의도적인 설계입니다 - 값 객체를 “원시 값처럼” 쓰는 것을 방지하고, 타입 안전성을 유지합니다.
외부에서 내부 값이 필요한 경우 세 가지 방법이 있습니다:
// 방법 1: 암시적 변환 연산자 정의 (권장)// Email을 string이 필요한 곳에 바로 전달 가능public static implicit operator string(Email email) => email.Value;
string emailString = email; // 암시적 변환SendEmail(email); // string 매개변수에 직접 전달
// 방법 2: 의미 있는 파생 속성 제공// 단순히 Value를 노출하는 것보다 도메인 의미를 담은 속성이 좋음public string LocalPart { get; } // user@example.com에서 "user" 부분public string Domain { get; } // user@example.com에서 "example.com" 부분
// 방법 3: ToString() 오버라이드// 디버깅이나 로깅에 유용public override string ToString() => Value;참고: 방법 1의 암시적 변환은 편리하지만, 남용하면 값 객체의 타입 안전성이 약해질 수 있습니다. 꼭 필요한 경우에만 사용하세요.
Q5. 언제 SmartEnum을 사용하나요?
섹션 제목: “Q5. 언제 SmartEnum을 사용하나요?”C#의 기본 enum은 단순한 정수 상수에 불과합니다. 값마다 다른 속성이나 동작이 필요하면 SmartEnum을 사용합니다.
기본 enum으로 충분한 경우:
// 단순한 상태 구분만 필요public enum OrderStatus { Pending, Confirmed, Shipped, Delivered }SmartEnum이 필요한 경우:
// 각 통화마다 고유한 속성(기호, 이름)과 동작(포맷팅)이 필요public sealed class Currency : SmartEnum<Currency, string>{ public static readonly Currency KRW = new("KRW", "KRW", "₩", "한국 원화"); public static readonly Currency USD = new("USD", "USD", "$", "미국 달러");
public string Symbol { get; } public string DisplayName { get; }
// 값마다 다른 동작 public string Format(decimal amount) => $"{Symbol}{amount:N2}";}| 상황 | 선택 |
|---|---|
| 단순한 상태/플래그 | 기존 C# enum |
| 값마다 고유 속성 필요 | SmartEnum |
| 값마다 다른 동작 필요 | SmartEnum |
| 런타임 타입 안전성 중요 | SmartEnum |
Q6. ValidationRules<T>와 ValidationRules.For() 차이점은?
섹션 제목: “Q6. ValidationRules<T>와 ValidationRules.For() 차이점은?”둘 다 같은 검증 메서드(NotEmpty, Positive 등)를 제공하지만, 타입 정보를 어디서 가져오는지가 다릅니다.
ValidationRules<T>는 “타입”에서 컨텍스트를 가져옵니다.
Value Object 클래스 내부에서 사용하며, 컴파일 타임에 타입이 결정됩니다.
// Price 클래스 내부에서public static Validation<Error, decimal> Validate(decimal value) => ValidationRules<Price>.Positive(value);ValidationRules.For()는 “문자열”에서 컨텍스트를 가져옵니다.
Value Object가 없는 상황(DTO 검증, API 입력 검증)에서 사용합니다.
// DTO 검증에서var result = ValidationRules.For("ProductPrice").Positive(request.Price);언제 어떤 것을 사용하나요?
// Domain Layer: 항상 ValidationRules<T> 사용public sealed class Price : ComparableSimpleValueObject<decimal>{ public static Validation<Error, decimal> Validate(decimal value) => ValidationRules<Price>.Positive(value); // 타입 안전}
// Application/Presentation Layer: ValidationRules.For() 사용 가능public class CreateProductValidator : AbstractValidator<CreateProductRequest>{ public CreateProductValidator() { // DTO 검증 - Value Object 없이 직접 검증 RuleFor(x => x.Price) .Must(v => ValidationRules.For("Price").Positive(v).IsSuccess); }}| 특성 | ValidationRules<T> (Typed) | ValidationRules.For() (Contextual) |
|---|---|---|
| 네임스페이스 | Validations.Typed | Validations.Contextual |
| 타입 소스 | 컴파일 타임 (제네릭) | 런타임 (문자열) |
| 권장 레이어 | Domain Layer | Presentation/Application Layer |
| 사용 대상 | Value Object | DTO, API 입력, 프로토타이핑 |
| 예시 | ValidationRules<Price>.Positive(v) | ValidationRules.For("Price").Positive(v) |
권장 사항:
- Domain Layer에서는 항상
ValidationRules<T>사용 (타입 안전성) - DTO나 API 입력 검증에서는
ValidationRules.For()사용 가능
검증 파이프라인의 역할과 책임
섹션 제목: “검증 파이프라인의 역할과 책임”값 객체 검증은 4개의 역할로 분담된다. 각 역할은 독립적이며, DDD의 Always-Valid 원칙을 서로 다른 수준에서 보장한다.
Role 1: Validate() — 도메인 지식 컨테이너
섹션 제목: “Role 1: Validate() — 도메인 지식 컨테이너”- 책임: “유효한 값이란 무엇인가”에 대한 도메인 지식을 캡슐화한다
- 포함 내용: 정규화(Trim, ToLower) + 구조적 검증(MaxLength, Matches)
- 정규화 배치 규칙: 존재성 검사(NotNull, NotEmpty) 직후, 구조적 검사 이전
- 반환:
Validation<Error, T>(정규화된 원시값) - 사용처:
Create()내부, Presentation Validator의MustSatisfyValidation
public static Validation<Error, string> Validate(string? value) => ValidationRules<ProductName> .NotNull(value) .ThenNotEmpty() // 존재성 검사 .ThenNormalize(v => v.Trim()) // 정규화 (존재성 검사 직후) .ThenMaxLength(MaxLength); // 구조적 검사 (정규화된 값 기준)Role 2: Create() — 권위적 팩토리 (Always-Valid 보증)
섹션 제목: “Role 2: Create() — 권위적 팩토리 (Always-Valid 보증)”- 책임: 유효하고 정규화된 값 객체를 생성하는 유일한 진입점이다
- 내부:
Validate()호출 → 성공 시 값 객체 구성 - 반환:
Fin<VO>— 유효한 값 객체 또는 에러 - 근거: “Always-valid 도메인 모델은 가장 기본적인 원칙이다” (Vladimir Khorikov)
Role 3: Handler + ApplyT — 도메인 검증 + 유스케이스 오케스트레이션
섹션 제목: “Role 3: Handler + ApplyT — 도메인 검증 + 유스케이스 오케스트레이션”- 책임: 값 객체 생성(= 도메인 검증) + 비즈니스 로직 실행
- ApplyT: 다중
Create()결과를 applicative하게 합성 → FinT LINQ 체인 시작 - 핵심: 이것은 “재검증”이 아니다 — 핸들러가 값 객체를 생성하는 것 자체가 도메인 검증이다
- 근거: “커맨드는 원시값을 운반하고, 핸들러가 값 객체를 생성한다” (Vladimir Khorikov)
FinT<IO, Response> usecase = from vos in ( ProductName.Create(request.Name), Money.Create(request.Price) ).ApplyT((name, price) => (Name: name, Price: price)) let product = Product.Create(vos.Name, vos.Price) from created in productRepository.Create(product) select new Response(...);Role 4: Presentation Validator — 선택적 UX 편의 기능
섹션 제목: “Role 4: Presentation Validator — 선택적 UX 편의 기능”- 책임: API 사용자에게 빠른 검증 피드백을 제공한다 (FluentValidation 포맷)
- 한계: 정규화된 결과를 폐기한다 (통과/실패만 확인)
- 원칙: 제거해도 도메인 정확성에 영향 없다
- 근거: “UI 검증은 UX를 위한 것이고, 도메인 검증은 정확성을 위한 것이다” (Microsoft .NET Architecture)
public sealed class Validator : AbstractValidator<Request>{ public Validator() { // Validate()를 재사용하여 통과/실패만 확인 — 정규화된 결과는 폐기 RuleFor(x => x.Name).MustSatisfyValidation(ProductName.Validate); RuleFor(x => x.Price).MustSatisfyValidation(Money.Validate); }}흐름 요약
섹션 제목: “흐름 요약”Request(원시값) → [Presentation Validator: UX 피드백] → Handler ↓ Create() via ApplyT (도메인 검증 + 정규화 + VO 생성) ↓ 비즈니스 로직 + 영속화참고 문서
섹션 제목: “참고 문서”- 값 객체: Union 타입 - Discriminated Union 패턴과 상태 전이
- 에러 시스템: 기초와 네이밍 - 에러 처리 기본 원칙과 네이밍 규칙
- 에러 시스템: Domain/Application 에러 - Domain/Application 에러 정의 및 테스트 패턴
- 단위 테스트 가이드
- LanguageExt
- Ardalis.SmartEnum