Ultimate guide to 3rd party calls from your Aggregate

— Published originally on blog.arkency.com

Ultimate guide to 3rd party calls from your Aggregate

If you ever wondered how to make 3rd party API call from Aggregate and not clutter it with dependencies, you may find this post interesting.

Some time ago I faced that problem while implementing Payment aggregate. Everything looked quite simple until the time a real request to payment gateway had to be performed.

I started wondering what is the right spot for that operation? Initially, I tried to do it in command handler. Let's take a look at the snippet below.

class Payment
  include AggregateRoot

  CreditCardAlreadyCharged = Class.new(Error)

  def initialize(id)
    @id = id
  end

  def charge_credit_card(amount)
    raise CreditCardAlreadyCharged if charged?
    apply(CreditCardCharged.new(data: { payment_id: @id, amount: amount })
  end

  on CreditCartCharged do |event|
    @state = :charged
  end

  private

  def charged?
    @charged
  end
end

class OnChargeCreditCard
  def call(cmd)
    ApplicationRecord.transaction do
      gateway.purchase(Integer(cmd.amount * 100), cmd.credit_card, { payment_id: cmd.payment_id })
      with_aggregate(Payment, cmd.payment_id) do |payment|
        payment.charge_credit_card(cmd.total_amount)
      end
    rescue Payment::CreditCardAlreadyCharged => doh
      handle_disaster(doh)
    rescue PaymentGateway::Error => doh
      handle_payment_error(doh)
    end
  end
end

Command handler gets command, makes API call to the gateway and then event is applied to the aggregate. But what if another command happens and customer will be charged twice? That's why we used aggregate pattern here, to guard the invariants. But this won't work as expected with current implementation, since gateway request happens before aggregate gets called. Let's change the order then:

def call(cmd)
  ApplicationRecord.transaction do
    with_aggregate(Payment, cmd.payment_id) do |payment|
      payment.charge_credit_card(cmd.total_amount)
    end
    gateway.purchase(Integer(cmd.amount * 100), cmd.credit_card, { payment_id: cmd.payment_id })
  end
end

This looks good at first sight. But what if payment doesn't get accepted because of invalid credit card data or random network error appear? We already applied an event, event handlers started processing it. In effect user has received e-mail about successful payment, he got link to download virtual products, etc. We could make compensating operation, of course. The question is if there's a possibility to do so.

We could try to do it other way around. Let's expose Payment internal state via charged? method to command handler and make the decision there. Even more, CreditCardCharged event could be published from command handler too. Introduction of aggregate wouldn't make any sense in such approach, it would be obsolete.

What about passing gateway as a dependency and calling it inside Payment aggregate? Sounds tempting, let's see:

class Payment
  include AggregateRoot

  CreditCardAlreadyCharged = Class.new(Error)
  CreditCardChargeFailed   = Class.new(Error)

  def initialize(id, gateway)
    @id      = id
    @gateway = gateway
  end

  def charge_credit_card(amount, credit_card)
    raise CreditCardAlreadyCharged if charged?
    @gateway.purchase(Integer(amount * 100), credit_card, { payment_id: @id })
    apply(CreditCardCharged.new(data: { payment_id: @id, amount: amount })
  rescue PaymentGateway::Error => doh
    raise CreditCardChargeFailed.new(doh)
  end

  on CreditCartCharged do |event|
    @state = :charged
  end

  private

  def charged?
    @charged
  end
end

class OnChargeCreditCard
  def call(cmd)
    ApplicationRecord.transaction do
      with_aggregate(Payment, cmd.payment_id, gateway) do |payment|
        payment.charge_credit_card(cmd.total_amount, cmd.credit_card)
      end
    rescue Payment::CreditCardAlreadyCharged => doh
      handle_disaster(doh)
    rescue Payment::CreditCardChargeFailed => doh
      handle_payment_failure(doh)
    end
  end
end

Payment class got cluttered and its responsibilities expanded. I'm not convinced that such technical details are the part of aggregate interests and I disliked this approach as soon as I implemented it. I started thinking how to make decision about the payment inside the aggregate but keep all the payment technicals out of it.

class Payment
  include AggregateRoot

  CreditCardAlreadyCharged = Class.new(Error)
  CreditCardChargeFailed   = Class.new(Error)

  def initialize(id)
    @id = id
  end

  def charge_credit_card(amount, request)
    raise CreditCardAlreadyCharged if charged?
    response = request.()
    if response.success?
      apply(CreditCardCharged.new(data: { payment_id: @id, amount: amount })
    else
      raise CreditCardChargeFailed.new(response)
    end
  end

  on CreditCartCharged do |event|
    @state = :charged
  end

  private

  def charged?
    @charged
  end
end

class OnChargeCreditCard
  def call(cmd)
    ApplicationRecord.transaction do
      with_aggregate(Payment, cmd.payment_id) do |payment|
        payment.charge_credit_card(cmd.total_amount, cmd.credit_card, request)
      end
    rescue Payment::CreditCardAlreadyCharged => doh
      handle_disaster(doh)
    rescue Payment::CreditCardChargeFailed => doh
      handle_payment_failure(doh)
    end
  end

  private

  def request
    -> { gateway.purchase(Integer(cmd.amount * 100), cmd.credit_card, { payment_id: cmd.payment_id }) }
  end
end

Instead of passing gateway as a dependency, we pass a payment gateway call wrapped in lambda. The only thing we need to do is to check whether response is successful to decided whether apply CreditCardCharged event or not. We assume that payment gateway call returns Response object responding to success? method, but it's not a topic of this post and I believe that you know how wrap gateways response into Value Object.

Lambda gives us great possibility of currying arguments and getting some from inside of aggregate state. Let's use two-step payment scenario like CC Authorization & Capture. Often you need to refer original transaction when capturing the real money. Just prepare request as a lambda with argument:

def request
  ->(transaction_id) { gateway.capture(transaction_id, Integer(cmd.amount * 100), cmd.credit_card, { payment_id: cmd.payment_id }) }
end

As a bonus, you get nice and clean aggregate tests without messing with mocks, VCRs, massive fake gateway adapters. Aggregate can remain interested in single method of Response object only.

class PaymentTest < ActiveSupport::TestCase
  def test_credit_card_charge_succeeded
    payment_id = SecureRandom.uuid
    payment    = Payment.new(payment_id)
    payment.charge_credit_card(amount, credit_card, successful_request)

    assert_changes(payment.unpublished_events, [
        CreditCardPaymentCharged.new(
          data: {
            payment_id:     payment_id,
            amount:         BigDecimal("123.45"),
            transaction_id: '53433'
          }
        )
      ]
    )
  end

  def test_credit_card_charge_failed
    payment = Payment.new(SecureRandom.uuid)

    assert_raises(Payment::AlreadyAuthorized) do
      payment.charge_credit_card(amount, credit_card, failure_request)
    end
  end

  private

  def amount
    BigDecimal("123.45")
  end

  def credit_card
    {
      name:               'Jane Doe',
      number:             '4111 1111 1111 1111',
      month:              1,
      year:               2028,
      verification_value: 123,
      brand:              'visa'
    }
  end

  def transaction_id
    '12345'
  end

  def successful_request
    ->(*) {Struct.new(:success?, :transaction_id).new(true, transaction_id)}
  end

  def failure_request
    ->(*) {Struct.new(:success?, :transaction_id).new(false, transaction_id)}
  end
end
Tags: · ·

Avatar of Author

Szymon Fiedler

I solve problems. This is the place where I share my thoughts on Software Engineering.