Common Pitfalls when Implementing Use Cases in Clean Architecture

George
Technical blog from UNIL engineering teams
8 min readJul 30, 2024
Photo by Oscar Sutton on Unsplash

We address here some common pitfalls related to implementation of use cases in Clean Architecture. We have already written extensively about different aspects of Use Cases on this blog. However, we think it is useful to have these important observations listed here in one place for a reference. So without further ado…

Ignoring exceptional outcomes of a use case

Very often we see use cases which completely ignore any exceptional outcomes of a use case. Let’s take a look at an example of withdrawing cash from an ATM. Here how different steps and outcomes of a this activity may be represented:

Successful and exceptional outcomes of a use case

What we can observe is that for a single successful path from the moment a user inserts her card into an ATM to the cash distribution there are numerous things which could go wrong. And, typically, for each successful outcome there will be several exceptional outcomes (different paths to the the leaves marked with “X”).

It’s important to note that all outcomes — both successful and exceptional — must be dealt with by the use case we are trying to implement. Often we see examples of implementations either ignoring exceptional outcomes altogether or throwing a runtime error hoping that the caller (usually a controller) will deal with them appropriately. This is incorrect. Only the use case at hand is able to react to any of exceptional outcomes by calling an appropriate presentation method or any other relevant output port. A correctly implemented use case will address all possible outcomes of a concrete business scenario in an analogous fashion.

Another way to look at this is to imagine that we have a situation where the same use case is being executed by users with different roles. We may want to stipulate in our scenario that in case of an exception and if the user is an administrator, then we must present the outcome slightly differently than if the case occurs while the use case is being executed by a simple user. Maybe we want to log the error with more granularity or send some information about the error to the external system. Well, this differentiation of the presentation behavior is precisely the business logic of the use case itself. And as such it must not be delegated to the caller (i.e. Controller or the framework).

Ignoring User in a use case

Another very common pitfall, which we can often observe, is that an implementation does not explicitly assert, nor does it enforce a specific role and/or permissions of a user on whose behalf a use case is being executed. Of course, there are use cases which are executed by the system itself (processing triggered by a batch job, for example). But overwhelming majority of use cases are executed on behalf of a specific user. We have looked elsewhere in more details at how a use case must assert a specific role of an authenticated user and check for permissions for any business actions executed on any of the aggregates involved in the use case.

There is another problematic point related to security and use cases. Often we can see the following pattern being used:

  1. an authenticated user executes an action on a UI
  2. a request handler reacts to the action by issuing a command (message) to an out-of-band message broker
  3. a command handler — running on a separate thread — receives the command and initiates use case processing (loads aggregates, invokes business methods, etc.)

While there is nothing wrong with this approach per se, we have to realize that once we are in a method of a use case invoked by the command handler as described in the third step above, we do not have a simple and straightforward way to assert that the use case at hand is being executed on behalf of a specific user. To illustrate this with a pseudocode in a familiar language/framework:


@Controller
public class RequestHandler(){

@Autowired
private MessageDispatcher commandBus;

@Post
public void handleRequestFromAdminToDeleteAllCustomers(){
/*
Assuming that a security filter chain is in place
and that a user has been authenticated OK, at this point
we can be pretty confident that this really an administrator
who is requesting this action. So far, so good.
*/

// create and dispatch a command
commandBus.send(new DeleteAllCustomersCommand());
}

}

// Meanwhile, running on an ANOTHER THREAD...

@Service
public class CommandHandler(){

/*
That's the beauty of Message-Driven Architecture we don't know
or do we care where this message (command) came from, right?
*/
@MessageListener
public void handle(DeleteAllCustomersCommand command) {

/*
Let's get our use case (omitting collaborators for clarity).
It could also be wired from the context as a bean.
*/
DeleteAllCustomersUseCase useCase = new DeleteAllCustomersUseCase();

/*
Current class is a "controller" in Clean Architecture
terms, so here we go: we are delegating all business
logic processing to our use case.
*/

// starting to have a suspicious feeling about this...
useCase.deleteAllCustomers();
}

}

// Finally, the use case which actually does some business.

