Transforming Legacy with Domain-Driven Design, IV: Insights
2024-11-11 15:0:15 Author: hackernoon.com(查看原文) 阅读量:0 收藏

In one of my previous articles, I explained in detail why I decided to implement Domain-Driven Design (DDD) in the project. In this part, I will discuss the practical implementation of DDD in a complex legacy project, focusing on potential mistakes, non-obvious nuances, and useful practical advice.

This project automated trading procedures in the B2B segment. We were involved in the development and maintenance of one of the largest electronic trading platforms where companies could conduct purchases and sales electronically. The trading procedures on the platform varied significantly depending on who was buying or selling and what they were trading. Many large corporations using the platform had their own purchasing regulations, leading to the creation of unique types of procedures for each of them. As a result, we supported over 50 different types of procedures and provided significant customization within each type.

I already had a good understanding of the domain by that time, but we still did not have clearly defined domain models. In a similar situation, it’s important to consider that creating a good domain model is very challenging from the outset; it is completely normal to start by creating prototypes that help you better understand how the domain works and how best to represent it in code. Therefore, first, it’s essential to be prepared for this, and second, to begin with the less complex parts and gradually complicate the models. To validate the concept, I chose one of the popular but simpler types of trading procedures and began rewriting its business logic into a separate folder, taking into account all the knowledge from books and articles.

Clarification of the Domain Model

The types of trading procedures varied significantly in terms of fields, with only a small portion of the fields overlapping between different types of procedures. I was working on just one type of procedure, but I was familiar with the specifics of the domain and aimed to lay the groundwork for easy expansion in the future. In attempting to create a single common entity, the number of fields in one object became too large, which only muddled the domain model. Therefore, despite the obvious similarities between different types of procedures, I initially decided to create a separate entity for each type of procedure. It was only about a month into working intensively on the domain layer, when most of the logic had already been transferred and implemented, that I realized the EAV pattern would be a good fit in this case. This approach was not new to me at that point, and it was even applied in one instance within the current project. However, building a correct domain model is not straightforward; it requires some time for additional immersion and consideration of the concepts underlying the domain. Information from books indicated how important it is to find the right representation of the model in a timely manner, so I decided not to postpone the transition to EAV and to make it part of the first concept.

Entity Attribute Value is a classic approach that allows for separating the description of attributes from their values and using only the necessary set of these attributes at any given moment. For example, we can model the attributes using the following classes.

abstract class Field {
    private field type: FieldType
    private field sysName: SysName

    public construct(type: FieldType, sysName: SysName)
    public method getType(): FieldType
    public method getSysName(): SysName
}

class FieldType {
    public const NULL = 0
    public const STRING = 1
    public const DATE = 2
    public const BOOL = 3

    private field value: Integer

    public construct(value: Integer)
}

class SysName {
    private field value: String

    public construct(value: String)
}

class FieldsCollection {
    private field fields: Field[]

    public construct(fields: Field[])
}

Then, the values of the attributes can be described by such classes.

abstract class FieldValue {
    private field type: FieldType
    private field fieldSysName: SysName

    public method construct(type: FieldType, fieldSysName: SysName)
    public method getType(): FieldType
    public method getFieldSysName(): SysName
}

class FieldsValuesCollection {
    prifate field fieldValues: FieldValue[]

    public construct(fieldValues: FieldValue[])
}

Thus, instead of multiple similar entities, I was able to create just one that contains two collections, FieldsCollection and FieldsValuesCollection, with the necessary set of fields depending on the type of trading procedure.

The approach gradually evolved; initially, the ability to have multiple values for a single attribute was added, which was accomplished by adding an additional identifier, SysName, within FieldValue.

abstract class FieldValue {
    private field type: FieldType
    private field fieldSysName: SysName
    private field sysName: SysName

    public construct(type: FieldType, fieldSysName: SysName, sysName: SysName)
    public method getType(): FieldType
    public method getFieldSysName(): SysName
    public method getSysName(): SysName
}

Then, the ability to combine existing types of fields into one specific composite field was added, which introduced a separate type, FieldsField, containing FieldsCollection within it.

