Do you write perfect software? If you answered yes, please send me your resume.. We’re hiring! For everyone else, writing maintainable software should be something we all strive towards. The majority of your professional experience will revolve around integrating new features into, fixing bugs in, and maintaining code that other people wrote. Don’t you wish that code was easy to understand and work with? Then you better start with the man in the mirror and ask him to change his ways!

Decompose your problem

When given a problem to solve, your first instinct should not be to sit down and start banging out lines of code. Gaining a deeper understanding of the problem at hand will not only make finding the correct solution easier, but it will make maintaining that solution possible.

Start with decomposing the problem into small sub-problems. These can then be assigned to any number of engineers, allowing for parallel work-streams to be completed in an easier to track manner. One of the sub-problems or groupings thereof may also be identified as having an existing solution that can be applied in your project, reducing the amount of remaining work to be completed.

Let’s run through an example scenario so we can see this in action

You’ve been tasked with creating a processing system which continuously receives a large volume of product reviews, analyzes review sentiment, aggregates that information, and stores the data so it can be used on a product details page.

One approach to this problem would be to design a service that accepts review data, analyzes review sentiment, aggregates the reviews, and stores the data, all in one swoop. One service to rule them all! But is this the best approach?

How will you collaborate with other team members on this project?

What if your service goes down, how much data will be lost?

What if there’s a sudden spike in product reviews being written?

What if sometime down the line functional requirements change?

How will you know how close to completion the project is?

Slow down.. maybe there’s a better approach

Let’s step back and try to gain a deeper understanding of the problem and potential solutions.

You’ll definitely need some ingress point for the review data to flow into for sentiment processing. This ingress point will likely have to receive and pass on review data to a downstream system at very high transaction rates.. so it should be fast and scale-able. Should that service also handle review aggregation? Well it could, but then your scaling characteristics are limited because you only have one dial to turn.. that’s not good. What if there was something that could sit in-between the ingress point and the review aggregation? Maybe some kind of message queue. Then you could subscribe a fleet of review aggregator services to that message queue! OK so now we just need to write the aggregate data into the database.

Product Review Architecture

How do we benefit from this new architecture as opposed to our original design?

Scaling can be fine tuned per service requirements.

Aggregation of reviews can be deferred, reducing back pressure on the system.

Services can be worker on independently by different individuals or different teams.

Progress on the overall project can easily be tracked by the completion of each service.

We identify the review data buffer as something that already exists within our company! The GenericDataQueueSubscriptionService.. Rad!

Hopefully you can apply this strategy to the next problem you’re tasked with solving. Stepping back and decomposing a problem makes it easier to parallelize work, it makes it easier to optimize, and it makes it easier to track progress against. It may seem like a lot of upfront cost, but just remember: months of refactoring can save you hours of planning.

Assume external dependencies exist

In my career I’ve seen entire projects stalled for days, and sometimes weeks while research is done on external dependencies that are considered necessary. I’ve also seen an absurd amount of time wasted by arguments over which external dependencies to use. In our previous example for our review buffer we might have someone arguing that of course we should be using Amazon SQS, while another swears that our product will fail unless we deploy our own RabbitMQ service.

When a dependency is decided too early in a projects lifetime it can cause you to design your solutions around the capabilities of that dependency. Some projects can remain in development for so long that by the time you’re ready to release, a new more optimal dependency was created but you can’t take advantage of it because of the decisions made too early in the project. Even worse, you might even scare some contributors to your project away because they might feel intimidated by having to learn how the dependency you chose works.

Instead of falling for these traps, ask yourself “Will this decision impact the goal that we’ve set out to achieve?” In some rare cases there are examples of where an entire project can be put on ice because the technology just isn’t there yet (I’m looking at you, Blizzard!) But in most cases, the external dependencies that you need to resolve are so common that a dozen existing solutions can suit your need. Don’t worry about it and defer that decision as much as possible. Focus on the problem you set out to solve — your business logic.

So how do you work around this? Obviously you can’t just ignore the fact that your review aggregator service will eventually need to subscribe to some sort of queuing service in the future..

Design the interface that perfectly fits your use case

Instead of locking yourself into the interface provided by some existing dependency, design your own interface! If your queuing service only needs to check to see if there are messages to process, and request the next message, then design an interface to encapsulate those requirements.

Once you’ve designed the interface that would perfectly suit your use case you don’t have to worry about making compromises in your business logic that might be necessary to accommodate some existing dependency.

This process will also help inform your decision on which dependency to ultimately choose because it will highlight what features are most important to your solution. It will also enable you to isolate your code from your dependency, so that later on if you need to adopt a different dependency all you need to do is write an adapter for that dependency to your interface.

Keep your interfaces lean

