본문으로 건너뛰기

5분 빠른시작

5분 안에 Functorium으로 상품(Product) 도메인 모델을 만들어 봅니다. Naive 코드에서 무엇이 깨지는지 확인하고, DDD 패턴으로 코드가 비즈니스 규칙을 말하도록 만듭니다.


온라인 상점에서 상품을 관리한다고 가정합니다. 가장 단순한 코드는 이렇습니다:

// Naive 코드 — 모든 값이 허용됩니다
public class Product
{
public string Name { get; set; } = "";
public decimal Price { get; set; }
}

이 코드는 다음을 모두 허용합니다:

  • Name = "" — 빈 상품명이 DB에 저장됩니다
  • Price = -500m — 음수 가격이 카탈로그에 노출됩니다
  • NameDescription이 모두 string — 컴파일러가 구분할 수 없습니다
  • new Product() — 어디서든 검증 없이 생성할 수 있습니다

DDD의 Value Object와 AggregateRoot는 이 모든 문제를 타입 시스템으로 차단합니다.


코드를 작성하기 전에 비즈니스 용어를 정의합니다. 이 용어는 코드에서 클래스명과 메서드명에 그대로 반영됩니다.

한글영문 (코드)정의비즈니스 규칙
상품명ProductName상품의 이름100자 이하, 빈 문자열 불가
가격Money양수 금액0원, 음수 불가
상품Product판매 가능한 품목검증된 값으로만 생성
상품 생성됨Product.CreatedEvent상품 생성 사실생성 시 자동 발행

  1. 프로젝트 생성

    Terminal window
    dotnet new classlib -n MyShop.Domain
  2. NuGet 패키지 설치

    Terminal window
    cd MyShop.Domain
    dotnet add package Functorium
    dotnet add package Functorium.SourceGenerators

4. Value Object — 규칙을 타입으로

섹션 제목: “4. Value Object — 규칙을 타입으로”

Value Object는 Naive 코드의 stringdecimal비즈니스 규칙이 내장된 타입으로 교체합니다. Create() 메서드는 예외 대신 Fin<T>(성공 또는 에러)를 반환합니다.

ProductName — “상품명은 비어있으면 안 된다”

섹션 제목: “ProductName — “상품명은 비어있으면 안 된다””

비즈니스 규칙: 상품명은 null/빈 문자열이 불가하고, 100자 이하여야 하며, 앞뒤 공백은 자동 정리됩니다.

using Functorium.Domains.ValueObjects;
public sealed class ProductName : SimpleValueObject<string>
{
public const int MaxLength = 100;
private ProductName(string value) : base(value) { }
// 팩토리: 검증 후 생성. 예외 없이 Fin<ProductName> 반환
public static Fin<ProductName> Create(string? value) =>
CreateFromValidation(Validate(value), v => new ProductName(v));
// 검증 규칙 체인: Null → Empty → MaxLength → Normalize
public static Validation<Error, string> Validate(string? value) =>
ValidationRules<ProductName>
.NotNull(value) // 실패 시 "DomainErrors.ProductName.Null"
.ThenNotEmpty() // 실패 시 "DomainErrors.ProductName.Empty"
.ThenMaxLength(MaxLength) // 실패 시 "DomainErrors.ProductName.TooLong"
.ThenNormalize(v => v.Trim());
// ORM 복원용 (검증 생략)
public static ProductName CreateFromValidated(string value) => new(value);
public static implicit operator string(ProductName name) => name.Value;
}

각 검증 단계가 어떤 비즈니스 규칙을 강제하는지 추적할 수 있습니다:

비즈니스 규칙코드위반 시 에러
null 불가.NotNull(value)DomainErrors.ProductName.Null
빈 문자열 불가.ThenNotEmpty()DomainErrors.ProductName.Empty
100자 이하.ThenMaxLength(MaxLength)DomainErrors.ProductName.TooLong
공백 정리.ThenNormalize(v => v.Trim())(항상 성공)

Money — “가격은 양수여야 한다”

섹션 제목: “Money — “가격은 양수여야 한다””

비즈니스 규칙: 가격은 반드시 양수여야 합니다. 0원 상품, 음수 가격은 존재할 수 없습니다.

using Functorium.Domains.ValueObjects;
public sealed class Money : ComparableSimpleValueObject<decimal>
{
public static readonly Money Zero = new(0m);
private Money(decimal value) : base(value) { }
public static Fin<Money> Create(decimal value) =>
CreateFromValidation(Validate(value), v => new Money(v));
public static Validation<Error, decimal> Validate(decimal value) =>
ValidationRules<Money>.Positive(value); // 실패 시 "DomainErrors.Money.NotPositive"
public static Money CreateFromValidated(decimal value) => new(value);
public static implicit operator decimal(Money money) => money.Value;
}

Money.Create(-500m)은 실패합니다. Naive 코드에서는 -500이 그대로 DB에 저장되었을 것입니다.


5. AggregateRoot — 상태 변경을 이벤트로

섹션 제목: “5. AggregateRoot — 상태 변경을 이벤트로”

왜 AggregateRoot인가? Product는 일관성 경계의 루트입니다. 상품 생성은 반드시 CreatedEvent와 함께 발생해야 하며, 외부에서 new Product()로 검증을 우회할 수 없어야 합니다.

[GenerateEntityId] 어트리뷰트로 Ulid 기반 ID 타입이 자동 생성됩니다.

