С возвращением. Это следующая статья из серии о практическом применении паттернов в Laravel, и в этом руководстве мы поговорим об одном из самых популярных паттернов, который используют многие разработчики — паттерне «Адаптер» (Adapter).

Введение

Начнем с определения Адаптера:

Паттерн  «Адаптер» (Adapter) — это structural-паттерн проектирования, который позволяет объектам с несовместимыми интерфейсами работать вместе, преобразуя интерфейс одного класса в интерфейс, ожидаемый клиентом.

Реальный пример из жизни

Представьте, что вы путешествуете из США в Европу. Приехав в отель, вы обнаруживаете, что в Европе используется другой тип розеток для электроприборов. Чтобы решить эту проблему, вы покупаете сетевой адаптер (переходник), который позволяет легко заряжать ваши устройства с американской вилкой.

Плохой пример (антипаттерн)

Сначала давайте посмотрим, как выглядит плохое решение без использования классов-адаптеров и без чистой архитектуры кода:

 <?php

namespace App\Http\Controllers;

use Illuminate\Http\Request;
use Stripe\StripeClient;
use Square\SquareClient;
use Square\Models\Money;
use Square\Models\CreatePaymentRequest;

class BadCheckoutController extends Controller
{
    public function store(Request $request)
    {
        // We get a lot of data from $request without any processing, which can create
        // a lot of issues in the future for transactions processing
        $gateway = $request->string('gateway');
        $amount  = (float) $request->input('amount');
        $currency = $request->input('currency', 'USD');
        $pm = $request->string('payment_method');

        // Another bad practice - is to use conditions for creating new class objects with hardcoded actions
        if ($gateway === 'stripe') {
            $stripe = new StripeClient(config('services.stripe.secret'));

            $intent = $stripe->paymentIntents->create([
                'amount' => (int) round($amount),
                'currency' => strtolower($currency),
                'payment_method' => $pm,
                'confirm' => true,
                'description' => $request->input('description', 'Order #'.now()->timestamp),
            ]);


            if ($intent->status !== 'succeeded') {
                return back()->withErrors(['payment' => 'Stripe failed: '.$intent->status]);
            }

            // All this business logic in controller will create another issues
            return redirect()->route('thankyou')->with('tx', $intent->id);
        } elseif ($gateway === 'square') {
            $square = new SquareClient([
                'accessToken' => config('services.square.access_token'),
                'environment' => config('services.square.environment', 'sandbox'),
            ]);

            $paymentsApi = $square->getPaymentsApi();

            $money = new Money();
            $money->setAmount((int) ($amount * 100.0));
            $money->setCurrency(strtoupper($currency));

            $requestObj = new CreatePaymentRequest(
                sourceId: $pm,
                idempotencyKey: (string) rand(),
                amountMoney: $money
            );

            try {
                $response = $paymentsApi->createPayment($requestObj);
                if ($response->isSuccess()) {
                    $payment = $response->getResult()->getPayment();
                    if ($payment->getStatus() !== 'COMPLETED') {
                        return back()->withErrors(['payment' => 'Square not completed: '.$payment->getStatus()]);
                    }
                    return redirect()->route('thankyou')->with('tx', $payment->getId());
                }

                $errs = collect($response->getErrors() ?? [])->map(fn($e) => $e->getDetail() ?: 'error')->implode('; ');
                return back()->withErrors(['payment' => 'Square failed: '.$errs]);
            } catch (\Throwable $e) {
                return back()->withErrors(['payment' => 'Square error: '.$e->getMessage()]);
            }

            return back()->withErrors(['payment' => 'Unknown gateway']);
        }
    }
}
 

Выглядит ужасно 0.0 Теперь самое время правильно разделить всю логику, используя лучшие практики, и для решения этой задачи мы воспользуемся паттерном «Адаптер».

Реализация паттерна «Адаптер»

Прежде всего, нам нужно определить интерфейс, который будет использоваться нашими классами-адаптерами для единообразной работы с различными платежными системами через один и тот же метод:

 <?php

namespace App\Domains\Payment;

interface PaymentGateawayInterface
{
    public function charge(
        int $amount,
        string $currency,
        string $source,
        string $description = ''       
    );
}
 

Также не забудьте установить библиотеку оплаты через Composer в ваш Laravel-проект:

 $ composer require stripe/stripe-php
 

После установки зависимостей нам также нужно создать класс, в котором мы будем хранить результаты транзакций

 <?php

namespace App\Domains\Payment;

class ChargeResult
{
    public function __construct(
        public bool $success,
        public string $transactionId,
        public string $message,
    ) {}
}
 

Теперь, когда основные компоненты определены, пришло время создать наш первый адаптер для обработки транзакций Stripe:

 <?php

namespace App\Domains\Payment;

use Stripe\StripeClient;
use Exception;

class StripeGateawayAdapter implements PaymentGateawayInterface
{
    // We use dependency injection to properly handle
    // object creation from the framework side
    public function __construct(
        private StripeClient $client
    ) {}

