Avoid Coupling External DTOs To Your Codebase
Integrating with external APIs or internal microservices is a common task in modern software development. These integrations often involve exchanging data using Data Transfer Objects (DTOs). While convenient, allowing these external DTOs to permeate deep into your application's codebase can lead to tight coupling, making your system brittle and difficult to maintain, especially when those external APIs evolve. This article explores the challenges of such coupling and demonstrates how to avoid it by decoupling your internal domain from external DTOs, leading to a more robust and adaptable codebase. We'll walk through a practical example, refactoring from a tightly coupled state to a clean, decoupled design using the Adapter pattern. The Initial Setup: A Common Scenario Consider a typical microservice interaction: a Product Service needs to display products along with their prices, which are determined by a separate Price Service due to complex business rules. The Product Service calls the Price Service API to fetch this pricing information. Suppose the Product Service uses a shared library provided by the Price Service which includes DTOs for the API responses. The Price Service V1 DTO might look like this: namespace CompanyNamespace\PriceServiceLib\Dto; // DTO from the external Price Service V1 library class PriceInfo { public string $productId; public int $basePrice; public string $currency; public int $discountAmount; } And the Product entity within the Product Service might directly use this DTO: namespace CompanyNamespace\ProductService\Entity; // Direct dependency on the external DTO use CompanyNamespace\PriceServiceLib\Dto\PriceInfo; class Product { private string $id; private string $name; // Tightly coupled: Product holds a reference to the external DTO public ?PriceInfo $priceInfo = null; // Assume getters for id and name exist public function getId(): string { return $this->id; } public function getName(): string { return $this->name; } } A service class fetches products and enriches them with price information: namespace CompanyNamespace\ProductService\Service; use CompanyNamespace\ProductService\Repository\ProductRepository; use CompanyNamespace\PriceServiceLib\Client\PriceServiceClient; use CompanyNamespace\ProductService\Entity\Product; class ProductService { private ProductRepository $productRepository; private PriceServiceClient $priceServiceClient; // Client for the Price Service API // Constructor injection... public function getProductsByIds(array $ids): array { $products = $this->productRepository->getByIds($ids); $productsByIdMap = []; foreach ($products as $product) { $productsByIdMap[$product->getId()] = $product; } // Fetch DTOs from the external service $productsPriceInfo = $this->priceServiceClient->getPriceInfosByIds($ids); // Returns PriceInfo[] DTOs foreach ($productsPriceInfo as $productPriceInfo) { if (isset($productsByIdMap[$productPriceInfo->productId])) { $product = $productsByIdMap[$productPriceInfo->productId]; // Direct assignment of the external DTO to the Product entity $product->priceInfo = $productPriceInfo; } } return $products; // Array of Product entities } } Finally, a controller uses the ProductService to build an API response: namespace CompanyNamespace\ProductService\Controller; use CompanyNamespace\ProductService\Service\ProductService; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\JsonResponse; class ProductController { private ProductService $productService; // Constructor injection... public function getProducts(Request $request): JsonResponse { $ids = []; // Assume IDs are extracted from the request $products = $this->productService->getProductsByIds($ids); $response = []; foreach ($products as $product) { // Directly accessing properties of the external DTO through the Product entity $priceData = null; if ($product->priceInfo !== null) { $priceData = [ "basePrice" => $product->priceInfo->basePrice, "currency" => $product->priceInfo->currency, "discount" => $product->priceInfo->discountAmount ]; } $response[$product->getId()] = [ "id" => $product->getId(), "name" => $product->getName(), "price" => $priceData ]; } return new JsonResponse($response); } } This setup might seem straightforward initially and works fine. However, the Product entity, ProductService, and ProductController are all directly coupled to the structure of the PriceInfo DTO from

