Mert Özen Further With Every Line
Software Design Clean Code — #08

Boundaries: Wrapping Third-Party Code and Learning Tests

Mert Özen Nov 7, 2026 11 min 3 views
Boundaries: Wrapping Third-Party Code and Learning Tests

Put code you don't control, such as a payment SDK, an email library and a cache, behind an interface shaped by your own need and a single adapter; don't let its types and exceptions leak in. Pin the library's behavior down with learning tests: MimeKit's TryParse even accepts "ada", and on the upgrade from 4.17.0 to 4.18.2 all 13 tests passed unchanged while a transitive security warning disappeared.

Version note: The code in this post was built and run with C# 14 and .NET 10.

Guess First: Does MimeKit Accept These Addresses?

We are replacing the static Mailer in the legacy OrderService with a real SMTP (Simple Mail Transfer Protocol) library, and we chose MailKit (and MimeKit, its MIME (Multipurpose Internet Mail Extensions) layer). Someone on the team sees a chance to hand the email.Contains("@") check in CreateCustomer over to MimeKit as well:

using MimeKit;

// Replacing the email.Contains("@") check in CreateCustomer
static bool IsValidEmail(string input) => MailboxAddress.TryParse(input, out _);

string[] inputs = ["ada@example.com", "Ada Lovelace <ada@example.com>", "ada@example", "ada", "ada@@example.com"];
foreach (string input in inputs)
    Console.WriteLine($"{input,-32} {IsValidEmail(input)}");

Which of the five addresses return True? And which ones do you think should be valid for a customer record?

Where Is the Boundary? Three Boundaries in the Legacy Code

The output with MimeKit 4.18.2:

ada@example.com                  True
Ada Lovelace <ada@example.com>   True
ada@example                      True
ada                              True
ada@@example.com                 False

Four out of five pass; only @@ is rejected. MimeKit isn't doing anything wrong: MailboxAddress is designed to read an address from an email header, and it accepts everything RFC (Request for Comments, the document series that defines internet standards) 5322 allows, including the display-name form and local addresses without a domain. Our question is a different one: "can I send an order confirmation to this customer?" The gap between the library's answer and our question is exactly the boundary this post is about.

The boundaries chapter of Clean Code (2008; second edition 2025) by Robert C. Martin (Uncle Bob) was written by James Grenning (a signatory of the Agile Manifesto and a consultant who writes about TDD (Test-Driven Development) for embedded systems), and its core idea is this: manage the places where code you don't control meets your own code deliberately; don't spread that code's types and assumptions through your system. The legacy file has three such boundaries:

  • private PaymentClient pc = new PaymentClient("merchant-001", 30); the payment SDK (Software Development Kit) is a field directly on OrderService; the SDK's "00" success code is written separately in ProcessData and CancelOrder, and the minor-unit conversion (long)(o.Total * 100) also appears twice.
  • private Dictionary<string, object> cache and a public GetFromCache(string key) that returns object; every caller has to guess what is stored under which type.
  • The static Mailer.Send(to, subject, body, html); it can't be tested, can't be replaced, and its failures can be swallowed.

Putting Third-Party Code Behind Your Own Interface

The payment SDK

The first step is an interface that describes payment in the shape of our needs. The vendor's ChargeResult, "00" and long amountMinor are not in this interface:

public sealed record PaymentRequest(int OrderId, string CardToken, decimal Amount, string Currency);

public abstract record PaymentOutcome
{
    public sealed record Approved(string TransactionId) : PaymentOutcome;
    public sealed record Declined(string Reason) : PaymentOutcome;
}

// The shape of our need, not the vendor's
public interface IPaymentGateway
{
    Task<PaymentOutcome> ChargeAsync(PaymentRequest request, CancellationToken ct = default);
}

Everything there is to know about the vendor is gathered in a single adapter class:

public sealed class VendorPaymentGateway(PaymentClient client) : IPaymentGateway
{
    private const string ApprovedCode = "00";

