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.
Top comments (9)
While I agree with the conclusion to set boundaries for each service. It feels like it is a lot of work for the functionality in the example code.
There has to be a reason the
Product
is structured that way other than convenience. So I wouldn't touch that if thePriceService
wants to use another structure.I think the biggest flaw in the example it that the information of the external API is already too deep if it reaches the
ProductService
.The change should happen in the
PriceServiceClient
getPriceInfosByIds
method.I assume that method knows which of the external versions it talks to, so you can create something like this.
I could use a more loosely coupled pattern. But what are the chances a new API version is created in the next year with another structure? I bet they are slim to none.
@xwero interesting point. I can see how this might be useful in some very narrow domain where
getPriceInfoByIds
is used by one consumer that wants the response of the base api response converted into another single form immediately everywhere.But doing it this way breaks the Single Responsibility Principle. Now,
getPriceInfoByIds
does two things:getPriceInfoByIds
might not really want.Imagine if
getPriceInfoByIds
is also used by some other CLI Command PHP code that needs the PriceInfo in a different form. Then it needs to then convert the converted Price Info object into the shape it wants again.It's better to let client Services or Commands, that uses
getPriceInfoByIds
, to determine the shape they want to transform the results of it to.In the case of ProductService, it needs the results of
getPriceInfoByIds
to be transformed into implementations ( ofInternalPriceInfoInterface
) suitable for$product->priceInfo
I assume the
PriceServiceClient
is part of the ProductService codebase. I can do whatever I want with that client in my service.Even if there is a general
PriceServiceClient
, It can be extended in the ProductService codebase to transform to the DTOs it is familiar with.I feel most comfortable when the data within the service is in a know structure as soon as possible. If that means violating SRP so be it.
I'm not saying it is always the right decision, but for the example from the post I feel good about that solution.
@xwero
PriceServiceClient
is indeed part of ProductService codebase. Like i noted earlier, your proposed version might work in a simple codebase where you might not even need any more transformation of the result from thegetPriceInfoByIds
.The purpose of this article is to teach best practices that works in much bigger codebases while trying to make the example as simple as possible. Though, i agree, it might seem like an overkill in some very small cases or codebases.
I'm not quite sure about the problem with your other CLI command example. I assume you mean internally? Why would you use two different structures for the same data in the same service.
If the example is external, I would find it strange that a subservice would dictate the data structure futher down the line. but even if that happens transform it when it is outgoing.
I understand it is hard to create a post with a great example, especially when it is a more complex topic.
The thing that triggered me was the fact that the context is a split codebase with different services. If the services get big, I would check if the right functionality is grouped together for the app.
I don't consider a design pattern a good practice out of the gate. The use of design patterns has to be the right choice for the application. That is one of the things I fear the most if I'm writing posts. That people use something that doesn't fit their needs.
Interesting points. I’m not sure of your level of expertise working with medium to large codebases. But in my time working with many codebases, I’ve had many instances where various places in the codebase have different requirements regarding how or even which fields from client services response DTO they want to use.
While i understand you might think otherwise, I believe the example shown in this article does a decent job explaining and highlighting the benefits and reasons behind the refactoring.
Definitely, Design patterns when applied when appropriate are a great way of making codebases less fragile to change, which is the main essence of this article.
That's a solid point. I've run into issues before where tightly coupling external DTOs made future upgrades or API changes a nightmare. Mapping them to internal models has added a bit of boilerplate, but the long-term flexibility is definitely worth it. Appreciate you bringing this up!
I assume this comment was meant for the author and not as a reply to my comment :)
Nice article 💯.
This makes sense especially when the price info DTO (external to the Product service) isn't changing frequently