Reconstructing document approval items with DDD (Domain Driven Design) -- continued

        I wrote an article like the title of this article before, but considering that the length does not introduce the reconstruction process of the project, I will fill in this pit today to solve a heart disease.

        If you want to use DDD, relevant knowledge is essential, so first recommend several books about DDD, starting with "learning". The first book is, of course, the domain driven design software core complexity response by Eric Evans, the author of DDD. This book, which took the author four years to start DDD, is worth reading again and again. The way of complex software design: comprehensive analysis and practice of domain driven design is compiled by Peng Chenyang, a domestic DDD preacher. The book has rich examples and is very helpful to practice. By the way, I like the Panther on the cover very much. The next two books are from Vaughn Vernon. One is "implementing Domain Driven Design". This book is rich in Eric's book and is also worth reading repeatedly. One is the essence of domain driven design, which is very thin and can quickly lead readers into DDD. The last book is Chris Richardson's Micro service architecture design pattern. The rise of DDD in China is closely related to the wide application of microservices. In recent years, service splitting has become another important reason for the further reduction of architects. This book introduces the guiding role of DDD in the process of service splitting.

Previous review

         In a previous article, I mentioned that I took over a document approval system. Although there are dozens of menus in this system, there are only two real core functions, one is document approval (implemented by Activiti, which is mainly focused on the maintenance of the approval node, which is usually completed by business personnel on the page), and the other is to generate vouchers for the approved documents. Other functions serve these two core functions, such as account maintenance. In other words, the ultimate purpose of this system is to generate accounting vouchers, which is what I reconstruct. The reason for refactoring was introduced in detail in the previous article. In general, it is poor business scalability and difficult to maintain and test.

        Voucher generation depends on voucher generation rules, that is, first obtain the previously configured rules in the database according to the document type and other attributes, and then create vouchers according to the rules. The code structure before refactoring is shown in the figure below.

         The biggest problem before refactoring is that the voucher generation logic is scattered in the facade and voucher services, that is, to modify or add a rule, two services must be modified at the same time. It is unreasonable only from the perspective of service splitting, not to mention the bad smell in the code. As shown below, code like this can be seen everywhere.

if("0".equals(bill.getRelatedParty())) {//Related party transactions
			buildAdCreateRequestAmounts_RelatedParty(bill,adRequest);
		}else{//Non related party transactions
			buildAdCreateRequestAmounts_common(bill,adRequest);
		}

Rule coding

         As mentioned in the previous article, because a voucher generation rule needs to be configured on two pages in the system, because the configuration involves some details in the code, this configuration can only be completed by the developer according to the relevant description provided by the product. If this rule can be configured by the product itself, I think it is no problem to design it in page form, but unfortunately, the product will not and cannot. Since it can only be configured by developers, what is the significance of this form of page configuration? In my opinion, the page configuration form is either used to make the configuration effective immediately without restarting the system, or to allow the users of the system to change the system behavior through self configuration. For example, the list component can select the maximum number of records that can be displayed on a page. For the above two features, the page configuration mode of the rule does not comply with. So I decided to start with the voucher generation rules and code the rules in the form of records in the database. This can not only reduce a database access, but also improve the maintainability of the code.  

        According to statistics, there are 55 voucher generation rules in the database. If each rule in the database corresponds to a class, 55 classes are required. Although the amount of code of each class is small, it will indeed cause class expansion. At first, it was decided to subcontract rule classes by voucher type, but this method did not fundamentally solve the problem of class inflation. After further analysis, it is decided to define a rule class based on each voucher type, and then further distinguish specific rules in the class. Although the code in each class increases slightly, this design scheme reduces the number of classes from 55 to 7. Different from adding new types to documents from time to time, voucher types are very stable, which is why you choose to use voucher types as rule classes to create dimensions. However, when adding rules, you need to modify the rule class. Although the modified code is not much (add addBillSpecification()), it still violates OCP to a certain extent. However, in order to avoid class expansion, the principle of moderate relaxation is also acceptable. They seem to be at both ends of the balance, and finding the balance point is the most important. The following figure shows the rule class defined by a / P voucher type. createRuleDefinition() is the abstract method of RuleHolder class, which is implemented by the inheritance class.

