Modeling payment workflows with rich domain objects

The domain owns the workflow

Geschrieben von Timo Rieber am 19. August 2025

When I added PayPal to cloudapps, my first instinct was to reach for the SDK and wire up three REST endpoints. Well-documented API, straightforward flow. Could've lived in a single controller file.

But a payment lifecycle has rules that don't belong in a controller:

  • You can't initiate a payment on a receivable that's already paid or cancelled.
  • You can't confirm a payment that was never initiated.
  • A confirmed payment records the amount against the receivable and may trigger a registration confirmation downstream.
  • A cancellation needs a reason.

Those are domain constraints, not HTTP concerns.

Where the rules live

A Receivable aggregate already existed - it tracks what a customer owes and how much they've paid. Adding PayPal meant giving it a PayPalPayment entity that carries an explicit status: INITIATED, COMPLETED, or CANCELLED. Every transition runs through the receivable. Initiating checks three preconditions. Confirming verifies a payment was actually initiated and hasn't already been completed. Only then does it record the amount and publish a PayPalPaymentCompleted event.

def initiate_paypal_payment(self, order_id: str) -> None:
    if self.payable <= Money(0):
        raise ValueError('Receivable is already paid')
    if self.is_cancelled():
        raise BaseError('Receivable is canceled')
    if self.sepa_direct_debit is not None:
        raise ValueError('SEPA direct debit already set')

    self.paypal_payment = PayPalPayment(
        order_id=order_id, amount=self.payable, status='INITIATED'
    )
    publish(PayPalPaymentInitiated(...))
Python

None of this mentions PayPal's REST API, OAuth tokens, or SDK objects. The aggregate defines what a valid payment lifecycle looks like - PayPal has order IDs and capture flows, Stripe has payment intents and confirmation tokens, SEPA has mandates and debit dates. Preconditions stay the same regardless of provider.

Behind a protocol

Actual PayPal API calls live behind a PayPalGateway protocol with two methods: create_order and capture_order. Return types are frozen dataclasses - PayPalCapture carries a capture ID and a Money amount, PayPalOrder just strings. No SDK objects cross the boundary. One adapter file handles OAuth, SDK initialization, response parsing. Everything PayPal-specific stays there.

Commands sit between the two layers. A ConfirmPayPalPayment command first validates the order ID against the aggregate, then calls the gateway to capture, then hands the result back for the state transition. Validation before capture matters - a completed PayPal capture settles the payment immediately, so you don't want to capture and then discover the aggregate rejects it.

Events across contexts

PayPalPaymentCompleted gets picked up by a handler in a different bounded context: the course registration module. That handler confirms the registration. Payment doesn't know registrations exist. Registration just reacts to an event carrying a correlation ID, a receivable reference, and a capture ID.

SEPA direct debit already had a similar setup - a SepaDirectDebitPaymentRecorded event with its own handlers for attendee notification and registration confirmation. Adding PayPal didn't touch any of that. New event, new handler, new bridge between contexts. Neither payment method knows the other exists.

Three commits, 57 test cases. Domain layer tests don't need the PayPal SDK at all - the gateway is a protocol, so a stub covers every state transition in isolation.