본문으로 건너뛰기

설계 철학

Functorium의 설계는 세 가지 원칙에 기반합니다. 각 원칙이 왜 필요한지, 그리고 Functorium이 이를 어떻게 구현하는지 설명합니다.

원칙: 모든 핵심 비즈니스 로직은 도메인 모델 안에 위치하며, 공통 언어(Ubiquitous Language)가 코드, 문서, 운영 지표에까지 일관되게 반영됩니다.

왜 필요한가: 비즈니스 규칙이 Controller나 Service에 흩어지면, 규칙 변경 시 여러 곳을 수정해야 하고 일관성이 깨집니다. 도메인 모델이 규칙의 단일 원천(Single Source of Truth)이 되면, 변경은 한 곳에서만 발생합니다.

Functorium 구현 방식: Value Object(값 기반 동등성 + 불변성), Entity/AggregateRoot(Ulid ID + 이벤트), DomainError(구조화된 에러 코드), Domain Event(Mediator Pub/Sub)로 도메인 모델을 구성합니다.

Value Object — 값 기반 동등성과 불변성

섹션 제목: “Value Object — 값 기반 동등성과 불변성”

Value Object는 참조가 아닌 값으로 비교됩니다. GetEqualityComponents()가 반환하는 구성 요소가 동일하면 같은 객체로 취급됩니다.

public abstract class AbstractValueObject : IValueObject, IEquatable<AbstractValueObject>
{
protected abstract IEnumerable<object> GetEqualityComponents();
// 값 기반 동등성, 캐시된 해시코드, ORM 프록시 처리
}

검증 규칙은 Value Object 한 곳에서 정의하고, ValidationRules<T> 체이닝으로 조건별 에러 코드를 자동 생성합니다. 다음은 이메일 주소를 표현하는 값 객체입니다. Create가 반환하는 Fin<Email>은 유효한 입력이면 Email 인스턴스를, 유효하지 않으면 에러 코드를 담습니다. 예외는 발생하지 않으며, 검증 로직이 분산될 여지가 구조적으로 제거됩니다.

// 검증 실패 시 조건별 DomainError 자동 생성:
// "DomainErrors.Email.Null", "DomainErrors.Email.Empty",
// "DomainErrors.Email.TooLong", "DomainErrors.Email.InvalidFormat"
public sealed partial class Email : SimpleValueObject<string>
{
public const int MaxLength = 320;
private Email(string value) : base(value) { }
public static Fin<Email> Create(string? value) =>
CreateFromValidation(Validate(value), v => new Email(v));
// 각 검증 조건이 실패하면 조건에 대응하는 에러 코드가 자동 생성됩니다.
// NotNull → "DomainErrors.Email.Null"
// NotEmpty → "DomainErrors.Email.Empty"
// MaxLength→ "DomainErrors.Email.TooLong"
// Matches → "DomainErrors.Email.InvalidFormat"
// 복합 Value Object는 Apply 패턴으로 복수 필드를 병렬 검증하여
// 실패한 모든 에러를 한꺼번에 수집합니다.
public static Validation<Error, string> Validate(string? value) =>
ValidationRules<Email>
.NotNull(value) // → "DomainErrors.Email.Null"
.ThenNotEmpty() // → "DomainErrors.Email.Empty"
.ThenNormalize(v => v.Trim().ToLowerInvariant())
.ThenMaxLength(MaxLength) // → "DomainErrors.Email.TooLong"
.ThenMatches(EmailRegex(), "Invalid email format"); // → "DomainErrors.Email.InvalidFormat"
}

Entity / AggregateRoot — Ulid 기반 ID와 도메인 이벤트

섹션 제목: “Entity / AggregateRoot — Ulid 기반 ID와 도메인 이벤트”

Entity는 Ulid 기반 ID로 식별되고, AggregateRoot는 도메인 이벤트를 관리합니다.

public interface IEntityId<T> : IEquatable<T>, IComparable<T>
where T : struct, IEntityId<T>
{
Ulid Value { get; }
static abstract T New();
static abstract T Create(Ulid id);
static abstract T Create(string id);
}
public abstract class AggregateRoot<TId> : Entity<TId>, IDomainEventDrain
where TId : struct, IEntityId<TId>
{
protected void AddDomainEvent(IDomainEvent domainEvent);
public void ClearDomainEvents();
}

