Blog/Bounded Contexts Are a Team Problem, Not a Code Problem
dddbounded-contextsarchitectureteam-topology

Bounded Contexts Are a Team Problem, Not a Code Problem

January 30, 2024·12 min read·by Bishwambhar Sen
A context map diagram showing three bounded contexts with labeled integration relationships: shared kernel, anticorruption layer, and conformist

Concept

A bounded context is an explicit, named boundary within which a domain model is internally consistent and the ubiquitous language applies without ambiguity. Within the Ordering context, "customer" means an authenticated account holder with a billing address and payment method. Within the Shipping context, "customer" means a physical delivery destination with a recipient name and postal code. These are not the same concept, and pretending they are — by sharing a single Customer entity across both contexts — is how you accumulate the accidental complexity that breaks systems under change.

Eric Evans coined the term in Domain-Driven Design (2003). The boundary is linguistic as much as it is technical. When two developers in the same room use the same word to mean different things in different parts of the system, they are operating across context boundaries without recognising them. The work of identifying bounded contexts is the work of making these linguistic boundaries explicit, naming them, and deciding how they communicate.

The code is the easy part. You can refactor code. You can rename classes. You can split a repository. The hard part is the organisational alignment: which team owns which context, how teams negotiate the terms of inter-context integration, and what happens when a team upstream of you has business incentives that diverge from yours.

Conway's Law Is Not a Joke

Melvin Conway's 1968 observation — that "organisations which design systems are constrained to produce designs which are copies of the communication structures of those organisations" — is the most empirically validated observation in software architecture. Boundaries in your system will, over time, converge on boundaries in your organisation. If your payment team and your order team share a service with a single deployment pipeline, the integration friction between them will eventually produce a monolith, regardless of how carefully you drew context boundaries in a design document.

The implication for architects: design your context boundaries and your team topology simultaneously. A bounded context without a dedicated team to own and evolve it is a boundary that will be violated by whoever needs to ship next.

Constraints

The Context Mapping Vocabulary

Eric Evans defines a set of relationships between bounded contexts that describe the power dynamics and integration obligations involved. Understanding these relationships is essential for reasoning about the organisational and technical trade-offs of your context map.

Shared Kernel: Two contexts share a small, explicitly agreed-upon subset of the domain model. Both teams own and must approve changes to the shared portion. This minimises integration code but tightly couples the teams' release cycles. Use sparingly — only for concepts so fundamental that duplication would be genuinely harmful.

Customer-Supplier: An upstream context produces data or events that a downstream context consumes. The upstream team (supplier) has the power to change their model; the downstream team (customer) must accommodate. If the relationship is formalized — the upstream team treats the downstream as a customer whose needs they actively serve — the integration is manageable. If the upstream team is indifferent or hostile, the downstream team needs an Anticorruption Layer.

Anticorruption Layer (ACL): A translation layer in the downstream context that converts the upstream context's model into the downstream context's own domain model. The ACL prevents the upstream model's language and structure from "leaking" into the downstream context and corrupting its domain integrity. The ACL adds maintenance overhead (it must evolve as both models evolve), but it grants the downstream team full autonomy over their own model.

