Skip to main content
Welcome. This site supports keyboard navigation and screen readers. Press ? at any time for keyboard shortcuts. Press [ to focus the sidebar, ] to focus the content. High-contrast themes are available via the toolbar.
serard@dev00:~/cv

Part VII: Fix and Formalize ACLs

"The purpose of the Anti-Corruption Layer is to ensure that your model stays clean, regardless of how messy the external model is." -- Eric Evans, Domain-Driven Design, 2003

Phase 2 gave us bounded context libraries. Billing, Subscriptions, Notifications, and Analytics each have their own project, their own DbContext, their own namespace. The compiler enforces the boundaries. The architecture diagram looks clean.

But the connections between those boundaries are still the same raw wires from the mud ball. The Billing context returns Stripe.Charge from its PaymentGateway. The Notifications context queries the main AppDbContext for entities it does not own. The Analytics ETL runs hardcoded SQL against the replica. The Tax API is called via raw HttpClient inside business logic with no abstraction at all.

We formalized the boundaries in Phase 2. Now we fix the crossings.

This is the most practical phase of the migration. No new domain modeling. No new aggregates. No event sourcing. Just plumbing -- but plumbing that determines whether your bounded contexts are actually bounded or just directories with ambitions.


What Is an ACL in Practice

The Anti-Corruption Layer is not a theoretical DDD pattern you read about and nod at. It is the single most useful tool in a brownfield migration. Every legacy system has at least one integration that leaks -- a third-party SDK type in business logic, a direct database query across team boundaries, an HTTP call inlined where a domain concept should be. The ACL is the fix.

In code, an ACL is two things:

  1. A port -- an interface in the Domain layer that declares what the domain needs, in the domain's own language. It knows nothing about how the need is fulfilled.
  2. An adapter -- a class in the Infrastructure layer that implements the port by calling the external system and translating its response into domain types.

The domain says "I need to charge a payment." The adapter says "I know how to call Stripe, translate the response, and give you back a PaymentResult." Tomorrow, if the adapter calls PayPal instead, the domain does not change. The port did not change. Only the adapter changed.

This is the Ports and Adapters pattern (Hexagonal Architecture) applied at the boundary between bounded contexts and external systems. It is also how ACLs work between two bounded contexts within the same monolith -- one context defines a port for what it needs from the other, and an adapter translates.

The Three Shapes

Not all ACLs are equal. They come in three shapes, ordered by complexity:

  1. Translator -- maps types from one model to another. No logic beyond mapping. Stripe.Charge becomes PaymentResult. This is the most common shape and covers 80% of cases.

  2. Facade -- simplifies a complex external API into a narrow interface. The external system has 47 methods; your domain needs 3. The facade exposes only those 3 and hides the rest.

  3. Anti-Corruption Service -- a full service with its own logic, caching, retry policies, and state management. Used when the external system's model is so different from yours that translation requires business rules. For example: converting between two different billing cycle models that use incompatible calendar logic.

For SubscriptionHub, we need all three. Let's see what we have and what we need.

Diagram

The green box is what the domain owns and controls. The gray box is the translation machinery. The red box is the outside world -- messy, unstable, someone else's problem. The adapter is the buffer between green and red. When red changes, gray absorbs the impact. Green never knows.


Fix the PaymentGateway

This is the first ACL violation we diagnosed in Part II. The PaymentGateway class wraps Stripe, but it wraps without translating. The return type is Stripe.Charge -- a third-party SDK type -- and every caller reaches into it:

Before

public class PaymentGateway
{
    private readonly StripeClient _stripeClient;

    public PaymentGateway(StripeClient stripeClient)
    {
        _stripeClient = stripeClient;
    }

    public async Task<Stripe.Charge> CreateCharge(
        string stripeCustomerId, decimal amount, string currency)
    {
        var options = new ChargeCreateOptions
        {
            Amount = (long)(amount * 100),
            Currency = currency.ToLower(),
            Customer = stripeCustomerId
        };

        var service = new ChargeService(_stripeClient);
        return await service.CreateAsync(options);
    }
}

And in BillingService:

var charge = await _paymentGateway.CreateCharge(
    customer.StripeCustomerId, invoice.TotalAmount, invoice.Currency);
invoice.StripeChargeId = charge.Id;
invoice.PaymentStatus = charge.Status;
invoice.BalanceTransactionId = charge.BalanceTransactionId;
invoice.PaymentMethodDetails = charge.PaymentMethodDetails?.Card?.Brand;

Four lines reaching into Stripe.Charge. The BillingService knows that Stripe charges have a BalanceTransactionId property. The SubscriptionController knows that Stripe uses the string "succeeded" for successful charges. Twenty-three call sites across four projects depend on the Stripe SDK's type model. The gateway gates nothing.

Why does this happen? Because a developer needed one more field. The gateway returned Stripe.Charge, so they accessed .BalanceTransactionId directly instead of adding a property to a result type. The next developer did the same. And the next. The path of least resistance is the path of maximum coupling.

After

