问题
I have a domain model Product with a list of Prices.
public class Product
{
private List<int> _prices; //Note that this is a value object in my actual code
public void AddPrice(int price)
{
var currentPrice = _prices.LastOrDefault();
if(price < currentPrice)
_prices.add(price)
}
}
When a price changes I want a bunch of things to happen. Using an anemic domain model this is quite easy because I can just keep this bit in my service:
if(price < currentPrice)
_prices.Add(price)
And then tack on a bunch of stuff I want to do:
if(price < currentPrice)
{
product.Prices.Add(price);
_emailService.Email();
_discordBot.Broadcast();
_productUpdater.UpdateRatings();
//etc etc
}
How can I implement this without making my domain reliant on the services? Or should I be passing those to my domain?
Unsure on best approach (or any approach to be honest), I have read about Domain Events but I think those are a bit above my current experience level and I didn't understand the material very well
回答1:
Unfortunately, if the Product
class is an ORM entity you end up with anemic domain model and so called service-entity architecture where models are data structures and services are stateless bunch of procedures. However you can still organize your code in layers, therefore the Product
class should not depends on application layer. This can be solved by Observer pattern. On the client side it will be looked like
product.OnPriceAdded(
new EmailObserver(emailService)
)
product.OnPriceAdded(
new ProductObserver(productUpdater)
)
Or, if you have service-entity architecture, you can use event dispatcher in the service that handle price adding.
if (product.AddPrice(price)) { // you'll have to modify AddPrice to return bool
this.dispatcher.dispatch(new PriceAddedEvent(product, price))
}
And when you initialize your app you register listeners for concrete events in the EventDispathcher and then inject dispatcher instance to the needed services. This articles about centralized event dispatcher Part 1, Part 2.
Also you can use event dispatcher as colaborator object:
public void AddPrice(int price, IEventDispatcher $dispatcher)
{
var currentPrice = _prices.LastOrDefault();
if(price < currentPrice) {
_prices.add(price)
dispatcher.dispatch(new PriceAddedEvent(this, price))
}
}
In this case EventDispatcher interface becomes a part of domain model.
There is another way if you have Product
interface. You will be able to wrap original product by Dispatchable implementation:
class DispatchableProduct : IProduct
{
public DispatchableProduct(IProduct origin, IEventDispathcer disp) {
// init properties
}
public void AddPrice(int price) bool {
if (origin.AddPrice(price)) {
disp.dispatch(new PriceAddedEvent(origin, price))
return true;
}
return false;
}
}
And on the client side it will be looks like
new DispatchableProduct(originProduct, dispatcher).AddPrice(price)
P.S. always use curly braces with if
statement
回答2:
How can I implement this without making my domain reliant on the services?
Two common answers.
The first is that your domain model has the single responsibility of managing information, and your application code has the responsibility of retrieving and distributing information.
if(price < currentPrice)
_prices.Add(price)
This is manipulation of local information, so it would normally be in your domain model
{
_emailService.Email();
_discordBot.Broadcast();
_productUpdater.UpdateRatings();
//etc etc
}
This is distribution of information, so it would normally live in your application code.
The predicate will normally be in the application code, using information from the domain model
product.addPrice(price)
if (product.IsPriceChanged()) {
_emailService.Email();
_discordBot.Broadcast();
_productUpdater.UpdateRatings();
}
The alternative approach is to pass to the domain model the capability to communicate with the outside world, and host the side effect logic within the model itself. You'll sometimes hear people referring to this as the "domain service" pattern.
public class Product
{
private List<int> _prices; //Note that this is a value object in my actual code
public void AddPrice(int price, IEmailService emailService, IDiscordBot discordBot, IProductUpdater productUpdater) {
// you know what goes here
}
You need to be a little bit careful with the dependency arrows here - this model depends on those interfaces, and therefore it is only as stable as the interfaces are themselves -- if you decide to change IEmailService, that change ripples through everything. So it is common to see these interfaces defined within the model, and your application code provides the implementation.
how would you implement product.IsPriceChanged(). Are we just updating a bool during the product.AddPrice() call and then checking its state with the IsPriceChanged()?
You do it any way you like, really - choosing the right data structure, and the methods for extracting information from that data structure, is part of the work.
As noted by Pavel Stepanets, this is a temporal query, so you should probably be modeling time. So it won't be "is the price changed" but rather "is the price changed since X" where X is a measurement of some sort of clock (system time, version of the object, changed in comparison to some previously cached value, and so on).
It may also turn out that the bookkeeping for the "add price" protocol is a distinct object from the Product aggregate. If so, you may want to model it that way explicitly - it may be that the boolean, or whatever, that you are looking for should be in the protocol data structure rather than in the object data structure.
回答3:
I can think of different options which are - depending on your concrete requirements - more or less suited and it is also OK to choose different approaches for different use cases and mix them in your solution.
To illustrate this I want to look into different options based on an operation of a product application which I simply call AddPriceToProduct(AddProductPriceCommand pricingCommand). It represents the use case where a new price for a product is added. The AddProductPriceCommand is a simple DTO which holds all required data to perform the use case.
Option (A): Inject the corresponding service (for instance, an email service) you need to call when executing your domain logic into your domain object's methods (here AddPrice).
If you choose this approach always pass in an interface (which is defined in your domain layer) rather than the actual implementation (which should be defined in the infrastructure layer). Also, I would not choose this approach if several things should happen after something has happened in your domain operation.
public void AddPriceToProduct(AddProductPriceCommand pricingCommand)
{
var product = _productRepository.findById(pricingCommand.productId);
product.AddPrice(pricingCommand.price, _emailService);
_productRepository.Update(product);
}
And the corresponding AddPrice method might look like this:
public void AddPrice(int price, IEmailService emailService)
{
var currentPrice = _prices.LastOrDefault();
if(price < currentPrice)
{
_prices.add(price);
// call email service with whatever parameters required
emailService.Email(this, price);
}
}
Option (B): Let the application service (which orchestrates the use cases) call the corresponding service(s) after you called the corresponding aggregate (or domain service) method which needs to be executed for the application use case.
This can be a simple and valid approach if this should always happen after a specific domain model operation has been executed. By that I mean, after calling the method on your aggregate (or domain service), in your case the AddPrice method, there is no conditional logic if the other services (e.g. email) should be called or not.
public void AddPriceToProduct(AddProductPriceCommand pricingCommand)
{
var product = _productRepository.findById(pricingCommand.productId);
product.AddPrice(pricingCommand.price);
_productRepository.Update(product);
// always send an email as part of the usual workflow
_emailService.Email(product, pricingCommand.price);
}
In this case we assume that the normal workflow will always include this additional step. I do not see a problem with being pragmatic here and just call the corresponding service in the application service method.
Option (C): Similar to Option (B) but there is conditional logic to be executed after AddPrice has been called. In this case this logic can be wrapped into a separate domain service which would take care of the conditional part based on the current state of the Product or the result - if there is any - of the domain operation (AddPrice).
Let's first simply change the application service method by including some domain knowledge:
public void AddPriceToProduct(AddProductPriceCommand pricingCommand)
{
var product = _productRepository.findById(pricingCommand.productId);
product.AddPrice(pricingCommand.price);
_productRepository.Update(product);
if (product.HasNewPrice())
{
_emailService.Email(product, pricingCommand.price;
}
if (product.PriceTargetAchieved())
{
_productUpdater.UpdateRatings(product, pricingCommand.price);
}
}
Now this approach has some space for improvements. As the logic to performed is bound to the AddPrice() method of the product it might be easy missed that the additional logic needs to be called (calling the email service or the updater service under certain circumstances). Of course you could inject all services into the AddPrice() method of the Product entity but in this case we want to look into the option of extracting the logic into a domain service.
At first let's look at a new version of the application service method:
public void AddPriceToProduct(AddProductPriceCommand pricingCommand)
{
var product = _productRepository.findById(pricingCommand.productId);
_productPricingService.AddPrice(product, pricingCommand.price);
_productRepository.Update(product);
}
And now let's look at the corresponding domain service method of a domain service called, e.g. ProductPricingService:
public void AddPrice(Product product, int price)
{
if (product.HasNewPrice())
{
_emailService.Email(product, pricingCommand.price;
}
if (product.PriceTargetAchieved())
{
_productUpdater.UpdateRatings(product, pricingCommand.price);
}
}
Now the logic for handling price updates to a product are handled at the domain layer. In addtion, the domain logic is easier to unit test as there are fewer dependencies (e.g. the repository is not of concern here) and with that fewer test doubles (mocking) need to be used.
It is of course still not the highest degree of business logic encapsulation in combination with the lowest degree of dependencies inside the domain model, but it comes at least a little closer.
To achieve the above mentioned combination domain events will be at service, but of course these could also come with a higher amount of implementation efforts. Let's look at this in the next option.
Option (D): Raise domain events from your domain entities and implement the corresponding handlers which could be domain services or even infrastructure services.
The connection between domain event publishers (your domain entities or domain services) and the subscribers (e.g. email service, product updater, etc.).
In this case I recommend to not immediately dispatch raised events but rather collecting them and only after everything has worked out fine (i.e. no exceptions have been thrown, state has been persisted, etc.) dispatch them to be handled.
Let's look at the AddPrice() method of the Product entity again by using a corresponding domain event.
public void AddPrice(int price, IEmailService emailService)
{
var currentPrice = _prices.LastOrDefault();
if(price < currentPrice)
{
_prices.add(price);
RaiseEvent(
new ProductPriceUpdatedEvent(
this.Id,
price
));
}
}
The ProductPriceUpdateEvent is a simple class which represents the business event that has happened in the past along with the information required by subscribers to this event. In your case the subscribers would be the email service, the product update service, etc.
Consider the RaiseEvent() method as a simple method which adds the created event object to a collection of the product entity in order to collect all events happending during one or more business operations that are called from an application or domain service. This event collecting functionality could also be part of an entity base class but that is an implementation detail.
The important thing is that after the AddPrice() method has been executed the application layer will make sure that all collected events will be dispatched to the corresponding subscribers.
With that the domain model is completely independent of the infrastructure service dependencies as well as from the event dispatching code.
The "Committing before dispatching" approach described in this blog post by Vladimir Khorikov illustrates this idea and is also based on your technology stack.
Note: Unit testing the logic of your Product domain entity is now very simple as opposed to the other solutions as you don't have any dependencies and mocking should not be necessary at all. And testing if the corresponding domain events have been called at the right operations is also easy as you simply have to query the collected events from the Product entity after calling a business method on it.
To get back to your questions:
How can I implement this without making my domain reliant on the services?
To achieve this you can look into options (B), (C) and (D)
Or should I be passing those to my domain?
This can be a valid approach - see option (A) - but be aware that it will make things more complicated if there are several dependencies to be injected in terms of maintainability and testability of your domain model classes.
When I choose between these different options I always try to find out what parts of the performed actions do really belong to that corresponding business operation and what parts are more or less unrelated and are not really required to make the business transaction a valid one.
For instance, if some operation that needs to be performed by a service is required to happen or otherwise the whole operation should not happen at all (in terms of consistency) then option (A) - injecting a service into a domain model method - might be a good fit. Otherwise I would try to decouple any subsequent steps from the domain model logic in which case the other options should be considered.
回答4:
Let's try to separate your logic into 2 parts:
- You have product domain logic, which is checking if the price lowest than current price, you can update it.
- You have side effects of that product domain logic which is also the part of domain logic
Good solution for side effect is event. You can check this article Domain events: design and implementation
One point I want highlight is Open/Close Principle (open for extension, but closed for modification). You product logic should don't know about email or other notifications service. If it knows, you gonna face problem with open/close principle. Let's try make example: If product.AddPrice(...)
sends notifications, let's use email and discord for now, then when your application growing, you want to add SMS notificaiton or more side effects, you will need to change the product.AddPrice(...)
code, which is not good in perspective of open/clos principle.
A good solution for this is Event pattern. You can inject IEventDispathcer
as Pavel Stepanets said above or populate events as the microsoft's article above. Personally, I prefer to populate events and then my application layer do orchestration part (dispatching etc)
Here the example code:
public abstract class EntityBase {
public IReadOnlyList<IEvent> Events { get;}
protected AddEvent(...){...}
public ClearEvent(..){...}
}
public class ProductPriceChangedEvent : IEvent {
public Product Product { get; private set; }
public int OldValue {get; private set;}
public int NewValue {get; private set;}
public ProductPriceChangedEvent(...) {...}
}
public class Product : EntityBase
{
private List<int> _prices;
public bool TryAddPrice(int price)
{
var currentPrice = _prices.LastOrDefault();
if(price < currentPrice) {
_prices.add(price)
AddEvent(new ProductPriceChangedEvent(this, currentPrice, price));
return true;
}
return false;
}
}
public class SendEmailNotificationOnProductPriceChanged : IEventHandler<ProductPriceChangedEvent> {
public void Handle(ProductPriceChangedEvent eventItem) { ... }
}
public class SendDiscordNotificationOnProductPriceChanged : IEventHandler<ProductPriceChangedEvent> {
public void Handle(ProductPriceChangedEvent eventItem) { ... }
}
public class UpdatedRatingOnProductPriceChanged : IEventHandler<ProductPriceChangedEvent> {
public void Handle(ProductPriceChangedEvent eventItem) { ... }
}
// Your application logic
// You can also wrap dispatching event inside UnitOfWork when you want to save them to database
public class UpdatePriceCommandHandler or Controller {
private IProductRepository _productRepository;
private IEventDispatcher _eventDispatcher;
public Handle(UpdatePriceCommand command)
{
var product = _productRepository.FindById(command.ProductId);
var isPriceChanged = product.TryAddPrice(command.Price);
if(isPriceChanged)
{
_eventDispatcher.Dispatch(product.Events)
}
else {
throw new Exception("Your message here")
}
}
}
Or for more defensive programming style you can throw exception instead of return boolean so you don't need to check the price is changed or not (can be confusing), but you need to handle that failure or exception (it is clear that it is failure).
public void AddPrice(int price)
{
var currentPrice = _prices.LastOrDefault();
if(price >= currentPrice) {
{
throws new ArgumentException(price, "Product price should less than blabla")
}
_prices.add(price)
AddEvent(new ProductPriceChangedEvent(this, currentPrice, price));
}
For C# community, there is MediatR library can be used for Event Pattern. I don't know for other language. Maybe someone else can add them.
来源:https://stackoverflow.com/questions/63151430/communicate-from-domain-model-back-down-to-application-layer