
Standardizing Integrations with DTOs and Actions
The problem: different APIs for the same action
If you’ve ever had to integrate multiple APIs that do exactly the same thing, you know how messy it can get. In my case, the challenge was to process credit card payments using different payment gateways.
Each provider had its own API, data format, authentication method, response structure — and of course, their own quirks. The integration logic grew into a tangled mess across controllers, services, and helpers. This made it hard to:
- Keep the system maintainable;
- Add new gateways;
- Understand what was happening at each point.
The challenge
I needed a way to:
- Standardize the input and output data;
- Isolate the logic of each provider;
- Easily swap or add new gateways;
- Make the system more predictable and testable.
The solution: DTOs + Actions + Factory
✅ DTOs to represent data
The first step was to create well-defined objects to represent the input and output of each integration.
A ChargeRequestDTO carried the required data for making a charge (amount, card info, etc.), and a ChargeResponseDTO encapsulated the expected output (status, message, transaction ID…).
This way, the rest of the application didn’t need to worry about the internal details of each API.
class ChargeRequestDTO
{
public function __construct(
public int $amount,
public string $cardNumber,
public string $cardCvv,
public string $cardExpMonth,
public string $cardExpYear,
) {}
}
✅ Actions to encapsulate integration logic
Each integration was isolated in an Action class. Its only job: receive the request DTO, make the API call, and return a response DTO.
class ChargeWithStripeAction
{
public function execute(ChargeRequestDTO $dto): ChargeResponseDTO
{
// Build the payload for Stripe
// Make the HTTP call
// Build and return the response DTO
return new ChargeResponseDTO(
success: true,
transactionId: 'abc123',
message: 'Charge successfully completed'
);
}
}
This separation made everything easier to test and maintain — each Action had a single, focused responsibility.
✅ Factory to resolve the right Action
To avoid bloated switch or if statements everywhere, I created a GatewayActionFactory that receives an enum value and returns the proper Action class instance.
enum PaymentGateway: string
{
case STRIPE = 'stripe';
case MERCADOPAGO = 'mercadopago';
case PAGARME = 'pagarme';
}
class GatewayActionFactory
{
public function make(PaymentGateway $gateway): ChargeActionInterface
{
return match ($gateway) {
PaymentGateway::STRIPE => new ChargeWithStripeAction(),
PaymentGateway::MERCADOPAGO => new ChargeWithMercadoPagoAction(),
PaymentGateway::PAGARME => new ChargeWithPagarmeAction(),
};
}
}
Now, using it was clean and simple:
$dto = new ChargeRequestDTO(...);
$action = $factory->make(PaymentGateway::STRIPE);
$response = $action->execute($dto);
No conditionals, no coupling, no headaches.
Results
- Scalability: Adding a new gateway became a predictable task, with minimal impact on the rest of the system.
- Organization: Each provider has its own isolated, testable Action.
- Consistency: The entire app now speaks a common language via DTOs.
- Reusability: This pattern worked so well that we adopted it for other use cases like sending emails, notifications, and CRM integrations.
Final thoughts
This pattern helped me turn a chaotic integration layer into something clean, predictable, and scalable. If you’re working with multiple APIs that perform the same type of action, I highly recommend trying out this approach with DTOs, Actions, and a Factory to tie it all together.
It worked great for me — and it might do the same for you.
Published on July 31, 2025