ThreeDotsLabs / wild-workouts-go-ddd-example

Go DDD example application. Complete project to show how to apply DDD, Clean Architecture, and CQRS by practical refactoring.
https://threedots.tech
MIT License
5.31k stars 485 forks source link

Not completed logic which needs transaction. #48

Open timsolov opened 2 years ago

timsolov commented 2 years ago

Thank you for your repo and articles. This is awesome work! I'm trying to rewrite my legacy service to the yours approach.

And I have a question:

How to guarantee that all hours in this command will be updated atomic?

https://github.com/ThreeDotsLabs/wild-workouts-go-ddd-example/blob/33e3c49f825ad1e542de2bf3ab7da70af3b23bc3/internal/trainer/app/command/make_hours_available.go#L24

Or what will happen when repo will be updated here: https://github.com/ThreeDotsLabs/wild-workouts-go-ddd-example/blob/master/internal/trainings/app/command/schedule_training.go#L56 but one of external service won't be available? Will we have incompleteness here?

m110 commented 2 years ago

Hey @timsolov! Thanks, it's great to hear the project is helpful. 🙂

What you mention is correct, these two places don't have consistent updates. We did this on purpose to show how to refactor it later, but didn't come to this stage yet.

The short answer is, keep things you want to be atomic in a single transaction. So definitely in a single service, where you have control over it.

Another approach is using event-driven patterns: saving one entity in one of the services, and asynchronously updating the other one. In this case, you have to be able to accept eventual consistency.

If your application is just a single service it should be possible to keep database updates within transactions where applicable. Sadly this won't work for external calls — events work great for these. I've described one approach in this example: https://github.com/ThreeDotsLabs/watermill/tree/master/_examples/real-world-examples/transactional-events

wintermonth2298 commented 1 year ago

Hi! Thank you for your work, your articles are really cool and useful!

The updateFn approach is awesome, but I'm not sure how to apply it across entities. I'm having difficulty putting 2 tables into one transaction.

I'll give you an example. I need to implement BuyProductCommand(customer, product) where product and customer are two different business entities. How can i do this? It comes to mind to implement the Update method, which will use a single updateFn function with 2 arguments (one for customer, and the second for product), but then the question arises, to which repository should this method be assigned and what should it be called?

Is it generally correct to use this approach for multiple tables?

m110 commented 1 year ago

Hey @wintermonth2298!

This is a common question about this pattern, and the reason usually is thinking about the business entities as SQL tables and modeling the code around them. But they don't need to map 1:1, and bigger entities (aggregates) can easily span multiple tables.

I've described it in this article: https://threedots.tech/post/common-anti-patterns-in-go-web-applications/#starting-with-the-database-schema

The point is there's no rule that CustomerRepository can't know about the Product domain model (or table). If Product is part of the Customer model, the repository needs to know how to handle saving it.

You can consider having a regular Update() method on the customer repository, fetching the product before running it, and calling the domain method of BuyProduct() inside.

product, err := productsRepo.ByID(productID)
err := customerRepo.Update(ctx, func(customer *Customer) error {
    customer.BuyProduct(product)
    return nil
})

Or, consider a dedicated method like BuyProduct(customer, product) directly on the repository if you also bump the number of bought copies on the product model. I'd say it's not a big deal which repository has this method then, it's an implementation detail anyway.

I hope this helps! As always, the context matters, and I don't know your domain well enough.