Separate plumbing code from business logic
Over the course of your career you will hear about many design patterns. From higher-level design patterns like model-view-controller and model-view-presenter to implementation patterns such as observer, singleton, and adapter pattern.
These patterns are valuable to establish a consistent design / architecture and separation of concerns in your application.
But many of the services we build today are largely integrations between different systems, with business logic sprinkled on top. How can we design the internals of these systems so they are understandable, maintainable, and testable?
Separate the plumbing aspects from the business logic.
Plumbing and business logic
What is plumbing code? Code that handles integrations between systems, such as:
- REST / gRPC / GraphQL endpoint controllers
- Datastore querying / persistence
- Kafka consumers / producers
- REST / gRPC / GraphQL downstream clients
And business logic is the section of code unique to the given service. The reason that service was built.
Separating plumbing and business logic isn't a revolutionary idea. Common frameworks try to encourage this pattern. But in practice, you will often see plumbing code and business logic interleaved - making the code harder to read, modify, and test. By consciously separating the two, we can improve our services in the short and long term.
REST & persistence
For example, say we have a REST service that processes requests and reads/writes to a database.
In this case, our plumbing code starts with the REST controller which handles:
- Parsing the incoming request
- Validating the request parameters
- Transforming the request into plain business objects
- Delegating to the business logic layer
The business logic can operate on plain objects without any parsing/deserialization code mixed in. And the business logic can be fully tested in many different permutations without needing to set up a testing harness for the REST endpoints or stand up the database.
And we can thoroughly test the REST controller and persistence layers as well. Is the parsing / validation correct in the REST layer? Are the database queries correct and performant?
We will want to have a few end-to-end tests that do ensure the different layers of the application work together, but that setup won't be needed for the majority of our test cases. If we have to stand up a full REST server and database to test all the branches in our business logic, we have failed.
Kafka & downstream services
In another example: we have a Kafka consumer that processes messages and then calls downstream services:
No Kafka details need to leak into our business logic. We can keep the parsing, validation, message acknowledgement, etc. logic in the Kafka layer. And similar to the REST example, the Kafka layer can translate the messages into business objects to then pass into the business logic layer.
In the downstream services section, we can isolate the mechanics of communicating with those services:
- Constructing downstream request objects
- Implementation details of the downstream clients (REST, gRPC, etc.) such as retries, timeouts, etc.
- Parsing and transforming responses from the downstream services
Then we can pass those simple, focused business objects back to our business logic layer, isolating that layer from any implementation details of calling those downstream services.
And again we can test each of these layers in isolation. Allowing for more thorough testing of the layers with less test infrastructure required.
Conclusion
Separating your business logic and plumbing code allows you to evolve and thoroughly test each portion of your service in isolation, leading to code that is more robust and easier to maintain versus interleaving those concerns together.