Frankly, I might be using the term wrong. It seems possible “surface area” usually refers to API signature of some objects. What I’m talking about here is the following train of thought:
- I change or add a line of code
- What did I just affect?
The “surface area” is the other things I have to look over. It is the area that I have to make sure has appropriate test coverage. Having a large surface area is what slows down development teams. The goal is to minimize it.
So how does our use of service objects relate to this concept?
Let’s say we have a new requirement that’s applicable when a Tasker submits an invoice that modifies what gets saved. If I were to add the code to the
InvoiceJobOp from the previous article, then it will only apply when the
Op is run. If we were to do something in a
before_save in the
Invoice model, then it might accidentally kick in anytime an
Invoice is changed.
That’s a lot more tests and things to keep in our mind. If it is just in the
Op, that is less of those kinds of debt, so adding in the
Op is an example of minimizing the surface area of the change.
We went through a roundabout journey to end up where were we are. Many of the changes were about surface area and trying to reduce it.
People like microservices and SOA because of this same principle. We tried it and that part of it worked out really well. There was just no way that a change in service A could affect service B. As discussed, however, we ran into issues in other dimensions.
Our current use of engines follows the same approach to achieve the same surface area effect. It is all about namespacing. Modifying the user management engine can not affect the marketplace engine. This allows us to proceed with more confidence when making such changes.
A particular aspect of our setup is that any given model is “owned” by only one engine. The rest of the engines are allowed to read from the database but they cannot write. This provides sanity and minimizes the surface area. For example, the validations only need to live in one spot. You also know that no other code can go rogue and start messing with the data by accident or otherwise.
Of course, the world isn’t always cut and dry. Venn diagrams overlap. No abstraction or encapsulation is perfect. The seams in namespacing show up when something that happens in one service (engine) needs to affect something in another one.
For example, we were so happy just a few paragraphs ago that changes to the user management engine do not affect the marketplace engine. That is true and it is great. There is no direct effect from the code. However, as they tend to do, these pesky functional requirements always mess up perfect plans for the code. In this case, when a user changes their first name (in the account engine), the marketplace engine might need to update some data in Elasticsearch.
We use a message bus to observe changes like this and react as appropriate.
# Whenever the user changes subscribe 'user_may_have_changed', bus_observer_touched: 'user' do |attributes| # update the profile in ElasticSearch ProfileStoreWorker.enqueue(user_id: attributes['id']) end
An important note here is that
ProfileStoreWorker is idempotent. It writes everything that should go in Elasticsearch every time. This technique reduces surface area by not depending on this single event and its contents, but rather only as a trigger.
One might say that these subscriptions are just as coupled as doing everything all in one spot. I see that point because, of course, the same things end up happening. However, we have this technique to be better for a few reasons.
- The trigger code (in the account engine) does not need to know about the rest of the system. It can mind its own business.
- The subscribing code (in the marketplace engine) can be self-contained instead of being mixed up in the trigger code path.
- Many different code paths might necessitate the
ProfileStoreWorkerto run. By decoupling it, we actually save complexity in many code paths.
In code, developers tend to weave a tangled web wherein seemingly innocuous changes have far-reaching effects. We have been able to create more stable and agile code by considering the “surface area” of a change and minimizing it through some encapsulation and decoupling techniques.