Step 1: define a port in Billing.Domain. This interface declares what the domain needs in the domain's language. No Stripe types. No SDK references.

namespace SubscriptionHub.Billing.Domain.Ports;

public interface IPaymentGateway
{
    Task<Result<PaymentResult, PaymentError>> CreateCharge(
        Money amount,
        CustomerId customerId);

    Task<Result<RefundResult, PaymentError>> RefundCharge(
        PaymentId paymentId,
        Money amount);
}

public sealed record PaymentResult(
    PaymentId PaymentId,
    PaymentStatus Status,
    string TransactionReference,
    string? PaymentMethodBrand,
    DateTimeOffset ProcessedAt);

public sealed record RefundResult(
    PaymentId OriginalPaymentId,
    Money RefundedAmount,
    DateTimeOffset RefundedAt);

public enum PaymentStatus
{
    Succeeded,
    Pending,
    Failed,
    RequiresAction
}

public sealed record PaymentError(
    string Code,
    string Message,
    bool IsRetryable);

The domain defines its own PaymentResult, its own PaymentStatus enum, its own PaymentError. These types use domain concepts: Money (a Value Object -- coming in Part VIII), CustomerId (a typed ID), PaymentId (another typed ID). No strings where enums belong. No decimal amount, string currency pairs where Money should be.

Step 2: implement the adapter in Billing.Infrastructure. This is the translator -- the class that knows about Stripe and shields the domain from it.

namespace SubscriptionHub.Billing.Infrastructure.Adapters;

public sealed class StripePaymentAdapter : IPaymentGateway
{
    private readonly StripeClient _stripeClient;
    private readonly ILogger<StripePaymentAdapter> _logger;

    public StripePaymentAdapter(
        StripeClient stripeClient,
        ILogger<StripePaymentAdapter> logger)
    {
        _stripeClient = stripeClient;
        _logger = logger;
    }

    public async Task<Result<PaymentResult, PaymentError>> CreateCharge(
        Money amount,
        CustomerId customerId)
    {
        try
        {
            var options = new ChargeCreateOptions
            {
                Amount = amount.ToCents(),
                Currency = amount.Currency.Code.ToLowerInvariant(),
                Customer = customerId.StripeReference
            };

            var service = new ChargeService(_stripeClient);
            var charge = await service.CreateAsync(options);

            return Result.Success(TranslateCharge(charge));
        }
        catch (StripeException ex)
        {
            _logger.LogWarning(ex,
                "Stripe charge failed for customer {CustomerId}: {Code}",
                customerId, ex.StripeError?.Code);

            return Result.Failure(new PaymentError(
                Code: ex.StripeError?.Code ?? "unknown",
                Message: ex.StripeError?.Message ?? ex.Message,
                IsRetryable: IsRetryable(ex)));
        }
    }

    public async Task<Result<RefundResult, PaymentError>> RefundCharge(
        PaymentId paymentId,
        Money amount)
    {
        try
        {
            var options = new RefundCreateOptions
            {
                Charge = paymentId.Value,
                Amount = amount.ToCents()
            };

            var service = new RefundService(_stripeClient);
            var refund = await service.CreateAsync(options);

            return Result.Success(new RefundResult(
                OriginalPaymentId: paymentId,
                RefundedAmount: Money.FromCents(refund.Amount, amount.Currency),
                RefundedAt: refund.Created));
        }
        catch (StripeException ex)
        {
            return Result.Failure(new PaymentError(
                Code: ex.StripeError?.Code ?? "unknown",
                Message: ex.StripeError?.Message ?? ex.Message,
                IsRetryable: IsRetryable(ex)));
        }
    }

    private static PaymentResult TranslateCharge(Stripe.Charge charge)
    {
        return new PaymentResult(
            PaymentId: new PaymentId(charge.Id),
            Status: charge.Status switch
            {
                "succeeded" => PaymentStatus.Succeeded,
                "pending" => PaymentStatus.Pending,
                "failed" => PaymentStatus.Failed,
                _ => PaymentStatus.RequiresAction
            },
            TransactionReference: charge.BalanceTransactionId,
            PaymentMethodBrand: charge.PaymentMethodDetails?.Card?.Brand,
            ProcessedAt: charge.Created);
    }

    private static bool IsRetryable(StripeException ex)
    {
        return ex.HttpStatusCode is
            System.Net.HttpStatusCode.TooManyRequests or
            System.Net.HttpStatusCode.ServiceUnavailable or
            System.Net.HttpStatusCode.GatewayTimeout;
    }
}

The TranslateCharge method is the heart of the ACL. It maps charge.Status (a string from Stripe) to PaymentStatus (a domain enum). It maps charge.Id (a Stripe identifier string) to PaymentId (a typed domain ID). It maps charge.BalanceTransactionId to TransactionReference -- a domain name, not a Stripe name. The mapping is explicit, auditable, and testable.

Tomorrow, when the company adds PayPal as a second payment provider, there will be a PayPalPaymentAdapter : IPaymentGateway next to this class. The domain code -- BillingService, proration logic, invoice generation -- does not change. The port stays the same. Only a new adapter appears.