DomainError — 구조화된 에러 코드

섹션 제목: “DomainError — 구조화된 에러 코드”

에러 코드는 타입 이름에서 자동으로 생성됩니다. 이 구조 덕분에 에러 코드가 코드, 로그, 운영 대시보드에서 동일한 이름으로 나타납니다.

// "DomainErrors.Email.Empty"
DomainError.For<Email>(new Empty(), value, "Email cannot be empty");
// "DomainErrors.Password.TooShort"
DomainError.For<Password>(new TooShort(MinLength: 8), value, "Password too short");

도메인 이벤트는 Mediator 기반 Pub/Sub과 이벤트 추적을 통합합니다. CorrelationIdCausationId로 이벤트 인과 관계를 추적할 수 있습니다.

public interface IDomainEvent : INotification
{
DateTimeOffset OccurredAt { get; }
Ulid EventId { get; }
string? CorrelationId { get; }
string? CausationId { get; }
}

원칙: 핵심 도메인 로직은 순수 함수로 구성됩니다. 입력이 동일하면 항상 동일한 출력을 반환하고, 사이드 이펙트는 타입 수준에서 명시됩니다.

왜 필요한가: 순수 함수는 입력이 같으면 항상 같은 출력을 반환합니다. 이는 테스트를 단순하게 만들고, 코드의 동작을 예측 가능하게 합니다. 예외 기반 흐름 제어는 암묵적 분기를 만들어 코드 경로를 추적하기 어렵게 합니다. Fin<T>FinT<IO, T>를 사용하면 모든 실패 경로가 타입에 드러나고, LINQ 합성으로 도메인 흐름을 구성할 수 있습니다.

Functorium 구현 방식: Fin<T>로 예외 대신 명시적 결과 타입을 사용하고, FinT<IO, T>로 사이드 이펙트를 추적하며, CQRS로 Command/Query를 분리합니다.

Fin<T>, FinT<IO, T> — 명시적 결과 타입

섹션 제목: “Fin<T>, FinT<IO, T> — 명시적 결과 타입”

Repository는 FinT<IO, T>를 반환하여 사이드 이펙트를 타입 수준에서 표현합니다. Fin<T>는 순수한 성공/실패를, FinT<IO, T>는 IO 사이드 이펙트가 포함된 성공/실패를 나타냅니다.

public interface IRepository<TAggregate, TId> : IObservablePort
where TAggregate : AggregateRoot<TId>
where TId : struct, IEntityId<TId>
{
FinT<IO, TAggregate> Create(TAggregate aggregate);
FinT<IO, TAggregate> GetById(TId id);
FinT<IO, TAggregate> Update(TAggregate aggregate);
FinT<IO, Unit> Delete(TId id);
}

Command(쓰기)와 Query(읽기) 경로를 분리하고, FinResponse<T>로 결과를 통합합니다. Command는 IRepository(Domain 계층, EF Core, Aggregate Root 반환), Query는 IQueryPort(Application 계층, Dapper, DTO 직접 프로젝션)로 구현됩니다.

public interface ICommandRequest<TSuccess> : ICommand<FinResponse<TSuccess>> { }
public interface ICommandUsecase<in TCommand, TSuccess>
: ICommandHandler<TCommand, FinResponse<TSuccess>>
where TCommand : ICommandRequest<TSuccess> { }

모든 유스케이스는 Pipeline을 자동으로 통과합니다. Command와 Query는 경로 특성에 따라 서로 다른 Pipeline 구성을 갖습니다.

Command (7단계): Metrics → Tracing → Logging → Validation → Exception → Transaction → Custom → Handler

Query (8단계): Metrics → Tracing → Logging → Validation → Caching → Exception → Custom → Handler

