Table of Contents

Adapter Pattern Guide

Comprehensive guide to using the Adapter pattern in PatternKit.

Overview

Adapter provides a fluent way to map objects from one type to another with ordered transformations and integrated validation. It's ideal for DTO projection, legacy integration, and input normalization.

flowchart LR
    subgraph Adapter
        S[Seed] --> M[Map Steps]
        M --> V[Validators]
    end
    I[TIn] --> S
    V --> O[TOut]

Getting Started

Installation

using PatternKit.Structural.Adapter;

Basic Usage

// Define source and destination types
public record User(int Id, string FirstName, string LastName, DateTime BirthDate);
public class UserDto
{
    public int Id { get; set; }
    public string FullName { get; set; } = "";
    public int Age { get; set; }
}

// Create adapter
var adapter = Adapter<User, UserDto>
    .Create(static () => new UserDto())
    .Map(static (in User u, UserDto d) => d.Id = u.Id)
    .Map(static (in User u, UserDto d) => d.FullName = $"{u.FirstName} {u.LastName}")
    .Map(static (in User u, UserDto d) => d.Age = CalculateAge(u.BirthDate))
    .Build();

var dto = adapter.Adapt(user);

Core Concepts

Seeding

Create the destination object before mapping:

Parameterless seed - when destination doesn't depend on input:

.Create(static () => new Dest())

Input-aware seed - when destination needs input values:

.Create(static (in Source s) => new Dest { Id = s.Id, Timestamp = s.Created })

Mapping Steps

Map steps transform source data into the destination:

.Map(static (in Source s, Dest d) => d.Name = s.Name)
.Map(static (in Source s, Dest d) => d.Price = s.Amount * s.Quantity)
.Map(static (in Source s, Dest d) => d.Status = MapStatus(s.Code))

Steps execute in registration order, allowing later steps to use values set by earlier steps.

Validation

Validators run after all mapping steps:

.Require(static (in Source _, Dest d) =>
    string.IsNullOrEmpty(d.Name) ? "Name is required" : null)
.Require(static (in Source _, Dest d) =>
    d.Price < 0 ? "Price must be positive" : null)

First validator returning a non-null message fails the adaptation.

Safe Adaptation

Use TryAdapt to avoid exceptions:

if (adapter.TryAdapt(source, out var result, out var error))
{
    // Use result
}
else
{
    // Handle error (first validation message)
    logger.LogWarning("Adaptation failed: {Error}", error);
}

Common Patterns

API Response Mapping

public class ApiResponseAdapter
{
    private readonly Adapter<Order, OrderResponse> _adapter;

    public ApiResponseAdapter()
    {
        _adapter = Adapter<Order, OrderResponse>
            .Create(static (in Order o) => new OrderResponse { Id = o.Id })
            .Map(static (in Order o, OrderResponse r) =>
            {
                r.CustomerName = o.Customer.Name;
                r.CustomerEmail = o.Customer.Email;
            })
            .Map(static (in Order o, OrderResponse r) =>
            {
                r.Items = o.Items.Select(i => new OrderItemDto
                {
                    Sku = i.Sku,
                    Name = i.Product.Name,
                    Quantity = i.Quantity,
                    UnitPrice = i.UnitPrice
                }).ToList();
            })
            .Map(static (in Order o, OrderResponse r) =>
            {
                r.Subtotal = o.Items.Sum(i => i.Quantity * i.UnitPrice);
                r.Tax = r.Subtotal * o.TaxRate;
                r.Total = r.Subtotal + r.Tax;
            })
            .Map(static (in Order o, OrderResponse r) =>
            {
                r.Status = o.Status.ToString();
                r.CreatedAt = o.CreatedAt.ToString("O");
            })
            .Build();
    }

    public OrderResponse ToResponse(Order order) => _adapter.Adapt(order);
}

Form Input Normalization

var formAdapter = Adapter<FormInput, CreateUserCommand>
    .Create(static () => new CreateUserCommand())
    .Map(static (in FormInput f, CreateUserCommand c) =>
        c.Email = f.Email?.Trim().ToLowerInvariant())
    .Map(static (in FormInput f, CreateUserCommand c) =>
        c.FirstName = f.FirstName?.Trim())
    .Map(static (in FormInput f, CreateUserCommand c) =>
        c.LastName = f.LastName?.Trim())
    .Map(static (in FormInput f, CreateUserCommand c) =>
        c.Phone = NormalizePhone(f.Phone))
    .Require(static (in FormInput _, CreateUserCommand c) =>
        IsValidEmail(c.Email) ? null : "Invalid email format")
    .Require(static (in FormInput _, CreateUserCommand c) =>
        string.IsNullOrEmpty(c.FirstName) ? "First name required" : null)
    .Require(static (in FormInput _, CreateUserCommand c) =>
        string.IsNullOrEmpty(c.LastName) ? "Last name required" : null)
    .Build();

Legacy System Integration

