Readable, Leakproof API with zero cost abstraction.

Published: (December 15, 2025 at 07:29 PM EST)
6 min read
Source: Dev.to

Source: Dev.to

Writing code provides everyday challenges

One of the challenges we faced was when we were designing a backend trade‑service library.
This library provides an API for users to book a trade. The story below shows 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, but in practice it exposed a recurring design tension around contract narrowness—and, more importantly, around bounded‑context violations.

The problem

  • The primary user‑facing input object, TradeInformation, contains nearly 100 fields.
  • Any given adaptor typically needs fewer than 10 of those fields.

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 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:

  1. Implicit DependenciesTradeInformation silently expands the contract without any signature‑level friction.
  2. Erosion of Bounded Contexts – Contexts bleed into each other, making the model noisy.
  3. Testing Friction as a Symptom – The TradeInformation object 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:

// 1. Declarative but misleading
Deal buildDeal(const TradeInformation& request);
// 2. Brutally explicit
Deal buildDeal(
    int makerId,
    int takerId,
    double amount,
    const std::string& tradeIdentifierInternal,
    const std::string& tradeIdentifierExternal,
    Venue::Enum tradingVenue,
    const MakerDetails& makerDetails
);
// 3. “Compromise” struct
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.

OptionProsCons
1. DeclarativePleasant at the call site; short signature.Hides the real dependency; the builder pretends to need “trade information” when it only needs a specific projection.
2. ExplicitDependencies are crystal‑clear; unit tests are trivial.Callers must perform domain‑extraction logic; long parameter lists flatten the domain language.
3. StructGroups parameters into a single type.The struct carries no behavior or invariants; it is merely a disguised parameter list and adds a type that says nothing about its purpose.

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, teams addressed this with inheritance‑based adapters: a narrow interface would be introduced and the large input object would implement it. While effective, that approach tightly couples unrelated domains and introduces rigid hierarchies—an approach many teams now rightly avoid.

The solution: Views

We ultimately solved the problem 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 exactly what the API 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.

Example: PartiesView

For us, a view marks a minimum set of domain concepts that is less leaky than our declarative API using TradeInformation but more readable. Below is a concrete example that provides details about trading parties.

#include <string>
#include <concepts>

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(); }

private:
    const TradeInformation& d_trade;
};

The PartiesView is a read‑only projection that exposes only the data required by adaptors dealing with party information. Any adaptor that accepts a PartiesViewLike can be confident it is operating within the correct bounded context, and the compiler will enforce the contract.

Takeaways

  1. Never pass a “god object” (e.g., TradeInformation) into components that only need a slice of it.
  2. Model the contract that each component truly requires—prefer explicit, domain‑specific types over primitive parameter lists.
  3. Use view objects to create lightweight, read‑only projections that act as clear boundaries between bounded contexts.
  4. Leverage concepts (or interfaces) to make those boundaries compile‑time enforceable.

By adopting views, we restored clear domain boundaries, eliminated implicit dependencies, and made testing straightforward—all without sacrificing readability at the call site.

// Example usage of view adapters in a builder pattern

class Builder1 {
public:
    // The builder works with a read‑only view of the trade information.
    // The view exposes only the data the builder actually needs.
    template <typename TradeView>
    Deal buildDeal(const TradeView& v) const {
        Deal d;
        // ... use v.makerId(), v.internalId(), etc.
        return d;
    }

private:
    const TradeInformation& d_trade;
};

class Builder2 {
public:
    // A second builder that needs a different subset of the data.
    template <typename V>
    Deal buildDeal(const V& v) const {
        Deal d;
        // ... use v.makerId(), v.internalId(), etc.
        return d;
    }
};

Why use view adapters?

  • Builders depend on exactly what they need – no more, no less.
  • Call sites stay clean because views are cheap to construct from existing inputs.
  • Unit tests become simpler and more focused; views can be instantiated with minimal data.
  • Context leakage becomes visible and intentional, rather than accidental.

Benefits

  • Re‑introduces the advantages of the Adapter pattern without inheritance and without widening contracts.
  • If an adaptor needs only 10 fields, it should not see 100.
  • Painful unit tests signal that your boundaries may be wrong.
  • Hiding dependencies for the sake of “cleanliness” often undermines the domain model.

Final Takeaway

Narrow contracts are not just about aesthetics or test convenience.
They respect bounded contexts and preserve domain integrity over time.

Back to Blog

Related posts

Read more »

Deprecate like you mean it

Article URL: https://entropicthoughts.com/deprecate-like-you-mean-it Comments URL: https://news.ycombinator.com/item?id=46232898 Points: 37 Comments: 98...