DEV Community

Alexey Zaitsev
Alexey Zaitsev

Posted on

How to keep list of different payment methods in a web account following DDD principles?

Hello everyone! I really would like to know your opinion about how better handle and keep different payment methods in a single web account. I consider the account as aggregate root in DDD terms and I want keep list of payment methods there. Also account has to have methods such as makePaymentMethodDefault, addPaymentMethod, removePaymentMethod etc. There is business rule saying that there is only one default payment method so when I remove or add new one I have to make other payment methods as not default. Bellow I showed how it looks now for me but I really don't like it. Actually there are other ways to do it, make a service to handle payment methods for account for instance but I want to make everything in account to be sure that all business rules always go correct and keep related items in one place.

Also in system can be a few payment methods:

  1. Stripe tokenized card
  2. PayPal
  3. Something else in a future

Making payment happens in a different module so my question is not about how to charge money or make a payment but about how to keep payment methods.

This is my common payment method interface (TypeScript):

export interface IPaymentMethod {
  isDefault(): boolean;

  makeDefault(): void;

  getClass(): new(...args: any[]) => any;

  getPaymentProvider(): PaymentProvider;

  getPaymentMethodType(): PaymentMethodType;

  remove(): void;

  init(owner: IOwningPaymentMethod): void;
}
Enter fullscreen mode Exit fullscreen mode

This is my first concrete payment method (TypeScript):

export class StripeTokenizedCardPaymentMethod implements IPaymentMethod {
  readonly token: string;
  readonly last4: string;
  readonly expYear: number;
  readonly expMonth: number;
  readonly country: string;
  readonly brand: string;
  private _owner: IOwningPaymentMethod;

  constructor(props: IStripeTokenizedCardPaymentMethodProps) {
    this.token = props.token;
    this.last4 = props.last4;
    this.expYear = props.expYear;
    this.expMonth = props.expMonth;
    this.country = props.country;
    this.brand = props.brand;
  }

  init(owner: IOwningPaymentMethod): void {
    this._owner = owner;
  }

  getClass() {
    return StripeTokenizedCardPaymentMethod;
  }

  getPaymentMethodType(): PaymentMethodType {
    return PaymentMethodType.StripeTokenizedCard;
  }

  getPaymentProvider(): PaymentProvider {
    return PaymentProvider.Stripe;
  }

  isDefault(): boolean {
    return this._owner.getDefaultPaymentMethod().getPaymentProvider() === this.getPaymentProvider()
      && this._owner.getDefaultPaymentMethod().getPaymentMethodType() === this.getPaymentMethodType();
  }

  makeDefault(): void {
    this._owner.makePaymentMethodDefault(this);
  }

  remove(): void {
    this._owner.removePaymentMethod(this);
  }
}
Enter fullscreen mode Exit fullscreen mode

And finally aggregate root account (TypeScript):

export class Account extends BaseEntity<AccountId> implements IOwningPaymentMethod {
  get updatedAt() {
    return this._updatedAt;
  }

  readonly id: AccountId;
  readonly user: User;
  readonly createdAt: Moment;
  private _updatedAt: Moment;
  private _paymentMethods: IPaymentMethod[] = [];
  private _defaultPaymentMethod: IPaymentMethod;

  constructor(props: IAccountProps) {
    super();
    this.user = props.user;
    this.id = props.id;
    this._updatedAt = props.updatedAt;
    this.createdAt = props.createdAt;
  }

  getDefaultPaymentMethod(): IPaymentMethod {
    return this._defaultPaymentMethod;
  }

  makePaymentMethodDefault(paymentMethod: IPaymentMethod): void {
    this._defaultPaymentMethod = paymentMethod;
  }

  removePaymentMethod(paymentMethod: IPaymentMethod): void {
    throw new Error("Method not implemented.");
  }

  addPaymentMethod(paymentMethod: IPaymentMethod): void {
    paymentMethod.init(this);
    this._paymentMethods.push(paymentMethod);
  }

  static create(props: IAccountProps): Result<Account> {
    const guardResult = Guard.againstNullOrUndefinedBulk([
      { argument: props.user, argumentName: "user" }
    ]);

    if (!guardResult.succeeded) {
      return Result.fail(new Error(guardResult.message));
    }

    const defaultProps: IAccountProps = {
      ...props,
      id: props.id ?? GUID.generateUUID4(),
      createdAt: props.createdAt ?? moment(),
      updatedAt: props.updatedAt ?? moment()
    };

    const account = new Account(defaultProps);

    return Result.ok(account);
  }
}
Enter fullscreen mode Exit fullscreen mode

Discussion (4)

Collapse
benjioe profile image
Benjioe • Edited

You can encapsulate payment in his own ValueObject :


export class Account {
  private AccountPaymentMethod paymentMethod;

  addPaymentMethod(paymentMethod: IPaymentMethod): void {
    this.paymentMethod= this.paymentMethod.addMethod(paymentMethod);
  }

}

class AccountPaymentMethod {
  constructor(private _paymentMethods: IPaymentMethod[], private _defaultPaymentMethod: IPaymentMethod) {}

  default(): IPaymentMethod {
    return this._defaultPaymentMethod;
  }

  changeDefault(paymentMethod: IPaymentMethod): AccountPaymentMethod {
    return new AccountPaymentMethod(this._paymentMethods, paymentMethod);
  }

  removeMethod(paymentMethod: IPaymentMethod): AccountPaymentMethod {
    throw new Error("Method not implemented.");
  }

  addMethod(paymentMethod: IPaymentMethod): AccountPaymentMethod {
    paymentMethod.init(this);
    return new AccountPaymentMethod(
       [...this._paymentMethods, paymentMethod], 
       _defaultPaymentMethod: IPaymentMethod
    );
  }
}
Enter fullscreen mode Exit fullscreen mode
Collapse
benjioe profile image
Benjioe • Edited

And I thinks your method create is too generic, you can be more explicit :


type PaymentMethodParam = {
  paymentMethods: IPaymentMethod[]
  defaultPaymentMethod: IPaymentMethod
}

export class Account {
  static create(user: User, {paymentMethods, defaultPaymentMethod} : PaymentMethodParam): Result<Account> {
     // ...
  }
  static createAccountForFutur(user: User,  createdAt: Date /* Date encapsulate Moment for the day Moment will be abandonned */): Result<Account> {
     // for some reason, you wants to create an Account with createdAt !== today
    //  and you don't want to let them pay
  }
}
Enter fullscreen mode Exit fullscreen mode
Collapse
ilotus profile image
Alexey Zaitsev Author

It looks really interesting and I like your approach to encapsulate logic with payment methods in one object. Thanks!

Collapse
benjioe profile image
Benjioe

Hello,

Why you don't like it ?