Integrating with external APIs or internal microservices is a common task in modern software development. These integrations often involve exchanging data using Data Transfer Objects (DTOs). While convenient, allowing these external DTOs to permeate deep into your application's codebase can lead to tight coupling, making your system brittle and difficult to maintain, especially when those external APIs evolve.
This article explores the challenges of such coupling and demonstrates how to avoid it by decoupling your internal domain from external DTOs, leading to a more robust and adaptable codebase. We'll walk through a practical example, refactoring from a tightly coupled state to a clean, decoupled design using the Adapter pattern.
The Initial Setup: A Common Scenario
Consider a typical microservice interaction: a Product Service
needs to display products along with their prices, which are determined by a separate Price Service
due to complex business rules. The Product Service
calls the Price Service
API to fetch this pricing information.
Suppose the Product Service
uses a shared library provided by the Price Service
which includes DTOs for the API responses.
The Price Service
V1 DTO might look like this:
namespace CompanyNamespace\PriceServiceLib\Dto;
// DTO from the external Price Service V1 library
class PriceInfo {
public string $productId;
public int $basePrice;
public string $currency;
public int $discountAmount;
}
And the Product
entity within the Product Service
might directly use this DTO:
namespace CompanyNamespace\ProductService\Entity;
// Direct dependency on the external DTO
use CompanyNamespace\PriceServiceLib\Dto\PriceInfo;
class Product {
private string $id;
private string $name;
// Tightly coupled: Product holds a reference to the external DTO
public ?PriceInfo $priceInfo = null;
// Assume getters for id and name exist
public function getId(): string { return $this->id; }
public function getName(): string { return $this->name; }
}
A service class fetches products and enriches them with price information:
namespace CompanyNamespace\ProductService\Service;
use CompanyNamespace\ProductService\Repository\ProductRepository;
use CompanyNamespace\PriceServiceLib\Client\PriceServiceClient;
use CompanyNamespace\ProductService\Entity\Product;
class ProductService {
private ProductRepository $productRepository;
private PriceServiceClient $priceServiceClient; // Client for the Price Service API
// Constructor injection...
public function getProductsByIds(array $ids): array {
$products = $this->productRepository->getByIds($ids);
$productsByIdMap = [];
foreach ($products as $product) {
$productsByIdMap[$product->getId()] = $product;
}
// Fetch DTOs from the external service
$productsPriceInfo = $this->priceServiceClient->getPriceInfosByIds($ids); // Returns PriceInfo[] DTOs
foreach ($productsPriceInfo as $productPriceInfo) {
if (isset($productsByIdMap[$productPriceInfo->productId])) {
$product = $productsByIdMap[$productPriceInfo->productId];
// Direct assignment of the external DTO to the Product entity
$product->priceInfo = $productPriceInfo;
}
}
return $products; // Array of Product entities
}
}
Finally, a controller uses the ProductService
to build an API response:
namespace CompanyNamespace\ProductService\Controller;
use CompanyNamespace\ProductService\Service\ProductService;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\JsonResponse;
class ProductController {
private ProductService $productService;
// Constructor injection...
public function getProducts(Request $request): JsonResponse {
$ids = []; // Assume IDs are extracted from the request
$products = $this->productService->getProductsByIds($ids);
$response = [];
foreach ($products as $product) {
// Directly accessing properties of the external DTO through the Product entity
$priceData = null;
if ($product->priceInfo !== null) {
$priceData = [
"basePrice" => $product->priceInfo->basePrice,
"currency" => $product->priceInfo->currency,
"discount" => $product->priceInfo->discountAmount
];
}
$response[$product->getId()] = [
"id" => $product->getId(),
"name" => $product->getName(),
"price" => $priceData
];
}
return new JsonResponse($response);
}
}
This setup might seem straightforward initially and works fine. However, the Product
entity, ProductService
, and ProductController
are all directly coupled to the structure of the PriceInfo
DTO from the external Price Service
. The Product
entity, potentially a core part of your domain, now has a direct dependency on an external data structure.
The Challenge of API Evolution
The problems with tight coupling surface when the external API changes. Suppose the Price Service
releases a V2 API with a different structure for its PriceInfo
DTO, perhaps nesting the price and currency:
namespace CompanyNamespace\PriceServiceLib\Dto\V2;
class Price {
public int $price;
public string $currency;
}
class PriceInfo { // V2 DTO
public string $productId;
public Price $basePrice; // Changed structure: nested object
public int $discountAmount;
}
Now, the Product Service
needs to support both V1 and V2 responses, perhaps during a transition period or for different client configurations. How do we handle this with the existing coupling?
A naive approach might be to change the Product
entity to hold either DTO version using a union type:
namespace CompanyNamespace\ProductService\Entity;
use CompanyNamespace\PriceServiceLib\Dto\PriceInfo as V1PriceInfo;
use CompanyNamespace\PriceServiceLib\Dto\V2\PriceInfo as V2PriceInfo;
class Product {
// ... other properties
// Now Product needs to know about BOTH external DTO versions
public V1PriceInfo|V2PriceInfo|null $priceInfo = null;
}
This forces complexity downstream. Any code interacting with $product->priceInfo
must now check its type:
// Inside ProductController or other consumers...
// ...
$priceInfo = $product->priceInfo;
$priceData = null;
if ($priceInfo instanceof V1PriceInfo) {
$priceData = [
"basePrice" => $priceInfo->basePrice,
"currency" => $priceInfo->currency,
"discount" => $priceInfo->discountAmount
];
} elseif ($priceInfo instanceof V2PriceInfo) {
$priceData = [
"basePrice" => $priceInfo->basePrice->price, // Access path changed
"currency" => $priceInfo->basePrice->currency, // Access path changed
"discount" => $priceInfo->discountAmount
];
}
$response[$product->getId()]["price"] = $priceData;
// ...
This approach is problematic:
-
Violates Encapsulation: The internal structure of external DTOs leaks into various parts of the
Product Service
. - Increases Complexity: Conditional logic (instanceof) spreads throughout the codebase wherever price information is needed.
- Poor Maintainability: Adding support for a V3 would require modifying all these conditional blocks. The Product entity becomes increasingly burdened with knowledge of external systems.
This tight coupling makes the codebase fragile and resistant to change.
The Decoupling Principle: Isolating External Data
The core principle to solve this is decoupling. Treat external DTOs purely as data carriers for crossing service boundaries.
Principle: Do not allow external API DTOs to be directly accessed or passed around deep within your application's logic after being fetched from an API client.
DTOs, as the name suggests, are just Data transfer Objects. Their main purpose is just to convey data to be transferred over the network to another service. They are not objects meant to be passed around after fetched from a client in the dependent service.
Instead, immediately map the incoming DTO to an internal representation (an object or data structure defined within your service's domain) at the boundary – typically right after receiving it from the API client. This internal representation acts as an Adapter, shielding the rest of your codebase from the specifics of the external API's structure.
If you need to access data from the external DTO later, do so indirectly through this internal adapter object.
Implementing the Adapter Pattern
Let's create an internal adapter class within Product Service
to represent the price information we actually need, regardless of the DTO version. Based on the ProductController's
requirements, we need base price, currency, and discount amount.
Let's call our adapter InternalPriceInfo
:
namespace CompanyNamespace\ProductService\<...><...>; // Internal representation
use CompanyNamespace\PriceServiceLib\Dto\PriceInfo as V1PriceInfo;
use CompanyNamespace\PriceServiceLib\Dto\V2\PriceInfo as V2PriceInfo;
class InternalPriceInfo {
// The adapter holds the original DTO but doesn't expose it directly
public function __construct(private readonly V1PriceInfo|V2PriceInfo $externalPriceInfo) {}
// Methods provide a stable internal interface to the data
public function getBasePrice(): int {
if ($this->externalPriceInfo instanceof V2PriceInfo) {
return $this->externalPriceInfo->basePrice->price;
}
// Assumes V1 otherwise (can be made more robust)
return $this->externalPriceInfo->basePrice;
}
public function getCurrency(): string {
if ($this->externalPriceInfo instanceof V2PriceInfo) {
// Bug Fix: V2 currency is nested
return $this->externalPriceInfo->basePrice->currency;
}
return $this->externalPriceInfo->currency;
}
public function getDiscountAmount(): int {
// This property happens to be the same in V1 and V2
return $this->externalPriceInfo->discountAmount;
}
}
Now, update the Product
entity to use this internal adapter:
namespace CompanyNamespace\ProductService\Entity;
// Dependency is now on an internal adapter, not the external DTO
use CompanyNamespace\ProductService\<...>\InternalPriceInfo;
class Product {
private string $id;
private string $name;
// Product now depends on the internal adapter
public ?InternalPriceInfo $priceInfo = null;
// ... getters ...
}
The ProductService
is responsible for performing the mapping (adaptation) immediately after fetching the external DTOs:
namespace CompanyNamespace\ProductService\Service;
use CompanyNamespace\ProductService\<...>\InternalPriceInfo;
// Other use statements...
class ProductService {
// ... properties and constructor ...
public function getProductsByIds(array $ids): array {
// ... fetch products ...
$productsPriceInfo = $this->priceServiceClient->getPriceInfosByIds($ids); // Returns V1 or V2 PriceInfo DTOs
foreach ($productsPriceInfo as $externalDto) {
$productId = $externalDto->productId; // Assuming productId exists in both DTOs
if (isset($productsByIdMap[$productId])) {
$product = $productsByIdMap[$productId];
// Adapt the external DTO to the internal representation
$product->priceInfo = new InternalPriceInfo($externalDto);
}
}
return $products;
}
}
And the ProductController
becomes much cleaner, interacting only with the stable interface of the InternalPriceInfo
adapter:
namespace CompanyNamespace\ProductService\Controller;
// Other use statements...
class ProductController {
// ... property and constructor ...
public function getProducts(Request $request): JsonResponse {
// ... get ids, fetch products ...
$response = [];
foreach ($products as $product) {
// Access data via the adapter's stable methods
$priceData = null;
if ($product->priceInfo !== null) {
$priceData = [
"basePrice" => $product->priceInfo->getBasePrice(), // Use getter
"currency" => $product->priceInfo->getCurrency(), // Use getter
"discount" => $product->priceInfo->getDiscountAmount() // Use getter
];
}
$response[$product->getId()] = [
"id" => $product->getId(),
"name" => $product->getName(),
"price" => $priceData
];
}
return new JsonResponse($response);
}
}
Refining the Adapter with Polymorphism
Our InternalPriceInfo
adapter still contains conditional instanceof
checks.
public function getBasePrice(): int {
if ($this->externalPriceInfo instanceof V2PriceInfo) {
return $this->externalPriceInfo->basePrice->price;
}
// Assumes V1 otherwise (can be made more robust)
return $this->externalPriceInfo->basePrice;
}
While better than spreading them around, we can improve this further using polymorphism, adhering better to the Open/Closed Principle (open for extension, closed for modification).
We can do better by creating individual InternalPriceInfo
classes for each version of price info object. Since we want to be able to call getBasePrice
, getCurrency
and getDiscountAmount
from the whatever version, we can make an interface InternalPriceInfoInterface
which any class we want to create must implement.
Define an interface for our internal price representation:
namespace CompanyNamespace\ProductService\<...>;
interface InternalPriceInfoInterface {
public function getBasePrice(): int;
public function getCurrency(): string;
public function getDiscountAmount(): int;
}
Create specific adapter classes for each DTO version, implementing this interface:
// V1 Adapter
namespace CompanyNamespace\ProductService\<...>\V1;
use CompanyNamespace\PriceServiceLib\Dto\PriceInfo as V1PriceInfo;
use CompanyNamespace\ProductService\<...>\InternalPriceInfoInterface;
class PriceInfoAdapter implements InternalPriceInfoInterface {
public function __construct(private readonly V1PriceInfo $priceInfo) {}
public function getBasePrice(): int {
return $this->priceInfo->basePrice;
}
public function getCurrency(): string {
return $this->priceInfo->currency;
}
public function getDiscountAmount(): int {
return $this->priceInfo->discountAmount;
}
}
// V2 Adapter
namespace CompanyNamespace\ProductService\<...>\V2;
use CompanyNamespace\PriceServiceLib\Dto\V2\PriceInfo as V2PriceInfo;
use CompanyNamespace\ProductService\<...>\InternalPriceInfoInterface;
class PriceInfoAdapter implements InternalPriceInfoInterface {
public function __construct(private readonly V2PriceInfo $priceInfo) {}
public function getBasePrice(): int {
return $this->priceInfo->basePrice->price;
}
public function getCurrency(): string {
return $this->priceInfo->basePrice->currency;
}
public function getDiscountAmount(): int {
return $this->priceInfo->discountAmount;
}
}
Update the Product
entity to depend on the interface:
namespace CompanyNamespace\ProductService\Entity;
use CompanyNamespace\ProductService\<...>\InternalPriceInfoInterface; // Depend on the abstraction
class Product {
// ... other properties
public ?InternalPriceInfoInterface $priceInfo = null; // Use the interface type
// ... getters ...
}
To simplify creating the correct adapter instance, we can use a Factory:
namespace CompanyNamespace\ProductService\<...>;
use CompanyNamespace\PriceServiceLib\Dto\PriceInfo as V1PriceInfo;
use CompanyNamespace\PriceServiceLib\Dto\V2\PriceInfo as V2PriceInfo;
use CompanyNamespace\ProductService\<...>\V1\InternalPriceInfo as V1InternalPriceInfo;
use CompanyNamespace\ProductService\<...>\V2\InternalPriceInfo as V2InternalPriceInfo;
class InternalPriceInfoFactory {
public static function create(V1PriceInfo|V2PriceInfo $externalDto): InternalPriceInfoInterface {
if ($externalDto instanceof V2PriceInfo) {
return new V2InternalPriceInfo($externalDto);
}
// Assume V1 if not V2 (could add explicit check or error handling)
if ($externalDto instanceof V1PriceInfo) {
return new V1InternalPriceInfo($externalDto);
}
// Handle cases where the DTO type is unexpected
throw new \InvalidArgumentException("Unsupported PriceInfo DTO type provided.");
}
}
Finally, update the ProductService
to use the factory:
namespace CompanyNamespace\ProductService\Service;
use CompanyNamespace\ProductService\<...>\InternalPriceInfoFactory;
// Other use statements...
class ProductService {
// ... properties and constructor ...
public function getProductsByIds(array $ids): array {
// ... fetch products ...
$productsPriceInfo = $this->priceServiceClient->getPriceInfosByIds($ids); // Returns V1 or V2 PriceInfo DTOs
foreach ($productsPriceInfo as $externalDto) {
$productId = $externalDto->productId;
if (isset($productsByIdMap[$productId])) {
$product = $productsByIdMap[$productId];
// Use the factory to create the appropriate adapter instance
$product->priceInfo = InternalPriceInfoFactory::create($externalDto);
}
}
return $products;
}
}
Now, the ProductService
uses the factory to create the correct adapter, and the rest of the code (like Product
entity and ProductController
) interacts purely with the InternalPriceInfoInterface
. Adding support for a V3 DTO would involve creating a new V3 adapter class and updating the factory – no changes needed in the Product
entity, ProductController
, or other consumers. This design is much more maintainable and follows the Open/Closed Principle.
Conclusion
Directly coupling your application's domain logic and entities to external API DTOs creates fragility and hinders maintainability. When external APIs inevitably change, this coupling forces complex and widespread modifications throughout your codebase.
By applying the Adapter pattern at the boundary where external data enters your system, you can isolate your core logic from the volatile structures of external DTOs. Mapping external DTOs to stable internal representations (interfaces and implementing classes) leads to:
Improved Maintainability: Changes in external APIs only affect the specific adapter implementation and potentially a factory, not the entire codebase.
Enhanced Robustness: Your core domain logic is shielded from external volatility.
Better Testability: You can easily test your internal logic using mock implementations of the internal interfaces.
Clearer Boundaries: It enforces a clean separation between your internal domain and external integration concerns.
While it requires a bit more upfront effort to create these adapters, the long-term benefits in flexibility and reduced maintenance costs make decoupling from external DTOs a worthwhile investment for any application interacting with external services.