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

May 2, 2025 - 10:55
 0
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.

Image illustrating the connection between two services: Product Service and Price Service

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:

  1. Violates Encapsulation: The internal structure of external DTOs leaks into various parts of the Product Service.
  2. Increases Complexity: Conditional logic (instanceof) spreads throughout the codebase wherever price information is needed.
  3. 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.