using Functorium.Domains.Entities;
using Functorium.Domains.Events;
using Functorium.SourceGenerators;
[GenerateEntityId] // → ProductId 구조체 자동 생성 (Ulid 기반)
public sealed class Product : AggregateRoot<ProductId>
{
#region Domain Events
// 도메인 이벤트: Aggregate 내부에 중첩 record로 정의
public sealed record CreatedEvent(
ProductId ProductId, ProductName Name, Money Price) : DomainEvent;
#endregion
// Value Object 속성 (private set으로 불변성 보장)
public ProductName Name { get; private set; }
public Money Price { get; private set; }
public DateTime CreatedAt { get; private set; }
// private 생성자: 외부에서 new 금지
private Product(ProductId id, ProductName name, Money price) : base(id)
{
Name = name;
Price = price;
CreatedAt = DateTime.UtcNow;
}
// 팩토리 메서드: 검증된 Value Object를 받아 생성 + 이벤트 발행
public static Product Create(ProductName name, Money price)
{
var product = new Product(ProductId.New(), name, price);
product.AddDomainEvent(new CreatedEvent(product.Id, name, price));
return product;
}
}

Naive 코드와 비교하면 각 패턴이 어떤 문제를 차단하는지 명확합니다:

패턴코드Naive 코드의 문제
private 생성자private Product(...)new Product()로 검증 우회 가능
팩토리 메서드Product.Create(name, price)검증된 VO만 받아 잘못된 값 차단
도메인 이벤트AddDomainEvent(new CreatedEvent(...))상태 변경 사실이 기록되지 않음
Ulid IDProductId.New()string으로 아무 문자열이나 ID가 됨

도메인이 정의하고 인프라가 구현하는 포트입니다. 도메인 코드가 DB 기술에 의존하지 않습니다.

using Functorium.Domains.Repositories;
// IRepository<TAggregate, TId>는 기본 CRUD를 제공
public interface IProductRepository : IRepository<Product, ProductId>
{
// 도메인 고유 쿼리가 필요하면 여기에 추가
}

IRepository<T, TId>가 제공하는 기본 메서드:

메서드반환 타입설명
Create(aggregate)FinT<IO, T>생성
GetById(id)FinT<IO, T>단건 조회
Update(aggregate)FinT<IO, T>수정
Delete(id)FinT<IO, int>삭제

FinT<IO, T>사이드 이펙트가 있는 연산의 결과를 표현합니다. 성공 시 T, 실패 시 Error를 반환하며, 예외를 발생시키지 않습니다.


7. Command Usecase — 흐름을 조율하다

섹션 제목: “7. Command Usecase — 흐름을 조율하다”

Command Usecase는 쓰기 연산을 조율하는 Application Layer 로직입니다. FinT<IO, T> LINQ 합성으로 Repository 호출을 체이닝합니다.

using Functorium.Applications.Usecases;
public sealed class CreateProductCommand
{
// Request / Response (CQRS)
public sealed record Request(string Name, decimal Price)
: ICommandRequest<Response>;
public sealed record Response(string ProductId, string Name, decimal Price);
// Usecase Handler
public sealed class Usecase(IProductRepository productRepository)
: ICommandUsecase<Request, Response>
{
public async ValueTask<FinResponse<Response>> Handle(
Request request, CancellationToken cancellationToken)
{
// 파이프라인 Validator가 검증 완료. Create()는 정규화 목적.
var name = ProductName.Create(request.Name).Unwrap();
var price = Money.Create(request.Price).Unwrap();
// 2. Aggregate 생성
var product = Product.Create(name, price);
// 3. Repository 호출 (FinT<IO, T> LINQ 합성)
FinT<IO, Response> usecase =
from created in productRepository.Create(product)
select new Response(
created.Id.ToString(),
created.Name,
created.Price);
// 4. IO 모나드 실행 → FinResponse 변환
Fin<Response> response = await usecase.Run().RunAsync();
return response.ToFinResponse();
}
}
}

핵심 흐름 — 각 단계가 어떤 비즈니스 규칙을 강제하는가:

  1. ProductName.Create(request.Name)“상품명은 비어있으면 안 된다” 검증. 실패 시 DomainErrors.ProductName.Empty
  2. Money.Create(request.Price)“가격은 양수여야 한다” 검증. 실패 시 DomainErrors.Money.NotPositive
  3. Product.Create(name, price) → 검증된 VO로만 Aggregate 생성 + CreatedEvent 발행
  4. UsecaseTransactionPipeline이 SaveChanges + 이벤트 발행을 자동 처리

처음에 본 Naive 코드의 문제가 어떻게 해결되었는지 되돌아봅니다:

Naive 코드의 문제DDD + Functorium의 해결보장 수준
Name = "" (빈 상품명)ProductName.Create("")Fail생성 시점 차단
Price = -500m (음수 가격)Money.Create(-500m)Fail생성 시점 차단
new Product() (검증 우회)private 생성자 + 팩토리 메서드컴파일 타임 차단
상태 변경 추적 불가AddDomainEvent(CreatedEvent)생성 시 자동 발행
string ID 혼동ProductId (Ulid 기반 타입)컴파일 타임 차단

코드가 비즈니스 규칙을 말합니다. ProductName은 “상품명”이고, Money는 “가격”이며, Product.Create는 “상품 생성”입니다. Naive 코드의 stringdecimal은 아무 의미도 전달하지 않았습니다.



이 가이드의 전체 코드는 리포지토리에 포함되어 있습니다.

Terminal window
# 빌드
dotnet build Docs.Site/src/content/docs/quickstart/quickstart.slnx
# 테스트 (10개)
dotnet test --solution Docs.Site/src/content/docs/quickstart/quickstart.slnx