본문으로 건너뛰기

Adapter 구현

이 문서는 Port 인터페이스의 구현체인 Adapter를 유형별로 구현하는 가이드입니다. Port 정의는 12-ports.md, Pipeline 생성과 DI 등록은 14a-adapter-pipeline-di.md을 참조하세요.

“InMemory 구현에서 EF Core 구현으로 전환할 때 Usecase 코드를 수정해야 하는가?” “외부 HTTP API 호출의 예외를 어떻게 Fin<T> 에러로 변환하는가?” “[GenerateObservablePort]를 적용하면 로깅과 메트릭이 자동으로 생성된다는데, 어떤 구조인가?”

Adapter는 Port 인터페이스의 구현체로, 실제 인프라 기술과 도메인 로직 사이의 다리 역할을 합니다. 이 문서는 Repository, External API, Messaging, Query Adapter의 유형별 구현 패턴과 에러 처리 전략을 다룹니다.

이 문서를 통해 다음을 학습합니다:

  1. Adapter 공통 패턴IO.lift/IO.liftAsync 선택과 Mapper 패턴
  2. Repository Adapter — InMemory와 EF Core 구현의 비교
  3. External API Adapter — HTTP 상태 코드별 에러 매핑과 예외 처리
  4. Messaging Adapter — Request/Reply와 Fire-and-Forget 패턴
  5. Query Adapter (CQRS Read) — Dapper 기반 DTO 직접 반환

이 문서를 이해하기 위해 다음 개념에 대한 기본적인 이해가 필요합니다:

Adapter는 “순수한 비즈니스 로직”과 “인프라 기술 세부사항”을 분리하는 경계입니다. IO.lift로 래핑하고 [GenerateObservablePort]를 적용하면, 관측성은 자동으로 따라옵니다.

// Adapter 기본 구조 (베이스 클래스 상속)
[GenerateObservablePort]
public class InMemoryProductRepository
: InMemoryRepositoryBase<Product, ProductId>, IProductRepository
{
protected override ConcurrentDictionary<ProductId, Product> Store => Products;
public override FinT<IO, Product> GetById(ProductId id)
{
return IO.lift(() => { /* 비즈니스 로직 */ });
}
}
// 동기 작업: IO.lift
return IO.lift(() => Fin.Succ(value));
// 비동기 작업: IO.liftAsync
return IO.liftAsync(async () => { var result = await ...; return Fin.Succ(result); });
// 에러 반환
return Fin.Fail<T>(AdapterError.For<TAdapter>(errorType, context, message));
  1. [GenerateObservablePort] 어트리뷰트를 클래스에 적용
  2. Port 인터페이스 구현 및 RequestCategory 프로퍼티 정의
  3. 모든 인터페이스 메서드에 virtual 키워드 추가
  4. IO.lift() (동기) 또는 IO.liftAsync() (비동기)로 비즈니스 로직 래핑
  5. 성공은 Fin.Succ(value), 실패는 AdapterError.For<T>(...) 사용
  6. 필요 시 Mapper 클래스를 internal로 정의하여 도메인/기술 모델 변환
개념설명
[GenerateObservablePort]Source Generator가 Observability Pipeline을 자동 생성하는 어트리뷰트
IO.lift / IO.liftAsync동기/비동기 작업을 FinT<IO, T>로 래핑하는 메서드
virtual 키워드Pipeline이 메서드를 override하기 위해 필수
RequestCategoryObservability 로그에서 사용할 카테고리 ("Repository", "ExternalApi" 등)
Mapper 패턴도메인 모델과 기술 모델(POCO, DTO) 간 변환을 담당하는 internal 클래스
AdapterErrorAdapter 레이어 전용 에러 타입 (For<T>, FromException<T>)

애플리케이션이 데이터베이스, 외부 API, 메시징 시스템과 직접 결합되면 두 가지 문제가 발생합니다. 첫째, 인프라 기술을 변경할 때 비즈니스 로직까지 수정해야 합니다. 둘째, 단위 테스트에서 실제 데이터베이스나 외부 서비스를 준비해야 하므로 테스트가 느리고 불안정해집니다.

Adapter 패턴은 이 결합을 끊습니다. Domain과 Application 계층은 Port 인터페이스만 알고, Adapter가 해당 인터페이스의 구현을 제공합니다. 테스트 시에는 InMemory 구현체로 대체하고, 프로덕션에서는 EF Core나 Dapper 기반 구현체를 사용합니다.

Functorium은 여기에 Observability를 더합니다. [GenerateObservablePort] 어트리뷰트를 적용하면 Source Generator가 Adapter에 Logging, Metrics, Tracing을 자동으로 추가하는 Observable wrapper를 생성합니다. Adapter 코드에 관측 로직을 직접 작성할 필요가 없습니다.

Adapter 패턴의 필요성을 이해했으니, 이제 유형별로 실제 구현 방법을 살펴보겠습니다.


Adapter는 Port 인터페이스의 구현체입니다. [GenerateObservablePort] 어트리뷰트를 통해 Observability Pipeline이 자동 생성됩니다.

Source Generator 참고: [GenerateObservablePort]는 Roslyn Incremental Source Generator로 구현되어 있어 빌드 시 증분 생성됩니다. Adapter 수가 많은 프로젝트에서는 obj/GeneratedFiles/에 생성된 코드를 확인하여 Pipeline이 올바르게 생성되었는지 검증하세요. IO.lift/IO.liftAsync로 래핑된 메서드만 Pipeline 대상이 되며, virtual 키워드가 없으면 Pipeline이 메서드를 오버라이드할 수 없습니다.

모든 Adapter 구현에 필수인 항목입니다.

  • [GenerateObservablePort] 어트리뷰트를 클래스에 적용했는가?
  • Port 인터페이스를 구현하는가?
  • RequestCategory 프로퍼티를 정의했는가?
  • 모든 인터페이스 메서드에 virtual 키워드를 추가했는가?
  • IO.lift() 또는 IO.liftAsync() 로 비즈니스 로직을 래핑했는가?
  • Mapper 클래스가 internal로 선언되어 있는가? (해당 시)

모든 Adapter 유형에 공통으로 적용되는 패턴입니다. 유형별 Adapter 구현 전에 먼저 숙지하세요.

모든 Adapter 메서드는 FinT<IO, T>를 반환하며, 내부 작업 유형에 따라 래핑 방식을 선택합니다.

기준IO.lift(() => { ... })IO.liftAsync(async () => { ... })
작업 유형동기 (sync)비동기 (async/await)
대표 사례In-Memory 저장소, 캐시 조회HTTP 호출, 메시지 전송, DB 비동기 쿼리
반환Fin<T>Fin<T>
사용 유형Repository (동기)External API, Messaging

판단 기준: 내부에서 await를 사용해야 하는가?

  • IO.liftAsync
  • 아니오IO.lift

참고: EF Core 등 비동기 DB 접근 시에는 Repository에서도 IO.liftAsync를 사용합니다.

Adapter 내부에서 Port의 도메인 모델과 기술 관심사 DTO 간의 변환을 처리합니다. Mapper 클래스는 반드시 internal로 선언합니다.

다음 코드에서 주목할 점은 Mapper를 통해 Port Request를 Query Parameter로, Infrastructure DTO를 Port Response로 변환하는 ACL(Anti-Corruption Layer) 구조입니다.