Conformist: The downstream context simply adopts the upstream context's model without translation. This eliminates the ACL overhead but sacrifices the downstream context's domain autonomy. Appropriate when the upstream is an external system with high authority (a payment gateway's API, a regulatory data model) that you cannot influence and that has a well-defined, stable schema.

Open Host Service / Published Language: The upstream context exposes its model through a well-defined, versioned API or message schema, designed explicitly to be consumed by many downstream contexts. REST APIs and event schemas are typically Open Host Services. This relationship decouples the upstream implementation from downstream consumers.

Separate Ways: Two contexts have no integration relationship. Each evolves independently and serves different user needs. Often the appropriate relationship for contexts that appear related on the surface but serve fundamentally different domains (e.g., a customer-facing e-commerce context and an internal warehouse management context in a large company where they happen to share "order" terminology).

Ubiquitous Language Enforcement

The ubiquitous language — the shared vocabulary within a bounded context — must be enforced at every level: in code (class names, method names, property names), in team communication (engineers should use the same terms as domain experts), and in documentation. When the code uses CustomerDto where the domain says Buyer, the boundary is starting to erode.

Enforcement mechanisms:

  • Code review gates that flag domain term violations
  • Architectural fitness functions that verify no external context's model types are imported directly (without an ACL) into a bounded context's domain layer
  • Structured domain glossaries reviewed in sprint planning

Trade-offs

Monolith vs. Context-Per-Service Deployment

A common mistake is equating bounded contexts with microservices. A bounded context is a modelling boundary; a microservice is a deployment boundary. These are not the same thing.

A monolith can contain multiple well-defined bounded contexts — as modules with explicit interfaces between them — and be a better architecture than a poorly bounded collection of microservices that share databases and violate each other's language. Start with modular monolith boundaries aligned to bounded contexts, and extract to separate deployable services only when the operational reasons (independent scaling, independent deployment, team autonomy at runtime) become concrete enough to justify the distributed systems overhead.

The modular monolith approach allows teams to work independently on separate modules without the latency, partial failure, and eventual consistency overhead of inter-service calls. Extraction to services can happen later, guided by actual load and team growth — not speculative decomposition.

The Shared Database Anti-Pattern

When two bounded contexts share a physical database and each writes to the other's tables directly, the contexts are not bounded — they are coupled at the data layer, which is the tightest possible coupling. A schema change in the Orders table breaks the Shipping context. A new index needed by Shipping degrades Order write throughput.

The enforcement rule: each bounded context owns exactly one database (or schema, in a modular monolith), and no other context reads or writes to it directly. Cross-context data needs are served through the context's API or event stream, never through direct database access. This rule is non-negotiable. Every exception to it will eventually become a production incident.

Code

The following C# demonstrates an Anticorruption Layer translating a payment gateway's external model into the domain's own Billing context model. The ACL is a thin, explicit translation layer — not business logic.

// External payment gateway model — we do not control this schema
// It arrives from the payment provider's SDK or API
namespace PaymentGateway.External.Models
{
    public class GatewayTransactionResult
    {
        public string TxnRef { get; set; } = string.Empty;
        public string StatusCode { get; set; } = string.Empty; // "00" = success, "05" = declined
        public decimal NetAmt { get; set; }
        public string CurrCd { get; set; } = "USD";
        public long EpochMs { get; set; }
        public string MerchantId { get; set; } = string.Empty;
        public string? FailReason { get; set; }
    }
}

// Our Billing bounded context's domain model — clean, intention-revealing language
namespace MPC.Billing.Domain
{
    public class PaymentCapture
    {
        public PaymentCaptureId Id { get; init; }
        public Money Amount { get; init; }
        public PaymentStatus Status { get; init; }
        public DateTimeOffset CapturedAt { get; init; }
        public string? DeclineReason { get; init; }

        private PaymentCapture() { }

        public static PaymentCapture Successful(
            PaymentCaptureId id, Money amount, DateTimeOffset capturedAt) =>
            new() { Id = id, Amount = amount, Status = PaymentStatus.Captured, CapturedAt = capturedAt };

        public static PaymentCapture Declined(
            PaymentCaptureId id, Money amount, DateTimeOffset attemptedAt, string reason) =>
            new() { Id = id, Amount = amount, Status = PaymentStatus.Declined,
                    CapturedAt = attemptedAt, DeclineReason = reason };
    }

    public enum PaymentStatus { Captured, Declined, Pending, Refunded }
}

// The Anticorruption Layer — lives in the Infrastructure layer of the Billing context
namespace MPC.Billing.Infrastructure.Adapters
{
    public class PaymentGatewayAdapter : IPaymentGateway
    {
        private readonly IGatewayHttpClient _gatewayClient;
        private readonly ILogger<PaymentGatewayAdapter> _logger;

        public PaymentGatewayAdapter(
            IGatewayHttpClient gatewayClient,
            ILogger<PaymentGatewayAdapter> logger)
        {
            _gatewayClient = gatewayClient;
            _logger = logger;
        }

        public async Task<PaymentCapture> CapturePaymentAsync(
            PaymentCaptureRequest request,
            CancellationToken ct)
        {
            // Translate our domain request to the gateway's protocol
            var gatewayRequest = new GatewayChargeRequest
            {
                MerchantId = request.MerchantAccountId.Value,
                Amount = request.Amount.Value,
                CurrencyCode = request.Amount.Currency.IsoCode,
                Reference = request.CorrelationId.ToString()
            };

            var gatewayResult = await _gatewayClient.ChargeAsync(gatewayRequest, ct);

            // Translate the gateway's result to our domain model — ACL translation happens here
            return TranslateGatewayResult(request, gatewayResult);
        }

        private PaymentCapture TranslateGatewayResult(
            PaymentCaptureRequest request,
            GatewayTransactionResult gatewayResult)
        {
            var captureId = new PaymentCaptureId(gatewayResult.TxnRef);
            var amount = new Money(gatewayResult.NetAmt, Currency.FromIsoCode(gatewayResult.CurrCd));
            var timestamp = DateTimeOffset.FromUnixTimeMilliseconds(gatewayResult.EpochMs);

            // Map the gateway's cryptic status codes to our domain's intention-revealing enum
            return gatewayResult.StatusCode switch
            {
                "00" => PaymentCapture.Successful(captureId, amount, timestamp),
                "05" or "51" or "61" => PaymentCapture.Declined(
                    captureId, amount, timestamp,
                    reason: TranslateDeclineReason(gatewayResult.StatusCode, gatewayResult.FailReason)),
                _ => throw new UnrecognisedPaymentGatewayResponseException(
                    $"Unrecognised gateway status code '{gatewayResult.StatusCode}' " +
                    $"for transaction {gatewayResult.TxnRef}. Manual review required.")
            };
        }

        private static string TranslateDeclineReason(string statusCode, string? gatewayReason) =>
            statusCode switch
            {
                "05" => "Card declined by issuer",
                "51" => "Insufficient funds",
                "61" => "Transaction limit exceeded",
                _    => gatewayReason ?? "Payment declined"
            };
    }
}

The context mapping relationship declaration below shows a lightweight C# approach to documenting context relationships in code — making them visible to future architects who read the codebase.

// Context relationship registry — architectural decision record in code
// Lives in the solution root, reviewed as part of architectural fitness functions

namespace MPC.Architecture.ContextMap
{
    /// <summary>
    /// Declares the integration relationship between the Billing context (downstream)
    /// and the external Payment Gateway (upstream, Conformist relationship for schema,
    /// ACL pattern for domain isolation).
    ///
    /// Team alignment: Billing team owns the ACL translation logic.
    /// The external Payment Gateway team has no knowledge of our domain model.
    /// Schema changes from the gateway require updating <see cref="PaymentGatewayAdapter"/>.
    /// </summary>
    [ContextRelationship(
        Upstream = "PaymentGateway.External",
        Downstream = "MPC.Billing",
        RelationshipType = ContextRelationshipType.ConformistWithAcl,
        AclAdapter = typeof(PaymentGatewayAdapter),
        ReviewedBy = "Platform Architecture Team",
        LastReviewedDate = "2024-01-15")]
    internal sealed class BillingToPaymentGatewayRelationship { }

    /// <summary>
    /// Declares the Customer-Supplier relationship between the Ordering context (downstream, customer)
    /// and the Catalogue context (upstream, supplier). The Catalogue team actively supports
    /// Ordering's requirements. Ordering consumes ProductAvailabilityChanged events via the
    /// shared message bus with an ACL to translate catalogue product models to order line items.
    /// </summary>
    [ContextRelationship(
        Upstream = "MPC.Catalogue",
        Downstream = "MPC.Ordering",
        RelationshipType = ContextRelationshipType.CustomerSupplierWithAcl,
        AclAdapter = typeof(CatalogueToOrderingAdapter),
        ReviewedBy = "Platform Architecture Team",
        LastReviewedDate = "2024-01-15")]
    internal sealed class OrderingToCatalogueRelationship { }
}

This [ContextRelationship] attribute pattern is not a framework feature — it is a documentation convention enforced by an architectural fitness function (a unit test that reflects over the assembly and asserts that every cross-context dependency has a declared relationship). When a developer imports a type from MPC.Catalogue directly into MPC.Ordering.Domain without a registered adapter, the fitness function fails the build.

Further Reading