Diagram

In the "Before" diagram, Stripe types flow all the way to the caller. In the "After" diagram, Stripe types stop at the adapter. Everything above the adapter speaks domain language. That dotted line is the DI container -- the domain declares the port, DI wires the adapter.


Fix the NotificationService

The second violation from Part II. The Notifications team queries the main AppDbContext for entities owned by Billing and Subscriptions:

Before

public class NotificationService
{
    private readonly AppDbContext _context;
    private readonly SmtpClient _smtpClient;
    private readonly ITemplateEngine _templateEngine;

    public async Task SendInvoiceNotification(int invoiceId)
    {
        var invoice = await _context.Invoices
            .Include(i => i.Customer)
                .ThenInclude(c => c.NotificationPreferences)
            .Include(i => i.LineItems)
            .Include(i => i.Subscription)
                .ThenInclude(s => s.Plan)
            .FirstOrDefaultAsync(i => i.Id == invoiceId);

        if (invoice == null) return;

        if (!invoice.Customer.NotificationPreferences
            .Any(p => p.Channel == "Email"
                    && p.Category == "Billing"
                    && p.IsEnabled))
            return;

        var model = new InvoiceEmailModel
        {
            CustomerName = invoice.Customer.Name,
            InvoiceNumber = invoice.Id.ToString("D8"),
            Amount = invoice.TotalAmount,
            Currency = invoice.Currency,
            PlanName = invoice.Subscription.Plan.Name,
            LineItems = invoice.LineItems.Select(li => new LineItemModel
            {
                Description = li.Description,
                Amount = li.Amount
            }).ToList(),
            DueDate = invoice.InvoiceDate.AddDays(30)
        };

        // ...send email...
    }
}

Count the boundary crossings. Invoices -- owned by Billing. Customer -- owned by... everyone? Nobody? NotificationPreferences -- owned by Notifications but stored in the same table as Customer. LineItems -- Billing. Subscription -- Subscriptions. Plan -- Subscriptions.

The .Include() chain reaches across three bounded contexts in a single query. When the Billing team renames InvoiceLineItem.Description to InvoiceLineItem.Label, the Notification service breaks. When the Subscriptions team adds a required column to Plans, the notification queries return incomplete data. When anyone runs a migration on any of those tables, the Notifications team gets surprised.

The InvoiceEmailModel is a hand-copied subset of data from three contexts. It drifted out of sync two sprints ago when Billing added TaxBreakdown to Invoice. The notification email still shows the flat tax amount.

After

The fix has two parts: own your data, and get it through events.

Part 1: Notifications owns its own read models.

namespace SubscriptionHub.Notifications.Domain.ReadModels;

/// <summary>
/// What Notifications needs to know about a recipient.
/// Three fields. Not forty-seven.
/// </summary>
public sealed class NotificationRecipient
{
    public string RecipientId { get; init; } = default!;
    public string Name { get; init; } = default!;
    public string Email { get; init; } = default!;
    public string PlanTier { get; init; } = default!;
    public NotificationPreferences Preferences { get; init; } = default!;
}

public sealed class NotificationPreferences
{
    public bool InvoiceEmails { get; init; }
    public bool PlanChangeEmails { get; init; }
    public bool PaymentFailureEmails { get; init; }
}

/// <summary>
/// What Notifications needs to know about an invoice.
/// Enough to render an email template. Nothing more.
/// </summary>
public sealed class InvoiceNotification
{
    public string InvoiceNumber { get; init; } = default!;
    public string RecipientId { get; init; } = default!;
    public decimal Amount { get; init; }
    public string Currency { get; init; } = default!;
    public string PlanName { get; init; } = default!;
    public DateTime DueDate { get; init; }
    public IReadOnlyList<NotificationLineItem> LineItems { get; init; }
        = Array.Empty<NotificationLineItem>();
}

public sealed record NotificationLineItem(string Description, decimal Amount);

These are Notifications' own types. They live in Notifications.Domain. They have exactly the fields Notifications needs and nothing else. Notifications does not know what a Subscription is, what PlanChangedDate means, or how many properties Invoice has. It knows InvoiceNumber, Amount, Currency, PlanName, and a flat list of line items. That is all it needs to render an email.

Part 2: Events populate the read models.

Instead of querying the main database, Notifications subscribes to domain events published by other contexts. When a customer is created, Billing publishes CustomerCreatedEvent. When an invoice is generated, Billing publishes InvoiceGeneratedEvent. Notifications listens, extracts the three fields it cares about, and writes them to its own store.

namespace SubscriptionHub.Notifications.Application.EventHandlers;

public sealed class CustomerCreatedHandler
    : IEventHandler<CustomerCreatedEvent>
{
    private readonly INotificationRecipientStore _store;

    public CustomerCreatedHandler(INotificationRecipientStore store)
    {
        _store = store;
    }

    public async Task Handle(
        CustomerCreatedEvent @event, CancellationToken ct)
    {
        var recipient = new NotificationRecipient
        {
            RecipientId = @event.CustomerId,
            Name = @event.CustomerName,
            Email = @event.Email,
            PlanTier = @event.PlanTier,
            Preferences = new NotificationPreferences
            {
                InvoiceEmails = true,
                PlanChangeEmails = true,
                PaymentFailureEmails = true
            }
        };

        await _store.Save(recipient, ct);
    }
}

