Leaky Contracts
Service contract design is hard. People do it all the time, but it is not always correct. Most of the time, engineers pay more attention to the service's internal implementation than the contract. A Service contract is the most important part of a service. A good contract provides an abstraction that allows us to have flexibility and allow future refactoring without impacting clients. Being able to refactor code without breaking consumers is pure gold. It's priceless. Not all industries are the same; however, the more dynamic and agile the industry is, the more you need to be able to do Refactoring. To do refactoring more often, you need to make decisions that allow refactoring to happen at a lower cost. If the price of refactoring is always high, no refactoring will happen, and that is game over; that is how we create inadequate legacy systems. A service contract provides a high level of abstraction(ideally) or might(if they do a poor job) leak internal implementation details. That's bad because coupling will make refactoring harder. So, we need to be able to identify such leaks.
2. Too many operations
3. Hidden non-obvious behavior and compound attributes
4. No Validation
5. Dependency on libs
6. Returning internal IDs and dependencies IDs
7. Returning 3rd party fields as is
8. Naming - tell x mean y
Proper Contract Design Guidance
1. Contracts need to be Explicit (instead of implicit)
2. Contracts should be minimalist and expose as little as possible (abstraction)
3. Contracts should hide how service works (information hiding)
4. Contracts should avoid to receive and return booleans (Uncle Bob)
5. Services should be smart and always validate and abstract something (otherwise, they might be doing things thats are not their responsibility - Pull Complexity Downwards from the Philosophy of Software Design).
6. Contracts should be naked and have zero dependencies in libraries
7. Naming matters in the sense that it should not be tricky, deceiving, or misleading.
8. Services that aggregate services hide data and should apply normalization as much as possible.
9. Sometimes, it is better to receive data rather than IDs
Leaky Contracts (Smells)
These are not rules but more like smells. They do not necessarily mean something is wrong, but they are good indicators that can easily be wrong. So, use this as a strong smoke signal; there will likely be a fire. Sometimes, a BBQ is desired, but don't take it lightly; think about it and consider the consequences of the technical decisions.
Leaky Contracts (Smells)
1. Received too many ids 2. Too many operations
3. Hidden non-obvious behavior and compound attributes
4. No Validation
5. Dependency on libs
6. Returning internal IDs and dependencies IDs
7. Returning 3rd party fields as is
8. Naming - tell x mean y
Use Case
Before we dive deep into smell by smell, let's first understand our use case. Let's say we are building an E-Commerce application where there are many products; the user case searches by product name and descriptions, the user adds products to the shopping cart, and then pays for the cart. Depending on the subscription level, the user might have more flexibility and payment methods. Consider the following use case solved by this architecture:
We have five services here. One is a product search service that can perform searches using open search. The Shopping Cart service calls the Payment Service to process payments. The payment service can process Strip and Affirm (Allowing split payments) payments, while the ACH service must process ACH payments.
The BFF orchestrates all this experience. BFF is written in Typescript and Bun, while the Services are all in Java 23 and run on Netty. Let's look at some contracts to understand the smells, but first, let's observe that Payment Service is not being called directly. He is called by the Shopping Cart service, and ACH is called by the Payment Service, which means that if the contracts are not well established and crafted, we will have to leak too many layers up to the BFF (which happens often).
The problem with leaking contracts is that refactoring now will cost a lot(Which is all we don't want). Imagine we change something in the ACH contract, and that contract leaks to the Payment Service, shopping cart, and BFF. This refactoring will cost a lot and be very hard to accommodate because it will impact all other services and every service that is called that service.
Smell #1 - Received Too Many IDs
IDs are links; sometimes, the link is for your own service or some other place. You need to be very careful with IDs. IMHO, we should always minimize the number of IDs we expose or require in a service contract because it means the client will need to keep track of all of them. Also, what happens if the ID gets deleted? How many services will it impact? Would you know if the ID got deleted or changed?
The smell is why we need four IDs. The Payment Service contract does not do a good job abstracting Stripe, Affirm, and ACH, so it leaks all IDs here; therefore, the same IDs will be leaked to the BFF. What if we add another payment provider? Do we need to expose another ID? What if we retire our ACH Service and use a new external provider? You see, all will result in painful refactorings.
Sometimes centralization is better, and having a dedicated service that can handle all links a user might need between products, entitlements, and "services" could be better. Other times, you don't need to go that far; you could receive the data(i.e., name, address, payment info) instead of receiving IDs, and another option is to hide all that in the Payment Service. After all, why do we have a service if we will leak everything? What is the value of the service?
Smell #2 Too Many Operations
Too many operations do not necessarily mean something is Bad. Services can be a better fit and have more responsibilities; they don't need to be "Micro" all the time. However, this is always an interesting flag to pay attention to.
Here, we can see that Payments can only be called via the Shopping Cart, and that's fine for now. But what happens when we want to introduce One-Click Payments? The payment would be invoked outside the shopping cart, so we have unnecessary coupling here. However, we don't need to wait to create a new feature to see that; we just need to pay close attention to the shopping cart contract.
Smell #3 Hidden Non-Obvious Behavior and Compound Attributes
I mixed two smells together just for fun. Did you get the joke? Non-obvious behavior must be documented, but we should minimize it as much as possible. Compound attributes are problematic because they create coupling and are non-obvious and obscure. Secondly, if you need to refactor, it will negatively impact consumers.
Good naming can avoid obscurity, so be careful how you do many things. In Compound Attributes, often used as a hack to prevent refactoring, you need to use API Versioning and publish such changes in a V2. Another option is to (IMHO, much better) have explicit fields, even if you need multiple fields. For the case of multiple fields, try not to create much nesting.
Smell #4 No Validations
This is a very efficient Smell. It is a service that takes whatever the consumer passes and stores it in the database, no questions asked. There are no validations, nothing; the service is just a remote table for the consumer.
Now you need to wonder why that happens? Maybe because the service is being called by the BFF in NodeJS, and they do not have a Database and need to store the information someplace? A simple solution for this case would be to create a Preferences Service dedicated to that or even empower BFF to use something like Supabase or Firebase.
Besides, a service must provide abstraction and do something with the data. Without validation, the service is a database proxy with a very low value. Why do we need a service like that? The reality is that several decisions are pushed to the back because of fear.
Smell #5 Dependency on Libraries
This is a very, very tricky one. Open-source libraries are fantastic, and we should always leverage them. However, the service contract should not have dependencies; we should only use standard types in your language SDK.
The second tricky thing here is that JWT is a RFC and, therefore, an open standard. I have no issues with JWT. My issue is passing the JWT token around, especially on the contract, because it creates a coupling between the library and whatever 3rdy graph of transitive dependencies the library will bring. It's much better to open the token in an upper level (BFF) and pass the information you need(very likely an ID).
Someone might say, "I want the JWT token everywhere as a form of security so we can verify that you are always authenticated." Sure, but in that case, the JWT token should, at minimum, be on the HEADERS and should not be mixed with the service business contract.
A better option might be to offload all that to a SideCar application or even onto the infrastructure. Zero Trust is about checking things all the time and everywhere, but why do we need to create a couple to do that? We can do better.
Smell #6 Returning Internal IDs and Dependencies IDs
Here is another tricky one. The more IDs your service needs and returns, the worse it gets. One ID, whether your internal ID or a unique global ID, is fine. However, be very careful, and always ask yourself why you must pass multiple IDs. Why can I not abstract that? Is it tech debt? Is a service or key concept missing?
If you are building an aggregator, you should hide as many internal IDs as possible. So you can change the underlying systems that need to refactor your consumers. Here, we have a Payment Service contract, and what's happening here is that we are returning ACH and Internal Bank ID; this creates coupling in all layers up to the BFF. This not only makes refactoring harder but makes the consumer's life harder.
The consumer should know very little about how the service implements things. If ACH services use a bank core to do things, that is the ACH problem, but now, because of a very poor abstraction, we have made it everybody else's problem.
Not all problems have the same criticality; the more global and used the service is, the more the contract matters; sometimes, we need to push back and re-design services for a better future.
Smell #7 Returning 3rdy part fields as is
It's normal for services to call other services. Sometimes, an internal service calls an external service. I'm making up things here just to make a point. But imagine Stripe returns a bunch of "_stripe" on his fields; we should not return that. OH Diego but this is more work, yes it is and we should be abstracting thart.
A Service must provide abstraction; it's not uncommon to change providers, and a good normalization layer would make provider changes much more straightforward. People confuse usage and value with coupling. Very likely, you don't need to create your own database implementation, nowadays people don't even host databases anymore. Everything is a managed service(very likely in AWS). So if we pay for RDS Postgres, we want to use it, but just because we are paying for it, we will not couple our business contracts with AWS RDS Postgres APIs.
For instance, when we code a repository using spring data or JPA, we couple it with the framework (spring or JPA/Hibernate, in case). When we create a Service in Spring we do not couple with JPA on the service contract, we often return a List<Users> let's say, so coupling is in a layer and not all over the place. Service contracts are not different!
Smell #8 Naming - Tell X means Y
Ok, Naming might throw you off. Clean code feelings :-) But take it this way: the name cannot be misleading, obscure, or even induce you to do the wrong thing.
Where we have an example of extremely poor naming. A simple Design session review might save you from coupling and creating an expensive design refactoring scenario. IF this service is used by 100+ consumers, it would be costly to change it. But if we review it and get it before proceeding, we can fix it much cheaper. Another technique is to use API Versioning, let's say we fix the name in a different Version of the API, reducing the cost of the refactgoring.
Doing Better
Yes, abstractions are hard, and they take time to master, yes there is pressure to deliver, and people keep changing their minds, but we still can do better if:
- We do contract first and discuss the contracts explicitly.
- Make sure the service provides abstraction and does something that adds value.
- Perform contract reviews and agree on the contract before agreeing on all implementation.
IF you wonder how you could do better contract design, here is a list with some guidance:
1. Contracts need to be Explicit (instead of implicit)
2. Contracts should be minimalist and expose as little as possible (abstraction)
3. Contracts should hide how service works (information hiding)
4. Contracts should avoid to receive and return booleans (Uncle Bob)
5. Services should be smart and always validate and abstract something (otherwise, they might be doing things thats are not their responsibility - Pull Complexity Downwards from the Philosophy of Software Design).
6. Contracts should be naked and have zero dependencies in libraries
7. Naming matters in the sense that it should not be tricky, deceiving, or misleading.
8. Services that aggregate services hide data and should apply normalization as much as possible.
9. Sometimes, it is better to receive data rather than IDs
Principles of Software Architecture Modernization is full of examples, principles, and techniques on how to deal with monoliths and distributed monoliths at Scale. Continuous Modernization covers the mindset, practices, and shift to better work data with teams dealing with such systems.
Cheers,
Diego Pacheco