Four Context Mapping Patterns for Cross‑Bounded‑Context Collaboration
The article explains how to integrate multiple bounded contexts in a payroll system by using four DDD‑based relationship patterns—Shared Kernel, Customer‑Supplier, Anti‑Corruption Layer, and Open Host Service—detailing their motivations, code organization, risks, and selection criteria.
Why Communication Between Bounded Contexts Matters
The previous post split a payroll system into four bounded contexts: Employee Information, Attendance, Payroll Calculation, and Tax. Each context has its own language and model, but real‑world requirements force them to communicate, e.g., payroll needs attendance data, employee salary grades, and external bank APIs.
Directly importing classes from another context (e.g., AttendanceRecord) leads to compile‑time failures and subtle bugs when the supplier changes method names or semantics, illustrating the cost of missing collaboration contracts.
Context Mapping Is More Than an API Topology
Context Mapping should answer who decides, who compromises, and where the boundaries lie when two bounded contexts need to cooperate. DDD lists eight relationship modes; this article condenses them into four practical patterns that cover 95 % of the payroll system scenarios.
Shared Kernel : a minimal set of common concepts.
Customer‑Supplier : internal teams negotiate an API contract.
Anti‑Corruption Layer (ACL) : isolates dirty external data.
Open Host Service (OHS) : the provider publishes a stable protocol for many downstream consumers.
Posture One – Shared Kernel
Problem it solves
When several contexts need the same identifier or value object (e.g., EmployeeId, Money, PayCycle), duplicating them forces conversion code and risks mismatched definitions.
Code organization
payroll-shared-kernel/
├── src/main/java/com/company/shared/domain/
│ ├── EmployeeId.java // identifier
│ ├── Money.java // value object with currency
│ ├── PayCycle.java // year‑month period
│ └── DomainEvent.java // base event class public final class EmployeeId {
private final String value;
private EmployeeId(String value) { /* validation */ }
public static EmployeeId of(String value) { return new EmployeeId(value); }
public String getValue() { return value; }
// equals, hashCode omitted for brevity
}Risks & Best practice
The biggest danger is boundary creep: starting with EmployeeId and Money, teams may keep adding DepartmentCode, EmployeeStatus, etc., turning the shared kernel into a full‑blown table. Keep it strictly to identifiers and immutable value objects; any business logic belongs elsewhere.
Posture Two – Customer‑Supplier
Problem it solves
Payroll (customer) needs monthly attendance summaries from the Attendance (supplier) context. Both teams can sit down, agree on required fields and format, and the supplier commits to a stable interface.
Code organization
package com.company.attendance.application;
public interface AttendanceFacade {
/** Query monthly attendance summary for an employee */
AttendanceSummary queryMonthlySummary(EmployeeId employeeId, PayCycle cycle);
}
public class AttendanceSummary {
private EmployeeId employeeId;
private PayCycle cycle;
private int scheduledWorkDays;
private int actualWorkDays;
private int absentDays;
private int overtimeHours;
private List<LeaveRecord> leaves;
// getters only – immutable DTO
} package com.company.payroll.application;
@Service
public class SalaryCalculationAppService {
private final AttendanceFacade attendanceFacade;
private final SalaryCalculationDomainService calculationService;
public PaySlip calculateMonthlyPaySlip(EmployeeId employeeId, PayCycle cycle) {
AttendanceSummary attendance = attendanceFacade.queryMonthlySummary(employeeId, cycle);
Payee payee = payeeRepository.findByEmployeeId(employeeId);
return calculationService.calculate(payee, attendance, cycle);
}
}The key is that the customer depends only on the AttendanceFacade contract, not on any internal domain classes of the supplier.
Posture Three – Anti‑Corruption Layer
Problem it solves
When a bounded context must call an external system (e.g., a bank API), the external data format is often messy: strings for amounts and dates, magic return codes, and many nullable fields. Directly using these structures pollutes the domain model.
Code organization
// External bank response (dirty data)
public class BankTransferResponse {
private String retCode; // "0000" means success
private String retMsg;
private String txnRef;
private String bizFlowNo;
private String txnAmt; // e.g. "1234.56"
private String txnDate; // e.g. "20241201"
private String txnTime; // e.g. "143022"
private String acctNo; // masked account number
// many other nullable fields
} // ACL implementation in infrastructure layer
@Component
public class BankPaymentGatewayAdapter implements PaymentGateway {
private final BankApiClient bankApiClient;
@Override
public PaymentResult transfer(BankAccount target, Money amount, PaymentReference ref) {
BankTransferRequest request = buildBankRequest(target, amount, ref);
BankTransferResponse response = bankApiClient.transfer(request);
return translateResponse(response);
}
private PaymentResult translateResponse(BankTransferResponse response) {
if (!"0000".equals(response.getRetCode())) {
return PaymentResult.failure(
BankErrorCode.of(response.getRetCode()),
response.getRetMsg()
);
}
return PaymentResult.success(
BankTransactionId.of(response.getBizFlowNo()),
Money.ofYuan(new BigDecimal(response.getTxnAmt())),
LocalDate.parse(response.getTxnDate(), DateTimeFormatter.BASIC_ISO_DATE)
);
}
} // Domain‑level contract (clean language)
public interface PaymentGateway {
PaymentResult transfer(BankAccount target, Money amount, PaymentReference ref);
}
public class PaymentResult {
private final boolean success;
private final BankTransactionId transactionId; // value object
private final Money amount; // value object
private final LocalDate settleDate; // value object
private final String failureReason;
// constructors, getters omitted
}When the bank changes, only a new adapter (e.g., NewBankPaymentGatewayAdapter) is needed; the domain layer stays untouched, demonstrating the Dependency Inversion Principle in practice.
Posture Four – Open Host Service
Problem it solves
The Employee Information context is the source of truth for many downstream services. Instead of customizing an interface for each consumer, it publishes a versioned, stable API that all downstream contexts adapt to.
Code organization
@RestController
@RequestMapping("/api/v1/employees")
public class EmployeeOpenHostController {
@GetMapping("/{id}/payroll-profile")
public ResponseEntity<EmployeePayrollProfileDTO> getPayrollProfile(@PathVariable String id) {
Employee employee = employeeRepository.findById(EmployeeId.of(id))
.orElseThrow(() -> new EmployeeNotFoundException(id));
return ResponseEntity.ok(EmployeePayrollProfileDTO.from(employee));
}
}
public class EmployeePayrollProfileDTO {
private String employeeId;
private String employmentType; // "REGULAR" / "PROBATION" / "CONTRACT"
private LocalDate hireDate;
private String salaryLevel; // "P5" / "P6" …
public static EmployeePayrollProfileDTO from(Employee employee) { /* mapping */ }
} @Component
public class HrEmployeeQueryAdapter implements EmployeeProfileQuery {
private final HrOpenHostClient hrClient;
@Override
public EmployeePayrollInfo queryPayrollInfo(EmployeeId employeeId) {
EmployeePayrollProfileDTO dto = hrClient.getPayrollProfile(employeeId.getValue());
return EmployeePayrollInfo.builder()
.employeeId(employeeId)
.employmentType(EmploymentType.fromCode(dto.getEmploymentType()))
.hireDate(dto.getHireDate())
.salaryLevel(SalaryLevel.fromCode(dto.getSalaryLevel()))
.build();
}
}The adapter performs a lightweight format conversion; the heavy‑weight ACL concerns are unnecessary because both sides share a common publishing language.
How to Choose the Right Posture
Multiple contexts share the same identifier or value object → Shared Kernel
Two internal contexts collaborate and the consumer has bargaining power → Customer‑Supplier
Integration with third‑party or legacy systems with messy contracts → Anti‑Corruption Layer
A context is upstream of many downstream consumers → Open Host Service
In practice, adding an ACL is rarely a waste; even internal APIs benefit from a thin adapter when the two teams use divergent styles. If two contexts truly have no need to integrate, the best solution is to keep them separate.
Conclusion
Context Mapping addresses the relational question of “who is stronger, who compromises, and where the boundary lies” rather than merely the technical design of an API. The four patterns—Shared Kernel, Customer‑Supplier, Anti‑Corruption Layer, and Open Host Service—provide a practical toolbox for clean bounded‑context integration.
Signed-in readers can open the original source through BestHub's protected redirect.
This article has been distilled and summarized from source material, then republished for learning and reference. If you believe it infringes your rights, please contactand we will review it promptly.
Tinker Programmer
Solving problems with code, sharing practical tech insights, and leveling up together!
How this landed with the community
Was this worth your time?
0 Comments
Thoughtful readers leave field notes, pushback, and hard-won operational detail here.