이 순서에는 이유가 있습니다:

  1. Metrics와 Tracing이 가장 먼저 실행되어, 후속 Pipeline에서 발생하는 모든 활동을 포함한 전체 실행 시간을 측정합니다.
  2. Logging은 Validation 전에 위치하여, 검증 실패를 포함한 모든 요청을 기록합니다.
  3. Validation은 비즈니스 로직 실행 전에 입력을 검증하여, 불필요한 데이터베이스 라운드트립을 방지합니다.
  4. Caching은 Query 전용입니다. Exception 전에 위치하여, 캐시 히트 시 트랜잭션 없이 즉시 반환합니다.
  5. Exception은 예외를 구조화된 에러로 변환하여, 후속 Pipeline의 롤백을 깔끔하게 처리합니다.
  6. Transaction은 Command 전용입니다. 실제 비즈니스 로직만을 트랜잭션 경계로 감싸고, SaveChanges와 도메인 이벤트 발행을 자동 처리합니다.
  7. Custom은 프로젝트별 확장 지점입니다. 감사 로그, 권한 검사 등 프로젝트 고유 횡단 관심사를 삽입할 수 있습니다.

원칙: 운영 안정성은 배포 이후가 아니라 설계 단계에서 고려됩니다.

왜 필요한가: 관측성을 사후에 추가하면 일관성이 깨지고 누락이 발생합니다. 장애 발생 시 “이 유스케이스에는 로그가 없다”는 상황을 피하려면, 모든 유스케이스에 자동으로 계측이 적용되어야 합니다. Functorium은 이 문제를 세 가지 계층의 자동 관측으로 해결합니다.

3-Pillar 자동 계측 — 코드 한 줄 없이 모든 것이 기록된다

섹션 제목: “3-Pillar 자동 계측 — 코드 한 줄 없이 모든 것이 기록된다”

모든 Command/Query가 Pipeline을 통과하면, 요청부터 응답까지 Logging, Metrics, Tracing 3개 Pillar에 동일한 필드가 일관되게 기록됩니다. 개발자가 로그 코드를 직접 작성할 필요가 없습니다.

Field/TagLoggingMetricsTracing의미
request.layer아키텍처 레이어 ("application")
request.category.typeCQRS 타입 ("command", "query", "event")
request.handler.namehandler 클래스 이름
response.status응답 상태 ("success", "failure")
response.elapsed경과 시간(초). Metrics는 duration Histogram 사용
error.type오류 분류 ("expected", "exceptional", "aggregate")
error.code도메인 특화 오류 코드

이 필드 체계는 Adapter 레이어에도 동일하게 적용됩니다. Repository, QueryAdapter, ExternalService 등 [GenerateObservablePort]로 감싸진 모든 포트가 같은 request.* / response.* / error.* 구조로 기록됩니다. request.layer"adapter"로 바뀔 뿐, 나머지 필드 이름은 동일합니다.

에러 분류도 자동입니다. 비즈니스 규칙 위반("재고 부족")은 expected, 시스템 장애(NullReferenceException)는 exceptional, 복합 검증 실패는 aggregate로 자동 분류됩니다. 운영팀이 Seq나 Grafana에서 error.type = "expected"로 필터링하면 비즈니스 오류만, "exceptional"로 필터링하면 시스템 장애만 조회할 수 있습니다.

DomainEvent 관측 — 이벤트 발행과 처리도 빠짐없이

섹션 제목: “DomainEvent 관측 — 이벤트 발행과 처리도 빠짐없이”

DomainEvent Publisher와 Handler도 3-Pillar로 자동 관측됩니다. Publisher는 request.event.count(배치 이벤트 수)를, Handler는 request.event.type(이벤트 타입명)과 request.event.id(이벤트 고유 ID)를 기록합니다. 부분 실패(Partial Failure) 시에는 response.event.success_countresponse.event.failure_count가 자동으로 분리 기록되어, 어떤 이벤트가 실패했는지 추적할 수 있습니다.

ctx.* — 비즈니스 컨텍스트를 3-Pillar에 동시 전파

섹션 제목: “ctx.* — 비즈니스 컨텍스트를 3-Pillar에 동시 전파”

프레임워크가 자동 기록하는 request.* / response.* 필드 외에, 비즈니스 고유 속성(고객 ID, 주문 금액, 지역 코드 등)도 관측에 포함해야 합니다. Functorium은 CtxEnricher Source Generator가 Request/Response record의 공개 프로퍼티를 자동 감지하여 ctx.{snake_case} 필드로 변환합니다.