Adapters.Infrastructure/Apis/CriteriaApi/CriteriaApiService.cs
[GenerateObservablePort]
public class CriteriaApiService : ICriteriaApiService
{
private readonly HttpClient _httpClient;
public string RequestCategory => "ExternalApi";
#region Error Types
public sealed record ResponseNull : AdapterErrorType.Custom;
#endregion
public virtual FinT<IO, ICriteriaApiService.Response> GetEquipHistoriesAsync(
ICriteriaApiService.Request request,
CancellationToken cancellationToken)
{
return IO.liftAsync(async () =>
{
// 1. Port Request → Query Parameters 변환
var queryParams = CriteriaApiMapper.ToQueryParams(request);
// 2. HTTP 호출
var url = QueryHelpers.AddQueryString("/api/v2/criteria/equips/history", queryParams);
var response = await _httpClient.GetAsync(url, cancellationToken);
if (!response.IsSuccessStatusCode)
{
var errorContent = await response.Content.ReadAsStringAsync(cancellationToken);
return Fin.Fail<ICriteriaApiService.Response>(
AdapterError.For<CriteriaApiService>(
new ConnectionFailed("HTTP"),
url,
$"API call failed: {response.StatusCode} - {errorContent}"));
}
// 3. Infrastructure DTO → Port Response 변환
var dto = await response.Content.ReadFromJsonAsync<GetEquipHistoryResponseDto>(cancellationToken);
return dto?.Histories is not null
? Fin.Succ(CriteriaApiMapper.ToResponse(dto))
: Fin.Fail<ICriteriaApiService.Response>(
AdapterError.For<CriteriaApiService>(new ResponseNull(), url, "Response data is null"));
});
}
}
// Mapper 클래스 (Infrastructure 내부 - internal)
internal static class CriteriaApiMapper
{
public static Dictionary<string, string?> ToQueryParams(ICriteriaApiService.Request request)
=> new()
{
["connType"] = request.ConnType,
["equipTypeId"] = request.EquipTypeId
};
public static ICriteriaApiService.Response ToResponse(GetEquipHistoryResponseDto dto)
=> new(Equipments: dto.Histories
.Select(h => new ICriteriaApiService.Equipment(
h.LineId, h.TypeId, h.ModelId, h.EquipId,
h.Description, h.UpdateTime, h.ConnectionType,
h.ConnIp, h.ConnPort, h.ConnId, h.ConnPw, h.ServiceName))
.ToSeq());
}
// Infrastructure 내부 DTO (internal - 외부 노출 안 함)
internal record GetEquipHistoryResponseDto(List<EquipDto> Histories);
internal record EquipDto(string LineId, string TypeId, string ModelId, ...);

Persistence Adapter는 Persistence Model(POCO)Mapper(확장 메서드) 를 사용하여 도메인 엔티티와 DB 모델을 분리합니다. EF Core HasConversion 대신 Mapper에서 명시적으로 변환합니다.

다음 코드에서 주목할 점은 ToModel()이 도메인을 POCO로, ToDomain()이 POCO를 CreateFromValidated()를 통해 도메인으로 복원하는 양방향 매핑입니다.

// Persistence Model — POCO (primitive 타입만, 도메인 의존성 없음)
// 파일: {Adapters.Persistence}/Repositories/EfCore/Models/ProductModel.cs
public class ProductModel
{
public string Id { get; set; } = default!;
public string Name { get; set; } = default!;
public string Description { get; set; } = default!;
public decimal Price { get; set; }
public DateTime CreatedAt { get; set; }
public DateTime? UpdatedAt { get; set; }
public DateTime? DeletedAt { get; set; }
public string? DeletedBy { get; set; }
public List<ProductTagModel> ProductTags { get; set; } = [];
}
// Mapper — internal static class, 확장 메서드
// 파일: {Adapters.Persistence}/Repositories/EfCore/Mappers/ProductMapper.cs
internal static class ProductMapper
{
public static ProductModel ToModel(this Product product) => new()
{
Id = product.Id.ToString(),
Name = product.Name,
Description = product.Description,
Price = product.Price,
CreatedAt = product.CreatedAt,
UpdatedAt = product.UpdatedAt.ToNullable(),
DeletedAt = product.DeletedAt.ToNullable(),
DeletedBy = product.DeletedBy.Match(Some: v => (string?)v, None: () => null),
ProductTags = product.TagIds.Select(tagId => new ProductTagModel
{
ProductId = product.Id.ToString(),
TagId = tagId.ToString()
}).ToList()
};
public static Product ToDomain(this ProductModel model)
{
var tagIds = model.ProductTags.Select(pt => TagId.Create(pt.TagId));
return Product.CreateFromValidated( // 검증 없이 복원
ProductId.Create(model.Id),
ProductName.CreateFromValidated(model.Name),
ProductDescription.CreateFromValidated(model.Description),
Money.CreateFromValidated(model.Price),
tagIds,
model.CreatedAt,
Optional(model.UpdatedAt),
Optional(model.DeletedAt),
Optional(model.DeletedBy));
}
}
// Repository — EfCoreRepositoryBase 상속 + Mapper 확장 메서드 사용
// 파일: {Adapters.Persistence}/Repositories/EfCore/EfCoreProductRepository.cs
[GenerateObservablePort]
public class EfCoreProductRepository
: EfCoreRepositoryBase<Product, ProductId, ProductModel>, IProductRepository
{
private readonly LayeredArchDbContext _dbContext;
public EfCoreProductRepository(
LayeredArchDbContext dbContext,
IDomainEventCollector eventCollector,
Func<IQueryable<ProductModel>, IQueryable<ProductModel>>? applyIncludes = null,
PropertyMap<Product, ProductModel>? propertyMap = null)
: base(eventCollector, applyIncludes, propertyMap)
{
_dbContext = dbContext;
}
protected override DbContext DbContext => _dbContext;
protected override DbSet<ProductModel> DbSet => _dbContext.Products;
protected override Product ToDomain(ProductModel model) => model.ToDomain();
protected override ProductModel ToModel(Product aggregate) => aggregate.ToModel();
// CRUD (Create, GetById, Update, Delete 등)는 EfCoreRepositoryBase가 기본 구현 제공
// 도메인 전용 메서드만 오버라이드 또는 추가
public virtual FinT<IO, bool> Exists(Specification<Product> spec)
{
return ExistsBySpec(spec); // 베이스 클래스의 BuildQuery 활용
}
}

핵심: EfCoreRepositoryBase가 CRUD 8개 메서드(Create, GetById, Update, Delete, CreateRange, GetByIds, UpdateRange, DeleteRange)를 기본 구현하므로, 서브클래스는 ToDomain()/ToModel() 변환과 도메인 전용 메서드만 구현합니다. IHasStringId 인터페이스를 통해 모든 Model의 Id 속성이 string 타입임을 보장하며, ReadQuery()가 Include를 자동 적용하여 N+1 문제를 구조적으로 방지합니다.

LanguageExt는 Error → Fin<T> 암시적 변환을 제공합니다. 따라서 Fin.Fail<T>(error) 대신 error를 직접 반환할 수 있습니다:

// 기존 방식 (verbose)
return Fin.Fail<Money>(AdapterError.For<MyAdapter>(
new NotFound(), context, "Not found"));
// 권장 방식 (implicit conversion)
return AdapterError.For<MyAdapter>(
new NotFound(), context, "Not found");

예외 처리에서도 동일하게 적용됩니다:

catch (HttpRequestException ex)
{
// 기존 방식
return Fin.Fail<Money>(AdapterError.FromException<MyAdapter>(
new ConnectionFailed("ServiceName"), ex));
// 권장 방식
return AdapterError.FromException<MyAdapter>(
new ConnectionFailed("ServiceName"), ex);
}

참고: 메서드 반환 타입이 Fin<T> 또는 FinT<IO, T>로 명시되어 있어야 암시적 변환이 작동합니다.

// AdapterErrorType 사용 패턴
using static Functorium.Adapters.Errors.AdapterErrorType;
// NotFound - 리소스를 찾을 수 없음
AdapterError.For<ProductRepository>(
new NotFound(),
productId.ToString(),
"Product not found");
// AlreadyExists - 리소스가 이미 존재함
AdapterError.For<ProductRepository>(
new AlreadyExists(),
productName,
"Product already exists");
// ConnectionFailed - 외부 시스템 연결 실패
AdapterError.For<CriteriaApiService>(
new ConnectionFailed("HTTP"),
url,
"API connection failed");
// Custom - 사용자 정의 에러 타입
// Error type definition: public sealed record ReservationFailed : AdapterErrorType.Custom;
AdapterError.For<InventoryRepository>(
new ReservationFailed(),
orderId.ToString(),
"Failed to reserve inventory");
// Exception 래핑
AdapterError.FromException<ProductRepository>(
new PipelineException(),
exception);
에러 타입 로그 레벨 메트릭 태그
────────────────────────────────────────────────────────────────
IHasErrorCode + IsExpected ────────► Warning error.type: "expected"
IHasErrorCode + IsExceptional ──────► Error error.type: "exceptional"
ManyErrors ─────────────────────────► Warning/Error error.type: "aggregate"
┌──────────────────────────────────────────────────────────────┐
│ Domain Layer │
│ ┌────────────────────────────────────────────────────────┐ │
│ │ Value Objects (모든 레이어에서 공유) │ │
│ │ - ProductId, ProductName, Money, Quantity │ │
│ │ - EquipId, EquipTypeId, RecipeHostId │ │
│ │ - EquipmentConnectionInfo │ │
│ └────────────────────────────────────────────────────────┘ │
└──────────────────────────────────────────────────────────────┘
┌───────────────┼───────────────┐
▼ ▼ ▼
┌──────────────────┐ ┌──────────────┐ ┌───────────────────────┐
│ Application │ │ Infrastructure│ │ Persistence │
│ (Usecase) │ │ (API Adapter) │ │ (Repository) │
│ │ │ │ │ │
│ ProductId 사용 │ │ ProductId → │ │ ProductModel (POCO) │
│ │ │ string (DTO) │ │ ProductId → string │
└──────────────────┘ └──────────────┘ └───────────────────────┘

외부 시스템 유형별 ACL 체크리스트

섹션 제목: “외부 시스템 유형별 ACL 체크리스트”
  • Port는 도메인 타입(VO, Entity, Domain Event)만 사용한다
  • Adapter 내부에 기술 특화 모델/DTO를 정의한다 (internal 가시성)
  • Adapter 내부에 Mapper를 정의한다 (internal static class, 확장 메서드)
  • 외부 타입은 Application/Domain 레이어로 절대 노출하지 않는다

외부 시스템 유형에 따라 ACL 구현에 사용하는 타입과 Mapper 패턴이 달라집니다.

외부 시스템 유형Adapter 프로젝트내부 변환 타입Mapper 패턴기존 예시
Database (RDBMS)Persistenceinternal class XxxModel (POCO)internal static class XxxMapper (확장 메서드)ProductModel + ProductMapper (§2.2)
External HTTP APIInfrastructureinternal record XxxDtointernal static class XxxApiMapperCriteriaApiMapper (§2.2)
Message BrokerInfrastructureinternal record XxxMessageinternal static class XxxMessageMapper해당 시 적용 (§2.5 참조)
File SystemInfrastructureinternal record/class XxxFileModelinternal static class XxxFileMapper— (패턴 동일)
CacheInfrastructureinternal record XxxCacheEntryinternal static class XxxCacheMapper— (패턴 동일)
외부 인증/인가Infrastructureinternal record XxxAuthResponseinternal static class XxxAuthMapper— (패턴 동일)
새 외부 시스템 연동
├─ 외부 스키마가 독립적으로 변경 가능? → ACL 필수 (internal DTO + Mapper)
└─ 공유 계약(shared contract)으로 공동 관리? → ACL 선택적 (Pass-through 허용)
  • ACL 필수 예: 레거시 DB, 외부 팀의 API, 서드파티 메시지 스키마
  • Pass-through 허용 예: 같은 팀의 공유 메시지 계약 (현재 Messaging Adapter 패턴)

Repository Adapter는 데이터 저장소에 대한 CRUD 작업을 구현합니다.

InMemoryRepositoryBase<TAggregate, TId>ConcurrentDictionary 기반으로 IRepository 전체 8개 CRUD를 기본 구현하는 프레임워크 베이스 클래스입니다. 서브클래스는 Store 프로퍼티만 제공하면 되며, Soft Delete 등 특수 로직이 필요한 메서드만 오버라이드합니다.

다음 코드에서 주목할 점은 베이스 클래스가 CRUD를 기본 제공하고, Soft Delete가 필요한 GetByIdDelete만 오버라이드하는 구조입니다.

// 파일: {Adapters.Persistence}/Repositories/InMemory/InMemoryProductRepository.cs
using Functorium.Adapters.Repositories;
using Functorium.Adapters.SourceGenerators;
[GenerateObservablePort] // 1. Pipeline 자동 생성
public class InMemoryProductRepository
: InMemoryRepositoryBase<Product, ProductId>, IProductRepository // 2. 베이스 클래스 + Port 구현
{
internal static readonly ConcurrentDictionary<ProductId, Product> Products = new();
protected override ConcurrentDictionary<ProductId, Product> Store => Products; // 3. 저장소 제공
public InMemoryProductRepository(IDomainEventCollector eventCollector)
: base(eventCollector) { } // 4. 베이스 생성자 호출
// ─── Soft Delete 오버라이드 ──────────────────────
// 베이스의 Create/GetById/Update/Delete/CreateRange/GetByIds/UpdateRange/DeleteRange를
// 기본 제공받고, Soft Delete가 필요한 메서드만 override합니다.
public override FinT<IO, Product> GetById(ProductId id)
{
return IO.lift(() =>
{
if (Products.TryGetValue(id, out Product? product) && product.DeletedAt.IsNone)
{
return Fin.Succ(product);
}
return NotFoundError(id); // 5. 베이스 에러 헬퍼
});
}
public override FinT<IO, int> Delete(ProductId id) // 6. 반환 타입: int (영향 행 수)
{
return IO.lift(() =>
{
if (!Products.TryGetValue(id, out var product))
{
return Fin.Succ(0);
}
product.Delete("system");
EventCollector.Track(product);
return Fin.Succ(1);
});
}
// ... Product 고유 메서드 (Exists, GetByIdIncludingDeleted 등)
}

참조: Tests.Hosts/01-SingleHost/Src/LayeredArch.Adapters.Persistence/Repositories/InMemory/Products/InMemoryProductRepository.cs

InMemoryRepositoryBase 제공 기능:

멤버타입설명
Storeabstract ConcurrentDictionary<TId, T>서브클래스가 제공하는 인메모리 저장소
Create / CreateRangevirtual저장 + 이벤트 추적
GetById / GetByIdsvirtual조회 + Not Found 에러
Update / UpdateRangevirtual갱신 + 이벤트 추적
Delete / DeleteRangevirtual삭제 (반환: int — 영향 행 수)
NotFoundError()protected에러 헬퍼
EventCollectorprotected도메인 이벤트 수집기

Repository Adapter 핵심 패턴:

패턴코드설명
IO 래핑IO.lift(() => { ... })동기 작업은 IO.lift 사용
성공Fin.Succ(value)성공 값 래핑
도메인 실패AdapterError.For<T>(errorType, context, message)비즈니스 실패 (not found 등)
삭제 반환Fin.Succ(1) / Fin.Succ(0)영향 행 수 (int)
OptionalFin.Succ(Optional(value))Option<T> 래핑
컬렉션Fin.Succ(toSeq(values))Seq<T> 래핑

InMemory(ConcurrentDictionary) 대신 EF Core를 사용하는 Repository Adapter 패턴입니다. 동일한 Port 인터페이스를 구현하되, IO.liftAsync를 사용하여 EF Core의 비동기 API를 래핑합니다.

DbContext는 Persistence Model(POCO) 을 DbSet 타입으로 사용합니다. 도메인 엔티티가 아닌 Model을 직접 참조합니다.

// 파일: {Adapters.Persistence}/Repositories/EfCore/{ServiceName}DbContext.cs
public class LayeredArchDbContext : DbContext
{
public DbSet<ProductModel> Products => Set<ProductModel>();
public DbSet<InventoryModel> Inventories => Set<InventoryModel>();
public DbSet<OrderModel> Orders => Set<OrderModel>();
public DbSet<OrderLineModel> OrderLines => Set<OrderLineModel>();
public DbSet<CustomerModel> Customers => Set<CustomerModel>();
public DbSet<TagModel> Tags => Set<TagModel>();
public DbSet<ProductTagModel> ProductTags => Set<ProductTagModel>();
public LayeredArchDbContext(DbContextOptions<LayeredArchDbContext> options) : base(options)
{
}
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder.ApplyConfigurationsFromAssembly(typeof(LayeredArchDbContext).Assembly);
}
}

참조: Tests.Hosts/01-SingleHost/Src/LayeredArch.Adapters.Persistence/Repositories/EfCore/LayeredArchDbContext.cs

핵심 포인트:

  • DbSet 타입은 Persistence Model (ProductModel, OrderModel, …) — 도메인 엔티티(Product, Order, …)가 아님
  • ApplyConfigurationsFromAssembly로 동일 어셈블리의 IEntityTypeConfiguration<T> 구현체를 자동 검색
  • DbSet 프로퍼티는 => Set<T>() 표현식으로 정의
Entity Configuration — Persistence Model 직접 매핑
섹션 제목: “Entity Configuration — Persistence Model 직접 매핑”

Persistence Model은 primitive 타입만 사용하므로, EF Core HasConversion이 불필요합니다. Configuration은 IEntityTypeConfiguration<XxxModel>을 구현합니다.

Model 프로퍼티 타입EF Core 설정비고
string (EntityId)HasMaxLength(26)Ulid 문자열 (26자)
string (이름 등)HasMaxLength(N).IsRequired()
decimal (금액)HasPrecision(18, 4)
int (수량)기본 매핑
DateTime? (삭제일시)Soft Delete 지원
string? (삭제자)HasMaxLength(320)
List<ProductTagModel> (컬렉션)HasMany().WithOne().HasForeignKey().OnDelete(Cascade)

Entity Configuration 예시:

// 파일: {Adapters.Persistence}/Repositories/EfCore/Configurations/ProductConfiguration.cs
public class ProductConfiguration : IEntityTypeConfiguration<ProductModel>
{
public void Configure(EntityTypeBuilder<ProductModel> builder)
{
builder.ToTable("Products");
builder.HasKey(p => p.Id);
builder.Property(p => p.Id)
.HasMaxLength(26);
builder.Property(p => p.Name)
.HasMaxLength(200)
.IsRequired();
builder.Property(p => p.Description)
.HasMaxLength(1000)
.IsRequired();
builder.Property(p => p.Price)
.HasPrecision(18, 4);
builder.Property(p => p.CreatedAt);
builder.Property(p => p.UpdatedAt);
builder.Property(p => p.DeletedAt);
builder.Property(p => p.DeletedBy).HasMaxLength(320);
// Global Query Filter: 삭제된 상품 자동 제외
builder.HasQueryFilter(p => p.DeletedAt == null);
builder.HasMany(p => p.ProductTags)
.WithOne()
.HasForeignKey(pt => pt.ProductId)
.OnDelete(DeleteBehavior.Cascade);
}
}

참조: Tests.Hosts/01-SingleHost/Src/LayeredArch.Adapters.Persistence/Repositories/EfCore/Configurations/ProductConfiguration.cs

이전 패턴과의 차이: 도메인 엔티티를 직접 매핑하던 이전 방식에서는 Value Object마다 HasConversion + IdConverter/IdComparer가 필요했습니다. Persistence Model(POCO)을 사용하면 primitive 타입이므로 변환이 불필요합니다.

EfCoreRepositoryBase<TAggregate, TId, TModel>를 상속하여 CRUD 기본 구현을 받고, 도메인 전용 메서드만 추가합니다. DbContext는 Persistence Model 을 다루므로, Mapper 확장 메서드(ToModel() / ToDomain())로 도메인 엔티티와 변환합니다.

EfCoreRepositoryBase 주요 기능:

기능설명
ReadQuery()Include가 자동 적용된 읽기 전용 쿼리. N+1 문제를 구조적으로 방지
BuildQuery(spec)Specification → Model Expression 쿼리 빌더. PropertyMap 필수
ExistsBySpec(spec)Specification 기반 존재 여부 확인. PropertyMap 필수
PropertyMapAggregate → Model 프로퍼티 매핑. Specification SQL 변환에 사용
IdBatchSizeSQL IN 절 파라미터 한계 방지를 위한 배치 크기 (기본값: 500)
IHasStringId모든 Model이 구현해야 하는 인터페이스. string Id 속성 보장
// 파일: {Adapters.Persistence}/Repositories/EfCore/EfCoreProductRepository.cs
using Functorium.Adapters.Errors;
using Functorium.Adapters.Repositories;
using Functorium.Adapters.SourceGenerators;
using LayeredArch.Adapters.Persistence.Repositories.EfCore.Mappers;
using LayeredArch.Adapters.Persistence.Repositories.EfCore.Models;
using static Functorium.Adapters.Errors.AdapterErrorType;
[GenerateObservablePort]
public class EfCoreProductRepository
: EfCoreRepositoryBase<Product, ProductId, ProductModel>, IProductRepository
{
private readonly LayeredArchDbContext _dbContext;
public EfCoreProductRepository(
LayeredArchDbContext dbContext,
IDomainEventCollector eventCollector)
: base(
eventCollector,
applyIncludes: q => q.Include(p => p.ProductTags), // N+1 방지
propertyMap: ProductPropertyMap.Instance) // Specification SQL 변환
{
_dbContext = dbContext;
}
// --- 필수 추상 멤버 구현 ---
protected override DbContext DbContext => _dbContext;
protected override DbSet<ProductModel> DbSet => _dbContext.Products;
protected override Product ToDomain(ProductModel model) => model.ToDomain();
protected override ProductModel ToModel(Product aggregate) => aggregate.ToModel();
// CRUD 8개 메서드는 EfCoreRepositoryBase가 기본 구현 제공
// --- 도메인 전용 메서드 ---
public virtual FinT<IO, bool> Exists(Specification<Product> spec)
{
return ExistsBySpec(spec);
}
public virtual FinT<IO, int> Delete(ProductId id)
{
return IO.liftAsync(async () =>
{
var model = await _dbContext.Products
.IgnoreQueryFilters()
.Include(p => p.ProductTags)
.FirstOrDefaultAsync(p => p.Id == id.ToString());
if (model is null)
{
return AdapterError.For<EfCoreProductRepository>(
new NotFound(),
id.ToString(),
$"상품 ID '{id}'을(를) 찾을 수 없습니다");
}
var product = model.ToDomain();
product.Delete("system");
_dbContext.Products.Update(product.ToModel());
_eventCollector.Track(product);
return Fin.Succ(1);
});
}
// ... 나머지 메서드도 동일 패턴
}

참조: Tests.Hosts/01-SingleHost/Src/LayeredArch.Adapters.Persistence/Repositories/EfCore/EfCoreProductRepository.cs

InMemory vs EF Core Repository 비교:

동일한 Port를 구현하되 저장소 기술에 따라 달라지는 부분을 비교하면 다음과 같습니다.

항목InMemoryEF Core
IO 래핑IO.lift(() => { ... })IO.liftAsync(async () => { ... })
저장소ConcurrentDictionary<TId, T>DbContext.Set<TModel>()
저장/조회 변환불필요 (도메인 객체 직접 저장)product.ToModel() / model.ToDomain()
조회Products.TryGetValue(id, ...)_dbContext.Products.FirstOrDefaultAsync(...)
Navigation 로딩불필요 (메모리 내 참조).Include(p => p.ProductTags)
삭제 방식Soft Delete (product.Delete(...))Soft Delete (product.Delete(...) + Update)
트랜잭션 관리No-op (InMemoryUnitOfWork)DbContext.SaveChangesAsync() (EfCoreUnitOfWork)
에러 패턴AdapterError.For<T>(...)AdapterError.For<T>(...) (동일)
Pipeline 생성[GenerateObservablePort][GenerateObservablePort] (동일)
DI 등록RegisterScopedObservablePort<>RegisterScopedObservablePort<> (동일)

Unit of Work(UoW)는 Usecase에서 트랜잭션을 커밋하는 Port입니다. Repository는 엔티티 변경만 추적하고, 실제 커밋은 UoW가 담당합니다.

위치: Functorium.Applications.Persistence

public interface IUnitOfWork : IObservablePort
{
FinT<IO, Unit> SaveChanges(CancellationToken cancellationToken = default);
}

DbContext.SaveChangesAsync()를 호출하여 변경사항을 커밋합니다. DbUpdateException 계열의 예외를 AdapterError로 변환합니다.

// 파일: {Adapters.Persistence}/Repositories/EfCore/EfCoreUnitOfWork.cs
[GenerateObservablePort]
public class EfCoreUnitOfWork : IUnitOfWork
{
private readonly LayeredArchDbContext _dbContext;
public string RequestCategory => "UnitOfWork";
#region Error Types
public sealed record ConcurrencyConflict : AdapterErrorType.Custom;
public sealed record DatabaseUpdateFailed : AdapterErrorType.Custom;
#endregion
public EfCoreUnitOfWork(LayeredArchDbContext dbContext) => _dbContext = dbContext;
public virtual FinT<IO, Unit> SaveChanges(CancellationToken cancellationToken = default)
{
return IO.liftAsync(async () =>
{
try
{
await _dbContext.SaveChangesAsync(cancellationToken);
return Fin.Succ(unit);
}
catch (DbUpdateConcurrencyException ex)
{
return AdapterError.FromException<EfCoreUnitOfWork>(
new ConcurrencyConflict(), ex);
}
catch (DbUpdateException ex)
{
return AdapterError.FromException<EfCoreUnitOfWork>(
new DatabaseUpdateFailed(), ex);
}
});
}
}

참조: Tests.Hosts/01-SingleHost/Src/LayeredArch.Adapters.Persistence/Repositories/EfCore/EfCoreUnitOfWork.cs

ConcurrentDictionary 기반 InMemory 저장소는 즉시 반영되므로 SaveChanges가 no-op입니다.

// 파일: {Adapters.Persistence}/Repositories/InMemory/InMemoryUnitOfWork.cs
[GenerateObservablePort]
public class InMemoryUnitOfWork : IUnitOfWork
{
public string RequestCategory => "UnitOfWork";
public virtual FinT<IO, Unit> SaveChanges(CancellationToken cancellationToken = default)
{
return IO.lift(() => Fin.Succ(unit));
}
}

참조: Tests.Hosts/01-SingleHost/Src/LayeredArch.Adapters.Persistence/Repositories/InMemory/InMemoryUnitOfWork.cs

IDomainEventCollector — Repository와 Publisher의 브릿지
섹션 제목: “IDomainEventCollector — Repository와 Publisher의 브릿지”

IDomainEventCollector는 Repository에서 추적된 Aggregate를 DomainEventPublisher에 전달하는 브릿지 역할을 합니다.

위치: Functorium.Applications.Events

public interface IDomainEventCollector
{
void Track(IHasDomainEvents aggregate);
IReadOnlyList<IHasDomainEvents> GetTrackedAggregates();
}

Repository에서의 사용: Repository의 Create(), Update() 메서드에서 _eventCollector.Track(aggregate)를 호출하여 Aggregate를 추적 대상으로 등록해야 합니다:

public FinT<IO, Product> Create(Product product)
{
_eventCollector.Track(product); // 필수: 도메인 이벤트 수집 대상 등록
// ... 저장 로직 ...
}

등록: RegisterDomainEventPublisher() 호출 시 IDomainEventCollector가 Scoped 서비스로 자동 등록됩니다:

services.RegisterDomainEventPublisher(); // IDomainEventPublisher + IDomainEventCollector 등록
Repository에서 SaveChanges를 호출하지 않는 이유
섹션 제목: “Repository에서 SaveChanges를 호출하지 않는 이유”

Repository의 Create(), Update(), Delete() 메서드는 EF Core 변경 추적(Change Tracking)에 엔티티를 등록만 합니다. 실제 SaveChangesAsync() 호출은 UsecaseTransactionPipeline이 Handler 실행 후 자동으로 수행합니다.

이 분리를 통해:

  • 여러 Repository 변경을 하나의 트랜잭션으로 묶을 수 있음 (파이프라인 보장)
  • 이벤트 발행을 트랜잭션 성공 후로 보장할 수 있음 (파이프라인 보장)
  • Repository가 순수한 데이터 접근 계층으로 유지됨
  • Repository는 IDomainEventCollector.Track(aggregate)를 호출하여 도메인 이벤트 수집 대상을 등록

참조: 파이프라인 패턴은 11-usecases-and-cqrs.md §트랜잭션과 이벤트 발행을 참조하세요.

Repository Adapter가 데이터 영속화를 담당한다면, External API Adapter는 외부 시스템과의 HTTP 통신을 담당합니다.

External API Adapter는 HTTP 클라이언트를 통한 외부 시스템 호출을 구현합니다.

다음 코드에서 주목할 점은 HTTP 상태 코드별 에러 매핑(HandleHttpError)과 예외 유형별 AdapterError 변환 구조입니다.

// 파일: {Adapters.Infrastructure}/ExternalApis/ExternalPricingApiService.cs
using Functorium.Adapters.Errors;
using Functorium.Adapters.SourceGenerators;
using static Functorium.Adapters.Errors.AdapterErrorType;
[GenerateObservablePort]
public class ExternalPricingApiService : IExternalPricingService
{
private readonly HttpClient _httpClient; // 1. HttpClient 주입
public string RequestCategory => "ExternalApi"; // 2. 요청 카테고리
#region Error Types
public sealed record OperationCancelled : AdapterErrorType.Custom;
public sealed record UnexpectedException : AdapterErrorType.Custom;
public sealed record RateLimited : AdapterErrorType.Custom;
public sealed record HttpError : AdapterErrorType.Custom;
#endregion
public ExternalPricingApiService(HttpClient httpClient) // 3. 생성자 주입
{
_httpClient = httpClient;
}
public virtual FinT<IO, Money> GetPriceAsync(
string productCode, CancellationToken cancellationToken)
{
return IO.liftAsync(async () => // 4. IO.liftAsync (비동기)
{
try
{
var response = await _httpClient.GetAsync(
$"/api/pricing/{productCode}",
cancellationToken);
// 5. HTTP 오류 처리
if (!response.IsSuccessStatusCode)
{
return HandleHttpError<Money>(response, productCode);
}
// 6. 응답 역직렬화
var priceResponse = await response.Content
.ReadFromJsonAsync<ExternalPriceResponse>(
cancellationToken: cancellationToken);
// 7. null 응답 처리
if (priceResponse is null)
{
return AdapterError.For<ExternalPricingApiService>(
new Null(),
productCode,
$"외부 API 응답이 null입니다. ProductCode: {productCode}");
}
return Money.Create(priceResponse.Price);
}
catch (HttpRequestException ex) // 8. 연결 실패
{
return AdapterError.FromException<ExternalPricingApiService>(
new ConnectionFailed("ExternalPricingApi"),
ex);
}
catch (TaskCanceledException ex) when (ex.CancellationToken == cancellationToken)
{
return AdapterError.For<ExternalPricingApiService>( // 9. 사용자 취소
new OperationCancelled(),
productCode,
"요청이 취소되었습니다");
}
catch (TaskCanceledException ex) // 10. 타임아웃
{
return AdapterError.FromException<ExternalPricingApiService>(
new AdapterErrorType.Timeout(TimeSpan.FromSeconds(30)),
ex);
}
catch (Exception ex) // 11. 기타 예외
{
return AdapterError.FromException<ExternalPricingApiService>(
new UnexpectedException(),
ex);
}
});
}
// HTTP 상태 코드별 에러 매핑
private static Fin<T> HandleHttpError<T>(
HttpResponseMessage response, string context) =>
response.StatusCode switch
{
HttpStatusCode.NotFound => AdapterError.For<ExternalPricingApiService>(
new NotFound(), context, "리소스를 찾을 수 없습니다"),
HttpStatusCode.Unauthorized => AdapterError.For<ExternalPricingApiService>(
new Unauthorized(), context, "인증에 실패했습니다"),
HttpStatusCode.Forbidden => AdapterError.For<ExternalPricingApiService>(
new Forbidden(), context, "접근이 금지되었습니다"),
HttpStatusCode.TooManyRequests => AdapterError.For<ExternalPricingApiService>(
new RateLimited(), context, "요청 제한에 도달했습니다"),
HttpStatusCode.ServiceUnavailable => AdapterError.For<ExternalPricingApiService>(
new ExternalServiceUnavailable("ExternalPricingApi"),
context, "서비스를 사용할 수 없습니다"),
_ => AdapterError.For<ExternalPricingApiService, HttpStatusCode>(
new HttpError(), response.StatusCode,
$"API 호출 실패. Status: {response.StatusCode}")
};
}

참조: Tests.Hosts/01-SingleHost/Src/LayeredArch.Adapters.Infrastructure/ExternalApis/ExternalPricingApiService.cs

HTTP 상태 코드 → AdapterErrorType 매핑 참조:

HTTP 상태 코드AdapterErrorType설명
404new NotFound()리소스 없음
401new Unauthorized()인증 실패
403new Forbidden()접근 거부
429new RateLimited()요청 제한 초과
503new ExternalServiceUnavailable(name)서비스 불가
기타new HttpError()일반 HTTP 에러

예외 → AdapterErrorType 매핑 참조:

예외 타입AdapterErrorType설명
HttpRequestExceptionnew ConnectionFailed(name)연결 실패
TaskCanceledException (사용자)new OperationCancelled()요청 취소
TaskCanceledException (타임아웃)new Timeout(timespan)응답 시간 초과
Exceptionnew UnexpectedException()예상 외 예외

Repository와 External API가 동기적 요청/응답을 처리한다면, Messaging Adapter는 비동기 메시지 기반 서비스 간 통신을 담당합니다.

Messaging Adapter는 메시지 브로커를 통한 서비스 간 통신을 구현합니다.

// 파일: {Adapters}/Messaging/RabbitMqInventoryMessaging.cs
using Functorium.Adapters.SourceGenerators;
using static LanguageExt.Prelude;
using Wolverine;
[GenerateObservablePort]
public class RabbitMqInventoryMessaging : IInventoryMessaging
{
private readonly IMessageBus _messageBus; // 1. MessageBus 주입
public string RequestCategory => "Messaging"; // 2. 요청 카테고리
public RabbitMqInventoryMessaging(IMessageBus messageBus) // 3. 생성자 주입
{
_messageBus = messageBus;
}
// Request/Reply 패턴
public virtual FinT<IO, CheckInventoryResponse> CheckInventory(
CheckInventoryRequest request)
{
return IO.liftAsync(async () => // 4. IO.liftAsync
{
try
{
var response = await _messageBus
.InvokeAsync<CheckInventoryResponse>(request); // 5. InvokeAsync
return Fin.Succ(response);
}
catch (Exception ex)
{
return Fin.Fail<CheckInventoryResponse>(
Error.New(ex.Message)); // 6. 에러 래핑
}
});
}
// Fire-and-Forget 패턴
public virtual FinT<IO, Unit> ReserveInventory(
ReserveInventoryCommand command)
{
return IO.liftAsync(async () =>
{
try
{
await _messageBus.SendAsync(command); // 7. SendAsync
return Fin.Succ(unit);
}
catch (Exception ex)
{
return Fin.Fail<Unit>(Error.New(ex.Message));
}
});
}
}

참조: Tutorials/Cqrs06Services/Src/OrderService/Adapters/Messaging/RabbitMqInventoryMessaging.cs

Messaging Adapter 핵심 패턴:

패턴API설명
Request/Reply_messageBus.InvokeAsync<TResponse>(request)응답을 기다리는 동기적 메시징
Fire-and-Forget_messageBus.SendAsync(command)응답 없이 메시지 전송
에러 래핑Fin.Fail<T>(Error.New(ex.Message))메시징 예외를 Fin.Fail로 변환
Messaging ACL: 메시지 스키마 변환이 필요한 경우
섹션 제목: “Messaging ACL: 메시지 스키마 변환이 필요한 경우”

현재 예시는 공유 DTO를 직접 전달하며, 공동 설계된 계약일 때 유효합니다. 외부/레거시 메시지 스키마와 통합 시에는 ACL을 적용합니다:

수신: Broker Message → internal XxxMessage → Mapper → Domain Type (Port)
발신: Domain Type (Port) → Mapper → internal XxxMessage → Broker Message

지금까지 다룬 Adapter는 모두 Command 측(쓰기)에 해당합니다. 마지막으로 CQRS의 Read 측을 담당하는 Query Adapter를 살펴보겠습니다.

Query Adapter는 CQRS의 Read 측을 담당하는 Adapter입니다. Aggregate 재구성 없이 DTO를 직접 반환하며, 페이지네이션/정렬을 DB 레벨에서 처리합니다.

Command 측과 Query 측의 기술 선택이 어떻게 다른지 비교하면 다음과 같습니다.

관점Command 측 (Repository)Query 측 (Query Adapter)
ORMEF CoreDapper + 명시적 SQL
이유변경 추적, UnitOfWork, 마이그레이션성능 극대화, SQL 튜닝 용이성
Aggregate 재구성O — 도메인 불변식 검증 필요X — DTO 직접 반환
데이터 변경O — Create/Update/DeleteX — 읽기 전용
페이지네이션/정렬X — 전체 조회 후 가공O — DB 레벨 처리
인터페이스 위치Domain 레이어Application 레이어

판단 기준: 조회 결과로 Aggregate를 재구성할 필요가 있는가?

  • 있다 → Repository (Command 측, EF Core)
  • 없다 (DTO 직접 반환) → Query Adapter (Query 측, Dapper)

CQRS 원칙에 따라 Command/Query의 기술 스택을 독립적으로 최적화합니다:

  • 성능: Dapper는 EF Core 대비 오버헤드가 적음 (변경 추적, 프록시 생성 없음)
  • SQL 튜닝: 명시적 SQL로 쿼리 플랜 최적화 가능 (JOIN, INDEX HINT 등)
  • 유지보수: 쿼리별 SQL이 명확하여 성능 병목 추적이 용이
  • 기술 독립: Command 측 ORM 변경이 Query 측에 영향 없음

페이지네이션/정렬 프레임워크 타입