// Adapt legacy COBOL-style record to modern model
var legacyAdapter = Adapter<LegacyRecord, ModernOrder>
    .Create(static () => new ModernOrder())
    .Map(static (in LegacyRecord r, ModernOrder o) =>
        o.OrderId = long.Parse(r.ORDERNUM.Trim()))
    .Map(static (in LegacyRecord r, ModernOrder o) =>
        o.CustomerName = $"{r.CUSTFIRST.Trim()} {r.CUSTLAST.Trim()}")
    .Map(static (in LegacyRecord r, ModernOrder o) =>
        o.Amount = decimal.Parse(r.AMOUNT.Replace(",", "")) / 100m)
    .Map(static (in LegacyRecord r, ModernOrder o) =>
        o.OrderDate = DateTime.ParseExact(r.ORDDATE, "yyyyMMdd", null))
    .Map(static (in LegacyRecord r, ModernOrder o) =>
        o.Status = r.ORDSTAT switch
        {
            "P" => OrderStatus.Pending,
            "C" => OrderStatus.Completed,
            "X" => OrderStatus.Cancelled,
            _ => OrderStatus.Unknown
        })
    .Build();

Nested Object Mapping

var adapter = Adapter<FullOrder, OrderSummary>
    .Create(static () => new OrderSummary())
    .Map(static (in FullOrder o, OrderSummary s) =>
    {
        s.OrderInfo = new OrderInfo
        {
            Id = o.Id,
            Number = o.OrderNumber,
            Date = o.CreatedAt
        };
    })
    .Map(static (in FullOrder o, OrderSummary s) =>
    {
        s.CustomerInfo = new CustomerInfo
        {
            Name = o.Customer.FullName,
            Email = o.Customer.Email,
            Phone = o.Customer.Phone
        };
    })
    .Map(static (in FullOrder o, OrderSummary s) =>
    {
        s.ShippingInfo = new ShippingInfo
        {
            Address = FormatAddress(o.ShippingAddress),
            Method = o.ShippingMethod.Name,
            EstimatedDelivery = o.EstimatedDelivery
        };
    })
    .Build();

Async Adapter

For I/O-bound transformations, use AsyncAdapter:

var asyncAdapter = AsyncAdapter<OrderRequest, EnrichedOrder>
    .Create(static () => new EnrichedOrder())
    .Map(async static (OrderRequest r, EnrichedOrder o, CancellationToken ct) =>
    {
        o.Customer = await customerService.GetAsync(r.CustomerId, ct);
    })
    .Map(async static (OrderRequest r, EnrichedOrder o, CancellationToken ct) =>
    {
        o.Products = await productService.GetManyAsync(r.ProductIds, ct);
    })
    .Map(async static (OrderRequest r, EnrichedOrder o, CancellationToken ct) =>
    {
        o.Pricing = await pricingService.CalculateAsync(o.Products, r.Quantity, ct);
    })
    .Build();

var order = await asyncAdapter.AdaptAsync(request, cancellationToken);

Best Practices

Use Static Lambdas

Avoid closures to prevent allocations:

// Good - static lambda
.Map(static (in Source s, Dest d) => d.Name = s.Name)

// Avoid - captures external variable
var prefix = "Mr. ";
.Map((in Source s, Dest d) => d.Name = prefix + s.Name)  // Captures prefix

Keep Validators Fast

Validators run on every adaptation:

// Good - fast check
.Require(static (in Source _, Dest d) => d.Price >= 0 ? null : "Invalid price")

// Avoid - expensive operation
.Require(static (in Source _, Dest d) =>
    database.Exists(d.Id) ? null : "Not found")  // I/O in validator

Use TryAdapt for User Input

if (!adapter.TryAdapt(userInput, out var command, out var error))
{
    return BadRequest(new { Error = error });
}

// Process valid command
return Ok(await handler.Handle(command));

Chain Adapters for Complex Transformations

var step1 = Adapter<External, Intermediate>.Create()...Build();
var step2 = Adapter<Intermediate, Final>.Create()...Build();

var final = step2.Adapt(step1.Adapt(external));

Thread Safety

Component Thread-Safe
Builder No - single-threaded configuration
Adapter<TIn, TOut> Yes - immutable after build
Adapt Yes - no shared state
TryAdapt Yes - no shared state

Troubleshooting

Validation fails unexpectedly

Validators see the destination after all mapping. Check mapping order:

// If validator checks d.Total, ensure it's set before validation
.Map(static (in S s, D d) => d.Subtotal = s.Price * s.Qty)
.Map(static (in S s, D d) => d.Tax = d.Subtotal * 0.1m)
.Map(static (in S s, D d) => d.Total = d.Subtotal + d.Tax)
.Require(static (in S _, D d) => d.Total > 0 ? null : "Total required")

TryAdapt returns false but Adapt worked before

A validator started returning an error. Check validator predicates against current input.

Destination properties not set

Ensure mapping steps actually assign values:

// Wrong - expression without assignment
.Map(static (in S s, D d) => s.Name)

// Correct - assignment
.Map(static (in S s, D d) => d.Name = s.Name)

See Also