Mert Özen Further With Every Line
Software Architecture Domain-Driven Design — #05

Context Mapping: Anti-Corruption Layer, Open Host Service, and Relationships Between Bounded Contexts

Mert Özen Nov 7, 2026 13 min 3 views
Context Mapping: Anti-Corruption Layer, Open Host Service, and Relationships Between Bounded Contexts

A context map names the relationships between bounded contexts on both the technical and the organizational level: upstream/downstream, Partnership, Shared Kernel, Customer/Supplier, Conformist, Anti-Corruption Layer, Open Host Service, Published Language, Separate Ways, and Big Ball of Mud. Bookstore's context map, a tested ACL that translates the legacy carrier's SOAP/XML model into Shipping's, a versioned Published Language, and the trap of a Shared Kernel turning into Common.dll.

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

Guess First

The shipment tracking service of Bookstore's carrier is a SOAP (Simple Object Access Protocol) service written years ago. Ask it for an order's status and the answer looks like this:

<ShipmentRecord>
  <RefNo>ORD-1001</RefNo>
  <TrkNo>0007700123456</TrkNo>
  <StatCd>05</StatCd>
  <StatDt>20261107143000</StatDt>
  <BranchCd>IST-034</BranchCd>
  <PcsCnt>1</PcsCnt>
</ShipmentRecord>

05 means "out for delivery", the date is in Turkish local time with no offset, and the tracking number has leading zeros. Bookstore's code uses this answer directly today: the order tracking page, the shipping e-mail, and a report each do their own StatCd == "05" check, in 14 places in total. The carrier announces that a new 04 code is coming for transfer centers. How many files change? And one more: is it possible to talk to the carrier and ask them to change their service to fit Bookstore's needs? Guess.

Context Map: Mapping the Existing Terrain

In the fourth post we gave each bounded context its own model. But contexts touch each other, and those points of contact are a design topic of their own. The Context Map pattern in Evans's DDD (Domain-Driven Design) Reference says: identify each model in play on the project and define its bounded context, including the implicit models of non-object-oriented subsystems. Name each context and make the names part of the ubiquitous language. Describe the points of contact between the models, outlining explicit translation for any communication, and highlighting any sharing, isolation mechanisms, and levels of influence.

The most important sentence in the pattern is this: map the existing terrain, and take up transformations later. A context map is not a drawing of the target architecture but an honest picture of today. If Bookstore's carrier integration carries the old service's XML (Extensible Markup Language) fields into the code today, the map must show that; only then can we decide how to fix it. The Reference also stresses that these relationships are shaped through non-technical channels, meaning the balance of power and communication between teams. Context mapping is half code, half organization.

Three Relationships Between Teams

Before getting to the patterns, the DDD Crew community's context mapping resource splits the relationship between teams into three kinds:

  • Mutually dependent: the two teams' software only works when delivered together. Constant coordination is needed.
  • Upstream/downstream: what the upstream team does affects the downstream team, but not the other way around. The upstream can succeed regardless of the downstream's fate.
  • Free: there is no organizational or technical link between the two teams; a change by one does not affect the other's success.

Upstream and downstream come from the river metaphor: something thrown into the river upstream reaches downstream, but something thrown downstream does not travel up. The carrier is Bookstore's upstream; when it changes its service Bookstore is affected, while Bookstore's needs hardly influence the carrier's roadmap. That is the answer to the second guessing question: most likely not.

Context Mapping Patterns

Evans's Reference lists nine relationship patterns; Partnership and Big Ball of Mud were added after the book. Each one names both the technical and the organizational side of a relationship between two contexts:

PatternWhenWhat you do
PartnershipTwo contexts succeed or fail togetherThe teams manage planning and integration together; interdependent features ship in the same release
Shared KernelTwo teams benefit from sharing a small part of the modelThe shared subset is explicitly bounded, kept small, and not changed without consulting the other team
Customer/SupplierAn upstream/downstream relationship where the upstream cares about the downstreamDownstream priorities go into the upstream's planning; joint acceptance tests are written
ConformistThe upstream has no motivation to meet the downstream's needsThe downstream uses the upstream's model as is and avoids the cost of translation
Anti-Corruption LayerThe upstream's model is weak or unsuitable, but integration is requiredThe downstream builds an isolating translation layer that presents the upstream in terms of its own model
Open Host ServiceMany contexts integrate with one contextAn open protocol everyone can use is defined; special needs are met with one-off translators
Published LanguageTranslation between models needs a common languageA well-documented shared data interchange language is used; often combined with Open Host Service
Separate WaysTwo pieces of functionality have no meaningful relationshipNo integration at all; each side builds its own simple solution
Big Ball of MudAn area where models are mixed and boundaries are inconsistentDraw a boundary around the whole thing, don't attempt sophisticated modeling inside, and keep it from spreading