Whether it be an interface for an external dependency or an interface for a component that you’re sharing with your team, you should keep the surface area of your interface as small as possible. Smaller interfaces are easier to reason about, I mean honestly, if you needed to write a product review to disk, would you rather try to understand a FileSystem interface or a FileWriter and FileReader interface?

By writing smaller interfaces, you also increase the likelihood that your interface will be adopted by more implementers. If you needed to implement an interface for your task, would you rather see 10 not implemented stubs, or 2?

Smaller interfaces has a nice side effect of keeping your components simpler as well. One component, one responsibility. Simpler components are easier to replace, meaning that #$!@ed up code you wrote in your junior years of software engineering can easily be replaced with a better version without you having to worry about bringing down your entire production environment.

Pass dependencies into your components using the constructor

Rather than assuming that anyone who uses your component will want to be communicating with a MySQL database 🤮, your component should allow your clients to use whichever data store they desire. This wouldn’t be possible if you exposed a MySQL connection string as a constructor parameter, but it would be possible if you used one of those nifty interfaces we discussed earlier and provided a ReviewDataSaver as a constructor parameter.

Initializing dependencies within your component will make your life harder. What if one of your dependencies throws an exception during initialization? Should your component really be the place where that dependency initialization failure occurs? No! That’s just more exception handling that just serves to clutter up your business logic.

Your component becomes a composition. You have your business logic, and all of the dependencies that are passed in to help you support the goal of your business logic. Composing components is extremely powerful, it allows you to use existing patterns to take a simpler component and plug it into a more complex application. It also allows you to create higher-order components that build upon your prior work.

Passing all of your dependencies in through constructor parameters will also make your life easier when it comes to documentation. “But my code is self documenting!” Well now it is.. at least a little bit more (please, PLEASE don’t take this as license to stop commenting your code!) Anyone who wants to use to use your component will know exactly what your component depends on and now has the flexibility to fit it into whichever set of dependencies they desire. They need only implement an adapter to the interface you have defined.

Avoid internal state whenever possible

“More internal state, more problems,” as I always say (I don’t, but the point still stands). I’m going to start pivoting into how writing maintainable software can make your life so much easier when it comes to testing.

I’m sure you have all been in at least one lecture where you you heard the good news of encapsulation, and how your eternal state will be saved so long as you keep your privates pure. It sound nice right? But if you can, I recommend avoiding internal state as much as possible. Adopt a more functional approach to the code that you write and use classes to group those functions by domain.

What do I mean by functional? If you write a function, it should have some inputs (preferably no more than 2.. but I don’t have time to preach that in this article!) and a single output. Yes I know, some new languages support multiple return values. That’s nice, but for the purposes of this discussion, consider that to just be a single tuple type that describes all of the returned values.

Now that you’ve designed your component with as little internal state as possible, it makes it much easier to test what you’ve written. Your ReviewDataSaver implementation has a constructor FileReviewDataSaver(fileReader: FileReader, fileWriter: FileWriter) and an interface function: CheckIfReviewExists(reviewID: string), which you need to test. In your unit test, you pass in your mocked versions of the FileReader and FileWriter interfaces, both which have been instrumented to help you test the cases when a review exists and when a review doesn’t exist in your given data store. Easy!

Mock your dependencies when testing

Do you want to be the engineer that wiped the QA database while writing a new test? I didn’t think so. So rather than using real implementations of interfaces you need, write mocks that conform to those interfaces.

When writing a mock, think of it as just implementing the interface in question, but with some added benefits. If you’ve designed simple to implement interfaces, and you’ve reduced the amount of internal state that your components require, writing a mock should be easy. You can implement a mock that maintains a list of all parameters that each function was called with for verification in a unit test. You can also instrument your mocks to return specific values when predetermined parameters are passed to each function.

Writing mocks for your dependencies allows you to test for very hard to reproduce failure modes. If your FileReviewDataSaver can sometimes blow up whenever the mounted filesystem is detached, it would be very hard to test with a real implementation of the FileReader or FileWriter interfaces. You’d need to do some low level hackery to cause that kind of failure for your test to verify. Instead of going through all that trouble, just implement a mock that has a flag you can turn on/off which causes this specific type of exception to be thrown.

Write hyper focused test cases

Have you ever spent more than a couple seconds looking at the output of your unit test runner just trying to figure out how you messed everything up? That’s not an ideal situation. If you write very focused test cases, it will be very clear from your test runner output as to what you screwed up. Knowing that a test case maps to a very specific use case makes pinpointing a problem much easier.

This can be cumbersome, as testing every failure mode, or every happy path for a given component can blow up into a fairly large matrix of test cases. I advise treating your test code no differently than you would your production code. Write helpers if necessary and apply the DRY principle.

Having hyper focused test cases will improve the confidence in both contributions you and others make to your code base.

Conclusion

It wouldn’t be a technical write-up if there wasn’t a section adorned with a bold “Conclusion” title, would it? I know I covered a lot in this article, but the main takeaways are: