ReHow to create fully encapsulated Domain Models
A while ago Udi talked about How to create fully encapsulated Domain Models. He gave a great example, but more importantly, he gave some general rules of the thumb about how to create such a system.
Those rules are, if I understand them correctly:
- Every message should resolve to a single method call on a domain object. The message handler is responsible for setting up the environment to call this method (getting the parameters, mostly).
- The domain model should not throw exceptions. This is mostly because attempting to handle exceptions often lead to handling too much or too little. It is easier to define all the domain operations as throwing no exceptions, in which case all exceptions are infrastructure exceptions (or bugs).
- Communication to the outside world is done using events. To decouple the domain from the service layer.
Things that I am not sure about:
- What are the roles of services using this model. As a simple example, when new user is created, we should send a welcome email. This involves technical services (IEmailSender, ITemplateEngine, etc) and probably a domain service as well (INotifications.SendWelcome) I am not sure where those fit in.
- Rules - Same thing. How do I handle things like executing a set of rules in a business action?
Anyway, I thought that it would be interesting to see how I would approach Udi's sample using my own style. What I ended up with is this:
public class AddGameToCartMessageHandler : BaseMessageHandler<AddGameToCartMessage>
{
IRepository<TradeInCart> tradesRepository;
IRepository<Game> gamesRepository;
public AddGameToCartMessageHandler(
IRepository<TradeInCart> tradesRepository,
IRepository<Game> gamesRepository)
{
this.tradesRepository = tradesRepository;
this.gamesRepository = gamesRepository;
}[Transaction]
public override void Handle(AddGameToCartMessage m)
{
Future<TradeInCart> cart = tradesRepository.GetFuture<TradeInCart>(m.CartId);
Future<TradeInCart> g = gamesRepository.GetFuture<Game>(m.GameId);SetupErrorHandling();
cart.Value.Add(g.Value);
}
private void SetupErrorHandling()
{
Domain.FailureEvents.GameReportedLost = delegate
{
Bus.Return((int) ErrorCodes.GameReportedLost);
};
Domain.FailureEvents.CartIsFull = delegate
{
Bus.Return((int) ErrorCodes.CartIsFull);
};Domain.FailureEvents.MaxNumberOfSameGamePerCartReached = delegate
{
Bus.Return((int) ErrorCodes.MaxNumberOfSameGamePerCartReached);
};
}
}
It doesn't look that different, but let us look at the details, shall we?
- We don't deal with the session. That is duplicate code that can get quite annoying.
- Transactions are implicit. This saves the whole (who forgot to call tx.Commit() ) again.
- The use of futures to get both cart and game at the cost of a single call to the DB.
- Moved error handling to a separate method.
- We do not use events, this saves us the trouble of having to unregister them (or manage them using weak references)
- What we can't see is that an attempt from the domain model to call a delegate that wasn't registered would raise an error. I don't believe in ignoring errors.
I am not sure what to do about the transaction when we run into a domain error. Should we assume that the domain is smart enough to revert state changes? I am pretty sure that this is not something that we would like to assume. But, does it make sense to rollback the transaction? An attempt to add a game that was report lost should probably trigger some alert somewhere, we don't want to roll that back, obviously.
Thoughts?
More posts in "Re" series:
- (19 Jun 2024) Building a Database Engine in C# & .NET
- (05 Mar 2024) Technology & Friends - Oren Eini on the Corax Search Engine
- (15 Jan 2024) S06E09 - From Code Generation to Revolutionary RavenDB
- (02 Jan 2024) .NET Rocks Data Sharding with Oren Eini
- (01 Jan 2024) .NET Core podcast on RavenDB, performance and .NET
- (28 Aug 2023) RavenDB and High Performance with Oren Eini
- (17 Feb 2023) RavenDB Usage Patterns
- (12 Dec 2022) Software architecture with Oren Eini
- (17 Nov 2022) RavenDB in a Distributed Cloud Environment
- (25 Jul 2022) Build your own database at Cloud Lunch & Learn
- (15 Jul 2022) Non relational data modeling & Database engine internals
- (11 Apr 2022) Clean Architecture with RavenDB
- (14 Mar 2022) Database Security in a Hostile World
- (02 Mar 2022) RavenDB–a really boring database
Comments
When a domain error is encountered, what about letting the transaction rollback and then send a failure message (separate from return error code)? If there's a handler listening for the "CannotAddStolenGameMessage" that would be your alert, which If I understand this correctly would be in a separate transaction.
I haven't played around with NServiceBus enough to know if thats feasible or sensible.
Shawn,
Oh, it is feasible all right. That is not the issue.
The issue is whatever this is the right thing to do.
As I said in the post, I can make arguments for both cases
How would the client code look in your example?
I guess I timeout would be used to give the user the "a Technical Error has occured" dialog if the processing takes to long?
And finally, I think you have a bug in your "in-brain" compiler:
Future<TradeInCart> g = gamesRepository.GetFuture<Game>(m.GameId);
should be:
Future<Game> g = gamesRepository.GetFuture<Game>(m.GameId);
:)
@Ayende
Are exceptions really completely unsuitable? In general I think they are a more natural way of reporting errors than events/callbacks.
Oh and I just had a "TypeMock" advert at the right hand side in the "Lounge" area :)
Shawn,
The main problem comes when you have this occuring in the context of handling a message - server side.
In order for the system to be robust, as in not lose messages when bad things (like server crashes) happen, then the receipt of the message from the queue and the DB transaction need to roll forwards and back together. This also includes sending of messages - rolling back means messages should NOT be sent, as the original message will be handled again.
In short, you can't/shouldn't count on sending an error message back to the client when rolling back.
Colin,
re: TypeMock - Yes, I find this amusing as well.
re: Exceptions - I am not 100% convinced, but I think that it makes a lot of sense.
I might go with an OperationResult instead, but it is basically the same idea.
OperationResult == the return value of the method, which can be used to stored all the validation, errors, etc.
Irrelevant minor fix:
Future<TradeInCart> g = gamesRepository.GetFuture<Game>(m.GameId);
should be
Future<Game> g = gamesRepository.GetFuture<Game>(m.GameId);
Great stuff!!
Any plans on integrating the FutureQueryOf with Rhino Common IRepository?
craig
Yes, in a short while
I prefer the OperationResult return values over exceptions for a lot of reasons. Primarily, it makes development easier for me, as a user of an API, because i do not have to magically know which exceptions may be thrown and what fields on those exceptions to check, to find out why the exception was thrown and how to recover.
in the world of Java, we have checked exceptions - every method that can thrown an exception has to state that is can, so our code is forced to account for that exception. in .NET, exceptions are truly for exceptional situations - when something CANNOT continue processing. think of them as the blue screen of death for your code.
on top of that, throwing exceptions is horrendously expensive compared to building an OperationResult object with values to express the issues that occured. I've seen debuggers step through exception throwing / handling, and take 5 to 10 seconds to find the correct exception handler, whereas creating an operating result is typically subsecond - as fast as you can click the step through button.
@Derick
Can't agree really, I know Ayende is not a fan of sections of the book but I think the Framework Design Guidelines are about right regarding exceptions. As you say though the cost which can't be ignored but not using them for control flow is a general principle anyway, vaguely remember reading about the cost some time ago but 5-10 seconds seems like something odd was going on?
"What are the roles of services using this model?"
None. They are handled outside the Domain Model.
"As a simple example, when new user is created, we should send a welcome email."
The domain objects would raise an event saying that user should be welcomed. Service Layer takes that event and sends a business message (WelcomeUserMessage) copying into it all relevant information from the domain.
The WelcomeUserMessage gets sent to an endpoint which knows that that involves email, builds an email from a template, and uses SMTP to send it out.
Failure modes of email and of the original use case (create user) are very different. At a snapshot in time, one can be successful whereas the other could have failed, and that's a consistent view that need not be kept isolated.
When using this design, we find that our OLTP performance is independent of SMTP performance, and vice versa - we're better set up to do batch email sending.
In other words, we get a more robust and performant solution once we get those external services out of the domain...
Sign here, on the dotted line :-)
"What we can't see is that an attempt from the domain model to call a delegate that wasn't registered would raise an error. I don't believe in ignoring errors."
That's just it - it's not an error, per se. It's the domain model's way of letting you know what it thinks. If you don't care about certain things, for instance, because you don't have a way to let the client know about it, or because the domain added an event you don't know about - why should that cause your system to break?
Believe me, you don't want that hidden coupling.
The problem with that is that I lose the fail as early as possible semantics, which are important for healthy system.
Let us say that we add an event for SuspectedFraud, and we aren't handling that in this message handler. Is it valid for it to keep on going?
I prefer it to fail, get the admin to notice that and hit me on the head for not fixing the message handlers when I added this.
The other approach would detect the fraud and do nothing with it.
Those cases are done with intercepting message handlers. While you might use the same domain model in your FraudDetectionMessageHandler, you can choose some other way to do that as well.
I am not following, are you saying that you would detect something like that anyway? I am not sure that I see how
I have more than one message handler that handles the BankTransactionMessage - I have one BankTransactionMessageHandler (BTMH), and another FraudDetectionMessageHandler (FDMH) - which is configured to run before all other handlers.
If the FDMH detects a fraud, it takes the appropriate action as well as calling:
this.Bus.DoNotContinueDispatchingCurrentMessageToHandlers();
which will prevent the BTMH from running at all. In this way, there is no need to undo any actions that the domain may have taken, because it may not even have been called at all.
Does that make sense?
It answers the wrong question, I am afraid.
Assume that you added something to the domain, something that requires handling. My approach ensures that it will be handled. If only because all messages would fail when it is pushed to production.
"Assume that you added something to the domain, something that requires handling."
From my experience, that assumption cannot be upheld in larger team environments. That family of problems is handled by intercepting message handlers. It is a separation of concerns issue. This side-steps the issue of the domain having to roll itself back without causing the system to crash.
To me, it doesn't make sense that you'd have to push something into production to find that failing - design and code reviews make sure that the domain doesn't assume more responsibility than it should.
I prefer a design that elegantly steps around the big hard problems at the cost of a little typing - but you already know that.
I am not talking about adding cross cutting concern. I am talking about changing the current business process and wanting it to be broken until it is fixed across the board.
Yes, waiting for production to catch this is about as bad as it can get, that is why I want it to be _broken_, not silently ignoring the change.
It sounds to me like fraud detection is something that should happen in multiple parts of the system - preferably before the actual action happens - ie transferring money. That is a domain type cross cutting concern.
Your assumption that you added something to the domain that NEEDS to be handled is a design choice that seems to be creating problems. I would choose a different design.
In terms of testing, these kinds of issues are tested for in end-to-end testing.
Comment preview