Khononov groups these patterns into three: cooperation (Partnership, Shared Kernel), customer-supplier (Conformist, Anti-Corruption Layer, Open Host Service), and Separate Ways. The grouping is a reminder that the choice is first an organizational question: do the two teams win and lose together, or is one more powerful than the other?

Bookstore's Context Map

Here is Bookstore's map, assuming the target team structure from the Conway's Law post of the Introduction to Architecture series: the Catalog & Search, Ordering & Payment, Fulfillment (Inventory and Shipping), and Customer Experience (Notification and accounts) teams:

05-context-map-en.png

Catalog and Ordering have a Customer/Supplier relationship: Ordering is Catalog's customer, and the Catalog team takes Ordering's needs into account when it plans. Catalog offers book information as an Open Host Service and price changes through a Published Language, the BookPriceChanged event; Inventory and, later, the mobile app will use the same protocol. Ordering publishes that an order was placed with the OrderPlaced event, and Inventory and Shipping listen to it. Shipping creates the shipment after PaymentCaptured arrives from Payment; we left that arrow out to keep the map readable.

Ordering and Payments are on the same team and succeed or fail together: an order flow that cannot take payment is useless. That is a Partnership, and the two share a small Shared Kernel: only the Money value object. Payments' relationship with the payment provider and Shipping's relationship with the legacy carrier system are anti-corruption layers: in both cases the upstream will not change for us, and its model does not fit ours.

Notification, on the other hand, is a deliberate Conformist: it uses Ordering's and Shipping's events as they are and fills its e-mail templates with their fields. Translating into its own model would be a cost without value; Notification's only job is to notify. The identity provider, which is not on the map, is similar: we take the user and session concepts as they are from the off-the-shelf solution's model. And we must not forget: part of the Bookstore.Business project in the current layered monolith has not yet been split along this map. An honest map marks that area as a Big Ball of Mud and stops new code from attaching to it.

Coding the Anti-Corruption Layer: The Legacy Carrier System

The problem with the 14 places in the guessing question is that the old system's model has leaked into Bookstore. The carrier's StatCd is interpreted separately on the order tracking page, in the e-mail, and in the report; when a new code arrives, all of them change. An anti-corruption layer (ACL) gathers that leak into one place. First, Shipping's own model and the question it asks the outside world:

namespace Bookstore.Shipping;

public enum ShipmentStatus { Accepted, InTransit, OutForDelivery, Delivered, DeliveryFailed, ReturnedToSender }

public readonly record struct TrackingNumber(string Value)
{
    public override string ToString() => Value;
}

public sealed record ShipmentTracking(string OrderReference, TrackingNumber TrackingNumber, ShipmentStatus Status, DateTimeOffset UpdatedAtUtc);

// Port: the Shipping model asks in its own words
public interface IShipmentTracker
{
    Task<ShipmentTracking> TrackAsync(string orderReference, CancellationToken cancellationToken);
}

Shipping does not know "status codes"; it knows ShipmentStatus and TrackingNumber and keeps time in UTC (Coordinated Universal Time). A translator does the translation:

using System.Globalization;
using System.Xml.Linq;

namespace Bookstore.Shipping.LegacyCarrier;

public sealed class LegacyCarrierContractException(string message) : Exception(message);

// Translator: legacy SOAP/XML model -> Shipping model; nothing from the legacy model leaks out
public static class LegacyShipmentTranslator
{
    private static readonly XNamespace Ns = "urn:legacy-carrier:v1";
    private static readonly TimeSpan CarrierClockOffset = TimeSpan.FromHours(3);

    private static readonly Dictionary<string, ShipmentStatus> StatusCodes = new()
    {
        ["01"] = ShipmentStatus.Accepted,
        ["03"] = ShipmentStatus.InTransit,
        ["04"] = ShipmentStatus.InTransit,
        ["05"] = ShipmentStatus.OutForDelivery,
        ["07"] = ShipmentStatus.Delivered,
        ["09"] = ShipmentStatus.ReturnedToSender,
        ["11"] = ShipmentStatus.DeliveryFailed,
    };