public sealed class PlanChangedHandler
    : IEventHandler<PlanChangedEvent>
{
    private readonly INotificationRecipientStore _store;

    public PlanChangedHandler(INotificationRecipientStore store)
    {
        _store = store;
    }

    public async Task Handle(
        PlanChangedEvent @event, CancellationToken ct)
    {
        var recipient = await _store.GetByCustomerId(
            @event.CustomerId, ct);

        if (recipient is null) return;

        var updated = recipient with
        {
            PlanTier = @event.NewPlanTier
        };

        await _store.Save(updated, ct);
    }
}

public sealed class InvoiceGeneratedHandler
    : IEventHandler<InvoiceGeneratedEvent>
{
    private readonly INotificationRecipientStore _store;
    private readonly INotificationSender _sender;

    public InvoiceGeneratedHandler(
        INotificationRecipientStore store,
        INotificationSender sender)
    {
        _store = store;
        _sender = sender;
    }

    public async Task Handle(
        InvoiceGeneratedEvent @event, CancellationToken ct)
    {
        var recipient = await _store.GetByCustomerId(
            @event.CustomerId, ct);

        if (recipient is null || !recipient.Preferences.InvoiceEmails)
            return;

        var notification = new InvoiceNotification
        {
            InvoiceNumber = @event.InvoiceNumber,
            RecipientId = recipient.RecipientId,
            Amount = @event.TotalAmount,
            Currency = @event.Currency,
            PlanName = @event.PlanName,
            DueDate = @event.DueDate,
            LineItems = @event.LineItems
                .Select(li => new NotificationLineItem(
                    li.Description, li.Amount))
                .ToList()
        };

        await _sender.SendInvoiceEmail(recipient, notification, ct);
    }
}

The InvoiceGeneratedEvent is a contract published by the Billing context. It contains exactly the data Billing is willing to share: invoice number, total amount, currency, plan name, due date, and line items. Not the internal Invoice entity. Not the full Customer object. A curated event payload.

The Notifications context never queries another context's database. It maintains its own NotificationRecipient read model -- a projection of events. When the Billing team renames InvoiceLineItem.Description to InvoiceLineItem.Label, nothing breaks in Notifications. The event contract has its own Description property. Internal schema changes in Billing do not propagate.

Diagram

The "Before" has one fat arrow: a 5-level .Include() chain crossing three context boundaries in a single query. The "After" has thin arrows: small events flow in, handlers project them into a local store, and the notification sender reads from its own data. The coupling direction reversed -- Notifications no longer reaches into other contexts. Other contexts publish events and do not know or care who listens.


Fix the Analytics ETL

The third violation. The Analytics team's nightly ETL job runs raw SQL against the production replica with hardcoded column names:

Before

public class AnalyticsEtlJob : BackgroundService
{
    private readonly string _connectionString;

    protected override async Task ExecuteAsync(CancellationToken ct)
    {
        using var connection = new SqlConnection(_connectionString);
        await connection.OpenAsync(ct);

        var revenueQuery = @"
            SELECT
                p.Name AS PlanName,
                p.MonthlyPrice AS PlanPrice,
                COUNT(s.Id) AS ActiveSubscriptions,
                SUM(s.CurrentPrice) AS MonthlyRevenue,
                s.Currency
            FROM Subscriptions s
            INNER JOIN Plans p ON s.PlanId = p.Id
            WHERE s.Status = 'Active'
            GROUP BY p.Name, p.MonthlyPrice, s.Currency";

        using var cmd = new SqlCommand(revenueQuery, connection);
        using var reader = await cmd.ExecuteReaderAsync(ct);

        while (await reader.ReadAsync(ct))
        {
            var planName = reader.GetString(0);
            var planPrice = reader.GetDecimal(1);
            // ... write to analytics warehouse
        }
    }
}

MonthlyPrice. CurrentPrice. Status = 'Active'. Hardcoded column names, hardcoded table names, hardcoded status strings. When the Subscriptions team renamed MonthlyPrice to BasePrice in Sprint 41, this job threw SqlException: Invalid column name 'MonthlyPrice' at 3am. The Analytics team found out when their dashboard went blank the next morning.

The fundamental problem: Analytics is consuming another context's internal storage format. It knows that subscriptions are stored in a table called Subscriptions, that the status column is a string, and that the price column is called MonthlyPrice. These are implementation details of the Subscriptions context's persistence layer. Analytics should not know any of them.

After

The ideal solution mirrors what we did for Notifications: Analytics subscribes to events and maintains its own projections. When a subscription is activated, SubscriptionActivatedEvent fires. When a plan changes, PlanChangedEvent fires. Analytics handlers write to analytics-owned tables with a stable schema designed for aggregation queries.

