0

I have a project to build, which has the following requirements:

  • topup a wallet by paying money in exchange for credit; this has also the constraint of needing an approval from the finance team, if the payment used is bank transfer. The finance team will then (in the future) issue invoices.
  • transfer credit from one wallet to another; requires no approvals or payments.
  • refund credit transferred to another wallet; this also requires approving from the finance team.

Wallets and Users logic live in separate services, so the above will only have to deal with moving credit around.

Trying to model this following a DDD approach, I came up with 2 possible solutions:

  • use composition and build a transaction class with interfaces such as Approvable, Payable, etc. Transaction will then become the Aggregate Root and encapsulate approvals within it. I like this approach, however I feel approvals may need to live in its own bounded context.
  • create more fine grained Aggregate Roots (Topup, Transfer, Refund, Approval) and have Approval live in its own bounded context. This means using composition on Approvals by having the AR satisfy an interface (Approve(), Reject()). The problem with this is cyclic dependencies and more complex application services.

I thought of a 3rd option, which is using events. However I feel this approach would be very close to option 2 and has another layer of complexity to take in consideration.

Code example of solution 1, for clarity (pseudo-code)

interface IApprovable {
  approve() 
  reject()
}

interface IPayable {
  pay()
}

class Approvable implements IApprovable {
  ...
  approve() {...}
  reject() {...}
}

class NotApprovable implements IApprovable {
  ...
  approve() {...} <-- throw an error
  reject() {...} <-- throw an error
}

class Payable implements IPayable {
  ...
  pay() {...}
}

class NotPayable implements IPayable {
  ...
  pay() {...} <-- throw an error
}


class Transaction {
  private IApprovable approvable;
  private IPayable payable;

  Transaction(IApprovable approvable, IPayable payable) {
    this.approvable = approvable;
    this.payable = payable;
  }
}

public static main() {
  Transaction topup = new Transaction(new Approvable(), new Payable());
  Transaction transfer = new Transaction(new NotApprovable(), new NotPayable());
  Transaction refund = new Transaction(new Approvable(), new NotPayable());
}

Is there any other approach that could be a better design or which, out of the 3 above, would be the best choice?

  • Can you clarify a few things for us? E.g, when you say "approvals may need to live in its own bounded context", are you talking about how to organize code into services? Because bounded contexts are not the right term for that - they are about designating within which part of a complex domain your model is applicable (i.e., throughout the entire BC, the same terms, concepts and ideas apply, while in another BC you have a different model, with a different set of concepts, and if there are shared terms, they have (often subtly) different meanings). It doesn't sound like that's the case here.1/2 – Filip Milovanović Apr 21 at 7:19
  • Tell us why do you want to separate Approvals out? (I'm not saying they shouldn't be, I'm just asking.) Then, I'm not entirely clear on what you mean when you say "use composition"; you describe which interfaces you would define, but you are not really telling us enough on how composition comes into play (again, not sure if the right term is being used). Also, for approvals, when is it that the actual approval happens? Immediately? At some later point in time? Do you need to model an Approval w/ Approve()/Reject() here, or is the result of these operations just an approval request? 2/2 – Filip Milovanović Apr 21 at 7:19
  • Thanks for your comment @FilipMilovanović and sorry it wasn't clear. The reason why I was thinking approvals should/could live in its own bounded context is because approvals are approved by our Finance team internally, whilst topping up, transferring credit and requesting refund is done by the customer. Our finance team internally must approve topping up as there's some manual checking involved with bank transfer references (which happens before the approval). – Gabs Apr 21 at 20:00
  • Composition in this case is used by having behaviour applied at runtime, via objects that implement the interface with the methods I mentioned above (approve(), reject()). So, instead of having different aggregate roots (Topup, Transfer, Refund), I thought of having a composable one (Transaction) with behaviour defined at runtime (Approvable, Payable, etc.). The only problem is that this also require their counterpart NotApprovable, NotPayable, etc. Let me know if you need further clarifications. Thank you! – Gabs Apr 21 at 20:05
  • @FilipMilovanović I added some code example – Gabs Apr 21 at 20:58
1

Ouch, that looks messy.

Workflows and Rule Books

I think what you need is a workflow, and a rule book.

In comes a TransferRequest, with some source information, destination information, and a balance.

enum AccountKind
{
    BankTransfer,
    Cashier,
    Wallet,
    ...
}

class Account
{
    AccountKind kind;
    AccountIdentifier identifier;
}

enum Currency
{
    Dollars,
    Credits
}

class Money
{
    Currency currency;
    decimal amount;
}

class Approval
{
   bool approved;
   ...
}

enum WorkflowState
{
    New,
    AwaitingMoney,
    MoneyReceived,
    AwaitingApproval,
    PayMoney,
    Done
    ...
}

class TransferRequest
{
   WorkflowState
   Account source;
   Account destination;
   Money from_source;
   Money to_destination;
   Approval approval;
}


Yes this is a data structure, not a full fledged object. The next piece of the puzzle is the Rule Book.

class Rule
{
    Func<TransferRequest, bool> precondition;
    Func<TransferRequest, TransferRequest> transition;
}

class RuleBook
{
    private Rule[] rules;

    TransferRequest Update(TransferRequest transferRequest)
    {
        return rules.single(r -> r.precondition(transferRequest)).transition(transferRequest);
    }
}

Each rule knows when it can be applied, and applies itself within a transaction. The result is moving the TransferRequest to the next appropriate point on its journey.

Now when a TransferRequest comes in:

  • with a source of BankTransfer, and its new the rule transitions the TransferRequest to awaitingApproval.
  • If it came from the cashier, it transitions directly to PayMoney.
  • If it came from another wallet, it transitions to AwaitingMoney.

Once the transition is complete and the background task associated with it completes the TransferRequest is marked as ready for its next stage. The rulebook is reapplied, moving the transfer to the next stage of its journey.

This allows you to compose a workflow, in which certain steps can be ignored for certain instances, but are needed in others. For example the transfer between wallets might enter an InsufficientFunds state (where that would be impossible from the bank transfer, or cashier). Conversely the bank transfer needs approval to indicate the funds where collected, where the cashier has already dealt with that.

  • Thank you for your answer! This is really interesting, I've never thought of doing it this way. The reason why I've used the aforementioned approach is to keep the logic contained within the Aggregate Root (or main entity). With your approach, the core logic is formed by a set of rules that change the state of the entity. Nonetheless I like your approach and I'll definitely give it a try. – Gabs Apr 23 at 14:53

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service, privacy policy and cookie policy

Not the answer you're looking for? Browse other questions tagged or ask your own question.