    public static ShipmentTracking ToTracking(XDocument soapResponse)
    {
        XElement record = soapResponse.Descendants(Ns + "ShipmentRecord").SingleOrDefault()
            ?? throw new LegacyCarrierContractException("ShipmentRecord is missing.");

        string code = Required(record, "StatCd");
        if (!StatusCodes.TryGetValue(code, out ShipmentStatus status))
            throw new LegacyCarrierContractException($"Unknown status code '{code}'.");

        // The carrier sends local time (UTC+3) without an offset
        DateTime local = DateTime.ParseExact(Required(record, "StatDt"), "yyyyMMddHHmmss", CultureInfo.InvariantCulture);
        DateTimeOffset updatedAtUtc = new DateTimeOffset(local, CarrierClockOffset).ToUniversalTime();

        return new ShipmentTracking(
            Required(record, "RefNo"),
            new TrackingNumber(Required(record, "TrkNo").TrimStart('0')),
            status,
            updatedAtUtc);
    }

    private static string Required(XElement record, string name) =>
        record.Element(Ns + name)?.Value.Trim() is { Length: > 0 } value
            ? value
            : throw new LegacyCarrierContractException($"{name} is missing.");
}

An adapter takes care of speaking SOAP and hands out only Shipping's own model:

using System.Text;
using System.Xml.Linq;

namespace Bookstore.Shipping.LegacyCarrier;

// Adapter: speaks SOAP to the legacy carrier, returns the Shipping model
public sealed class LegacyCarrierAdapter(HttpClient http) : IShipmentTracker
{
    public async Task<ShipmentTracking> TrackAsync(string orderReference, CancellationToken cancellationToken)
    {
        string envelope = $"""
            <soap:Envelope xmlns:soap="http://schemas.xmlsoap.org/soap/envelope/">
              <soap:Body>
                <QueryShipment xmlns="urn:legacy-carrier:v1"><RefNo>{orderReference}</RefNo></QueryShipment>
              </soap:Body>
            </soap:Envelope>
            """;
        using StringContent content = new(envelope, Encoding.UTF8, "text/xml");
        content.Headers.Add("SOAPAction", "urn:legacy-carrier:v1/QueryShipment");

        using HttpResponseMessage response = await http.PostAsync("ShipmentService.asmx", content, cancellationToken);
        response.EnsureSuccessStatusCode();
        await using Stream body = await response.Content.ReadAsStreamAsync(cancellationToken);
        XDocument document = await XDocument.LoadAsync(body, LoadOptions.None, cancellationToken);
        return LegacyShipmentTranslator.ToTracking(document);
    }
}

Let's run it with a stub that returns the same XML instead of the carrier's service:

using System.Globalization;
using System.Net;
using System.Text;
using Bookstore.Catalog.Contracts.V1;
using Bookstore.Shipping;
using Bookstore.Shipping.LegacyCarrier;

CultureInfo.CurrentCulture = CultureInfo.InvariantCulture;

// A stub that answers like the carrier's SOAP service
const string soapResponse = """
    <soap:Envelope xmlns:soap="http://schemas.xmlsoap.org/soap/envelope/">
      <soap:Body>
        <QueryShipmentResponse xmlns="urn:legacy-carrier:v1">
          <ShipmentRecord>
            <RefNo>ORD-1001</RefNo>
            <TrkNo>0007700123456</TrkNo>
            <StatCd>05</StatCd>
            <StatDt>20261107143000</StatDt>
            <BranchCd>IST-034</BranchCd>
            <PcsCnt>1</PcsCnt>
          </ShipmentRecord>
        </QueryShipmentResponse>
      </soap:Body>
    </soap:Envelope>
    """;

HttpClient http = new(new StubHandler(soapResponse)) { BaseAddress = new Uri("https://carrier.example/") };
IShipmentTracker tracker = new LegacyCarrierAdapter(http);

ShipmentTracking tracking = await tracker.TrackAsync("ORD-1001", CancellationToken.None);
Console.WriteLine(tracking);

BookPriceChanged priceChanged = new(Guid.Parse("0199a3e0-0000-7000-8000-000000000001"), 20.00m, 18.00m, "EUR", new DateTimeOffset(2026, 11, 7, 9, 0, 0, TimeSpan.Zero));
Console.WriteLine($"{BookPriceChanged.EventType}: {System.Text.Json.JsonSerializer.Serialize(priceChanged)}");