public sealed record Request(
string CustomerId, // → ctx.place_order_command.request.customer_id (L+T)
[CtxTarget(CtxPillar.All)] bool IsExpress, // → ctx.place_order_command.request.is_express (L+T+M)
[CtxTarget(CtxPillar.Default | CtxPillar.MetricsValue)]
int ItemCount // → ctx.place_order_command.request.item_count (L+T+Histogram)
) : ICommandRequest<Response>;

[CtxTarget] 어트리뷰트로 어떤 Pillar에 전파할지 세밀하게 제어합니다:

Pillar기본설명
LoggingSerilog LogContext.PushProperty로 구조화 로그 필드 출력
TracingActivity.Current?.SetTag으로 Span Attribute 출력
MetricsTagopt-inCounter/Histogram의 차원으로 추가 (저카디널리티만)
MetricsValueopt-in수치 필드를 Histogram instrument에 기록

[CtxIgnore]는 디버그 전용 필드를 모든 Pillar에서 제외하고, [CtxRoot]는 인터페이스 레벨의 공통 필드(예: RegionCode)를 ctx.region_code로 승격합니다. DomainEvent Handler에도 DomainEventCtxEnricherGenerator가 동일한 메커니즘을 적용합니다.

IObservablePort — Adapter 포트 자동 계측

섹션 제목: “IObservablePort — Adapter 포트 자동 계측”

모든 외부 의존성은 IObservablePort를 구현하여 관측 가능한 포트로 추상화됩니다.

public interface IObservablePort
{
string RequestCategory { get; } // "repository", "query", "external" 등
}

[GenerateObservablePort] 어트리뷰트를 선언하면, Source Generator가 원본 구현체를 감싸는 Observable{ClassName} wrapper를 자동 생성합니다. wrapper는 메서드 진입/종료 시점의 Tracing Span, 실행 시간 Histogram, 구조화된 Logging을 투명하게 제공합니다.

[GenerateObservablePort] // → ObservableOrderRepository 자동 생성
public class OrderRepository : IRepository<Order, OrderId> { ... }

DI 컨테이너에 Observable wrapper를 등록하면, 비즈니스 코드 수정 없이 모든 포트 호출이 자동 관측됩니다. Application 레이어의 유스케이스와 Adapter 레이어의 포트가 동일한 Field/Tag 네이밍(request.*, response.*, error.*)을 사용하므로, 하나의 대시보드 쿼리로 전체 요청 흐름을 추적할 수 있습니다.

5개 Source Generator — 컴파일 시점 자동 생성

섹션 제목: “5개 Source Generator — 컴파일 시점 자동 생성”

Functorium은 5개의 Source Generator로 반복적인 브릿지 코드를 컴파일 시점에 자동 생성합니다.

Generator트리거생성 결과
[GenerateEntityId]Entity 클래스{Entity}Id 구조체 + ValueConverter + ValueComparer
[GenerateObservablePort]Adapter 구현체Observable{Class} wrapper (Tracing + Logging + Metrics)
CtxEnricherGeneratorICommandRequest/IQueryRequest 구현{Request}CtxEnricher (ctx.* 3-Pillar 자동 enrichment)
DomainEventCtxEnricherGeneratorIDomainEventHandler 구현{Event}CtxEnricher (이벤트 ctx.* enrichment)
[UnionType]abstract partial recordMatch<T>, Switch, Is{Case}, As{Case}() 메서드

Functorium은 세 가지 경로로 관측성을 자동화합니다.