namespace SubscriptionHub.Analytics.Application.EventHandlers;

public sealed class SubscriptionActivatedHandler
    : IEventHandler<SubscriptionActivatedEvent>
{
    private readonly IAnalyticsProjectionStore _store;

    public SubscriptionActivatedHandler(IAnalyticsProjectionStore store)
    {
        _store = store;
    }

    public async Task Handle(
        SubscriptionActivatedEvent @event, CancellationToken ct)
    {
        var projection = new RevenueProjection
        {
            SubscriptionId = @event.SubscriptionId,
            PlanName = @event.PlanName,
            PlanPrice = @event.MonthlyPrice,
            Currency = @event.Currency,
            ActivatedAt = @event.ActivatedAt,
            IsActive = true
        };

        await _store.Upsert(projection, ct);
    }
}

public sealed class SubscriptionCancelledHandler
    : IEventHandler<SubscriptionCancelledEvent>
{
    private readonly IAnalyticsProjectionStore _store;

    public SubscriptionCancelledHandler(IAnalyticsProjectionStore store)
    {
        _store = store;
    }

    public async Task Handle(
        SubscriptionCancelledEvent @event, CancellationToken ct)
    {
        await _store.MarkInactive(@event.SubscriptionId, ct);
    }
}

The RevenueProjection is an analytics-owned table. Its schema is designed for analytics queries -- denormalized, pre-aggregated where possible, and stable. When the Subscriptions team renames MonthlyPrice to BasePrice internally, the event contract still says MonthlyPrice. The analytics projection does not change. The nightly ETL becomes a simple query against analytics-owned tables:

// Analytics queries its own projection -- no cross-context joins
var revenue = await _analyticsContext.RevenueProjections
    .Where(r => r.IsActive)
    .GroupBy(r => new { r.PlanName, r.Currency })
    .Select(g => new
    {
        PlanName = g.Key.PlanName,
        Currency = g.Key.Currency,
        ActiveSubscriptions = g.Count(),
        MonthlyRevenue = g.Sum(r => r.PlanPrice)
    })
    .ToListAsync(ct);

Clean, typed, and using EF Core against tables that Analytics owns. No raw SQL. No hardcoded column names. No 3am surprises when another team runs a migration.

The Pragmatic Intermediate Step

If introducing events is too much change for one phase -- and sometimes it is, especially if the team does not have an event bus yet -- there is a pragmatic intermediate step: database views.

-- Owned by Analytics, defined as a migration in Analytics.Infrastructure
CREATE VIEW analytics.vw_RevenueByPlan AS
SELECT
    p.Name AS PlanName,
    p.BasePrice AS PlanPrice,  -- Analytics controls the alias
    COUNT(s.Id) AS ActiveSubscriptions,
    SUM(s.CurrentPrice) AS MonthlyRevenue,
    s.Currency
FROM dbo.Subscriptions s
INNER JOIN dbo.Plans p ON s.PlanId = p.Id
WHERE s.Status = 'Active'
GROUP BY p.Name, p.BasePrice, s.Currency;

The view lives in the analytics schema. It is owned by the Analytics team. It is defined as a migration in Analytics.Infrastructure. When the Subscriptions team renames a column, the view definition breaks -- but it breaks in the Analytics project's migration, which means the Analytics team is aware of it at build time, not at 3am. The schema contract is explicit.

This is not the final state. The final state is event-driven projections. But the view is a good step from "raw SQL with hardcoded column names" to "a managed schema contract." The view is an ACL for data. It translates the source schema into the analytics schema, just as the StripePaymentAdapter translates Stripe types into domain types.


Wrap the Tax API

The fourth violation. This one is the most egregious because there is no ACL at all -- not even a broken one. The tax API is called via raw HttpClient directly inside BillingService:

Before

// Inside BillingService.ProcessMonthlyBilling()
// Raw infrastructure in business logic -- no abstraction whatsoever
var taxRequest = new
{
    amount = subtotal,
    currency = invoice.Currency,
    country = customer.BillingAddress?.Country ?? "US",
    state = customer.BillingAddress?.State,
    city = customer.BillingAddress?.City,
    postalCode = customer.BillingAddress?.PostalCode
};

var taxResponse = await _httpClient.PostAsJsonAsync(
    $"{_configuration["TaxApi:BaseUrl"]}/calculate", taxRequest);

if (taxResponse.IsSuccessStatusCode)
{
    var taxResult = await taxResponse.Content
        .ReadFromJsonAsync<TaxApiResponse>();
    taxAmount = taxResult!.TaxAmount;
    invoice.TaxRate = taxResult.Rate;
    invoice.TaxJurisdiction = taxResult.Jurisdiction;
}
else
{
    // Fallback: use cached tax rate
    _logger.LogWarning(
        "Tax API returned {Status}, using cached rate",
        taxResponse.StatusCode);
    var cachedRate = await _context.TaxRateCache
        .Where(t => t.Country == customer.BillingAddress!.Country)
        .OrderByDescending(t => t.CachedAt)
        .FirstOrDefaultAsync();
    taxAmount = subtotal * (cachedRate?.Rate ?? 0.20m);
}