sealed class StubHandler(string xml) : HttpMessageHandler
{
    protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) =>
        Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK) { Content = new StringContent(xml, Encoding.UTF8, "text/xml") });
}
ShipmentTracking { OrderReference = ORD-1001, TrackingNumber = 7700123456, Status = OutForDelivery, UpdatedAtUtc = 11/07/2026 11:30:00 +00:00 }
bookstore.catalog.book-price-changed.v1: {"BookId":"0199a3e0-0000-7000-8000-000000000001","OldPrice":20.00,"NewPrice":18.00,"Currency":"EUR","ChangedAtUtc":"2026-11-07T09:00:00+00:00"}

The first line is the ACL's output: 05 is now OutForDelivery, 14:30 Turkish time is now 11:30 UTC, and the tracking number is free of its leading zeros. We will come back to the second line in the Open Host Service section. When the carrier adds a new 04 code, the change is a single dictionary line; the order tracking page, the e-mail, and the report work with ShipmentStatus, so none of them is touched. The answer to the guessing question is not 14 but 1.

In his book Evans describes building the inside of an ACL with facades, adapters, and translators; the code above makes the same split. It is the bounded-context-boundary counterpart of the Adapter Pattern post in the Design Patterns series; the difference is that the adapter translates concepts, not just an interface. The Anti-corruption Layer pattern page in Microsoft's Azure Architecture Center describes the same idea and notes its price: the layer adds latency and one more piece to manage. Compared with the infrastructure adapters in the Clean, Onion, and Hexagonal Architecture series, IShipmentTracker is a port and LegacyCarrierAdapter its adapter; the ACL is the translation responsibility added inside that adapter.

Testing the ACL

The most valuable part of the ACL is the translator, and it is a pure function: it takes XML and returns the Shipping model. It can be tested without HTTP (Hypertext Transfer Protocol) or a network:

using System.Xml.Linq;
using Bookstore.Shipping;
using Bookstore.Shipping.LegacyCarrier;

namespace Bookstore.Shipping.Tests;

public class LegacyShipmentTranslatorTests
{
    private static XDocument Response(string statusCode, string statusDate = "20261107143000") => XDocument.Parse($"""
        <soap:Envelope xmlns:soap="http://schemas.xmlsoap.org/soap/envelope/">
          <soap:Body>
            <QueryShipmentResponse xmlns="urn:legacy-carrier:v1">
              <ShipmentRecord>
                <RefNo>ORD-1001</RefNo>
                <TrkNo>0007700123456</TrkNo>
                <StatCd>{statusCode}</StatCd>
                <StatDt>{statusDate}</StatDt>
              </ShipmentRecord>
            </QueryShipmentResponse>
          </soap:Body>
        </soap:Envelope>
        """);

    [Theory]
    [InlineData("01", ShipmentStatus.Accepted)]
    [InlineData("03", ShipmentStatus.InTransit)]
    [InlineData("04", ShipmentStatus.InTransit)]
    [InlineData("05", ShipmentStatus.OutForDelivery)]
    [InlineData("07", ShipmentStatus.Delivered)]
    [InlineData("09", ShipmentStatus.ReturnedToSender)]
    [InlineData("11", ShipmentStatus.DeliveryFailed)]
    public void Maps_legacy_status_codes(string code, ShipmentStatus expected) =>
        Assert.Equal(expected, LegacyShipmentTranslator.ToTracking(Response(code)).Status);

    [Fact]
    public void Converts_carrier_local_time_to_utc() =>
        Assert.Equal(new DateTimeOffset(2026, 11, 7, 11, 30, 0, TimeSpan.Zero),
            LegacyShipmentTranslator.ToTracking(Response("05")).UpdatedAtUtc);

    [Fact]
    public void Drops_leading_zeros_from_tracking_number() =>
        Assert.Equal(new TrackingNumber("7700123456"), LegacyShipmentTranslator.ToTracking(Response("05")).TrackingNumber);

    [Fact]
    public void Unknown_status_code_fails_loudly()
    {
        LegacyCarrierContractException ex = Assert.Throws<LegacyCarrierContractException>(
            () => LegacyShipmentTranslator.ToTracking(Response("99")));
        Assert.Equal("Unknown status code '99'.", ex.Message);
    }
}
dotnet test Bookstore.Shipping.Tests
Passed!  - Failed:     0, Passed:    10, Skipped:     0, Total:    10, Duration: 15 ms - Bookstore.Shipping.Tests.dll (net10.0)

These tests are also the written form of the contract between us and the carrier. The code table, the time zone, and the tracking number format live here; when the carrier changes something, an example is added to these tests first. Throwing an exception for an unknown code instead of silently falling back to a default is a deliberate choice too: showing the wrong status costs more than failing. Logging that exception in production and putting the shipment into an "unknown status" queue is a decision outside the ACL.