abstract class Field {
    private field type: FieldType
    private field sysName: SysName

    public construct(type: FieldType, sysName: SysName)
    public method getType(): FieldType
    public method getSysName(): SysName
}

class FieldsField extends Field {
    private field fields: FieldsCollection

    public construct(sysName: SysName, fields: FieldsCollection)
    public method getFields(): FieldsCollection
}

abstract class FieldValue {
    private field type: FieldType
    private field fieldSysName: SysName

    public construct(type: FieldType, sysName: SysName)
    public method getType(): FieldType
    public method getFieldSysName(): SysName
}

class FieldsFieldValue extends FieldValue {
    private field fieldValues: FieldsValuesCollection

    public construct(sysName: SysName, fields: FieldsValuesCollection)
    public method getFieldValue(): FieldsValuesCollection
}

To enable the addition of new operations to attributes and values without modifying the object classes themselves, the Visitor pattern was implemented. Below is an example of the implementation for an attribute and a collection of attributes, similarly realized for values and their collection.

interface IFieldVisitor {
    public method visitBoolField(field: BoolField)
}

abstract class Field {
    private field type: FieldType
    private field sysName: SysName

    public construct(type: FieldType, sysName: SysName)
    public method getType(): FieldType
    public method getSysName(): SysName
    public method abstract accept(visitor: IFieldVisitor)
}

class BoolField extends Field {
    public construct(sysName: SysName)
    public method accept(visitor: IFieldVisitor)
}

class FieldsCollection {
    private field fields: Collection<Field>

    public construct(fields: Field[])
    public method accept(visitor: IFieldVisitor)
}

Attributes and values became more complex, as did working with them, but at the same time, this approach modeled the domain much more accurately. Later, the business set the task of implementing dynamic fields in trading procedures, and the architecture was ready for this.

Skipping the Application Layer

In Eric Evans's book, he describes what the application layer should be responsible for; however, the description is quite abstract and lacks implementation examples. In the initial approach, when the domain had not yet accumulated many objects and there was no prior experience, it was quite challenging to understand how exactly to form this layer. In my case, it turned out this way: the prototype did not yet contain all the business logic of the domain, and at that stage, it seemed logical to simplify the architecture and not implement the application layer at all.

As a result, in many places in the client layer, I wrote approximately the following code:

entity = entityRepository->findById(id)

entity->edit(param1, param2)

entityRepository->save(entity)

While the code contained a minimum of actions, its repetition was not a significant problem since the calls were quite simple. However, the lack of isolation of the domain was evident: the client code had direct access to the entity from the domain layer and could invoke any public methods, altering the internal state of the entity arbitrarily. Initially, I addressed this by controlling invariants within the entity itself: if the new state was invalid, an exception was thrown. But not all checks could be placed within the entity. The project had quite a bit of complex logic for calculating the availability of certain actions for a specific user regarding a particular entity. Such a solution implied extracting this logic into a separate type of Service, which I designated as SecurityService. As a result, in most cases, the code at the client level began to look like this:

user = userRepository->findById(userId)

entity = entityRepository->findById(entityId)

if (! editEntitySecurityService->canEdit(user, entity)) {
    return
}

entity->edit(param1, param2)

entityRepository->save(entity)

In this variant, the amount of repetitive code increased, and, importantly, the control over the invocation of a specific SecurityService shifted to the client code level, making it easy to overlook or invoke a different SecurityService. Only at that moment did I develop an understanding of what code should reside at the application level. This layer should contain code that invokes domain entities in the correct sequence and returns results in an abstracted form from the domain layer, such as in the form of DTOs. The application layer should serve as an internal API for the domain logic. I implemented it using the Command pattern.

class EditEntitySecurityCommand {
    private field userId: int
    private field entityId: int

    public constructor(userId: int, entityId: int) {
        this->userId = userId
        this->entityId = entityId
    }

    public method execute(): bool {
        user = this->userRepository->findById(this->userId)
        entity = this->entityRepository->findById(this->entityId)
        return this->editEntitySecurityService->canEdit(userId, entityId)
    }
}

class EditEntityCommand {
    private field userId: int
    private field entityId: int