Raw HttpClient. URL built from IConfiguration. Anonymous type serialized to JSON. Manual response parsing. Fallback logic that queries a cache table. All of this inside a method that also handles proration, usage metering, and invoice generation. The tax calculation is welded to the HTTP plumbing, and the HTTP plumbing is welded to the business logic.

This code cannot be tested without an HTTP endpoint (or a mocked HttpClient, which is cumbersome). The fallback path cannot be tested without a database. The tax calculation logic -- "apply the rate to the subtotal" -- is trivial, but it is buried under 15 lines of infrastructure.

After

Step 1: define a port in Billing.Domain.

namespace SubscriptionHub.Billing.Domain.Ports;

public interface ITaxCalculator
{
    Task<Result<TaxCalculation, TaxError>> Calculate(
        Money amount,
        Address address,
        CancellationToken ct = default);
}

public sealed record TaxCalculation(
    Money TaxAmount,
    decimal Rate,
    string Jurisdiction,
    TaxCalculationSource Source);

public enum TaxCalculationSource
{
    Live,
    Cached
}

public sealed record TaxError(
    string Code,
    string Message);

The domain knows it needs a tax calculation. It provides an amount and an address. It receives a TaxCalculation with the tax amount, rate, jurisdiction, and whether the result came from the live API or a cache. The domain does not know about HTTP, JSON, URLs, or fallback logic.

Step 2: implement the adapter in Billing.Infrastructure.

namespace SubscriptionHub.Billing.Infrastructure.Adapters;

public sealed class ExternalTaxCalculatorAdapter : ITaxCalculator
{
    private readonly HttpClient _httpClient;
    private readonly ITaxRateCache _cache;
    private readonly ILogger<ExternalTaxCalculatorAdapter> _logger;

    public ExternalTaxCalculatorAdapter(
        HttpClient httpClient,
        ITaxRateCache cache,
        ILogger<ExternalTaxCalculatorAdapter> logger)
    {
        _httpClient = httpClient;
        _cache = cache;
        _logger = logger;
    }

    public async Task<Result<TaxCalculation, TaxError>> Calculate(
        Money amount,
        Address address,
        CancellationToken ct)
    {
        try
        {
            var request = new TaxApiRequest(
                Amount: amount.Amount,
                Currency: amount.Currency.Code,
                Country: address.Country,
                State: address.State,
                City: address.City,
                PostalCode: address.PostalCode);

            var response = await _httpClient.PostAsJsonAsync(
                "/calculate", request, ct);

            response.EnsureSuccessStatusCode();

            var apiResult = await response.Content
                .ReadFromJsonAsync<TaxApiResponse>(ct);

            return Result.Success(new TaxCalculation(
                TaxAmount: Money.From(apiResult!.TaxAmount, amount.Currency),
                Rate: apiResult.Rate,
                Jurisdiction: apiResult.Jurisdiction,
                Source: TaxCalculationSource.Live));
        }
        catch (HttpRequestException ex)
        {
            _logger.LogWarning(ex,
                "Tax API unavailable, falling back to cached rate for {Country}",
                address.Country);

            return await FallbackToCachedRate(amount, address, ct);
        }
    }

    private async Task<Result<TaxCalculation, TaxError>> FallbackToCachedRate(
        Money amount, Address address, CancellationToken ct)
    {
        var cachedRate = await _cache.GetLatestRate(address.Country, ct);

        if (cachedRate is null)
        {
            return Result.Failure(new TaxError(
                Code: "NO_CACHED_RATE",
                Message: $"No cached tax rate for {address.Country}"));
        }

        var taxAmount = Money.From(
            amount.Amount * cachedRate.Rate, amount.Currency);

        return Result.Success(new TaxCalculation(
            TaxAmount: taxAmount,
            Rate: cachedRate.Rate,
            Jurisdiction: cachedRate.Jurisdiction,
            Source: TaxCalculationSource.Cached));
    }

    private sealed record TaxApiRequest(
        decimal Amount, string Currency, string Country,
        string? State, string? City, string? PostalCode);

    private sealed record TaxApiResponse(
        decimal TaxAmount, decimal Rate, string Jurisdiction);
}

The adapter owns all the infrastructure complexity: HTTP call, JSON serialization, fallback logic, error handling. The TaxApiRequest and TaxApiResponse are private types inside the adapter -- they are the adapter's concern, not the domain's.

Now look at what the domain code becomes:

// In the billing use case -- clean domain logic
var taxResult = await _taxCalculator.Calculate(subtotal, customerAddress, ct);

if (taxResult.IsFailure)
{
    return Result.Failure(
        BillingError.TaxCalculationFailed(taxResult.Error.Message));
}

var tax = taxResult.Value;
invoice.ApplyTax(tax.TaxAmount, tax.Rate, tax.Jurisdiction);

Four lines. No HTTP. No JSON. No fallback logic. No IConfiguration. The business logic reads like business language: calculate tax, apply it to the invoice. The adapter is registered in DI:

