Vogen 라이브러리 소개 - 기본 요소 (int, decimal 등)를 도메인 개념으로 나타내는 값 객체로 표현

고객 아이디가 있다고 합시다. 고객 아이디는 int로 표현되어 있는데 소스 코드에서는 여러가지 이유 (인자 순서가 틀려서 버그 발생 등)로 CustomerId등으로 표현하고 싶을 때가 있습니다.

Vogen 라이브러리를 이용하면 다음의 방식으로 값 형태를 도메인 형태로 정의 내릴 수 있습니다.

[ValueObject<int>]
public partial struct CustomerId {
}

그러면 다음처럼 쓸 수 있게 됩니다.

CustomerId customerId = CustomerId.From(123);
SendInvoice(customerId);
...

public void SendInvoice(CustomerId customerId) { ... }

7개의 좋아요

Value Object 타입을 위한 라이브러리를 만들었는데 역시 사람 생각은 다 비슷해서 이미 존재… :rofl:
하지만 입맛에 안맞아서 결국 직접 만드네요.

바퀴의 재발명

2개의 좋아요

바퀴의 재발명을 포기하고 거인의 어깨에 올라타기로 했습니다. :sweat_smile:

3개의 좋아요

Dapper 직렬화기를 등록하는 소스생성기를 gpt4.5로 만들었습니다.
잘되는군요

using System.Collections.Immutable;
using Microsoft.CodeAnalysis;
using Microsoft.CodeAnalysis.CSharp.Syntax;
using Microsoft.CodeAnalysis.Text;
using System.Text;

[Generator]
public class ValueObjectGenerator : IIncrementalGenerator
{
    public void Initialize(IncrementalGeneratorInitializationContext context)
    {
        var provider = context.SyntaxProvider
            .ForAttributeWithMetadataName(
                "Vogen.ValueObjectAttribute`1",
                predicate: static (node, _) => node is RecordDeclarationSyntax,
                transform: static (ctx, _) => (RecordDeclarationSyntax)ctx.TargetNode)
            .Combine(context.CompilationProvider)
            .Collect();

        context.RegisterSourceOutput(provider, (spc, records) =>
        {
            foreach (var (record, compilation) in records)
            {
                var semanticModel = compilation.GetSemanticModel(record.SyntaxTree);
                var symbol = semanticModel.GetDeclaredSymbol(record);

                if (symbol is null)
                    continue;

                var recordName = symbol.Name;
                var ns = symbol.ContainingNamespace.IsGlobalNamespace ?
                    "" : $"namespace {symbol.ContainingNamespace};\n";

                var source = GenerateCode(ns, recordName);
                spc.AddSource($"{recordName}.g.cs", SourceText.From(source, Encoding.UTF8));
            }
        });
    }

    private static string GenerateCode(string ns, string recordName) => $$"""
        {{ns}}
        using Dapper;

        public readonly partial record struct {{recordName}}
        {
            static {{recordName}}()
            {
                SqlMapper.AddTypeHandler(new {{recordName}}.DapperTypeHandler());
            }
        }
        """;
}

2개의 좋아요

using CustomerId = System.int32;
이건 어떤가요?

1개의 좋아요

하고자 하는 목적이 너무 달라요

C#의 using은 타입의 alias를 지원하지만 다른 타입을 만드는 거는 아니에요.

using CustomerId = int;
using OrderId = int;

CustomerId customerId  = 1;
OrderId orderId  = 2;

customerId  = orderId; // 가능

OrderId 타입을 CustomerId 타입 변수에 넣고 싶지 않은데… C#은 언어에서 지원 하지 않지요.

F#은 언어에서 지원합니다 ㅋㅋ

type CustomerId = CustomerId of int
type OrderId = OrderId of int

let mutable customerId = CustomerId 1
let orderId = OrderId 1

customerId <- CustomerId 2

customerId <- orderId; // Error: This expression was expected to have type customerId but here has type OrderId
5개의 좋아요

F# 처럼 간단하진 않지만, C# 에는 readonly struct record 가 있습니다.

