I don't like this solution because it silently violates inversion of control.
No it doesn't. The rest of the answer will touch on this but I wanted to directly address this as it looks like it's the key issue you have with a UOW.
Take the simple example of an injected database context. Clearly, this is inversion of control, since it's an injected dependency. The service in which it gets injected has no say over the underlying connection pool that this context uses in its private implementation.
How would an injected UOW be any more of a violation here than an injected database context? Sure, the repository doesn't get a say in when the actual commit/rollback happens, but that's the entire point of having dependencies, i.e. that you don't need to do/control everything yourself.
application layer still has to define unit of work interface which is low level detail (even as an interface)
Answers can vary here based on what architecture you use, but overall the application layer (BLL) does not have to be the one to define the UOW that the data layer (DAL) makes use of. The UOW should be defined on the layer that implements it (DAL), not the layer that utilizes the implementation (BLL).
That being said, it's impossible to give you a definite blanket answer here as different architectures lead to different organization of dependencies, but I disagree with the assertion that your suggestion is the only right way to do it.
What if in theory I decided to replace repository implementations and not use relational database?
Then you will have to make changes to the implementation of your UOW (since the underlying storage technology has changed), but you should not be making any changes to the consumers of your UOW.
I don't really understand why you're focusing on a relational/non-relational database difference here. It's making me wonder if your current understanding of a UOW is too tightly coupled to a particular implementation you're currently working with.
To prove my point: use the same relational database provider, but remove every FK constraint. There, now you have a non-relational database. How has this somehow invalidated your injected UOW approach? I cannot see a way in which it has, and I suspect your answer to this is key to understanding why you (in my opinion erroneously) consider an injected UOW to be a violation of IOC.
Alternatively as another option I was thinking about using compensating actions to rollback previously executed operations if step 2 or 3 fails, still those compensating actions can fail, and to guard against that I would have to store request and implement consolidation approach (or use a message bus) to retry the use case and that seams like an overkill.
Without discussing your specific solution here, it's very relevant to point out that anything can fail in some way. Even if you write perfect code, a network issue or even a physical power outage can ruin your plans.
Rather than trying to write the perfect solution that cannot possibly have any issues, it's generally more productive to limit yourself to reasonable failures and how to protect against them.
As potential solution [..] and that seams like an overkill.
The main observation I'm making here about your approaches is that you seem to suggest solutions without really considering the complexity and effort to implement them. In my opinion, you seem to be striving for perfection at any cost, and that's just not realistic unless this is a personal hobby project with no deadline.
You do mention that this is a learning exercise, but I want to caution you against trying to learn something that is just not realistic. In the real world, you will have to work with imperfect solutions due to the inherent tension between perfect solutions, product deadlines and bottom-line cost-effectiveness.
For example, your use case is an odd one to consider in isolation. I don't know the rest of your application, but the first question that comes to mind if there is a 1:1 relationship between users and tenants. Can you add a second user to the same tenant? Can one user manage multiple tenants?
In the assumption that it is a 1:1 relationship, I generally advise against them simply because they create a lot of synchronization problems and there's usually little purpose to having two codependent entities that must live and die together.
If the tenant has one user and the user has one tenant, then the user is the tenant and the tenant is the user and I do not see enough justification in your question to need to distinguish between the two.
In the assumption that you can later create additional users (or a user can manage multiple tenants, but I'm choosing the former as the leading example here), then I would argue that tenant creation, user creation and user registration are three separate actions, and the registration use case hinges on a "get or create" style approach of the tenant and user. In other words:
// Get or create tenant
var tenant = tenantRepo.Get(myTenantId);
var tenantExists = tenant != null;
if(!tenantExists)
tenant = tenantRepo.Create(myTenantData);
// Get or create user
var user = userRepo.Get(myUserId);
var userExists = user != null;
if(!userExists)
user = userRepo.Create(myUserData);
// Create registration IF it doesn't exist
var registration = registrationRepo.Get(myTenantId, myUserId);
var alreadyRegistered = registration != null;
if(!alreadyRegistered)
registration = registrationRepo.Create(myTenantId, myUserId);
The above code is not separating the use cases, but I wanted to show you the full logic first. More realistically, I would expect a real world codebase to do something like:
var tenant = tenantService.GetOrCreate(myTenantData);
var user = userService.GetOrCreate(myUserData);
var registration = registrationRepo.Get(tenant.Id, user.Id);
var alreadyRegistered = registration != null;
if(!alreadyRegistered)
registration = registrationRepo.Create(myTenantId, myUserId);
Take note of the intentional difference between a service (BLL) and repo (DAL) in this code.
The implicit assumption is that the service (not repo!) methods will return an existing entity when one already exists. I don't know how you've structured your use cases (no code posted in question) so I'm just abstracting it away into some service classes for the sake of the example. The specific syntax is not the focus here.
Even without a transaction, what's the worst that could happen? You create a tenant, but not the underlying user (e.g because the user data contained bad data).
Okay, how does that break your application? If the user attempts their account creation a second time, it will work since you're able to fetch the already created tenant.
Similarly, if the registration step breaks, then you have a loose tenant and a loose user, but on retry they will be retrieved and the registration will be attempted again.
If you're worried about people who will not retry creation and therefore leaving pointless tenants and/or users in your database, I would ask how often this happens that it's an actual concern to you, and if a scheduled cleanup of the loose tenants/users would suffice to fix it.
There is not enough information in the question for me to definitively state that this is the correct approach for your particular situation. I used this example to highlight that you can sidestep complex problems rather than try to tackle them perfectly and needing to dig a very deep hole in order to do so.
AddUser(user, company, roles)
(or something like that), and push these underlying steps (and the transaction handling) into the underlying implementation layer.