// In Billing.Infrastructure DI registration
services.AddHttpClient<ITaxCalculator, ExternalTaxCalculatorAdapter>(client =>
{
    client.BaseAddress = new Uri(configuration["TaxApi:BaseUrl"]!);
    client.Timeout = TimeSpan.FromSeconds(5);
});

The HttpClient is configured once, with a base address and timeout, using the typed client pattern. The ITaxCalculator port is resolved by the DI container. The domain never sees an HttpClient.

Diagram

Domain on the left in green. Infrastructure in the middle in gray. External world on the right in red. The adapter absorbs the complexity. The port keeps the domain clean. The fallback logic is entirely within the adapter -- the domain does not even know that fallback exists. It receives a TaxCalculation with a Source property that says Live or Cached. If the domain cares about the source, it can check. If it does not care, it ignores it. The choice is the domain's.


Contract Tests

Every ACL adapter gets its own test suite. These are not unit tests for the domain. They are contract tests for the translation layer. They verify that the adapter correctly maps external types to domain types. They run in CI. They catch integration drift before production.

The question each contract test answers: "If the external system returns this, does the adapter produce that?"

Testing the StripePaymentAdapter

public class StripePaymentAdapterTests
{
    [Fact]
    public void TranslateCharge_SucceededCharge_MapsAllFields()
    {
        // Arrange: a known Stripe Charge response
        var stripeCharge = new Stripe.Charge
        {
            Id = "ch_1234567890",
            Status = "succeeded",
            BalanceTransactionId = "txn_9876543210",
            Created = new DateTime(2026, 3, 25, 14, 30, 0, DateTimeKind.Utc),
            PaymentMethodDetails = new PaymentMethodDetails
            {
                Card = new PaymentMethodDetailsCard { Brand = "visa" }
            }
        };

        // Act: translate
        var result = StripePaymentAdapter.TranslateCharge(stripeCharge);

        // Assert: domain types
        result.PaymentId.Value.Should().Be("ch_1234567890");
        result.Status.Should().Be(PaymentStatus.Succeeded);
        result.TransactionReference.Should().Be("txn_9876543210");
        result.PaymentMethodBrand.Should().Be("visa");
        result.ProcessedAt.Should().Be(
            new DateTimeOffset(2026, 3, 25, 14, 30, 0, TimeSpan.Zero));
    }

    [Theory]
    [InlineData("succeeded", PaymentStatus.Succeeded)]
    [InlineData("pending", PaymentStatus.Pending)]
    [InlineData("failed", PaymentStatus.Failed)]
    [InlineData("requires_action", PaymentStatus.RequiresAction)]
    [InlineData("some_future_status", PaymentStatus.RequiresAction)]
    public void TranslateCharge_MapsAllStatusValues(
        string stripeStatus, PaymentStatus expectedStatus)
    {
        var charge = new Stripe.Charge { Id = "ch_test", Status = stripeStatus };

        var result = StripePaymentAdapter.TranslateCharge(charge);

        result.Status.Should().Be(expectedStatus);
    }

    [Fact]
    public void TranslateCharge_NullPaymentMethodDetails_SetsNullBrand()
    {
        var charge = new Stripe.Charge
        {
            Id = "ch_test",
            Status = "succeeded",
            PaymentMethodDetails = null
        };

        var result = StripePaymentAdapter.TranslateCharge(charge);

        result.PaymentMethodBrand.Should().BeNull();
    }
}

Three tests. The first verifies that all fields map correctly for a successful charge. The second uses a [Theory] to cover every known Stripe status plus an unknown future status (mapped to RequiresAction as a safe default). The third covers the null case. If Stripe changes their Charge model -- adds a field, renames a property, changes a status string -- these tests fail. Not the domain tests. Not the integration tests. The adapter tests. The blast radius is contained to one class.

Testing the TaxCalculatorAdapter

public class ExternalTaxCalculatorAdapterTests
{
    [Fact]
    public async Task Calculate_SuccessfulApiResponse_ReturnsTaxCalculation()
    {
        // Arrange: mock HTTP response
        var handler = new MockHttpMessageHandler(new HttpResponseMessage
        {
            StatusCode = HttpStatusCode.OK,
            Content = JsonContent.Create(new
            {
                TaxAmount = 10.00m,
                Rate = 0.20m,
                Jurisdiction = "FR-VAT"
            })
        });

        var httpClient = new HttpClient(handler)
        {
            BaseAddress = new Uri("https://tax-api.example.com")
        };

        var adapter = new ExternalTaxCalculatorAdapter(
            httpClient,
            Mock.Of<ITaxRateCache>(),
            NullLogger<ExternalTaxCalculatorAdapter>.Instance);

        // Act
        var result = await adapter.Calculate(
            Money.From(50.00m, Currency.EUR),
            new Address("FR", null, null, null));

        // Assert
        result.IsSuccess.Should().BeTrue();
        result.Value.TaxAmount.Should().Be(Money.From(10.00m, Currency.EUR));
        result.Value.Rate.Should().Be(0.20m);
        result.Value.Jurisdiction.Should().Be("FR-VAT");
        result.Value.Source.Should().Be(TaxCalculationSource.Live);
    }