섹션 제목: “페이지네이션/정렬 프레임워크 타입”

Functorium.Applications.Queries 네임스페이스에 위치한 Application 레벨 쿼리 관심사 타입입니다.

PageRequest — Offset 기반 페이지네이션

섹션 제목: “PageRequest — Offset 기반 페이지네이션”
var page = new PageRequest(page: 2, pageSize: 10);
// page.Skip == 10, page.Page == 2, page.PageSize == 10
// 기본값: page=1, pageSize=20, 최대: 100
  • Page < 1 → 1로 클램핑
  • PageSize < 1 → DefaultPageSize(20)로 클램핑
  • PageSize > MaxPageSize(100) → MaxPageSize로 클램핑
var result = new PagedResult<ProductSummaryDto>(items, totalCount: 50, page: 2, pageSize: 10);
// result.TotalPages == 5, result.HasPreviousPage == true, result.HasNextPage == true
// 단일 필드
var sort = SortExpression.By("Name");
// 다중 필드
var sort = SortExpression.By("Price", SortDirection.Descending).ThenBy("Name");
// 정렬 없음
var sort = SortExpression.Empty;

DapperQueryBase — 프레임워크 베이스 클래스

섹션 제목: “DapperQueryBase — 프레임워크 베이스 클래스”

Functorium.Adapters.Repositories 네임스페이스에 위치한 프레임워크 제공 베이스 클래스입니다. 서브클래스는 SQL 선언과 WHERE 빌드만 담당하고, 인프라(Search 실행, ORDER BY, 페이지네이션, 파라미터 헬퍼)는 베이스가 처리합니다.

베이스 클래스 (인프라) 서브클래스 (SQL 선언)
┌────────────────────────────────┐ ┌──────────────────────────────────┐
│ DapperQueryBase<T,TDto> │ │ DapperProductQuery │
│ │ │ : DapperQueryBase<...> │
│ • Search() — 실행 엔진 │ ◄─── │ , IProductQuery │
│ • SearchByCursor() — 커서 검색 │ │ │
│ • Stream() — 스트리밍 │ │ • SelectSql, CountSql │
│ • BuildOrderByClause() │ │ • DefaultOrderBy │
│ • Params() 헬퍼 │ │ • AllowedSortColumns │
│ • IDbConnection 보유 │ │ • BuildWhereClause() (optional) │
└────────────────────────────────┘ └──────────────────────────────────┘

생성자 오버로드:

생성자설명
base(connection)기본 생성자. BuildWhereClause()를 직접 override해야 함
base(connection, translator, tableAlias)DapperSpecTranslator 기반. WHERE 변환을 translator에 위임

서브클래스가 선언하는 것 (abstract):

멤버역할예시
SelectSql전체 SELECT문 (WHERE/ORDER BY 제외)"SELECT Id AS ProductId, Name, Price FROM Products"
CountSql전체 COUNT문 (WHERE 제외)"SELECT COUNT(*) FROM Products"
DefaultOrderBy정렬 미지정 시 기본값"Name ASC"
AllowedSortColumns허용 정렬 필드 Allowlist{ ["Name"] = "Name", ["Price"] = "Price" }
BuildWhereClause()Spec → SQL WHERE + Parameters (virtual — translator 사용 시 override 불필요)ProductPriceRangeSpec → "WHERE Price >= @Min ..."

참조: Src/Functorium.Adapters/Repositories/DapperQueryBase.cs

핵심: SQL 선언부만 작성하면 Search/ORDER BY/페이지네이션은 베이스가 처리합니다.

[GenerateObservablePort]
public class DapperProductQuery
: DapperQueryBase<Product, ProductSummaryDto>, IProductQuery
{
public string RequestCategory => "QueryAdapter";
protected override string SelectSql => "SELECT Id AS ProductId, Name, Price FROM Products";
protected override string CountSql => "SELECT COUNT(*) FROM Products";
protected override string DefaultOrderBy => "Name ASC";
protected override Dictionary<string, string> AllowedSortColumns { get; } =
new(StringComparer.OrdinalIgnoreCase) { ["Name"] = "Name", ["Price"] = "Price" };
public DapperProductQuery(IDbConnection connection)
: base(connection, ProductSpecTranslator.Instance) { }
}

참조: Tests.Hosts/01-SingleHost/Src/LayeredArch.Adapters.Persistence/Repositories/Dapper/DapperProductQuery.cs

SelectSql/CountSql을 통째로 선언하므로 JOIN, GROUP BY 등 복잡한 쿼리도 자유롭게 작성할 수 있습니다.

[GenerateObservablePort]
public class DapperProductWithStockQuery
: DapperQueryBase<Product, ProductWithStockDto>, IProductWithStockQuery
{
public string RequestCategory => "QueryAdapter";
protected override string SelectSql =>
"SELECT p.Id AS ProductId, p.Name, p.Price, i.StockQuantity " +
"FROM Products p INNER JOIN Inventories i ON i.ProductId = p.Id";
protected override string CountSql =>
"SELECT COUNT(*) FROM Products p INNER JOIN Inventories i ON i.ProductId = p.Id";
protected override string DefaultOrderBy => "p.Name ASC";
protected override Dictionary<string, string> AllowedSortColumns { get; } =
new(StringComparer.OrdinalIgnoreCase)
{
["Name"] = "p.Name",
["Price"] = "p.Price",
["StockQuantity"] = "i.StockQuantity"
};
public DapperProductWithStockQuery(IDbConnection connection)
: base(connection, ProductSpecTranslator.Instance, "p") { }
}

참조: Tests.Hosts/01-SingleHost/Src/LayeredArch.Adapters.Persistence/Repositories/Dapper/DapperProductWithStockQuery.cs

Specification → SQL WHERE 변환 (DapperSpecTranslator)

섹션 제목: “Specification → SQL WHERE 변환 (DapperSpecTranslator)”

DapperSpecTranslator<T>는 Specification을 SQL WHERE 절로 변환하는 Fluent API 기반 translator입니다. Query 서브클래스에서 BuildWhereClause()를 직접 override하는 대신, translator를 생성자에 전달하면 베이스 클래스가 자동으로 위임합니다.

public static class ProductSpecTranslator
{
public static readonly DapperSpecTranslator<Product> Instance = new DapperSpecTranslator<Product>()
.WhenAll(alias =>
{
var p = DapperSpecTranslator<Product>.Prefix(alias);
return ($"WHERE {p}DeletedAt IS NULL", new DynamicParameters());
})
.When<ProductPriceRangeSpec>((spec, alias) =>
{
var p = DapperSpecTranslator<Product>.Prefix(alias);
var @params = DapperSpecTranslator<Product>.Params(
("MinPrice", (decimal)spec.MinPrice),
("MaxPrice", (decimal)spec.MaxPrice));
return ($"WHERE {p}DeletedAt IS NULL AND {p}Price >= @MinPrice AND {p}Price <= @MaxPrice", @params);
});
}

DapperSpecTranslator Fluent API:

메서드설명
.WhenAll(alias => ...)Specification.All (기본 조건) 처리
.When<TSpec>((spec, alias) => ...)특정 Specification 타입 처리
Translate(spec, tableAlias)(string Where, DynamicParameters Params) 반환

Static 헬퍼:

헬퍼설명예시
Prefix(alias)테이블 alias가 있으면 "p.", 없으면 "" 반환Prefix("p")"p."
Params(...)DynamicParameters 생성Params(("MinPrice", 100m))
  • 모든 WHERE 조건의 값은 @Parameter로 바인딩했는가? (문자열 결합 금지)
  • SelectSql/CountSqlWHERE/ORDER BY를 포함하지 않았는가? (베이스 클래스가 처리)
  • 컬럼 alias는 DTO 프로퍼티명과 일치하는가? (예: Id AS ProductId)
  • JOIN 시 테이블 alias를 사용했는가? (예: p.Name, i.StockQuantity)
  • AllowedSortColumns에 정렬 가능한 필드를 모두 등록했는가?
  • DefaultOrderBy에 유효한 기본 정렬을 지정했는가?
  • 미지원 Specification에 대해 NotSupportedException을 던지는가?