관측 경로대상메커니즘기록 내용
Usecase Pipeline모든 Command/QueryMediator IPipelineBehaviorrequest/response 필드 + ctx.* + 에러 분류
Observable PortRepository, QueryAdapter, ExternalService[GenerateObservablePort] Source Generator동일한 request/response 필드 체계
DomainEventPublisher + HandlerObservableDomainEventPublisher이벤트 타입/수량 + 부분 실패 추적
Pipeline역할적용
CtxEnricherPipelinectx.* 비즈니스 컨텍스트 3-Pillar 전파공통 (최선두)
UsecaseMetricsPipeline유스케이스 메트릭 자동 수집공통
UsecaseTracingPipeline분산 추적 컨텍스트 전파공통
UsecaseLoggingPipeline구조화된 로그 자동 기록공통
UsecaseValidationPipelineFluentValidation 기반 입력 검증공통
UsecaseCachingPipelineICacheable 요청 캐싱Query 전용
UsecaseExceptionPipeline예외 → 구조화된 에러 변환공통
UsecaseTransactionPipeline트랜잭션 경계 + 도메인 이벤트 발행Command 전용

세 가지 설계 철학은 독립적으로 존재하는 것이 아니라, 하나의 유스케이스 안에서 자연스럽게 결합됩니다. 여기서는 “고객 생성”이라는 하나의 시나리오를 따라가면서, 도메인 중심 설계가 어떻게 값 객체를 보호하고, 함수형 아키텍처가 어떻게 유스케이스를 조립하며, Observability가 어떻게 자동으로 적용되는지 확인합니다.

도메인 계층 — Always-valid Value Object

섹션 제목: “도메인 계층 — Always-valid Value Object”

고객을 생성하려면 먼저 이메일 주소가 유효해야 합니다. 앞서 살펴본 Email 값 객체는 생성 시점에 모든 검증을 완료하므로, 유효하지 않은 이메일이 시스템에 진입하는 경로 자체가 존재하지 않습니다. 각 검증 조건이 실패하면 "DomainErrors.Email.Empty", "DomainErrors.Email.TooLong" 같은 에러 코드가 자동 생성되어, 로그에서 바로 원인을 식별할 수 있습니다.

복합 Value Object(예: 고객 정보)는 Apply 패턴으로 여러 필드를 병렬 검증합니다. 이름이 비어 있고 이메일 형식이 잘못된 경우, 두 에러가 모두 수집되어 한 번의 응답으로 반환됩니다. 개별 필드를 순차적으로 검증하여 첫 번째 에러에서 멈추는 방식과 달리, 사용자는 모든 문제를 한꺼번에 확인하고 수정할 수 있습니다.

애플리케이션 계층 — CQRS Usecase

섹션 제목: “애플리케이션 계층 — CQRS Usecase”

도메인 객체가 준비되면, 유스케이스가 이를 조립합니다. Command(쓰기) 경로에서는 FinT<IO, T>from ... in ... select 구문으로 사이드 이펙트를 합성합니다. 이메일 중복 확인, 고객 생성, 응답 변환이 하나의 선언적 파이프라인으로 표현됩니다. 비즈니스 규칙 위반 시에는 ApplicationError"ApplicationErrors.CreateCustomerCommand.AlreadyExists" 형식의 에러 코드를 자동 생성합니다.

Query(읽기) 경로에서는 Aggregate를 재구성하지 않고 Dapper로 DTO를 직접 프로젝션합니다. 읽기와 쓰기가 각각 최적의 기술을 사용하면서도, 결과 타입은 FinResponse<T>로 통일됩니다.