@Component
public class ShouldPayRuleHolder extends RuleHolder {

    @Override
    public RuleDefinition createRuleDefinition() {
        return RuleDefinition.builder().voucherType(VoucherType.builder().code("00").name("cope with").build())
                .addBillSpecification(buildBillSpecificationForSupplierExpenseWithPnRn())
                .addBillSpecification(buildBillSpecificationForSupplierExpenseWithPnRy())
                .addBillSpecification(buildBillSpecificationForPurchaseExpense())
                .addBillSpecification(buildBillSpecificationForFingerWithPnRy())
                .addBillSpecification(buildBillSpecificationForFingerWithPnRn())
                .build();
    }

    private RuleDefinition.BillSpecification buildBillSpecificationForFingerWithPnRn() {
        return RuleDefinition.BillSpecification.builder()
                .billSubTypeEnum(BillSubTypeEnum.FINGER)
                .prepayment(FlagEnum.NO).relateDeal(FlagEnum.NO)
                .voucherItemRule(VoucherItemRule.builder()
                                .creditSubject(VoucherSubjectEnum.COST)
                                .debitSubject(VoucherSubjectEnum.BANK)
                                .creditDigestRule(billItem -> billItem.getBill().getCOMPANY_NAME()
                                + "pay" + billItem.getBill().getAPPLY_EMPL_ID() + "Personal reimbursement"
                                + ((FingertipItem) billItem.getItem()).getFEE_ITEM_CODE())
                                .debitDigestRule(billItem -> billItem.getBill().getAPPLY_EMPL_ID()
                                + "received" + billItem.getBill().getCOMPANY_NAME() + "Personal reimbursement")
                                .build())
                .build();
    }

Define document combination

          After completing the coding of rules, the next step is how to obtain rules. Before reconstruction, the voucher service receives the document data sent by the facade service, and then obtains the matching rules from the database according to the document type and other attributes. The following is the original method of creating vouchers. Others are put aside first. The most eye-catching and unbearable thing is the pile of magic numbers. As far as this method alone is concerned, there are a lot of places that can be optimized.

public ResponseBuilder create(List<AdCreateRequestVO> adCreateRequestVos) {
        //Authentication request
        log.info("Voucher generation request parameter validation");
        AccountDocumentCheckUtil.checkCreateRequest(adCreateRequestVos);
        //Verify whether the voucher already exists: 20191202
        AdQueryVO adQueryVO = new AdQueryVO();
        adQueryVO.setBillNo(adCreateRequestVos.get(0).getBillNo());
        Integer count = adAccountDocumentService.getCount(adQueryVO);
        if (count > 0) {
            log.error("When calling voucher generation, the system will prompt you to delete the existing voucher. Doc No.:{}", adCreateRequestVos.get(0).getBillNo());
            throw new BizException("Please refresh the page and delete the voucher first");
        }
        //Generate voucher
        log.info("Voucher generation start");
        this.saveAccountDocument(adCreateRequestVos);
        log.info("End of voucher generation");
        if ("3".equals(adCreateRequestVos.get(0).getBillTypeKind()) || BILL_TYPE_KIND_FINGERTIP.equals(adCreateRequestVos.get(0).getBillTypeKind())
                || "19".equals(adCreateRequestVos.get(0).getBillTypeKind())) {
            ResponseBuilder responseBuilder = new ResponseBuilder();
            Integer pushType = BILL_TYPE_KIND_FINGERTIP.equals(adCreateRequestVos.get(0).getBillTypeKind()) ? 1 : 0;
            try {
                 responseBuilder = pushVoucherLogic.pushVoucherByBillNo(adCreateRequestVos.get(0).getBillNo(), pushType, null, "");
            }  catch (Exception e) {
                e.printStackTrace();
                log.error("Other exceptions, voucher push failed:", e.getCause());
            }

            if("1".equals(responseBuilder.getStatus()) && "9999".equals(responseBuilder.getErrorCode())){
                log.error("Voucher push failed, voucher information was not obtained");
                throw new BizException(responseBuilder.getErrorMsg());
            }
        }
        //Successful return
        return ResponseBuilder.ok();
    }

