Pergunta

In discussion about an architecture decision that we do consider wrong and how exactly to address it, some lack of understanding on the matter arose.

In dealing with authentication and authorization, the decision to move it to outside our domain libraries has proved to be more difficult than at first glance it seems.

I could describe a very long list of issues we may antecipate, I'm not going to do so has it seems unlikely for someone to address all of them or at the very least is unfair. Instead I will pose the basis of the problem and ask for comments from someone more experienced in here.

The context for the problem is as follows.

The domain approach

The domain approach is one the is very familiar to us, hence easy to understand. But this approach poses some questions and those questions pose some challenges we're having a bit of trouble addressing, conceptually.

from dataclasses import dataclass
from uuid import uuid4

@dataclass
class User:
  id: uuid4
  name: str

  @classmethod
  def register(cls, name: str) -> 'User':
    return cls(uuid4(), name)

One could define a user domain entity as shown above. This looks familiar since it is pretty much the way every other scenario works in the application stack.

We would fool ourselves saying we have users and rules to register them.

Use cases

The whole interactions one could do with a user are then handled by one or more use cases. Of which they use a gateway abstraction to communicate with some persistance mechanism.

from abc import ABC, abstractmethod

def register_user(gw: 'RegisterUserGateway', name: str) -> dict:
  if gw.is_name_registered(name):
    return {
        'success': False,
        'error': 'User name already in use.'
    }

  user = User.register(name)

  gw.save(user)

  return {'success': True, 'user': _user_dto(user)}

class RegisterUserGateway(ABC):
  @abstractmethod
  def is_name_registered(name: str) -> bool: pass
  @abstractmethod
  def save(user: User) -> None: pass

def _user_dto(user):
  return {
      'id': user.id,
      'name': user.name,
  }

This very much, as far as we understand, complies with all principles of a clean architecture. Some factors we do consider as benefitial in the long run of the application, and specifically when dealing with interface adapters:

  • Controllers communicate with the application layer by means of cleary defined data structures. Use cases accept a defined set of primitive parameters.
  • Presenters deal with dictionaries containing the data they will need to present, also following the same principle.
  • Gateway implementations have the same principle enforced, this time by means of domain entity class constructors and domain entity class attributes to access data later required for instantiation.

So boundaries are well defined.

This as proved to be a fundamental benefit of this architecture, and most likely the single biggest contributor to the confusion that I'm about to describe.

The issue arrises when one figures out that user registration, authentication, access recovery, and the whole deal that comes with it, is an application concern. At that point I do have a very hard time considering user a domain entity.

The application approach

When we take the observation above and try to rearchitect authentication as an application rule alone we are facing some design issues.

Consider the same use case, but without domain entities involved.

Use cases already have a clear definition of data transfer objects, parameters for request data, dictionaries for response data. So we could simply use the _user_dto function as a basis for the boundary data model.

from abc import ABC, abstractmethod

def register_user(gw: 'RegisterUserGateway', name: str) -> dict:
  if gw.is_name_registered(name):
    return {
        'success': False,
        'error': 'User name already in use.'
    }

  user = {'id': uuid4(), 'name': name}

  gw.save(**user)

  return {'success': True, 'user': user}

class RegisterUserGateway(ABC):
  @abstractmethod
  def is_name_registered(name: str) -> bool: pass
  @abstractmethod
  def save(id: 'uuid4', name: str) -> None: pass

This is seems like a coherent approach. The benefits we do consider as fundamental are still kept.

But some questions obviously arise:

  • The gateway interface is not using a domain entity anymore. Is this an issue later down the road?
  • The use case itself is now responsible for id generation (and format). Is this not matter of the domain layer?
  • Some other aggregates need to know about this new user, and a domain event seems not appropriate. Does this mean we should call other use cases where we previously raised domain events?

And again I'm not looking for answers to those specific questions, other questions will arise; unless you believe its important to do so. We are just unsure of drawbacks that may haunt us in the future with such a change. Any comments on this matter are very much appreciated.

Thanks.

Note. The code samples provided are meant to help in describing a conceptual problem and have no relation with actual system code.

Foi útil?

Solução

In a comment you linked to another question which references an article, where the user is placed in a separate "use case layer" rather than the domain layer, but unlike in your example the user is still an entity:

The use cases layer for our shop mainly consists of a User entity and two use cases. The entity has a repository, just like the entities from the domain layer, because users need to be stored to and loaded from a persistence mechanism.

This seems to be the fundamental confusion in your question: you have assumed that entities can only exist in one layer of the application, but there's no reason that needs to be the case. The only restriction is that layers can't see details which belong to outer layers; how they handle their own responsibilities is a separate question.

Within the layer that's responsible for authentication, User is a valid entity - you can't describe a password reset flow without the password belonging to something. That entity must not be passed into the Domain layer; but it can reference anything from the Domain layer, e.g. linking a User to a particular Customer as defined by business rules; and it can be referenced by higher layers, e.g. to implement the UI for the relevant use cases.

A different layer wrapping the same domain library might have a completely different definition of User, or none at all. Take a batch processing application - it might need to track the contact details of the user who should be notified on completion, but not have any interactive authentication. On the other hand, it might need to track the progress of different batches, so have its own entity to represent that state.

So my answer to your examples would be:

The gateway interface is not using a domain entity any more. Is this an issue later down the road?

On the contrary, it is best for abstractions to only cross one boundary at a time. If a higher layer of the application has access to the User entity, it should be consistently using it, not by-passing it and accessing the domain layer directly.

The use case itself is now responsible for id generation (and format). Is this not matter of the domain layer?

Why should it be? If the user only exists in one part of the application, then no other part needs to know that an ID even exists. If the ID needs to be managed by the domain layer, it must belong to something that exists at that layer - e.g. a Customer - and should be modelled as such. So for instance rather than duplicating the ID field on the User entity, you would have a property referencing a Customer; creating a User would require first creating a Customer (and thus assigning it an ID).

The User entity might then expose an interface of "get User ID", which happened to be implemented by returning an unmodified Customer ID. If in future it was decided that the one-to-one mapping of Users to Customers needed to change, that implementation could be changed, and code would already be clearly distinguishing the two sets of IDs.

Some other aggregates need to know about this new user, and a domain event seems not appropriate.

If User is not part of the domain layer, this statement is already false: the requirement "do this when a User is created" can't exist in a layer where "User" has no meaning. So either those actions also belong in the use case layer, or they can be re-written without reference to "user", e.g. "finalise user setup" causes a domain event of "open customer account".

I find a key mental exercise for separating layers is imagining what alternative implementations might look like at a layer. If User is truly outside of the domain layer, we should be able to imagine an application that doesn't have it, but still uses the domain layer correctly. So to take that last example, we could imagine a batch import causing the "open customer account" domain event without touching any user entities.

Licenciado em: CC-BY-SA com atribuição
scroll top