// ── Command 경로 ──────────────────────────────────────────────
// ICustomerRepository : IRepository<Customer, CustomerId>
// → Domain 계층에 정의된 포트. Aggregate Root 단위로 영속화
// → 반환 타입: FinT<IO, Customer> (도메인 객체)
// → 구현: EF Core (변경 추적, 트랜잭션)
using static Functorium.Applications.Errors.ApplicationErrorType;
public sealed class CreateCustomerCommand
{
public sealed record Request(string Name, string Email, decimal CreditLimit)
: ICommandRequest<Response>;
public sealed record Response(string CustomerId, string Name, string Email);
public sealed class Usecase(ICustomerRepository repository)
: ICommandUsecase<Request, Response>
{
public async ValueTask<FinResponse<Response>> Handle(
Request request, CancellationToken token)
{
FinT<IO, Response> usecase =
from exists in repository.Exists(new CustomerEmailSpec(email))
from _ in guard(!exists, ApplicationError.For<CreateCustomerCommand>(
new AlreadyExists(), // ApplicationErrorType sealed record
request.Email, // 현재 값
$"Email already exists: '{request.Email}'"))
from customer in repository.Create(newCustomer)
select new Response(
customer.Id.ToString(), customer.Name, customer.Email);
Fin<Response> result = await usecase.Run().RunAsync();
return result.ToFinResponse();
}
}
}
// ── Query 경로 ────────────────────────────────────────────────
// ICustomerDetailQuery : IQueryPort
// → Application 계층에 정의된 포트. Aggregate 재구성 없이 DTO로 직접 프로젝션
// → 반환 타입: FinT<IO, CustomerDetailDto> (읽기 전용 DTO)
// → 구현: Dapper (경량 SQL 매핑)
public sealed class GetCustomerByIdQuery
{
public sealed record Request(string CustomerId) : IQueryRequest<Response>;
public sealed record Response(string CustomerId, string Name, string Email);
public sealed class Usecase(ICustomerDetailQuery query)
: IQueryUsecase<Request, Response>
{
public async ValueTask<FinResponse<Response>> Handle(
Request request, CancellationToken token)
{
FinT<IO, Response> usecase =
from dto in query.GetById(CustomerId.Create(request.CustomerId))
select new Response(dto.CustomerId, dto.Name, dto.Email);
Fin<Response> result = await usecase.Run().RunAsync();
return result.ToFinResponse();
}
}
}

Adapter 계층 — 두 가지 자동 관측 경로

섹션 제목: “Adapter 계층 — 두 가지 자동 관측 경로”

위 고객 생성 유스케이스를 실행하면, 개발자가 관측 코드를 한 줄도 작성하지 않았음에도 구조화된 로그가 자동으로 출력됩니다. 이것이 “Observability by Design”의 실체입니다.

Usecase Pipeline(IPipelineBehavior)은 모든 Command/Query의 외부 입출력을 계측하고, Source Generator([GenerateObservablePort])는 Repository·QueryAdapter 등의 내부 입출력을 계측합니다. 두 경로가 결합되어 요청의 전체 흐름이 자동으로 추적됩니다.

// ── 요청 (EventId: 1001 application.request) ──────────────────
{
"Level": "Information",
"EventId": { "Id": 1001, "Name": "application.request" },
"MessageTemplate": "{request.layer} {request.category}.{request.category.type} {request.handler}.{request.handler.method} requesting with {@request.message}",
"request.layer": "application",
"request.category": "usecase",
"request.category.type": "command",
"request.handler": "CreateCustomerCommand",
"request.handler.method": "Handle",
"@request.message": {
"Name": "Alice",
"Email": "alice@example.com",
"CreditLimit": 1000.00
}
}
// ── 성공 응답 (EventId: 1002 application.response.success) ────
{
"Level": "Information",
"EventId": { "Id": 1002, "Name": "application.response.success" },
"MessageTemplate": "... responded {response.status} in {response.elapsed:0.0000} s with {@response.message}",
"request.layer": "application",
"request.category": "usecase",
"request.category.type": "command",
"request.handler": "CreateCustomerCommand",
"request.handler.method": "Handle",
"response.status": "success",
"response.elapsed": 0.0234,
"@response.message": {
"CustomerId": "01JN...",
"Name": "Alice",
"Email": "alice@example.com"
}
}
// ── 실패 응답: 예상된 에러 (EventId: 1003 application.response.warning) ──
{
"Level": "Warning",
"EventId": { "Id": 1003, "Name": "application.response.warning" },
"MessageTemplate": "... responded {response.status} in {response.elapsed:0.0000} s with {error.type}:{error.code} {@error}",
"request.layer": "application",
"request.category": "usecase",
"request.category.type": "command",
"request.handler": "CreateCustomerCommand",
"request.handler.method": "Handle",
"response.status": "failure",
"response.elapsed": 0.0012,
"error.type": "expected",
"error.code": "ApplicationErrors.CreateCustomerCommand.AlreadyExists",
"@error": {
"ErrorCode": "ApplicationErrors.CreateCustomerCommand.AlreadyExists",
"ErrorCurrentValue": "alice@example.com",
"Message": "Email already exists: 'alice@example.com'"
}
}