2

I've started working on a LoB application (as a learning exercise) while trying to express business logic with use cases from Clean Architecture. First use case is customer onboarding where client can enter their company name, their email address and password.

Use case should do the following with provided request:

  1. Create user in DB
  2. Create tenant for the company in DB
  3. Related user to tenant (company) with appropriate role

I have OnboardFirstUserUseCase class with dependency on two repository interfaces, one for tenants: ITenantsRepository and the other for users: IUserRepository.

Before executing any of the steps, code in OnboardFirstUserUseCase checks for users with existing email address and tenants with existing natural key (type of string). In my opinion, this is not enough, because any of the three operations can still fail and state of the application will be invalid. So... transaction sounds as obvious solution, and as an interface for the transaction I have IUnitOfWork with Commit and Rollback methods. IUnitOfWork can be passed to repositories and Commit can be called from use case class. I don't like this solution because it silently violates dependency inversion principal. Repository implementations are dependent on application layer where use case and repository interfaces are defined, however, application layer still has to define unit of work interface which is low level detail (even as an interface), and should not be part of application layer. What if in theory I decided to replace repository implementations and not use relational database?

As potential solution I was thinking about turning Tenant entity into an entity root, but in that case retrieving tenant will fetch all users and user roles, and that seams like an overkill for other use cases (e.g. ChageUserPasswordUseCase). 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.

I'm sure I am not the first one to have this kind of question, but I wasn't able to find anything with my google-fu. What would be recommended steps to consolidate transactional nature of repositories and use cases and not violate inversion of control? Am I overthinking it? Should I just leave IUnitOfWork interface in application layer and deal with imperfect world and imperfect solutions?

EDIT: Changed "silently violates inversion of control" to "silently violates dependency inversion principal"

5
  • 1
    Note that the steps you listed in the use case are its implementation details - they don't describe the business use case. The business doesn't want to create two entries in two database tables and relate them via more entries in a third table; they want to add a user to the system with certain roles. So, don't have these steps in your use case layer, just have your repo/gateway do AddUser(user, company, roles) (or something like that), and push these underlying steps (and the transaction handling) into the underlying implementation layer. Apr 11, 2023 at 8:04
  • Thank you for the suggestion Filip. In this case one repostiry/db-gateway should handle all database steps? It makes sense to define method in db-gateway interface and implement it.
    – milan
    Apr 11, 2023 at 8:10
  • Yeah - except that you don't necessarily have to have a single repo/gateway for the entire application. What people normally do is either a generic repo, or they organize repos around database tables (repo class per table), but the approach that's more in alignment with CA is to have repos/gateways organized around the use cases (they aren't general-purpose DB-access classes, but they are designed, at the interface level, to support the specific needs of the use cases instead; under the hood they'd utilize a generic DB access technology). Apr 11, 2023 at 13:57
  • P.S. If you need a way to react to substeps before the function returns, pass in a handler method (or a more sophisticated handler object) as an extra parameter. Also, don't take this as a "cookbook" style advice - in a different domain, with different needs, you might do it in a different way. The principle is to try and understand, for a particular domain/application, what constitutes high level business logic, vs what's just a consequence of the fact that you're using a particular technology, and then try & express the business logic at one level, and juggle those details at a lower level. Apr 11, 2023 at 13:57
  • @FilipMilovanović can you post your comments as an answer? It did answer my question.
    – milan
    Apr 12, 2023 at 8:58

1 Answer 1

1

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.

1
  • Hi, thanks for the answer. I made a simple but crucial mistake. I wanted to say that defining UOW interface in application layer violates dependency inversion. I will update the question.
    – milan
    Apr 11, 2023 at 7:52

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge you have read our privacy policy.

Not the answer you're looking for? Browse other questions tagged or ask your own question.