    public Task<PaymentOutcome> ChargeAsync(PaymentRequest request, CancellationToken ct = default)
    {
        ArgumentException.ThrowIfNullOrEmpty(request.CardToken);
        ArgumentOutOfRangeException.ThrowIfNegativeOrZero(request.Amount);
        try
        {
            ChargeResult result = client.Charge(request.CardToken, ToMinorUnits(request.Amount), request.Currency);
            PaymentOutcome outcome = result.Code == ApprovedCode
                ? new PaymentOutcome.Approved(result.TransactionId)
                : new PaymentOutcome.Declined($"{result.Message} ({result.Code})");
            return Task.FromResult(outcome);
        }
        catch (Exception ex) when (ex is not ArgumentException)
        {
            throw new PaymentGatewayException(
                $"Charging order {request.OrderId} ({request.Amount:0.00} {request.Currency}) failed.", ex);
        }
    }

    // The minor-unit conversion lives in one place
    private static long ToMinorUnits(decimal amount) => (long)Math.Round(amount * 100m, MidpointRounding.AwayFromZero);
}
Approved { TransactionId = TX0001 }
Declined { Reason = Do not honor (05) }

This is the Adapter pattern from GoF (the Gang of Four: Gamma, Helm, Johnson and Vlissides' 1994 book Design Patterns); at the architectural scale it also goes by ports and adapters. The gain shows up in three places. If the provider changes, only this class changes. The tests for OrderPlacement run with the FakePaymentGateway from the previous post without touching the vendor at all. And the vendor's inconsistencies (we'll see them shortly in the learning tests) are normalized in one place.

.NET applies this approach to its own ecosystem. Behind ILogger<T> there can be the console, Serilog or OpenTelemetry; behind IDistributedCache, Redis or SQL (Structured Query Language) Server; TimeProvider abstracts the system clock itself. The newest example is IChatClient in Microsoft.Extensions.AI: it puts the SDKs of different LLM (Large Language Model) providers behind a single interface that is added to the DI (Dependency Injection) container with a single registration, and the LLM series post "Provider-Independent LLM Code: IChatClient, the OpenAI-Compatible /v1 and DI" shows it in practice.

The email library

In place of the static Mailer, an IEmailSender and a MailKit adapter built the same way:

public sealed record EmailMessage(string To, string Subject, string Body);

public interface IEmailSender
{
    Task SendAsync(EmailMessage message, CancellationToken ct = default);
}

public sealed class MailKitEmailSender(SmtpSettings settings) : IEmailSender
{
    public async Task SendAsync(EmailMessage message, CancellationToken ct = default)
    {
        var mime = new MimeMessage();
        mime.From.Add(new MailboxAddress("Bookstore", settings.FromAddress));
        mime.To.Add(MailboxAddress.Parse(message.To));
        mime.Subject = message.Subject;
        mime.Body = new TextPart("plain") { Text = message.Body };

        try
        {
            using var client = new SmtpClient();
            await client.ConnectAsync(settings.Host, settings.Port, SecureSocketOptions.StartTlsWhenAvailable, ct);
            await client.SendAsync(mime, ct);
            await client.DisconnectAsync(quit: true, ct);
        }
        catch (Exception ex) when (ex is SocketException or IOException or ProtocolException or CommandException)
        {
            // MailKit types never leave this class
            throw new EmailDeliveryException($"Sending '{message.Subject}' to {message.To} failed.", ex);
        }
    }
}

Half of the wrapping is about types, the other half about exceptions. Sending while the SMTP server is down:

EmailDeliveryException: Sending 'Order #1000' to ada@example.com failed.
  inner: System.Net.Sockets.SocketException: Connection refused

The rest of the application knows about EmailDeliveryException, not MailKit's SmtpCommandException or a SocketException. If you replace the library tomorrow with another provider's HTTP (Hypertext Transfer Protocol) API (Application Programming Interface), the retry and logging code stays the same. MimeMessage isn't in the interface either; if it were, changing MailKit would mean changing every caller.

Don't Spread Dictionaries and Caches Through the System

Grenning's first example in the chapter was Java's Map: pass a Map around inside your system and everyone can clear it, put anything into it, and has to cast when reading from it. The legacy cache field is the C# version of that. If one person stores the quote as a double and someone else reads it as a decimal:

var legacyCache = new Dictionary<string, object> { ["quote:1"] = 218.0 };
decimal quote = (decimal)legacyCache["quote:1"];
Unable to cast object of type 'System.Double' to type 'System.Decimal'.

A boxed double inside an object can only be unboxed to double. Switching to IMemoryCache doesn't fix this on its own, it only makes the failure silent; when TryGetValue<decimal> finds a value of the wrong type, it returns false instead of throwing:

IMemoryCache TryGetValue<decimal>: False

So every request misses the cache and nobody notices. The fix is to put the cache behind a type of its own; the key format, the value type and the lifetime all live in one place:

public sealed class QuoteCache(IMemoryCache cache)
{
    private static readonly TimeSpan Lifetime = TimeSpan.FromMinutes(5);

    public bool TryGet(int customerId, string cartKey, out decimal quote) =>
        cache.TryGetValue(Key(customerId, cartKey), out quote);

    public void Set(int customerId, string cartKey, decimal quote) =>
        cache.Set(Key(customerId, cartKey), quote, Lifetime);

    private static string Key(int customerId, string cartKey) => $"quote:{customerId}:{cartKey}";
}
QuoteCache: True 218.00

Martin's advice isn't to never use a Map, but to keep it from crossing the boundary: don't return or accept Dictionary<string, object> in a public API. Code using QuoteCache can't call Clear(), can't store a value of the wrong type, and doesn't know the expiration policy.

Learning Tests: Learning a Library Through Tests

We could have learned the opening surprise in a test instead of in production. Grenning's suggestion goes like this: you already write small experiments to try out a new library; don't throw them away, write them as unit tests. Tests that pin down the library behaviors we rely on are called learning tests:

// The MimeKit behaviors we rely on; these run first on a version upgrade
public class MimeKitLearningTests
{
    [Theory]
    [InlineData("ada@example.com")]
    [InlineData("Ada Lovelace <ada@example.com>")]
    [InlineData("ada@example")]
    [InlineData("ada")]
    public void TryParse_accepts_addresses_that_are_valid_by_rfc_but_not_for_us(string input)
    {
        Assert.True(MailboxAddress.TryParse(input, out _));
    }

    [Fact]
    public void Parse_throws_ParseException_not_FormatException()
    {
        Assert.Throws<ParseException>(() => MailboxAddress.Parse("ada@@example.com"));
    }

    [Fact]
    public void Non_ascii_subject_is_encoded_as_utf8_base64_and_ascii_tail_stays_readable()
    {
        var message = new MimeMessage { Subject = "Siparişiniz alındı #1042" };

        string? header = message.Headers[HeaderId.Subject];
        using var stream = new MemoryStream();
        message.Headers.WriteTo(stream);
        string raw = Encoding.ASCII.GetString(stream.ToArray());

        Assert.Equal("Siparişiniz alındı #1042", header);
        Assert.Contains("Subject: =?utf-8?b?U2lwYXJpxZ9pbml6IGFsxLFuZMSx?= #1042", raw);
    }
}

The same approach is even more valuable for the payment SDK, whose documentation is usually incomplete. Writing tests that pin down four of the SDK's behaviors surfaced an inconsistency: a declined card comes back with code "05", refunding an unknown transaction comes back with code "12", but a missing card token throws ArgumentNullException. And the SDK doesn't validate a negative amount at all:

[Fact]
public void Amount_is_in_minor_units_and_is_not_validated()
{
    ChargeResult result = _client.Charge("tok_1", -500, "TRY");

    Assert.Equal("00", result.Code);
}

These tests document why the two guards in the adapter, ThrowIfNullOrEmpty and ThrowIfNegativeOrZero, are there; the adapter rejects a -5 TRY (Turkish lira) request without ever sending it to the SDK. All 13 tests pass (an excerpt of the output):

  Passed Bookstore.LearningTests.PaymentClientLearningTests.Declined_card_is_a_result_code_not_an_exception [2 ms]
  Passed Bookstore.LearningTests.PaymentClientLearningTests.Refund_with_unknown_transaction_returns_code_12_instead_of_throwing [< 1 ms]
  Passed Bookstore.LearningTests.PaymentClientLearningTests.Amount_is_in_minor_units_and_is_not_validated [< 1 ms]
  Passed Bookstore.LearningTests.PaymentClientLearningTests.Missing_card_token_throws_instead_of_returning_a_code [< 1 ms]
  Passed Bookstore.LearningTests.MimeKitLearningTests.TryParse_accepts_addresses_that_are_valid_by_rfc_but_not_for_us(input: "ada") [< 1 ms]
  Passed Bookstore.LearningTests.MimeKitLearningTests.Parse_throws_ParseException_not_FormatException [< 1 ms]
  Passed Bookstore.LearningTests.MimeKitLearningTests.Non_ascii_subject_is_encoded_as_utf8_base64_and_ascii_tail_stays_readable [11 ms]

The second payoff of learning tests comes with version upgrades. When I first wrote the tests I was on MimeKit 4.17.0, and every build said this:

t08.csproj : warning NU1902: Package 'System.Security.Cryptography.Pkcs' 10.0.0 has a known moderate severity vulnerability, https://github.com/advisories/GHSA-2fmv-rv2c-59x7
t08.csproj : warning NU1903: Package 'System.Security.Cryptography.Pkcs' 10.0.0 has a known high severity vulnerability, https://github.com/advisories/GHSA-846f-f764-76mx

The vulnerability wasn't in MimeKit itself but in a transitive package it brings along. After upgrading to 4.18.2, restore pulled in System.Security.Cryptography.Pkcs 10.0.13, the warnings disappeared, and the 13 learning tests passed unchanged. We learned that the upgrade didn't break the behaviors we rely on without opening the application and trying it by hand. How NuGetAudit works and how to manage transitive dependencies, together with SRI (Subresource Integrity) checks on the browser side, is covered in the OWASP (Open Worldwide Application Security Project) series post "OWASP A03: Software Supply Chain Failures — NuGetAudit, npm audit and SRI".

Writing Code Against an API That Doesn't Exist Yet

The legacy ShipOrder writes the shipping label to a .txt file. In the new version a carrier's API will produce the label, but the carrier hasn't been chosen yet and the contract will take weeks. That was Grenning's story in the chapter too: while the API of a radio transmitter subsystem hadn't been designed yet, the team wrote the interface they wished they had, developed their code against it, and closed the gap with an adapter once the real API arrived.

public sealed record ShipmentRequest(int OrderId, string RecipientName, string Street, string Zip, string City, bool Express);

public sealed record ShippingLabel(string TrackingNumber, string Carrier);

// No carrier chosen yet: we define the interface around today's need
public interface IShippingLabelService
{
    Task<ShippingLabel> CreateLabelAsync(ShipmentRequest request, CancellationToken ct = default);
}

The fake implementation stores the requests and returns a fake tracking number:

ShippingLabel { TrackingNumber = FAKE000001, Carrier = fake }

The benefit of working in this order is that the interface speaks the user's language, not the library's. When the real API arrives it will probably bring concepts of its own such as AddressLine1, PostalCode and ServiceLevel; that translation will be the adapter's job. The fake, meanwhile, stays in the tests for good.

What Wrapping Buys You When a License Changes

The least discussed payoff of managing boundaries isn't technical but legal. In 2025 the licenses of three widely used libraries in the .NET world changed. In January 2025 FluentAssertions 8 moved from Apache 2.0 to a commercial license from Xceed (a software company selling .NET components); 7.x stayed on Apache 2.0. On July 2, 2025 Jimmy Bogard, the author of AutoMapper and MediatR, launched the commercial editions of AutoMapper 15 and MediatR 13; there is a free community license for small companies, and older versions keep their own licenses.

If IMapper is injected directly into 300 files, the license decision is a 300-file migration. If you put DTO (Data Transfer Object) mapping behind your own extension methods or a single mapping layer, the change is limited to that layer. The same goes for test assertions: a test suite that uses domain-specific assert helpers suffers far less when moving from FluentAssertions to Shouldly or to the community fork AwesomeAssertions; we will come back to this two posts from now, in the post on clean test code. The practical consequences of the AutoMapper license change and the Mapperly alternative are in the ASP.NET Core Web API series post "DTOs and Mapping in ASP.NET Core Web API: Hand Mapping, the AutoMapper License and Mapperly".

Common Pitfalls

Writing an interface that copies the library's API one to one. An interface called IMailKitClient that mirrors ConnectAsync, AuthenticateAsync and SendAsync(MimeMessage) exactly abstracts nothing; it only makes it mockable. Design the interface around the user's question: "send the order confirmation".

Leaking the library's types through signatures. If MimeMessage, ChargeResult or SmtpCommandException get out of the adapter, the boundary exists only on paper. The most common form of the same leak is returning IQueryable<T> from a repository; at that moment Entity Framework Core's (EF Core) translation rules become the rules of the whole application.

The fake and the real implementation drifting apart over time. If the fake returns Declined for "tok_declined" while the real adapter throws an exception in the same situation, the tests lie. Contract tests that run the same scenarios against both the fake and the real adapter (in a sandbox environment) catch this drift.

Mixing learning tests into the unit test folder and deleting them later. They test the library, not your code; keep them in a separate, clearly named class and look at them first on version upgrades.

When Not to Use It

Don't wrap everything. List<T>, Dictionary<TKey, TValue>, string, LINQ (Language Integrated Query) and most of the BCL (Base Class Library, .NET's core class library) are not third-party code; writing an IListWrapper protects you against no change at all. Two questions are enough for the wrapping decision: is this dependency likely to change (provider, license, version), and is the area where it's used large? If both answers are "no", use it directly.

Don't try to wrap a framework you have committed to on purpose. Putting ASP.NET Core's HttpContext or EF Core's DbContext behind interfaces of your own brings a large cost in most projects and makes the framework's best features (change tracking, LINQ translation) unusable. The debate about putting a repository on top of EF Core is in the ASP.NET Core Web API series post "Layered Structure in ASP.NET Core Web API: The Service Layer and the Repository Debate".

Don't wrap a library right away when it is used in a single place, either. If only one class that generates invoices uses a PDF (Portable Document Format) library, that class already is the boundary; a separate interface adds nothing more than a second boundary line.

A Small Experiment

★☆☆ Add a test to MimeKitLearningTests that pins down how the To header is encoded for a recipient with a Turkish display name (new MailboxAddress("Ayşe Yılmaz", "ayse@example.com")). Then check what Email.TryCreate from the sixth post does with the same address.

★★☆ Add RefundAsync to VendorPaymentGateway and decide how to translate the SDK's "12" code: Declined, or an exception? Document your decision with a learning test.

★★★ Write an abstract contract test class for IPaymentGateway, and create two derived test classes that run the same tests against both FakePaymentGateway and VendorPaymentGateway. Which test passed on the fake and broke on the real adapter?

In this post we separated the places where OrderService talks to the outside world. What's left is the inside: the 600-line class still carries pricing, shipping, cancellation, reporting, invoicing and import work all together. In the next post we will measure the size of a class by responsibility rather than by lines and make cohesion visible by looking at field usage.