Writing code provides everyday challenges. One of the challenge we faced was when we were designing what is a backend trade service library. This library provides API for user to book a trade. This story is how we ran into a design problem that at first appeared mechanical namely how to pass data into "adaptors" and "builders"—but quickly revealed itself as a deeper domain and boundary issue.
The library exposes an API that allows users to create trades with other entities. Internally, the library orchestrates several I/O calls to downstream services, aggregates their results, and produces a final response. Each downstream dependency demands its own data shape, so incoming user input must be transformed repeatedly as it flows through the system.
Our initial approach was conventional: use adaptors and builders to translate user input into the structures required by each downstream service. On the surface, this was straightforward. In practice, it exposed a recurring design tension around contract narrowness—and more importantly, around bounded context violations.
The Hidden Cost of Wide Inputs
The primary user-facing input object, TradeInformation, contains nearly 100 fields. Any given adaptor, however, typically needs fewer than 10. Passing TradeInformation directly into adaptors felt “clean” because it kept method signatures small and declarative.
But this cleanliness was deceptive.
From a Domain-Driven Design perspective, this approach effectively collapses multiple bounded contexts into one. The adaptor’s actual domain is small and specific—it does not operate on “a trade” in the abstract, but on a sharply defined subset of trade data relevant to a particular integration or workflow. Passing a large, all-encompassing object grants the adaptor access to information outside its bounded context, whether or not it intends to use it.
This creates three concrete problems:
Implicit Dependencies
The adaptor’s true dependencies are hidden. A future maintainer can easily add “just one more field” fromTradeInformation, silently expanding the contract without any signature-level friction.Erosion of Bounded Contexts
The adaptor is no longer forced to speak in its own domain language. Instead, it parasitically depends on another context - representation of a trade.Testing Friction as a Symptom
The difficulty of unit testing—having to construct a large, realisticTradeInformationobject—is not an accident. It is a signal that the adaptor is coupled to far more of the system than it should be.
Evaluating the Obvious Alternatives
Consider three possible builder APIs:
Deal buildDeal(const TradeInformation& request);
Deal buildDeal(
int makerId,
int takerId,
double amount,
const std::string& tradeIdentifierInternal,
const std::string& tradeIdentifierExternal,
Venue::Enum tradingVenue,
const MakerDetails& makerDetails
);
struct DealStruct {
int makerId;
int takerId;
double amount;
const std::string& tradeIdentifierInternal;
const std::string& tradeIdentifierExternal;
Venue::Enum tradingVenue;
const MakerDetails& makerDetails;
};
Deal buildDeal(const DealStruct&);
Each reveals a different philosophy.
Declarative but Leaky
The first option is declarative and pleasant at the call site, but it is also dishonest. It pretends the builder depends on “trade information” when in reality it depends on a very specific projection of that information. This is not abstraction—it is concealment.
Explicit but Hostile
The second option is brutally explicit. It makes dependencies clear and unit tests trivial. However, it pushes domain extraction logic onto every caller and replaces meaningful domain concepts with primitive noise. Long parameter lists are not just ugly—they are a sign that the domain language has been flattened.
Structs Without Meaning
The third option is often proposed as a compromise, but in practice it rarely is one. A DealStruct with no behavior, invariants, or semantic weight is just a parameter list in disguise. Worse, it introduces a new type that communicates nothing about why it exists.
The Real Problem: Boundary Modeling
All three options fail for the same reason: they do not model proper contexts.
What the builder actually needs is not “trade information” and not “seven primitives,” but a domain-specific view of a trade relevant to this context.
Historically, this problem was often addressed with inheritance-based adapters. A narrow interface would be introduced and the large input object would implement it. While effective, this approach tightly couples unrelated domains and introduces rigid hierarchies—an approach many teams now rightfully avoid.
Views as Explicit Context Boundaries
We ultimately solved this by introducing views—read-only projections over larger objects, similar in spirit to std::string_view.
A view represents a context-specific contract:
- It provides the API what it exactly needs in terms of bounded context.
- It speaks the language of that bounded context.
- It cannot accidentally expand without changing the type itself.
Crucially, a view is a DDD artifact. It formalizes the boundary between contexts and forces translation at that boundary.
But how do you envision view. For us, a view marks a minimum set of Domain context that is less leaky than our declarative API using TradeInformation but more readable. For example, we can have a PartiesView as shown below which provides details about trading parties.
#include <concepts>
#include <string>
template <typename V>
concept PartiesViewLike = requires(const V& v) {
{ v.makerId() } -> std::convertible_to<int>;
{ v.takerId() } -> std::convertible_to<int>;
{ v.amount() } -> std::convertible_to<double>;
{ v.internalId() } -> std::same_as<const std::string&>;
{ v.externalId() } -> std::same_as<const std::string&>;
{ v.venue() } -> std::same_as<Venue>;
{ v.makerDetails() } -> std::same_as<const MakerDetails&>;
};
class PartiesView {
public:
explicit PartiesView(const TradeInformation& ti) : d_trade(ti) {}
int makerId() const { return d_trade.makerId(); }
int takerId() const { return d_trade.takerId(); }
double amount() const { return d_trade.amount(); }
const std::string& internalId() const { return d_trade.internalId(); }
const std::string& externalId() const { return d_trade.externalId(); }
Venue venue() const { return d_trade.venue(); }
const MakerDetails& makerDetails() const { return d_trade.makerDetails(); }
const TakerDetails& makerDetails() const { return d_trade.makerDetails(); }
private:
const TradeInformation& d_trade;
};
class Builder2 {
public:
template <PartiesView V>
Deal buildDeal(const V& v) const {
Deal d;
// ... use v.makerId(), v.internalId(), etc.
return d;
}
};
With views:
- Builders depend on exactly what they need—no more, no less.
- Call sites remain clean, because views are cheap to construct from existing inputs.
- Unit tests become simpler and more focused, because views can be instantiated with minimal data.
- Context leakage becomes visible and intentional, rather than accidental.
This approach reintroduces the benefits of the Adapter pattern without inheritance and without widening contracts.
A Strong Opinion
If an adaptor needs only 10 fields, it should not see 100. If a unit test is painful to write, it is telling you something important about your boundaries. And if your API hides dependencies for the sake of “cleanliness,” it is likely undermining your domain model.
Narrow contracts are not just about aesthetics or test convenience. They are also about respecting bounded contexts and preserving domain integrity over time.
Top comments (0)