IAR: a mnemonic for a clean controller design
¿How do you design HTTP controllers? ¿How do you make sure responsibilities are correctly assigned? ¿How do you reduce maintenance issues? I'll introduce you to three letters that might help you remember the essentials of every controller…
How do you design HTTP controllers? How do you make sure responsibilities are correctly assigned? How do you reduce maintenance issues?
I'll introduce you to three letters that might help you remember the essentials of every controller:
IAR: I for Input, A for Action, R for Response.
💡Remember: controllers are interfaces. They have two “faces”: HTTP -or another protocol/external system- on one side and your business model on the other. They should not reveal details to each other.
I for Input
The first thing that a controller needs to do when we invoke an action on it is to deal with the input. It needs to read the parameters, headers, and query params, parsing them and making sure they are in the correct shape. If that is the case, you can continue. Otherwise, the interaction stops here with a Bad Request error (400).
It’s important to keep in mind (and reproduce it in the tests too) that all input comes as strings. If you are looking for a record by ID, and the ID should be an integer, make sure you parse it before continuing. Ruby’s ActiveRecord and Javascript’s == operator have implicit type conversions to treat 1 and '1' as the same “numeric” object. Don’t get used to it. It’s better to be explicit, coercion is an annoying source of complex bugs.
At this point, it is important to identify which input parameters are required and which are optional. If we expect an attribute that it’s not present, we should tell that in the response.
In this step, we don’t expect to validate the semantics of the information passed. For instance, we want to book a hotel room for 3 nights and we choose dates in the past. The responsibility of this step is to make sure the input is in the right shape (for instance, dates in ISO 8601 format). Then, we’ll pass the information along and let the domain model validate it. If there is an inconsistency related to the business, the model should raise an error with a clear message.
A for Action
This step is the most simple to explain, but at the same time it’s the most difficult to follow. I've seen many software systems fail at this principle as they grow in complexity.
The idea is to perform an action on the business model, ideally using one single collaboration. What does that look like? One message sent to one object, with all the needed objects collected from the previous step (the “I”). And store the result if you will use it later.
For example:
const updatedReservation = bookingsSystem.updateReservationStatus(reservationId, newStatus)
The object is bookingsSystem
, the message is updateReservationStatus
and the arguments are reservationId
and newStatus
. The result is stored in updatedReservation
.
If you did the “I” step well, you’d have all HTTP details decoupled from the business logic. This is important as you don’t want to send query strings or raw data to a business object. Only valid and complete objects. Your domain model can raise errors and give you feedback if you, as a controller, break this contract.
Also, the controller does not need to do business logic. A controller is one way to interact with a domain model, but not the only one. Having logic in controllers can lead to code duplication issues.
How do you detect if you are following this principle? Identify that single collaboration and make sure it is the only entry point to the domain model. If you have conditionals or sequences of interactions with the domain model, it’s very likely you are missing an abstraction.
Let's look at an example. In a controller, we mark a user as “subscribed” to a newsletter and we send an email with a confirmation message. Those are two actions! The controller should not be responsible for “gluing” those two actions. Instead, think of creating a SubscriptionSystem
that will handle both the user registration book and the email notifications.
Also, from a testing perspective, it requires more effort to test domain logic that is inside a controller. Your test has to deal with all the HTTP stuff plus setting up the case you want to test.
The resulting object is important here. It should be the deliverable from the domain model to the controller. At this point, we should say “thank you” to the domain model and stop interacting with it. The next step is to continue with...
R for Response
Based on the result you got from the previous step, now it's time to build and return a Response. The HTTP response includes a status code, a set of headers, and a body in a specific format. The responsibility of this step is to build all the response details.
The “R” step can be big and complex. All the principles and programming best practices apply here: division in subtasks, collaboration with other objects, Don’t Repeat Yourself... and so on. For instance, you need to serialize something as JSON. The payload has many attributes and you need to build the same object from two different places. In those cases, you might want to implement a shared serializer (and test it!). If you detect you are setting the same headers over and over, you can write helper modules or middlewares to have the logic in a single place.
For technologies with template engines like Rails, it’s hard to draw a line between controller and view. Looks like they are all happening in the same place at the same time. But there’s always a handoff: a controller generates a set of data for the view to consume. The same warning is valid for JSON APIs with serializers as well! Your serializer should only be responsible for transforming data from one format to another, not calculating it.
In summary, if you feel you are doing more than consuming data in the view or serializer, it's a smell. You might be “stealing” some responsibility that belongs somewhere in the domain model. Remember, some abstractions are hard to find. They can appear very late in the design process after several iterations and refactorings. Chase them.
What to test in a controller?
I hope this "I", "A" and "R" separation gives you better insights for testing. Focus your testing on the "I" and "R" phases as "A" should be already tested in isolation. I’m not saying you don’t need to test what "A" does, but don’t write all the possible test cases for it. Controller tests are up in the testing pyramid, not as high as end-to-end tests, but high enough to worry about them. We don’t want an inverted pyramid that is hard to maintain!
For the Input part, think that the input may come in any possible weird shape. We don’t know who is using our APIs and the default option is not to trust the users. So make sure your controllers:
- know how to parse different datatypes
- report issues if the data is not in the right format
- tell you if you missed some attributes
For the Response, make sure the body contains exactly what you expect, as well as the headers and status code.
What do we do with the exceptions?
So far, it looks like the “I”, “A” and “R” steps can be done only in sequence. But not necessarily! We need to start with I, that is true. Then try to do “A”. If “A” fails and throws an error, we move to an “R” step with an error flow. If it doesn’t, we go to the successful version of “R”.
Let’s look at an example: you have a UI to create time intervals and you have a business reason to not create intervals longer than 30 days. Who does the 30-day limit validation? The business model! How does it report the error? With an exception! What does the controller do? handle the exception and build a proper response for it! (R)
This does not mean you need to fill all your controller actions with try + catch statements. Duplication needs to be analyzed and refactored. Depending on the technology you are using, you have alternatives that reduce some duplication and are easier to maintain:
- Rails’ rescue_from
- Express middlewares
Wrapping up
Think about every controller action as a 3-step thing: interpreting input, triggering an action, and building a response. It’s easy to fall into coupling issues, but now you have a tool to resist it! It works as a mental model that you can apply and give the code a little bit of structure. Use it when building your own code, when reviewing others’ code, and as a driver to refactor things!