    public function charge(
        int $amount,
        string $currency,
        string $source,
        string $description = ''
    ) {
        try {
            $response = $this->client->paymentIntents->create([
                'amount' => $amount,
                'currency' => $currency,
                'payment_method' => $source,
                'confirm' => true,
                'description' => $description
            ]);

            return new ChargeResult(
                success: $response->status === 'succeeded',
                transactionId: $response->id,
                message: $response->status
            );
        } catch( Exception $e ) {
            return new ChargeResult(
                success: false,
                transactionId: null,
                message: $e->getMessage()
            );
        }
    }
}
 

Но это не единственный класс-адаптер, который нам нужен; мы также создадим адаптер для обработки транзакций платежной системы Square:

 <?php

namespace App\Domains\Payment;

use Exception;
use Square\Payments\Requests\CreatePaymentRequest;
use Square\Legacy\Models\Money;
use Square\SquareClient;
use Illuminate\Support\Str;

class SquareGateawayAdapter implements PaymentGateawayInterface
{
    public function __construct(
        private SquareClient $client
    ) {}

    public function charge(
        int $amount,
        string $currency,
        string $source,
        string $description = ''
    ) {
        $money = new Money();
        $money->setAmount($amount);
        $money->setCurrency(strtoupper($currency));

        $request = new CreatePaymentRequest([
            'idempotencyKey' => Str::uuid()->toString(),
            'sourceId' => $source,
            'amountMoney' => $money
        ]);

        if ($description !== '') {
            $request->setNote($description);
        }

        try {
            $response = $this->client->payments->create(
                $request
            );

            if ( $response->getPayment() ) {
                // ... return success here
            }
        } catch ( Exception $e ) {
            return new ChargeResult(
                success: false,
                transactionId: null,
                message: $e->getMessage(),
            );
        }
    }
}
 

Благодаря адаптерам код теперь выглядит намного лучше: он стал чище, читаемее, и мы следуем принципам SOLID и ООП. И, конечно же, если мы используем какой-либо интерфейс во фреймворке Laravel, нам нужно правильно зарегистрировать его связывание в AppServiceProvider.php:

 <?php

namespace App\Providers;

use App\Domains\Payment\PaymentGateawayInterface;
use App\Domains\Payment\SquareGateawayAdapter;
use App\Domains\Payment\StripeGateawayAdapter;
use Illuminate\Support\ServiceProvider;
use App\Domains\Report\Builders\IReportBuilder;
use App\Domains\Report\Builders\ReportBuilder;

class AppServiceProvider extends ServiceProvider
{
    /**
     * Register any application services.
     */
    public function register(): void
    {
        $this->app->bind(PaymentGateawayInterface::class, StripeGateawayAdapter::class);
        $this->app->bind(PaymentGateawayInterface::class, SquareGateawayAdapter::class);
    }

    /**
     * Bootstrap any application services.
     */
    public function boot(): void
    {
        //
    }
}
 

Отлично, теперь мы можем легко использовать его в наших контроллерах:

 <?php

namespace App\Http\Controllers;

use Illuminate\Http\Request;
use App\Domains\Payment\StripeGateawayAdapter;
use App\Domains\Payment\SquareGateawayAdapter;
use App\Domains\Payment\PaymentGateawayInterface;
use Stripe\StripeClient;
use Square\SquareClient;


class CheckoutController extends Controller
{
    private PaymentGateawayInterface $gateway;

    public function __construct()
    {
        // We will have config for all payments
        $driver = config('payments.driver', 'stripe');

        // Dynamically switch gateaway payment based on config
        // we can also move this code to service file
        $this->gateway = match ($driver) {
            'stripe' => new StripeGateawayAdapter(
                new StripeClient(config('services.stripe.secret'))
            ),
            'square' => new SquareGateawayAdapter(
                new SquareClient([
                    'accessToken' => config('services.square.access_token'),
                    'environment' => config('services.square.environment'),
                ])
            ),
            default => throw new \RuntimeException("Unknown payment driver [$driver]"),
        };
    }

    public function store(Request $request)
    {
        $result = $this->gateway->charge(
            (int) ($request->input('amount') * 100),
            $request->input('currency', 'USD'),
            $request->input('payment_method'),
            'Order #' . now()->timestamp
        );

        if (! $result->success) {
            return back()->withErrors(['payment' => $result->message]);
        }

        return redirect()
            ->route('thankyou')
            ->with('tx', $result->transactionId);
    }
}
 

Заключение

Теперь наш код стал намного чище и читаемее, и, разумеется, мы соблюдаем принципы SOLID. Помните: когда у вас есть внешняя библиотека или классы, несовместимые с вашим интерфейсом, вы можете использовать паттерн «Адаптер», чтобы легко внедрить их в свой код и правильно изолировать всю логику. Спасибо за чтение!