        Generally, the anemia model is used to obtain the document corresponding rules. The document ID is passed to a method of a service class. In this method, the document ID is used to query the document from the database to obtain a POJO, and then return the rules matching the corresponding attributes in the POJO. However, it is not the case to realize the above functions under the guidance of DDD.

        A document has a unique identifier, so it is not only an entity, but also the aggregation root of document aggregation. The entity in DDD is not only the carrier of data, but also has behaviors consistent with its responsibilities, that is, methods. The method here does not refer to setter getter, but business logic. Since the ultimate purpose of a document is to generate vouchers, the generation of vouchers depends on rules, and the attributes of matching rules all come from the document entity. Therefore, it is natural to add acquisition rules for the document entity.

public class BillAggregate {

    @Getter
    private final Bill bill;

    public List<?> getItems(VoucherService voucherService) {
        return Items.builder().bill(bill).build().getItems(voucherService);
    }

    public List<RuleHolder> getCreateVoucherRuleHolders() throws RuleConfigurationLoader.NoMatchVoucherTypeException
            , RuleConfigurationLoader.NoMatchedRuleHolderException {

          With the above aggregation, it becomes easy to obtain the rules matching the document, just like the following.

BillAggregate billAggregate = billRepository.get(billId);
try {
    billAggregate.getCreateVoucherRuleHolders().forEach(ruleHolder -> {

Service domain service - credential definition

        After the rules are obtained, vouchers will be generated based on them. Each voucher has its corresponding document, so the voucher is also an entity like the document. There are two functions that must be implemented in the voucher field: one is to generate vouchers, and the other is to push vouchers to the financial system. These two functions can be embodied as two methods in the code. Can these two methods also be placed in the voucher entity as the method of obtaining voucher rules is placed in the document entity? Of course you can, but it must be unreasonable. If these two methods are in the voucher entity, from the perspective of OO, that is, the voucher creates itself and pushes itself out, which is very similar to holding its own hair and lifting itself up. Moreover, the creation of vouchers also requires the cooperation of document entities, so the document entities need to be transferred to the voucher entities, and finally the voucher creation results need to be processed. For example, if the creation is successful, it will be pushed to the financial system, and if it fails, relevant personnel will be notified. If all the above are realized by the voucher entity, it obviously has too much responsibility, and these responsibilities should not be borne by it. The opportunity to save the field of service has come.

        When a method is not suitable for putting into any entity or value object, or a method needs to coordinate multiple aggregated functions to complete a task, domain Service is its destination. I assumed the responsibility of creating vouchers with voucher Service. The following is the original voucher creation method and the processing of voucher push and push results. Although the following code is not difficult to read, it has poor scalability.  

        log.info("Voucher generation start");
        this.saveAccountDocument(adCreateRequestVos);
        log.info("End of voucher generation");
        if ("3".equals(adCreateRequestVos.get(0).getBillTypeKind()) || BILL_TYPE_KIND_FINGERTIP.equals(adCreateRequestVos.get(0).getBillTypeKind())
                || "19".equals(adCreateRequestVos.get(0).getBillTypeKind())) {
            ResponseBuilder responseBuilder = new ResponseBuilder();
            Integer pushType = BILL_TYPE_KIND_FINGERTIP.equals(adCreateRequestVos.get(0).getBillTypeKind()) ? 1 : 0;
            try {
                 responseBuilder = pushVoucherLogic.pushVoucherByBillNo(adCreateRequestVos.get(0).getBillNo(), pushType, null, "");
            }  catch (Exception e) {
                e.printStackTrace();
                log.error("Other exceptions, voucher push failed:", e.getCause());
            }

//The following is part of the method called by pushVoucherLogic.pushVoucherByBillNo()
                try {
                    this.pushVoucher(accountDoc, accountDocInfo, errorMessageList, operatorId, operatorName);
                } catch (Exception ex) {
                    errorMessageList.add("Failed to send voucher, exception information:" + ex.getMessage());
                }

        In order to improve the scalability of the system and eliminate the coupling between codes, I identified and extracted the two domain events of "voucher generation result" and "voucher push result". At the same time, I followed the SRP, let the voucher service only be responsible for the creation of vouchers, and publish the voucher generation results in the form of events for the handler paying attention to relevant events to process. The following is the reconstructed voucher creation code. Event processing is implemented through observer mode.

        BillAggregate billAggregate = billRepository.get(billId);
        try {
            billAggregate.getCreateVoucherRuleHolders().forEach(ruleHolder -> {
                Voucher voucher = buildVoucher(ruleHolder, billAggregate);
                voucherRepository.save(voucher);
                eventPublisher.publish(VoucherCreateSucceedEvent.builder()
                                .billId(billId).voucherId(voucher.getID()).build());
            });
        } catch (RuleConfigurationLoader.NoMatchVoucherTypeException
                | RuleConfigurationLoader.NoMatchedRuleHolderException e) {
            log.error("Voucher create failed, billId -> [{}]", billId, e);
            eventPublisher.publish(VoucherCreateFailedEvent.builder().billId(billId).message(e.getMessage()).build());
        }

Remove credential service

        After the above reconstruction, the certificate service has no meaning of existence, because its functions have been moved to the facade service. In fact, this service is redundant. To put it bluntly, it is split for the purpose of splitting, and I also understand the purpose of the brothers who split this service. After all, when going out for an interview in the future, I can say: "I split the service...". If the system had a qualified architect, such a split would not be allowed.

         In my opinion, there are only two purposes for service splitting. One is to give maintenance to different people or teams to reduce the coupling between people; The other is to split the hot functions of a service into independent services to improve performance and stability. The certificate service does not achieve the above two purposes, but runs counter to them. First, for the requirement of adding rules, you need to modify the facade and voucher services at the same time. If the two services are maintained by different teams or personnel, they need to modify the same requirement together. In fact, the communication cost can be saved. Second, if the credential service uses an independent database instance, it can also be considered to have played a role in improving the stability of the system, but in fact it shares a database instance with the facade service. Therefore, the split credential service not only has no contribution to the performance and stability, but also adds a failure point. If the credential service fails, Vouchers cannot be generated even if the facade service is running normally. If both services are 99%, when they serve a system together, they are no longer 99%.

         Of course, it is not impossible to retain the voucher service, but you need to move all the codes responsible for the document data organization in the facade service to the voucher service. The facade service only needs to send the document ID to the voucher service, and then the voucher service obtains the document according to the document ID and generates vouchers for it, This compromise scheme does not need to modify the code of two services at the same time when implementing a requirement, and at least improves the cohesion of services.

epilogue

        So far, the general reconstruction has been basically introduced, but there are still many details not explained. For example, the convention over configuration principle is used to correspond the document type with its different details in a configuration class. For example, when the rule is coded, the Function class is used to define the "voucher summary Description" of its rules for different types of documents, These are the final design schemes after multiple rounds of reconstruction.

        After the above reconstruction, to add a new voucher generation rule, you need to find the corresponding classes in the inherited classes of RuleHolder, and then add a definition of document specification. Because this is the core function of the system, in order to ensure safety, the reconstructed code is deployed together with the original code, the vouchers generated by the reconstructed code are saved in a separate table, and the vouchers generated by the program are automatically compared with the vouchers generated by the original code.

         If you want to make good use of DDD, in addition to "learning", the more important thing is "learning". It's a long way to go. Sail against the current. If you don't advance, you'll fall back. Exercise yourself and carry out the inner roll to the end.

Posted by bqallover on Mon, 25 Oct 2021 04:06:34 -0700