계층보호 방식위치
Application ValidatorAllowedSortFields 검증FluentValidation (Request 검증)
Adapter AllowlistAllowedSortColumns Dictionary lookup → 미등록 필드는 기본 정렬로 폴백Query Adapter
Dapper Parameters모든 값은 @Parameter로 바인딩, 문자열 결합 없음SQL 실행

InMemoryQueryBase<TEntity, TDto>DapperQueryBase의 InMemory 대응 베이스 클래스입니다. 서브클래스는 데이터 소스 접근(GetProjectedItems)과 정렬 키(SortSelector)만 담당하고, Search/Stream/페이지네이션은 베이스가 처리합니다.

[GenerateObservablePort]
public class InMemoryProductQuery
: InMemoryQueryBase<Product, ProductSummaryDto>, IProductQuery
{
public string RequestCategory => "QueryAdapter";
protected override string DefaultSortField => "Name";
protected override IEnumerable<ProductSummaryDto> GetProjectedItems(Specification<Product> spec)
{
return InMemoryProductRepository.Products.Values
.Where(p => p.DeletedAt.IsNone && spec.IsSatisfiedBy(p))
.Select(p => new ProductSummaryDto(p.Id.ToString(), p.Name, p.Price));
}
protected override Func<ProductSummaryDto, object> SortSelector(string fieldName) => fieldName switch
{
"Name" => p => p.Name,
"Price" => p => p.Price,
_ => p => p.Name
};
}

참조: Tests.Hosts/01-SingleHost/Src/LayeredArch.Adapters.Persistence/Repositories/InMemory/Products/InMemoryProductQuery.cs

InMemoryQueryBase 제공 기능:

멤버타입설명
DefaultSortFieldabstract string정렬 미지정 시 기본 필드명
GetProjectedItems()abstract필터링 + DTO 프로젝션 (JOIN 로직 포함)
SortSelector()abstract필드명 → 정렬 키 셀렉터 함수
Search()virtualOffset 기반 페이지네이션 검색 (베이스 제공)
SearchByCursor()virtualCursor 기반 페이지네이션 검색 (베이스 제공)
Stream()virtualIAsyncEnumerable<TDto> 스트리밍 (베이스 제공)
  • InMemory는 테스트용이므로 Aggregate 재구성 비용이 무시 가능
  • InMemoryProductRepository.Products 정적 필드를 직접 참조하여 데이터 조회

virtual 키워드 누락으로 CS0506 빌드 에러 발생

섹션 제목: “virtual 키워드 누락으로 CS0506 빌드 에러 발생”

원인: Pipeline 클래스가 원본 Adapter 클래스를 상속받아 메서드를 override합니다. virtual이 없으면 override가 불가능하여 CS0506: cannot override because it is not virtual 에러가 발생합니다.

해결:

// Before - 빌드 에러
public FinT<IO, Product> GetById(ProductId id) { ... }
// After - Pipeline override 가능
public virtual FinT<IO, Product> GetById(ProductId id) { ... }

IO.lift 내부에서 await 사용 시 컴파일 에러

섹션 제목: “IO.lift 내부에서 await 사용 시 컴파일 에러”

원인: IO.lift는 동기 람다만 허용합니다. 내부에서 await를 사용하려면 IO.liftAsync를 사용해야 합니다.

해결:

// Before - 컴파일 에러
return IO.lift(() => { var result = await _httpClient.GetAsync(url); ... });
// After - 비동기 작업은 IO.liftAsync 사용
return IO.liftAsync(async () => { var result = await _httpClient.GetAsync(url); ... });

Mapper 클래스가 public으로 노출되어 도메인 경계가 깨진다

섹션 제목: “Mapper 클래스가 public으로 노출되어 도메인 경계가 깨진다”

원인: Adapter 내부의 Mapper 클래스가 public으로 선언되면 외부 프로젝트에서 기술 관심사 변환 로직에 접근할 수 있어 레이어 경계가 무너집니다.

해결:

// Before - 외부 노출
public static class ProductMapper { ... }
// After - Adapter 프로젝트 내부로 제한
internal static class ProductMapper { ... }

Q1. IO.lift와 IO.liftAsync 중 어떤 것을 사용해야 하나요?

섹션 제목: “Q1. IO.lift와 IO.liftAsync 중 어떤 것을 사용해야 하나요?”

내부에서 await를 사용해야 하면 IO.liftAsync, 그렇지 않으면 IO.lift를 사용합니다. In-Memory 저장소나 캐시 조회는 IO.lift, HTTP 호출이나 DB 비동기 쿼리는 IO.liftAsync를 사용합니다. EF Core 등 비동기 DB 접근 시에는 Repository에서도 IO.liftAsync를 사용합니다.

Q2. Adapter에서 에러를 반환할 때 Exception을 throw하면 안 되나요?

섹션 제목: “Q2. Adapter에서 에러를 반환할 때 Exception을 throw하면 안 되나요?”

Exception을 throw하면 Pipeline의 에러 처리 흐름을 우회하게 됩니다. 대신 AdapterError.For<T>(errorType, context, message)Fin.Fail을 반환하여 함수형 에러 처리 체인을 유지합니다. 외부 라이브러리에서 발생하는 Exception은 AdapterError.FromException<T>(errorType, ex)로 변환합니다.

Q3. Persistence Model(POCO)과 도메인 Entity를 왜 분리하나요?

섹션 제목: “Q3. Persistence Model(POCO)과 도메인 Entity를 왜 분리하나요?”

도메인 Entity는 비즈니스 불변식을 보호하는 반면, Persistence Model은 DB 스키마에 맞는 단순 POCO입니다. 분리하면 DB 스키마 변경이 도메인 모델에 영향을 주지 않고, 도메인 모델의 진화가 DB 마이그레이션과 독립적으로 이루어집니다.

Q4. Query Adapter에서 Aggregate를 재구성하지 않는 이유는?

섹션 제목: “Q4. Query Adapter에서 Aggregate를 재구성하지 않는 이유는?”

Query Adapter는 CQRS의 Read 측을 담당하며, 읽기 전용 조회에서는 도메인 불변식 검증이 불필요합니다. Aggregate 재구성 비용을 피하고 DTO를 직접 반환하여 조회 성능을 최적화합니다. Dapper 등으로 직접 SQL 쿼리를 실행할 수 있습니다.

Q5. [GenerateObservablePort] 어트리뷰트를 적용하지 않으면 어떻게 되나요?

섹션 제목: “Q5. [GenerateObservablePort] 어트리뷰트를 적용하지 않으면 어떻게 되나요?”

Pipeline 클래스가 생성되지 않으므로 로깅, 트레이싱, 메트릭이 자동 적용되지 않습니다. Adapter가 직접 Port 인터페이스로 DI 등록되며, Observability 코드를 수동으로 작성해야 합니다.


문서설명
12-ports.mdPort 아키텍처, IObservablePort 계층, Port 정의 규칙
14a-adapter-pipeline-di.mdPipeline 생성, DI 등록, Options 패턴
14b-adapter-testing.mdAdapter 단위 테스트, E2E Walkthrough
15a-unit-testing.md단위 테스트 작성 가이드
08a-error-system.md에러 시스템: 기초와 네이밍
08b-error-system-domain-app.md에러 시스템: Domain/Application 에러
08c-error-system-adapter-testing.md에러 시스템: Adapter 에러와 테스트