readonly record struct CustomerId(int Value);
readonly record struct OrderId(int Value);
var ci = new CustomerId(1);
CustomerId ci2 = default;

var equals = ci == ci2;
equals = ci.Equals(ci2);

// 에러
// ci.Value = 3; 
var oi = new OrderId(1);

// 에러
// ci = oi;

readonly record struct 는 EF Core 나 IEnumerable 에서 지원합니다.

class Customer
{
   public CustomerId Id { get; set; }
}
var customer = await db.Customers.FindAsync(ci);
var order = orders.FirstOrDefault(o => o.Id == oi);

아쉬운 점은 readonly 를 항상 붙여 줘야 해서 장황하다는 점입니다.
record struct 에 불변성을 부여하지 않아, 부랴 부랴 급조한 느낌?

1개의 좋아요

vogen에서도 readonly partial record struct을 사용합니다.
vogen의 장점은 직렬화 기능에 있습니다.

readonly record struct A(int Value);

A a = new("value");

a 를 json 직렬화 하면 object 타입을 반환하게 되는데요.

{ "Value" : "value" }

하고 싶은건 문자열로 직열화 하고 싶은 거지요.

[ValueObject<string>(Conversions.SystemTextJson)]
readonly partial record struct A;

A a = new("value");

직렬화 하면 다음 결과를 반환합니다.

"value"

https://stevedunn.github.io/Vogen/using-with-json.html

제가 본 Vogen의 예제 코드는 대부분 partial readonly struct 였습니다.
그래서, readonly record struct가 대부분의 기능이 커버가 가능하다는 점을 말씀 드리려 했습니다.

직렬화에 관해서는 그런 변환은 Vogen의 취지와 안 맞는 거 아닌가 하는 생각이 드네요.

class Strong { public A A { get; set; } }
class Primitive { public string A { get; set; }}

var strong = JsonSerializer.Deserialize<Strong>(json);
var aEquals = strong.A == myA;

// 문제 없음.
var primitive = JsonSerializer.Deserialize<Primitive>(json);
aEquals = primitive.A == "value";

직렬화 문자열은 타입을 포함하지 않으므로 비교를 사용하는 경우를 피해야 합니다.

보통 직렬화가 필요한 부분은 웹서비스에서는 web endpoint와 persistance 2군데 혹은 message queue가 있다면 3군데 정도이고 그 외에는 사용을 피하는 편이 좋지요

VO는 Domain Model을 설계하는 타입이고 primitive type 변환 없이 직렬화 할 수 있으면 서비스 레이어의 강형 타입 사용으로 논리적 오류를 최소화 하는 비즈니스를 작성 할 수 있지요.

readonly record struct 도 C# 10 표준에서 큰 진전은 보였는데요. 그 이후 지지부진해서… 아마 설계 하신분은 퇴사 했을 거 같네요.

글쎄요,

말씀하신 용도 외에 직렬화 사용을 피해야 하는 것과, 역직렬화된 값 객체의 (등가) 비교를 피해야하는 것에는 동의하기 어렵네요.

namespace Shared;
public record struct Point(int X, int Y)
{
   public static Point Origin => new Point(0, 0);
}
var points = Deserialize<Point>(serialized);
var hasOrigin = points.Any(p => p == Point.Zero);

Vogen 의 도메인(단일 값에 대한 Primitive Obsession 해결)에서는 그렇게 할 수도 있겠지만, 일반적으로도 그렇게 해야 한다는 주장은 무리가 있어 보입니다.

용처가 다른거 같습니다.

DDD가 아니라면 그런 규칙을 지킬 이유는 없어요.

뭔가 단순한 값타입 구분 이상의 기능이 있네요

const도 아닌 런타임에서 계산되도록 하드코딩된 문자열이 역직렬화 할때 문제가 생길거라는걸 소스분석단에서 파악한다음 컴파일오류를 내준다는게 신기하기도 하고 선넘는거같기도 하고…

2개의 좋아요