    public constructor(userId: int, entityId: int) {
        this->userId = userId
        this->entityId = entityId
    }

    public method getSecurityCommand(): EditEntitySecurityCommand {
        return new EditEntitySecurityCommand(userId, entityId)
    }

    public method execute(param1, param2): void {
        if ! this->getSecurityCommand()->execute() {
            throw new SecurityException()
        }

        entity = this->entityRepository->find(entityId)

        entity->edit(param1, param2)

        this->entityRepository->save(entity)
    }
}

The explicit naming of the command allows for easy identification among other commands at the application level, and the sequence of calls to domain entities is encapsulated within the command object. If it is necessary at the application level to check only the possibility of executing commands by users, it is also straightforward to do this with a separate security command related to the main one. Retrieving information about an entity can also be hidden at the application level in a command that will return the information in the form of a DTO.

class GetEntityCommand {
    private field userId: int
    private field entityId: int

    public constructor(userId: int, entityId: int) {
        this->userId = userId
        this->entityId = entityId
    }

    public method getSecurityCommand(): GetEntitySecurityCommand {
        return new GetEntitySecurityCommand(userId, entityId)
    }

    public method execute(param1, param2): EntityDTO {
        if ! this->getSecurityCommand()->execute() {
            throw new SecurityException()
        }

        entity = this->entityRepository->find(entityId)

        return this->entityMapper->mapToDTO(entity)
    }
}

Entity Validation at the Application Layer

As I described above, I managed to create a single entity for different types of trading procedures using the EAV pattern. Each type is now modeled through a different set of fields within a single object. Following DDD approaches, control over the transition of the entity from one state to another was carried out by code within the entity itself. This is a classic approach that allows encapsulating business logic. However, the problem was that for different types of trading procedures, the acceptable internal states (invariants) could vary.

The main validation was built on the Specification pattern, which I discussed in one of my previous articles. When each type of trading procedure had its own separate entity, individual specifications could be created for each type and action, applying them within the specific entity. But with a unified model, that option was not available. One could either complicate the specification by checking the type of procedure within it or use a factory that would return the appropriate specification depending on the type of procedure. Both options are workable, and I initially used them.

Over time, I noticed that the code managing the state of the procedure could be divided into two parts. The first part controlled the general rules and what should happen inside the entity, while the second dealt with validating input parameters, which often depended on the type of procedure. For example, the entity method responsible for editing the trading procedure by a user:

  • validated the provided set of field values,
  • updated the internal property of the set of field values,
  • set the modification date,
  • recorded the ID of the user who made the change,
  • created the corresponding domain event.

Validation of the provided data in this case can be seen as part of the domain logic but not as part of the entity itself. Thus, the check and selection of the appropriate specification can be moved to the Application Layer. Business rules remain at the domain level in the form of specifications, while using a factory allows keeping specifications separate for each type of procedure. This simplifies the entity code, keeping it focused on key actions for the overall model.

As a result, the command to edit the entity may look as follows:

class EditEntityCommand {
    private field userId: int
    private field entityId: int

    public constructor(userId: int, entityId: int) {
        this->userId = userId
        this->entityId = entityId
    }

    public method getSecurityCommand(): EditEntitySecurityCommand {
        return new EditEntitySecurityCommand(userId, entityId)
    }

    public method execute(param1, param2): void {
        if ! this->getSecurityCommand()->execute() {
            throw new SecurityException()
        }

        entity = this->entityRepository->find(entityId)

        entity->edit(param1, param2)

        entitySpecification = this->editEntitySpecificationFactory->createSpec(entity)

        if ! entitySpecification->isSatisfiedBy(entity) {
            throw new SpecificationException()
        }

        this->entityRepository->save(entity)
    }
}

Improving Domain Events

The event-subscriber approach is a very powerful mechanism in itself, allowing for explicit designation of side effects when performing certain actions within the domain, thereby enhancing transparency and separating the business logic of one entity from others. There was indeed a lot of business logic in the projects that needed to be triggered by specific events with domain entities, and we actively used this approach. An event was generated within the entity by a certain action and could then be released and processed at the application layer, with listeners naturally residing in the domain layer.