public class DeleteAllCustomersUseCase implement DeleteAllCustomersInputPort {

private PersistenceGatewayOutputPort persistenceGateway;

@Override
public void deleteAllCustomers() {

/*
A million dollars question here, folks: are we ABSOLUTELY sure
that there is an administrator (a human being with appropriate
ROLE) sitting at the keyboard somewhere, logged in into the system
at this point, and who has actually just requested this?
*/

persistenceGateway.deleteAllCustomers();

}

}

While Message-Driven or Event-Driven Architecture, CQRS, asynchronous processing, etc. — are all perfectly compatible with Clean Architecture, in general, and Use Case-Driven development, in particular, a special care must be taken to assure that actual business logic processing done by a use case happens on behalf of a user who actually has an appropriate role and/or permission.

Combining EDA and Use Case-Driven paradigms is an interesting topic which easily warrants its own post. Here, it suffices to say that it needs special consideration. One way to perform necessary security assertions under this pattern, would be to transmit an ID of the user in the command and pass this ID to the use case method. The use case would then be able to load the corresponding aggregate instance representing the user (domain model). Once the User aggregate instance is available to the use case, it can proceed to perform any relevant security checks employing an appropriate security output port, of course.

But a very pertinent question should always be asked by the developer. Do we really need this pattern of asynchronous request-command processing in our application in the first place?

Ignoring or misusing transaction management in a use case

Use Case orchestrates the flow of control in and out of (ports) and in and out of Domain Entities layer. As such, it must enforce a consistency boundary around some blocks of control flow. However, it is important to stress that the exact demarcation of this boundary will depend on the business scenario the use case is implementing. If we must assure that an aggregate instance A executes its business method (and changes its state consequently) if and only if another aggregate instance B successfully executes its own method as well, then we are obviously talking business logic here. And so the responsibility of drawing a consistency boundary around any interaction with A and/or B, as well as, any output ports which may be required by the business scenario, is truly a concern of the use case at hand.

One common way to draw a (strong) consistency boundary around some block of control flow in a use case is to use transactions management bound to some relation datastore (responsible for persistence of our aggregates). However, introducing this fine-grained transaction management correctly in an implementation of a use case requires special attention with respect to the dependency rule. We must take care not to pollute the use case with technical details of transaction management imposed to us by any framework we may be using.

Returning control flow to a controller from a use case

A very common pitfall when implementing a use case in Clean Architecture is to return a (non void) value from a use case method to the calling controller or to allow an exception to “bubble up” to the controller. One will usually return a value from a use case, treating the use case as a simple function or a service. We, however, have seen, from the first point mentioned above, that a use case has many more outcomes then just a single successful outcome. If we have to distinguish between these different outcomes (a success or an error) in the caller (Controller) of a use case, then we are inevitably leaking some business logic processing to Interface Adapters layer.

The correct way, of course, is to leave it to Use Case itself to handle all possible outcomes according to the business scenario it implements. This is usually done by calling a presenter wired into the use case. Controller, then, simply selects an appropriate use case to execute according to the intention of a user.

Single Responsibility Principle (SRP) and use cases

This one is not a pitfall but more of a misunderstanding. Developers sometimes debate on whether or not a typical use case in Clean Architecture tries to do “too much at once” — something which seem to go against Single Responsibility Principle. In our opinion, this is a bit of a misconception. SRP does not stipulate that a class or a method “must do one thing only”. Rather, SRP has been introduced and explained by Robert C. Martin as following:

“A class should have only one reason to change.” Robert C. Martin as quoted by the Wikipedia article on SRP

And while it is true that a typical implementation of a use case does usually involve several steps and operations: calling a number of output ports, invoking business methods on several aggregates, a use case, nevertheless, does have a one and only one “reason to change” — and this is precisely that — it changes if and only if the corresponding business scenario changes.

What one must be careful with, is that a use case must not deal with any specific technology aspects. Those must be abstracted away behind the output ports. And a use case must strive to delegate as much of business logic processing as possible to Domain Entities layer.

Conclusion

We have briefly addressed some common pitfalls which one may encounter when trying to implement a use case in Clean Architecture. We have given some ideas about why we consider them as pitfalls and we presented some ways they can be avoided. For an interested reader, here is the full source code of an application written with all the above observations in mind.

--

--