Open Host Service and Published Language

If the ACL is the downstream's defense, the Open Host Service is the upstream's courtesy. Ordering, Inventory, the mobile app, and later other contexts will connect to Catalog. Instead of writing a separate translation for each, Catalog publishes a versioned contract that is independent of its internal model:

namespace Bookstore.Catalog.Contracts.V1;

// Published Language: documented, versioned, separate from Catalog's internal Book model
public sealed record BookSummary(Guid BookId, string Isbn, string Title, decimal ListPrice, string Currency);

public sealed record BookPriceChanged(Guid BookId, decimal OldPrice, decimal NewPrice, string Currency, DateTimeOffset ChangedAtUtc)
{
    public const string EventType = "bookstore.catalog.book-price-changed.v1";
}

The last two lines of the demo print this event as JSON (JavaScript Object Notation); the second line of the output is the sentence Catalog says to the outside world. Catalog's internal Book may get ten new properties tomorrow; V1.BookSummary and V1.BookPriceChanged do not change. If a breaking change is needed, V2 is added next to it, and existing consumers keep living with V1 for a while. The .v1 suffix in the event type makes the same promise at the message level.

An everyday example of a Published Language in .NET is ASP.NET Core's ProblemDetails type. It returns error responses in the application/problem+json format defined by RFC (Request for Comments) 7807 and since updated by RFC 9457. This format is neither your model nor your client's; it is a common language both sides translate to and anyone can read. Documenting your endpoints with OpenAPI does the same job: the contract becomes separate from the code, readable, and versionable. We covered these tools in detail in the ProblemDetails-based global error handling and the Swagger and OpenAPI posts of the ASP.NET Core Web API (Application Programming Interface) series; from a context mapping perspective, what matters is that they play the role of a Published Language.

The Limits of a Shared Kernel

On paper a Shared Kernel looks harmless: two teams use a small part of the model in common. The Reference's warning is clear too: this is a very intimate interdependency that can leverage design work or undermine it; keep the kernel small and do not change it without consulting the other team. At Bookstore, Ordering and Payments share only Money in Bookstore.SharedKernel. If currency rules and rounding diverged between the two contexts, the cost would be real money; a single shared definition is genuinely valuable here.

The problem is that a Shared Kernel turns into a "Common" project over time. First Money, then an Address, then a DateHelper, then a BaseEntity everyone uses, then a Customer. One day a change in Common.dll breaks six contexts at once, and the giant class we split up in the fourth post returns under another name. A rule helps: every type added to the Shared Kernel goes in with the approval of both teams that share it, and nothing that does not truly carry the same meaning goes in at all.

Common Pitfalls

Becoming Conformist without noticing. Using the payment provider's status enum or the carrier's StatCd directly in your own entities does not look like a decision, but it is one: you have adopted the upstream's model. Conformist is sometimes the right choice; when it is made deliberately, as with Notification.

Putting an ACL everywhere. Writing a translation layer between two contexts on the same team whose models already fit produces maintenance cost. Build an ACL when the upstream's model really does not fit and you cannot influence the upstream.

Turning the Shared Kernel into a Common.dll dump. Shared code means shared decisions. A kernel that is not kept small quietly erases the boundaries.

Drawing the map as you wish it were. A context map should be a picture of today. Drawing a Big Ball of Mud area as clean boxes gives the team false confidence and makes it easier for new code to attach to that mud.

Keeping the contract identical to the internal model. Serializing Catalog's Book entity to JSON as it is and publishing it is not an Open Host Service; every change in the internal model breaks all consumers. The contract must be separate and versioned.

A Small Experiment

★☆☆ List the external systems of your own project: payment, shipping, e-mail, accounting, identity. Name the relationship for each: Conformist or ACL? Was it a deliberate decision?

★★☆ The carrier adds a new code 13: "recipient not at the address, waiting at the branch". Add it to ShipmentStatus and update the translator and the tests. How many files did you touch? Did any file outside Shipping change?

★★★ Draw a context map for your own system: contexts, external systems, upstream and downstream arrows, and a pattern name on each arrow. Then answer two questions: Which relationship should be different from what it is today? Which area of the map is a Big Ball of Mud?

With this post we have completed Bookstore's strategic design picture: the language, the subdomains, the bounded contexts, and the relationships between them. But we drew this picture at a desk, by reading code. In a real project this knowledge is scattered across domain experts' heads, and there is a fast way to bring it together. In the next post we rediscover Bookstore's order, payment, shipping, and return flow on a wall with EventStorming.