class Entity {
    private field events: IEvent[]

    protected method addEvent(event: IEvent): void
    {
        this->events[] = $event
    }

    public method releaseEvents(): IEvent[]
    {
        events = this->events
        this->events = []
        return events;
    }

    public method edit(...): void
    {
        ...
        this->addEvent(new EntityEditedEvent(this));
    }
}

class EditEntityCommand {
    private field userId: int
    private field entityId: int

    public constructor(userId: int, entityId: int) {
        this->userId = userId
        this->entityId = entityId
    }

    public method getSecurityCommand(): EditEntitySecurityCommand {
        return new EditEntitySecurityCommand(userId, entityId)
    }

    public method execute(param1, param2): void {
        if ! this->getSecurityCommand()->execute() {
            throw new SecurityException()
        }

        entity = this->entityRepository->find(entityId)

        entity->edit(param1, param2)

        entitySpecification = this->editEntitySpecificationFactory->createSpec(entity)

        if ! entitySpecification->isSatisfiedBy(entity) {
            throw new SpecificationException()
        }

        this->entityRepository->save(entity)

        this->eventDispatcher->dispatch(entity->releaseEvents());
    }
}

Over time, the number of events and listeners grew, which allowed us to better analyze our needs and make improvements to the classical approach.

The first thing I noticed was that some listeners contained business logic that could undo the original action. Of course, such logic could be moved inside the entity itself, but this was not always possible, for example, due to working with another entity in the listener. Or we didn't want to complicate the original entity. For instance, when editing a trading procedure by the organizer, depending on what and when was changed, some participants should be allowed to withdraw their applications since the initial conditions had changed. To do this, we updated the attributes of the application entities. If the update failed, it could have critical consequences for participants, which is unacceptable. An alternative would have been to maintain a change log and later calculate data based on it, but this would require storing the log and introducing additional objects into the domain layer, which we wanted to avoid.

Therefore, I divided the listeners into transactional and non-transactional. Transactional listeners were triggered in the same transaction as the entity's save and could interrupt its execution in case of an error. Non-transactional listeners were triggered after the transaction was completed and could not affect its outcome.

class EditEntityCommand {
    private field userId: int
    private field entityId: int

    public constructor(userId: int, entityId: int) {
        this->userId = userId
        this->entityId = entityId
    }

    public method getSecurityCommand(): EditEntitySecurityCommand {
        return new EditEntitySecurityCommand(userId, entityId)
    }

    public method execute(param1, param2): void {
        if ! this->getSecurityCommand()->execute() {
            throw new SecurityException()
        }

        entity = this->entityRepository->find(entityId)

        entity->edit(param1, param2)

        entitySpecification = this->editEntitySpecificationFactory->createSpecification(entity)

        if ! entitySpecification->isSatisfiedBy(entity) {
            throw new SpecificationException()
        }

        this->entityRepository->transactional(
            function () use (entity): void {
                this->entityRepository->save(entity)
                this->eventDispatcher->dispatch(entity->releaseEvents())
            }
        );
        this->eventDispatcher->dispatchCommitted()
    }
}

Some non-transactional listeners could perform resource-intensive or long-running operations. A natural solution was to divide them into synchronous and delayed. At the same time, the code at the Application Layer did not change; only the internal implementation of the eventDispatcher became more complex. Introducing delayed listeners significantly accelerated the main processing processes.

As with validation, the listener code often depended on the type of procedure. One event, for example, editing a trading procedure by the organizer, could require different actions depending on the type of procedure. Initially, I checked the type of procedure inside the listener, but as the code grew, I separated it into several parts, implementing early exits based on the procedure type. To better structure such listeners, I categorized them by procedure type. Events implemented an interface with a method that returned the procedure type, and the eventDispatcher selected listeners only for that procedure type.

This allowed us to structure the listener code and significantly simplified the maintenance of all code related to domain events.

In the next part, I will provide several additional insights that can help with implementing DDD in a legacy or large project.


文章来源: https://hackernoon.com/transforming-legacy-with-domain-driven-design-iv-insights?source=rss
如有侵权请联系:admin#unsafe.sh