    [Fact]
    public async Task Calculate_ApiUnavailable_FallsToCachedRate()
    {
        // Arrange: HTTP throws
        var handler = new MockHttpMessageHandler(
            new HttpRequestException("Connection refused"));

        var cache = new Mock<ITaxRateCache>();
        cache.Setup(c => c.GetLatestRate("FR", It.IsAny<CancellationToken>()))
            .ReturnsAsync(new CachedTaxRate("FR", 0.20m, "FR-VAT-CACHED"));

        var httpClient = new HttpClient(handler)
        {
            BaseAddress = new Uri("https://tax-api.example.com")
        };

        var adapter = new ExternalTaxCalculatorAdapter(
            httpClient,
            cache.Object,
            NullLogger<ExternalTaxCalculatorAdapter>.Instance);

        // Act
        var result = await adapter.Calculate(
            Money.From(50.00m, Currency.EUR),
            new Address("FR", null, null, null));

        // Assert: falls back to cache
        result.IsSuccess.Should().BeTrue();
        result.Value.TaxAmount.Should().Be(Money.From(10.00m, Currency.EUR));
        result.Value.Source.Should().Be(TaxCalculationSource.Cached);
    }

    [Fact]
    public async Task Calculate_ApiUnavailable_NoCachedRate_ReturnsFailure()
    {
        var handler = new MockHttpMessageHandler(
            new HttpRequestException("Connection refused"));

        var cache = new Mock<ITaxRateCache>();
        cache.Setup(c => c.GetLatestRate("FR", It.IsAny<CancellationToken>()))
            .ReturnsAsync((CachedTaxRate?)null);

        var httpClient = new HttpClient(handler)
        {
            BaseAddress = new Uri("https://tax-api.example.com")
        };

        var adapter = new ExternalTaxCalculatorAdapter(
            httpClient,
            cache.Object,
            NullLogger<ExternalTaxCalculatorAdapter>.Instance);

        var result = await adapter.Calculate(
            Money.From(50.00m, Currency.EUR),
            new Address("FR", null, null, null));

        result.IsFailure.Should().BeTrue();
        result.Error.Code.Should().Be("NO_CACHED_RATE");
    }
}

Three tests, three scenarios: happy path (API responds), degraded path (API down, cache available), failure path (API down, no cache). Each test verifies the adapter's translation logic in isolation. No real HTTP calls. No real database. The MockHttpMessageHandler returns a canned response, and we verify the adapter translates it correctly.

If the tax API changes its JSON schema -- renames TaxAmount to tax_amount, adds a required field, changes the rate format -- the first test fails. You fix the adapter. The domain tests remain green. The domain code that calls _taxCalculator.Calculate(subtotal, address) does not change.

The Philosophy

Contract tests are the proof that your ACL translates correctly. They document the external contract in executable form. They run in CI on every commit. They catch integration drift before production.

When a contract test fails, the fix is always in the adapter -- never in the domain. That is the entire point of the ACL. The adapter absorbs the change. The domain stays clean. The blast radius is one class and one test file.

For the deeper treatment of how ACLs relate to Bounded Contexts and Context Maps, see Bounded Contexts & Anti-Corruption Layers.


The Scorecard

Run the characterization tests from Phase 1. They still pass -- the external behavior has not changed. The same inputs produce the same outputs. The system behaves identically from the outside.

But run the new contract tests too. They pass. And they prove something the characterization tests could not: that each boundary crossing translates correctly, that each adapter maps every field, that each fallback path works, that each error case produces a domain error instead of a raw exception.

Here is what changed in Phase 3:

Before After
PaymentGateway returns Stripe.Charge IPaymentGateway returns PaymentResult
23 call sites depend on Stripe SDK types 0 call sites depend on Stripe SDK types
NotificationService queries 6 tables across 3 contexts Notifications subscribes to events, owns its read models
Analytics ETL hardcodes column names from Subscriptions schema Analytics owns its projections (or views as intermediate step)
Tax API called via raw HttpClient in business logic ITaxCalculator port + ExternalTaxCalculatorAdapter
0 contract tests Contract tests for every adapter

The boundaries are clean. The connections translate properly. Each context speaks its own language internally and uses adapters to communicate with the outside world. When Stripe changes, only StripePaymentAdapter changes. When the Subscriptions team renames a column, only the analytics view or event contract changes. When the tax API adds a field, only ExternalTaxCalculatorAdapter changes.

The blast radius of every external change is now one adapter class and one test file. Not 23 call sites across 4 projects. Not a 3am page when the nightly ETL discovers a renamed column.

The boundaries are clean. The connections translate properly. Now we can enrich what is inside each context.

Next: Part VIII: Extract Value Objects -- TDD. Write the test, then extract Money, SubscriptionPeriod, EmailAddress. The safest refactoring with the highest ROI.